diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4aabe3f..28108eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: - '!v*.*.*' jobs: - build-macOS-clients: + build-clients-macOS: runs-on: macos-latest strategy: @@ -51,7 +51,7 @@ jobs: build-others: - needs: [ build-macOS-clients ] + needs: [ build-clients-macOS ] runs-on: ubuntu-latest strategy: @@ -77,29 +77,18 @@ jobs: export PATH=$PATH:~/go/bin/ go install github.com/rakyll/statik - - name: Get artifacts from previous job (arm64) + - name: Get artifact from previous job (darwin_arm64) uses: actions/download-artifact@v3 with: name: darwin_arm64 path: ./built - - name: Get artifacts from previous job (amd64) + - name: Get artifact from previous job (darwin_amd64) uses: actions/download-artifact@v3 with: name: darwin_amd64 path: ./built - - name: Build and embed clients - run: | - chmod +x ./build.client.sh - export GOMOD=`pwd`/go.mod - export CGO_ENABLED=0 - go mod tidy - go mod download - - ./build.client.sh - statik -m -src="./built" -f -dest="./server/embed" -include=* -p built -ns built - - name: Build and pack static resources run: | cd ./web @@ -107,17 +96,23 @@ jobs: npm run build-prod statik -m -src="./dist" -f -dest="../server/embed" -p web -ns web cd .. - zip -q -r ./embed.zip ./server/embed - - name: Build server + - name: Set up go dependencies run: | - chmod +x ./build.server.sh export GOMOD=`pwd`/go.mod export CGO_ENABLED=0 go mod tidy go mod download + + - name: Build clients and servers + run: | + chmod +x ./scripts/build.client.sh + ./scripts/build.client.sh + statik -m -src="./built" -f -dest="./server/embed" -include=* -p built -ns built + + chmod +x ./scripts/build.server.sh mkdir ./releases - ./build.server.sh + ./scripts/build.server.sh - name: Prepare release note run: | @@ -128,6 +123,8 @@ jobs: run: | cd ./releases sudo apt install zip tar -y + tar -zcvf server_darwin_arm64.tar.gz server_darwin_arm64 + tar -zcvf server_darwin_amd64.tar.gz server_darwin_amd64 tar -zcvf server_linux_arm.tar.gz server_linux_arm tar -zcvf server_linux_arm64.tar.gz server_linux_arm64 tar -zcvf server_linux_i386.tar.gz server_linux_i386 @@ -137,17 +134,13 @@ jobs: zip -r server_windows_i386.zip server_windows_i386.exe zip -r server_windows_amd64.zip server_windows_amd64.exe - - name: Upload embedding resources (embed.zip) - uses: actions/upload-artifact@v2 - with: - name: embed.zip - path: embed.zip - - name: Release uses: softprops/action-gh-release@v1 with: body_path: CHANGELOG.md files: | + releases/server_darwin_arm64.tar.gz + releases/server_darwin_amd64.tar.gz releases/server_linux_arm.tar.gz releases/server_linux_arm64.tar.gz releases/server_linux_i386.tar.gz @@ -157,64 +150,9 @@ jobs: releases/server_windows_i386.zip releases/server_windows_amd64.zip - - - build-macOS-servers: - needs: [ build-others ] - runs-on: macos-latest - - strategy: - matrix: - go-version: [ 1.17 ] - - steps: - - uses: actions/checkout@v3 - - - name: Set up golang - uses: actions/setup-go@v3 - with: - go-version: ${{ matrix.go-version }} - - - name: Get artifacts from previous job (embed.zip) - uses: actions/download-artifact@v3 - with: - name: embed.zip - path: . - - - name: Build and compress servers - run: | - rm -rf ./server/embed - unzip -q embed.zip - - export COMMIT=`git rev-parse HEAD` - export GOMOD=`pwd`/go.mod - export CGO_ENABLED=0 - go mod tidy - go mod download - mkdir ./releases - - export GOOS=darwin - export GOARCH=arm64 - go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_darwin_arm64 Spark/server - export GOARCH=amd64 - go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_darwin_amd64 Spark/server - - cd ./releases - tar -zcvf server_darwin_arm64.tar.gz server_darwin_arm64 - tar -zcvf server_darwin_amd64.tar.gz server_darwin_amd64 - cd .. - - - name: Release - uses: softprops/action-gh-release@v1 - with: - files: | - releases/server_darwin_arm64.tar.gz - releases/server_darwin_amd64.tar.gz - - - name: Clean up - uses: geekyeggo/delete-artifact@v1 - with: - name: | - embed.zip - darwin_arm64 - darwin_amd64 + - name: Clean up + uses: geekyeggo/delete-artifact@v1 + with: + name: | + darwin_arm64 + darwin_amd64 diff --git a/API.ZH.md b/API.ZH.md index 51e2230..18daeba 100644 --- a/API.ZH.md +++ b/API.ZH.md @@ -5,21 +5,25 @@ ## 通用 所有请求均为`POST`。 - +
每次请求都必须在Header中带上`Authorization`。 - +
`Authorization`请求头格式:`Basic `(basic auth)。 ``` Authorization: Basic ``` +例如: +``` +Authorization: Basic WFpCOjEyNDg= +``` --- ## 响应 所有响应均是JSON格式。 - +
`code` 有三种结果,分别为`-1`,`0`和`1`,含义如下。 | code | meaning | @@ -55,9 +59,13 @@ Authorization: Basic 参数:**无** -设备的`id`是一串64位的字符串,每台设备独一无二,一般不会变化。识别设备主要靠这个。下文中提到的设备ID也指的是这个。 - -每个device对象所对应的key,是它的本次连接的连接ID,这个ID是随机、临时的,每次重连就会变化,不建议使用。 +设备的`id`是一串64位的字符串,每台设备独一无二,一般不会变化。 +
+识别设备主要靠这个。下文中提到的设备ID也指的是这个。 +
+每个device对象所对应的key,是它的本次连接的连接ID。 +
+连接ID是随机、临时的,每次重连就会变化,不建议使用。 ``` { @@ -123,7 +131,9 @@ Authorization: Basic 参数:`device`(设备ID) -如果截屏获取成功,则会直接以图片的形式输出。如果截屏失败,如下响应会被输出(错误信息不止这一个)。 +如果截屏获取成功,则会直接以图片的形式输出。 +
+如果截屏失败,如下响应会被输出(错误信息不一定是这一个)。 ``` { @@ -138,7 +148,9 @@ Authorization: Basic 参数:`file`(文件路径) 以及 `device`(设备ID) -如果文件存在且可访问,则文件会直接输出。否则,会给出以下响应。 +如果文件存在且可访问,则文件会直接输出。 +
+否则,会给出错误原因。 ``` { @@ -169,11 +181,54 @@ Authorization: Basic --- +### 上传文件到目录:`/device/file/upload` + +**GET**参数:`file`(文件名)、`path`(路径)和`device`(设备ID) + +文件内容需要作为**请求体body**发送。 +
+**请求体body**中的任何内容都会被写到指定地文件中。 +
+如果存在同名文件,则会被**覆盖**! + +Example: +```http request +POST http://localhost:8000/api/device/file/upload?path=D%3A%5C&file=Test.txt&device=bc7e49f8f794f80ffb0032a4ba516c86d76041bf2023e1be6c5dda3b1ee0cf4c HTTP/1.1 +Host: localhost:8000 +Content-Length: 12 +Content-Type: application/octet-stream +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.47 +Origin: http://localhost:8000 +Referer: http://localhost:8000/ + +Hello World. +``` + +如果文件上传成功,则`code`为`1`。 +
+文件`D:\Test.txt`会写入:`Hello World.`。 + +``` +{ + "code": 0 +} +``` +``` +{ + "code": 1, + "msg": "${i18n|fileOrDirNotExist}" +} +``` + +--- + ### 列举设备上的文件和目录:`/device/file/list` 参数:`path`(父目录路径) 以及 `device`(设备ID) -如果`path`为空,windows下会给出磁盘列表,其它系统会默认输出`/`目录下的文件和目录。 +如果`path`为空,windows下会给出磁盘列表。 +
+其它系统会默认输出`/`目录下的文件和目录。 `type`有三种结果:`0`代表文件,`1`代表目录,`2`代表磁盘(windows)。 diff --git a/API.md b/API.md index 51f7c04..13273ba 100644 --- a/API.md +++ b/API.md @@ -5,14 +5,18 @@ ## Common Only `POST` requests are allowed. - +
For every request, you should have `Authorization` on its header. - +
Authorization header is a string like `Basic `(basic auth). ``` Authorization: Basic ``` +Example: +``` +Authorization: Basic WFpCOjEyNDg= +``` --- @@ -53,8 +57,12 @@ All responses are JSON encoded. Parameters: **None** -The `id` of device is persistent, its length always equals 64. It's unique for every device and won't change, so you should identify every device by this. - +The `id` of device is persistent, its length always equals 64. +
+It's unique for every device and won't change. +
+You're recommend to recognize your device by device ID. +
The key of the device object is its connection UUID, it's random and temporary. ``` @@ -166,6 +174,47 @@ If file exists and is deleted successfully, then `code` will be `0`. --- +### Upload file: `/device/file/upload` + +**Query Parameters**: `file` (file name), `path` and `device` (device ID) + +File itself should be sent in the request **body**. +
+**Anything** represented in the request **body** will be saved to the device. +
+If same file exists, then it will be **overwritten**. + +Example: +```http request +POST http://localhost:8000/api/device/file/upload?path=D%3A%5C&file=Test.txt&device=bc7e49f8f794f80ffb0032a4ba516c86d76041bf2023e1be6c5dda3b1ee0cf4c HTTP/1.1 +Host: localhost:8000 +Content-Length: 12 +Content-Type: application/octet-stream +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.47 +Origin: http://localhost:8000 +Referer: http://localhost:8000/ + +Hello World. +``` + +If file uploaded successfully, then `code` will be `0`. +
+And `D:\Test.txt` will be created with the content of `Hello World.`. + +``` +{ + "code": 0 +} +``` +``` +{ + "code": 1, + "msg": "${i18n|fileOrDirNotExist}" +} +``` + +--- + ### List files: `/device/file/list` Parameters: `path` (folder to be listed) and `device` (device ID) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d78f1..30e9c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## v0.0.8 + +* Add: file upload. +* Optimize: project structure. + +* 新增: 文件上传功能。 +* 优化: 项目结构。 + + + ## v0.0.7 * Add: detail info tooltip of cpu, ram and disk. diff --git a/README.ZH.md b/README.ZH.md index 73e3adb..5fb3ce3 100644 --- a/README.ZH.md +++ b/README.ZH.md @@ -66,6 +66,20 @@ --- +## 截图 + +![overview](./screenshots/overview.ZH.png) + +![terminal](./screenshots/terminal.ZH.png) + +![procmgr](./screenshots/procmgr.ZH.png) + +![explorer](./screenshots/explorer.ZH.png) + +![overview.cpu](./screenshots/overview.cpu.ZH.png) + +--- + ## **开发** ### 注意 @@ -121,18 +135,6 @@ $ ./build.server.sh --- -## 截图 - -![overview](./screenshots/overview.ZH.png) - -![terminal](./screenshots/terminal.ZH.png) - -![procmgr](./screenshots/procmgr.ZH.png) - -![explorer](./screenshots/explorer.ZH.png) - ---- - ## 项目依赖 Spark使用了许多第三方的开源项目。 diff --git a/README.md b/README.md index 87f7b96..f867f33 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Only local installation are available yet. |-----------------|---------|-------|-------| | Process manager | ✔ | ✔ | ✔ | | Kill process | ✔ | ✔ | ✔ | -| Network Traffic | ✔ | ✔ | ✔ | +| Network traffic | ✔ | ✔ | ✔ | | File explorer | ✔ | ✔ | ✔ | | File transfer | ✔ | ✔ | ✔ | | Delete file | ✔ | ✔ | ✔ | @@ -66,6 +66,20 @@ Only local installation are available yet. --- +## Screenshots + +![overview](./screenshots/overview.png) + +![terminal](./screenshots/terminal.png) + +![procmgr](./screenshots/procmgr.png) + +![explorer](./screenshots/explorer.png) + +![overview.cpu](./screenshots/overview.cpu.png) + +--- + ## **Development** ### note @@ -123,18 +137,6 @@ Copy configuration file mentioned above into this dir, and then you can execute --- -## Screenshots - -![overview](./screenshots/overview.png) - -![terminal](./screenshots/terminal.png) - -![procmgr](./screenshots/procmgr.png) - -![explorer](./screenshots/explorer.png) - ---- - ## Dependencies Spark contains many third-party open-source projects. diff --git a/client/client.go b/client/client.go index dad88fc..ad58ee4 100644 --- a/client/client.go +++ b/client/client.go @@ -48,6 +48,11 @@ func init() { } func main() { + update() + core.Start() +} + +func update() { if len(os.Args) > 1 && os.Args[1] == `--update` { thisPath := os.Args[0] destPath := thisPath[:len(thisPath)-4] @@ -69,7 +74,6 @@ func main() { <-time.After(time.Second) os.Remove(os.Args[0] + `.tmp`) } - core.Start() } func decrypt(data []byte, key []byte) ([]byte, error) { diff --git a/client/config/config.go b/client/config/config.go index e37123c..ff8c53e 100644 --- a/client/config/config.go +++ b/client/config/config.go @@ -14,10 +14,11 @@ type Cfg struct { Key string `json:"key"` } -// localhost for debug only +// Localhost for my development only. +// Shall be commented out when development is done. //var CfgBuffer = "\x00\xcd\x90\x50\x43\xfc\x3d\x36\x56\x6d\xf6\x01\xd1\xcd\x81\xc3\x1b\x80\xc9\x61\xd8\xdf\x5b\x76\x48\x88\xc5\xb1\x74\x22\x23\xab\x3b\xfc\x8b\xbe\x98\x27\xed\x05\xec\xbb\x40\x4f\xe9\xe7\xe5\xe0\x84\xaa\xb7\xfd\x4a\x30\x71\x08\x6c\x02\x50\xe9\xc5\x22\xcf\xcb\x89\x16\x0a\x89\x08\xd4\x26\xdc\x5c\xc1\xc9\xbf\xc4\xac\x0d\x92\x2f\x34\x7f\x45\xeb\x55\xa0\x6d\xf6\x64\xbc\xd5\x15\x40\x96\x43\x64\xe0\x24\x51\xfb\xe8\xc9\x7f\x48\x60\xcd\x30\x5e\x5e\x78\xba\xb6\x6f\x07\x64\xe8\x59\x81\x0b\x91\x13\x92\x1a\xdd\x49\x8f\x28\xe7\x74\xea\xff\x5b\x45\x0e\x4a\x2d\x60\x4e\xc9\xde\x9c\xbe\x50\xc6\x12\xc7\x45\xa2\x15\xa0\x58\x62\x45\x86\x74\x9f\xa5\x14\x5c\x17\x8a\xcc\x56\x73\xa7\x75\xb7\xf6\x6d\x52\x0f\xb8\xc1\xff\x9c\x39\x39\x00\x74\xe1\x4d\x65\x73\x9c\x02\x57\x8b\xcf\xdf\x0a\x20\x4c\xed\xe2\x25\xea\x01\x36\x12\x37\x12\x2e\x1a\x03\x41\x19\x2e\xc9\xdd\x71\xac\x73\x90\xfa\x5e\x60\x08\x43\x35\xef\x61\x45\xf9\xe3\xba\xcb\xb1\xc5\x7c\xf0\x11\xcd\x47\x57\x53\xdc\x35\x6b\x9f\xac\xad\x43\x4a\xc7\x54\x20\xb8\xd0\xf8\xb5\x0c\x45\x76\x57\xb9\xee\x4a\x3f\xd2\xda\xf7\x94\x54\x74\xf3\x91\xf3\x4d\x49\x98\xc6\xf8\x60\x80\xad\x84\x04\xef\x35\xca\x3a\xcf\xd3\x7e\x74\xc2\x4b\xb8\xb3\x9f\xb2\x83\xb8\xbd\x29\x13\x9f\x2b\xaa\x60\x47\x24\x7e\x20\xb2\x85\xdc\x47\xfe\x8f\x68\xb6\xc3\x43\xad\x61\x3d\x9b\x35\x60\x2e\x6c\x44\xf0\xaf\xb2\xf3\xdb\xe2\x1b\x8a\xec\x0a\x48\x5e\x43\xa9\xb3\x3a\x5e\xb6\x90\xa9\x3d\xee\x4f\xa1\x57\x7c\x94\xf4\xb1\x36\xda\x04\xa8\x5e\x48\x2a\xc3\xa1\xf0\x97\xf0\xe0\x10\x46\x32\x10\xe5\xd8\x36\x5a\x56\xa5\xbb\x37\x3c\x9f\xbd\xef\xf5\x2f" -// none +// None var CfgBuffer = "\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19\x19" // COMMIT means this commit hash, help to identify version and self upgrade. diff --git a/client/core/core.go b/client/core/core.go index 6073aa5..c41f732 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -40,6 +40,7 @@ var handlers = map[string]func(pack modules.Packet, wsConn *common.Conn){ `resizeTerminal`: resizeTerminal, `killTerminal`: killTerminal, `listFiles`: listFiles, + `fetchFile`: fetchFile, `removeFile`: removeFile, `uploadFile`: uploadFile, `listProcesses`: listProcesses, @@ -54,14 +55,14 @@ func Start() { } common.WSConn, err = connectWS() if err != nil && !stop { - golog.Error(err) + golog.Error(`Connection error: `, err) <-time.After(3 * time.Second) continue } err = reportWS(common.WSConn) if err != nil && !stop { - golog.Error(err) + golog.Error(`Register error: `, err) <-time.After(3 * time.Second) continue } @@ -72,7 +73,7 @@ func Start() { err = handleWS(common.WSConn) if err != nil && !stop { - golog.Error(err) + golog.Error(`Execution error: `, err) <-time.After(3 * time.Second) continue } @@ -103,7 +104,7 @@ func reportWS(wsConn *common.Conn) error { if err != nil { return err } - pack := modules.CommonPack{Act: `report`, Data: device} + pack := modules.CommonPack{Act: `report`, Data: *device} err = common.SendPack(pack, wsConn) common.WSConn.SetWriteDeadline(time.Time{}) if err != nil { @@ -226,7 +227,7 @@ func heartbeat(wsConn *common.Conn) error { if t >= 20 { t = 0 } - err = common.SendPack(modules.CommonPack{Act: `setDevice`, Data: device}, wsConn) + err = common.SendPack(modules.CommonPack{Act: `setDevice`, Data: *device}, wsConn) if err != nil { return err } diff --git a/client/core/device.go b/client/core/device.go index 6835050..4fd726d 100644 --- a/client/core/device.go +++ b/client/core/device.go @@ -245,7 +245,7 @@ func GetDevice() (*modules.Device, error) { }, nil } -func GetPartialInfo(getDisk bool) (modules.Device, error) { +func GetPartialInfo(getDisk bool) (*modules.Device, error) { cpuInfo, err := GetCPUInfo() if err != nil { cpuInfo = modules.CPU{ @@ -280,7 +280,7 @@ func GetPartialInfo(getDisk bool) (modules.Device, error) { if err != nil { uptime = 0 } - return modules.Device{ + return &modules.Device{ Net: netInfo, CPU: cpuInfo, RAM: memInfo, diff --git a/client/core/handler.go b/client/core/handler.go index 75f42ab..a8714d6 100644 --- a/client/core/handler.go +++ b/client/core/handler.go @@ -13,41 +13,6 @@ import ( "strconv" ) -func getPackData(pack modules.Packet, key string, t reflect.Kind) (interface{}, bool) { - data, ok := pack.Data[key] - if !ok { - return nil, false - } - switch t { - case reflect.String: - val, ok := data.(string) - return val, ok - case reflect.Uint: - val, ok := data.(uint) - return val, ok - case reflect.Uint32: - val, ok := data.(uint32) - return val, ok - case reflect.Uint64: - val, ok := data.(uint64) - return val, ok - case reflect.Int: - val, ok := data.(int) - return val, ok - case reflect.Int64: - val, ok := data.(int64) - return val, ok - case reflect.Bool: - val, ok := data.(bool) - return val, ok - case reflect.Float64: - val, ok := data.(float64) - return val, ok - default: - return nil, false - } -} - func offline(pack modules.Packet, wsConn *common.Conn) { common.SendCb(modules.Packet{Code: 0}, pack, wsConn) stop = true @@ -110,8 +75,16 @@ func shutdown(pack modules.Packet, wsConn *common.Conn) { } func screenshot(pack modules.Packet, wsConn *common.Conn) { - if len(pack.Event) > 0 { - Screenshot.GetScreenshot(pack.Event) + var bridge string + if val, ok := pack.GetData(`bridge`, reflect.String); !ok { + common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|invalidParameter}`}, pack, wsConn) + return + } else { + bridge = val.(string) + } + err := Screenshot.GetScreenshot(bridge) + if err != nil { + common.SendCb(modules.Packet{Code: 1, Msg: err.Error()}, pack, wsConn) } } @@ -136,7 +109,7 @@ func killTerminal(pack modules.Packet, wsConn *common.Conn) { func listFiles(pack modules.Packet, wsConn *common.Conn) { path := `/` - if val, ok := getPackData(pack, `path`, reflect.String); ok { + if val, ok := pack.GetData(`path`, reflect.String); ok { path = val.(string) } files, err := file.ListFiles(path) @@ -147,9 +120,35 @@ func listFiles(pack modules.Packet, wsConn *common.Conn) { } } +func fetchFile(pack modules.Packet, wsConn *common.Conn) { + var path, filename, bridge string + if val, ok := pack.GetData(`path`, reflect.String); !ok { + common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|fileOrDirNotExist}`}, pack, wsConn) + return + } else { + path = val.(string) + } + if val, ok := pack.GetData(`file`, reflect.String); !ok { + common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|invalidParameter}`}, pack, wsConn) + return + } else { + filename = val.(string) + } + if val, ok := pack.GetData(`bridge`, reflect.String); !ok { + common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|invalidParameter}`}, pack, wsConn) + return + } else { + bridge = val.(string) + } + err := file.FetchFile(path, filename, bridge) + if err != nil { + common.SendCb(modules.Packet{Code: 1, Msg: err.Error()}, pack, wsConn) + } +} + func removeFile(pack modules.Packet, wsConn *common.Conn) { var path string - if val, ok := getPackData(pack, `file`, reflect.String); !ok { + if val, ok := pack.GetData(`file`, reflect.String); !ok { common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|fileOrDirNotExist}`}, pack, wsConn) return } else { @@ -165,18 +164,24 @@ func removeFile(pack modules.Packet, wsConn *common.Conn) { func uploadFile(pack modules.Packet, wsConn *common.Conn) { var start, end int64 - var path string - if val, ok := getPackData(pack, `file`, reflect.String); !ok { + var path, bridge string + if val, ok := pack.GetData(`file`, reflect.String); !ok { common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|fileOrDirNotExist}`}, pack, wsConn) return } else { path = val.(string) } + if val, ok := pack.GetData(`bridge`, reflect.String); !ok { + common.SendCb(modules.Packet{Code: 1, Msg: `${i18n|invalidParameter}`}, pack, wsConn) + return + } else { + bridge = val.(string) + } { - if val, ok := getPackData(pack, `start`, reflect.Float64); ok { + if val, ok := pack.GetData(`start`, reflect.Float64); ok { start = int64(val.(float64)) } - if val, ok := getPackData(pack, `end`, reflect.Float64); ok { + if val, ok := pack.GetData(`end`, reflect.Float64); ok { end = int64(val.(float64)) if end > 0 { end++ @@ -187,7 +192,7 @@ func uploadFile(pack modules.Packet, wsConn *common.Conn) { return } } - err := file.UploadFile(path, pack.Event, start, end) + err := file.UploadFile(path, bridge, start, end) if err != nil { common.SendCb(modules.Packet{Code: 1, Msg: err.Error()}, pack, wsConn) } @@ -207,7 +212,7 @@ func killProcess(pack modules.Packet, wsConn *common.Conn) { pid int64 err error ) - if val, ok := getPackData(pack, `pid`, reflect.String); ok { + if val, ok := pack.GetData(`pid`, reflect.String); ok { pid, err = strconv.ParseInt(val.(string), 10, 32) common.SendCb(modules.Packet{Code: 1, Msg: err.Error()}, pack, wsConn) return diff --git a/client/service/file/file.go b/client/service/file/file.go index ae02752..e9cea12 100644 --- a/client/service/file/file.go +++ b/client/service/file/file.go @@ -6,12 +6,13 @@ import ( "io" "io/ioutil" "os" + "path" "strconv" "github.com/imroc/req/v3" ) -type file struct { +type File struct { Name string `json:"name"` Size uint64 `json:"size"` Time int64 `json:"time"` @@ -19,8 +20,8 @@ type file struct { } // listFiles returns files and directories find in path. -func listFiles(path string) ([]file, error) { - result := make([]file, 0) +func listFiles(path string) ([]File, error) { + result := make([]File, 0) files, err := ioutil.ReadDir(path) if err != nil { return nil, err @@ -30,7 +31,7 @@ func listFiles(path string) ([]file, error) { if files[i].IsDir() { itemType = 1 } - result = append(result, file{ + result = append(result, File{ Name: files[i].Name(), Size: uint64(files[i].Size()), Time: files[i].ModTime().Unix(), @@ -40,6 +41,62 @@ func listFiles(path string) ([]file, error) { return result, nil } +// FetchFile saves file from bridge to local. +// Save body as temp file and when done, rename it to file. +func FetchFile(dir, file, bridge string) error { + url := config.GetBaseURL(false) + `/api/bridge/pull` + client := req.C().DisableAutoReadResponse() + resp, err := client.R().SetQueryParam(`bridge`, bridge).Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // If dest file exists, write to temp file first. + dest := path.Join(dir, file) + tmpFile := dest + destExists := false + if _, err := os.Stat(dest); !os.IsNotExist(err) { + tmpFile = getTempFileName(dir, file) + destExists = true + } + + fh, err := os.Create(tmpFile) + if err != nil { + return err + } + for { + buf := make([]byte, 1024) + n, err := resp.Body.Read(buf) + if err != nil && err != io.EOF { + fh.Truncate(0) + fh.Close() + os.Remove(tmpFile) + return err + } + if n == 0 { + break + } + _, err = fh.Write(buf[:n]) + if err != nil { + fh.Truncate(0) + fh.Close() + os.Remove(tmpFile) + return err + } + fh.Sync() + } + fh.Close() + + // Delete old file if exists. + // Then rename temp file to file. + if destExists { + os.Remove(dest) + err = os.Rename(tmpFile, dest) + } + return err +} + func RemoveFile(path string) error { if path == `\` || path == `/` || len(path) == 0 { return errors.New(`${i18n|fileOrDirNotExist}`) @@ -51,7 +108,7 @@ func RemoveFile(path string) error { return nil } -func UploadFile(path, trigger string, start, end int64) error { +func UploadFile(path, bridge string, start, end int64) error { file, err := os.Open(path) if err != nil { return err @@ -65,7 +122,6 @@ func UploadFile(path, trigger string, start, end int64) error { } size := stat.Size() headers := map[string]string{ - `Trigger`: trigger, `FileName`: stat.Name(), `FileSize`: strconv.FormatInt(size, 10), } @@ -96,8 +152,25 @@ func UploadFile(path, trigger string, start, end int64) error { } writer.Close() }() - url := config.GetBaseURL(false) + `/api/device/file/put` - _, err = uploadReq.SetBody(reader).SetHeaders(headers).Send(`PUT`, url) + url := config.GetBaseURL(false) + `/api/bridge/push` + _, err = uploadReq. + SetBody(reader). + SetHeaders(headers). + SetQueryParam(`bridge`, bridge). + Send(`PUT`, url) reader.Close() return err } + +func getTempFileName(dir, file string) string { + exists := true + tempFile := `` + for i := 0; exists; i++ { + tempFile = path.Join(dir, file+`.tmp.`+strconv.Itoa(i)) + _, err := os.Stat(tempFile) + if os.IsNotExist(err) { + exists = false + } + } + return tempFile +} diff --git a/client/service/file/file_others.go b/client/service/file/file_others.go index cbf9125..89266f2 100644 --- a/client/service/file/file_others.go +++ b/client/service/file/file_others.go @@ -1,8 +1,9 @@ +//go:build !windows // +build !windows package file -func ListFiles(path string) ([]file, error) { +func ListFiles(path string) ([]File, error) { if len(path) == 0 { path = `/` } diff --git a/client/service/file/file_windows.go b/client/service/file/file_windows.go index a3e1c64..1a30455 100644 --- a/client/service/file/file_windows.go +++ b/client/service/file/file_windows.go @@ -8,8 +8,8 @@ import "github.com/shirou/gopsutil/v3/disk" // ListFiles will only be called when path is root and // current system is Windows. // It will return mount points of all volumes. -func ListFiles(path string) ([]file, error) { - result := make([]file, 0) +func ListFiles(path string) ([]File, error) { + result := make([]File, 0) if len(path) == 0 || path == `\` || path == `/` { partitions, err := disk.Partitions(true) if err != nil { @@ -23,7 +23,7 @@ func ListFiles(path string) ([]file, error) { } else { size = stat.Total } - result = append(result, file{Name: partitions[i].Mountpoint, Type: 2, Size: size}) + result = append(result, File{Name: partitions[i].Mountpoint, Type: 2, Size: size}) } return result, nil } diff --git a/client/service/screenshot/screenshot.go b/client/service/screenshot/screenshot.go deleted file mode 100644 index e9eaf5b..0000000 --- a/client/service/screenshot/screenshot.go +++ /dev/null @@ -1,16 +0,0 @@ -package screenshot - -import ( - "Spark/client/config" - "github.com/imroc/req/v3" -) - -func putScreenshot(trigger, err string, body interface{}) (*req.Response, error) { - return req.R(). - SetBody(body). - SetHeaders(map[string]string{ - `Trigger`: trigger, - `Error`: err, - }). - Send(`PUT`, config.GetBaseURL(false)+`/api/device/screenshot/put`) -} diff --git a/client/service/screenshot/supported.go b/client/service/screenshot/supported.go index 09e44a8..7d7f33c 100644 --- a/client/service/screenshot/supported.go +++ b/client/service/screenshot/supported.go @@ -3,30 +3,30 @@ package screenshot import ( + "Spark/client/config" "bytes" "errors" + "github.com/imroc/req/v3" "github.com/kbinani/screenshot" "image/png" ) -func GetScreenshot(trigger string) error { +func GetScreenshot(bridge string) error { writer := new(bytes.Buffer) num := screenshot.NumActiveDisplays() if num == 0 { err := errors.New(`${i18n|noDisplayFound}`) - putScreenshot(trigger, err.Error(), nil) return err } img, err := screenshot.CaptureDisplay(0) if err != nil { - putScreenshot(trigger, err.Error(), nil) return err } err = png.Encode(writer, img) if err != nil { - putScreenshot(trigger, err.Error(), nil) return err } - _, err = putScreenshot(trigger, ``, writer) + url := config.GetBaseURL(false) + `/api/bridge/push` + _, err = req.R().SetBody(writer.Bytes()).SetQueryParam(`bridge`, bridge).Put(url) return err } diff --git a/client/service/screenshot/unsupported.go b/client/service/screenshot/unsupported.go index f06de4d..ced7966 100644 --- a/client/service/screenshot/unsupported.go +++ b/client/service/screenshot/unsupported.go @@ -2,9 +2,6 @@ package screenshot -import "Spark/utils" - -func GetScreenshot(trigger string) error { - _, err := putScreenshot(trigger, utils.ErrUnsupported.Error(), nil) - return err +func GetScreenshot(bridge string) error { + return utils.ErrUnsupported } diff --git a/client/service/terminal/terminal_others.go b/client/service/terminal/terminal_others.go index 552bcee..bad8bc9 100644 --- a/client/service/terminal/terminal_others.go +++ b/client/service/terminal/terminal_others.go @@ -10,13 +10,14 @@ import ( "github.com/creack/pty" "os" "os/exec" + "reflect" "time" ) type terminal struct { lastPack int64 - event string - pty *os.File + event string + pty *os.File } func init() { @@ -30,8 +31,8 @@ func InitTerminal(pack modules.Packet) error { return err } termSession := &terminal{ - pty: ptySession, - event: pack.Event, + pty: ptySession, + event: pack.Event, lastPack: time.Now().Unix(), } terminals.Set(pack.Data[`terminal`].(string), termSession) @@ -55,86 +56,54 @@ func InitTerminal(pack modules.Packet) error { } func InputTerminal(pack modules.Packet) error { - if pack.Data == nil { - return errDataNotFound - } - val, ok := pack.Data[`input`] - if !ok { - return errDataNotFound - } - hexStr, ok := val.(string) + val, ok := pack.GetData(`input`, reflect.String) if !ok { return errDataNotFound } - data, err := hex.DecodeString(hexStr) + data, err := hex.DecodeString(val.(string)) if err != nil { return errDataInvalid } - val, ok = pack.Data[`terminal`] - if !ok { - return errUUIDNotFound - } - termUUID, ok := val.(string) + val, ok = pack.GetData(`terminal`, reflect.String) if !ok { return errUUIDNotFound } + termUUID := val.(string) val, ok = terminals.Get(termUUID) if !ok { common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) return nil } - terminal, ok := val.(*terminal) - if !ok { - common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) - return nil - } - + terminal := val.(*terminal) terminal.lastPack = time.Now().Unix() terminal.pty.Write(data) return nil } func ResizeTerminal(pack modules.Packet) error { - if pack.Data == nil { - return errDataNotFound - } - val, ok := pack.Data[`width`] - if !ok { - return errDataInvalid - } - width, ok := val.(float64) - if !ok { - return errDataInvalid - } - val, ok = pack.Data[`height`] + val, ok := pack.GetData(`width`, reflect.Float64) if !ok { return errDataInvalid } - height, ok := val.(float64) + width := val.(float64) + val, ok = pack.GetData(`height`, reflect.Float64) if !ok { return errDataInvalid } + height := val.(float64) - val, ok = pack.Data[`terminal`] - if !ok { - return errUUIDNotFound - } - termUUID, ok := val.(string) + val, ok = pack.GetData(`terminal`, reflect.String) if !ok { return errUUIDNotFound } + termUUID := val.(string) val, ok = terminals.Get(termUUID) if !ok { common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) return nil } - terminal, ok := val.(*terminal) - if !ok { - common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) - return nil - } - + terminal := val.(*terminal) pty.Setsize(terminal.pty, &pty.Winsize{ Rows: uint16(height), Cols: uint16(width), @@ -143,28 +112,17 @@ func ResizeTerminal(pack modules.Packet) error { } func KillTerminal(pack modules.Packet) error { - if pack.Data == nil { - return errUUIDNotFound - } - val, ok := pack.Data[`terminal`] - if !ok { - return errUUIDNotFound - } - termUUID, ok := val.(string) + val, ok := pack.GetData(`terminal`, reflect.String) if !ok { return errUUIDNotFound } + termUUID := val.(string) val, ok = terminals.Get(termUUID) if !ok { common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) return nil } - terminal, ok := val.(*terminal) - if !ok { - terminals.Remove(termUUID) - common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) - return nil - } + terminal := val.(*terminal) doKillTerminal(terminal) return nil } @@ -187,17 +145,13 @@ func getTerminal() string { } func healthCheck() { - const MaxInterval = 180 + const MaxInterval = 300 for now := range time.NewTicker(30 * time.Second).C { timestamp := now.Unix() // stores sessions to be disconnected queue := make([]string, 0) terminals.IterCb(func(uuid string, t interface{}) bool { - termSession, ok := t.(*terminal) - if !ok { - queue = append(queue, uuid) - return true - } + termSession := t.(*terminal) if timestamp-termSession.lastPack > MaxInterval { queue = append(queue, uuid) doKillTerminal(termSession) diff --git a/client/service/terminal/terminal_windows.go b/client/service/terminal/terminal_windows.go index 789c0f8..06f5612 100644 --- a/client/service/terminal/terminal_windows.go +++ b/client/service/terminal/terminal_windows.go @@ -9,6 +9,7 @@ import ( "io/ioutil" "os" "os/exec" + "reflect" "runtime" "time" @@ -18,11 +19,11 @@ import ( type terminal struct { lastPack int64 - event string - cmd *exec.Cmd - stdout *io.ReadCloser - stderr *io.ReadCloser - stdin *io.WriteCloser + event string + cmd *exec.Cmd + stdout *io.ReadCloser + stderr *io.ReadCloser + stdin *io.WriteCloser } func init() { @@ -47,11 +48,11 @@ func InitTerminal(pack modules.Packet) error { return err } termSession := &terminal{ - cmd: cmd, - event: pack.Event, - stdout: &stdout, - stderr: &stderr, - stdin: &stdin, + cmd: cmd, + event: pack.Event, + stdout: &stdout, + stderr: &stderr, + stdin: &stdin, lastPack: time.Now().Unix(), } terminals.Set(pack.Data[`terminal`].(string), termSession) @@ -86,41 +87,26 @@ func InitTerminal(pack modules.Packet) error { } func InputTerminal(pack modules.Packet) error { - if pack.Data == nil { - return errDataNotFound - } - val, ok := pack.Data[`input`] - if !ok { - return errDataNotFound - } - hexStr, ok := val.(string) + val, ok := pack.GetData(`input`, reflect.String) if !ok { return errDataNotFound } - data, err := hex.DecodeString(hexStr) + data, err := hex.DecodeString(val.(string)) if err != nil { return errDataInvalid } - val, ok = pack.Data[`terminal`] - if !ok { - return errUUIDNotFound - } - termUUID, ok := val.(string) + val, ok = pack.GetData(`terminal`, reflect.String) if !ok { return errUUIDNotFound } + termUUID := val.(string) val, ok = terminals.Get(termUUID) if !ok { common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) return nil } - terminal, ok := val.(*terminal) - if !ok { - common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) - return nil - } - + terminal := val.(*terminal) terminal.lastPack = time.Now().Unix() if len(data) == 1 && data[0] == '\x03' { terminal.cmd.Process.Signal(os.Interrupt) @@ -136,28 +122,17 @@ func ResizeTerminal(pack modules.Packet) error { } func KillTerminal(pack modules.Packet) error { - if pack.Data == nil { - return errUUIDNotFound - } - val, ok := pack.Data[`terminal`] - if !ok { - return errUUIDNotFound - } - termUUID, ok := val.(string) + val, ok := pack.GetData(`terminal`, reflect.String) if !ok { return errUUIDNotFound } + termUUID := val.(string) val, ok = terminals.Get(termUUID) if !ok { common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) return nil } - terminal, ok := val.(*terminal) - if !ok { - terminals.Remove(termUUID) - common.SendCb(modules.Packet{Act: `quitTerminal`, Msg: `${i18n|terminalSessionClosed}`}, pack, common.WSConn) - return nil - } + terminal := val.(*terminal) doKillTerminal(terminal) return nil } @@ -210,17 +185,13 @@ func utf8ToGbk(s []byte) ([]byte, error) { } func healthCheck() { - const MaxInterval = 180 + const MaxInterval = 300 for now := range time.NewTicker(30 * time.Second).C { timestamp := now.Unix() // stores sessions to be disconnected queue := make([]string, 0) terminals.IterCb(func(uuid string, t interface{}) bool { - termSession, ok := t.(*terminal) - if !ok { - queue = append(queue, uuid) - return true - } + termSession := t.(*terminal) if timestamp-termSession.lastPack > MaxInterval { queue = append(queue, uuid) doKillTerminal(termSession) diff --git a/modules/modules.go b/modules/modules.go index 0f72ed4..c1664f6 100644 --- a/modules/modules.go +++ b/modules/modules.go @@ -1,5 +1,7 @@ package modules +import "reflect" + type Packet struct { Code int `json:"code"` Act string `json:"act,omitempty"` @@ -52,3 +54,41 @@ type Net struct { Sent uint64 `json:"sent"` Recv uint64 `json:"recv"` } + +func (p *Packet) GetData(key string, t reflect.Kind) (interface{}, bool) { + if p.Data == nil { + return nil, false + } + data, ok := p.Data[key] + if !ok { + return nil, false + } + switch t { + case reflect.String: + val, ok := data.(string) + return val, ok + case reflect.Uint: + val, ok := data.(uint) + return val, ok + case reflect.Uint32: + val, ok := data.(uint32) + return val, ok + case reflect.Uint64: + val, ok := data.(uint64) + return val, ok + case reflect.Int: + val, ok := data.(int) + return val, ok + case reflect.Int64: + val, ok := data.(int64) + return val, ok + case reflect.Bool: + val, ok := data.(bool) + return val, ok + case reflect.Float64: + val, ok := data.(float64) + return val, ok + default: + return nil, false + } +} diff --git a/screenshots/explorer.ZH.png b/screenshots/explorer.ZH.png index ef7ebf2..6010e05 100644 Binary files a/screenshots/explorer.ZH.png and b/screenshots/explorer.ZH.png differ diff --git a/screenshots/explorer.png b/screenshots/explorer.png index 8c5ec3b..0e5ad35 100644 Binary files a/screenshots/explorer.png and b/screenshots/explorer.png differ diff --git a/screenshots/overview.ZH.png b/screenshots/overview.ZH.png index f13a1e0..2919de7 100644 Binary files a/screenshots/overview.ZH.png and b/screenshots/overview.ZH.png differ diff --git a/screenshots/overview.cpu.ZH.png b/screenshots/overview.cpu.ZH.png new file mode 100644 index 0000000..d9a6926 Binary files /dev/null and b/screenshots/overview.cpu.ZH.png differ diff --git a/screenshots/overview.cpu.png b/screenshots/overview.cpu.png new file mode 100644 index 0000000..bacec4e Binary files /dev/null and b/screenshots/overview.cpu.png differ diff --git a/screenshots/overview.png b/screenshots/overview.png index d9ab91e..1862fee 100644 Binary files a/screenshots/overview.png and b/screenshots/overview.png differ diff --git a/screenshots/procmgr.ZH.png b/screenshots/procmgr.ZH.png index a0f5f9a..eb86b6d 100644 Binary files a/screenshots/procmgr.ZH.png and b/screenshots/procmgr.ZH.png differ diff --git a/screenshots/procmgr.png b/screenshots/procmgr.png index c3e6bf9..63fb884 100644 Binary files a/screenshots/procmgr.png and b/screenshots/procmgr.png differ diff --git a/screenshots/terminal.ZH.png b/screenshots/terminal.ZH.png index 64833a7..81a9e69 100644 Binary files a/screenshots/terminal.ZH.png and b/screenshots/terminal.ZH.png differ diff --git a/screenshots/terminal.png b/screenshots/terminal.png index 84134a2..d4ca089 100644 Binary files a/screenshots/terminal.png and b/screenshots/terminal.png differ diff --git a/build.client.bat b/scripts/build.client.bat similarity index 100% rename from build.client.bat rename to scripts/build.client.bat diff --git a/build.client.sh b/scripts/build.client.sh similarity index 100% rename from build.client.sh rename to scripts/build.client.sh diff --git a/scripts/build.server.bat b/scripts/build.server.bat new file mode 100644 index 0000000..57d2333 --- /dev/null +++ b/scripts/build.server.bat @@ -0,0 +1,38 @@ +cd .. +mkdir .\releases +for /F %%i in ('git rev-parse HEAD') do ( set COMMIT=%%i) + + + +set GOOS=linux + +set GOARCH=arm +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_linux_arm Spark/Server +set GOARCH=arm64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_linux_arm64 Spark/Server +set GOARCH=386 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_linux_i386 Spark/Server +set GOARCH=amd64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_linux_amd64 Spark/Server + + + +set GOOS=windows + +set GOARCH=arm +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_windows_arm.exe Spark/Server +set GOARCH=arm64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_windows_arm64.exe Spark/Server +set GOARCH=386 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_windows_i386.exe Spark/Server +set GOARCH=amd64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_windows_amd64.exe Spark/Server + + + +set GOOS=darwin + +set GOARCH=arm64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_darwin_arm64 Spark/server +set GOARCH=amd64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=%COMMIT%'" -tags=jsoniter -o ./releases/server_darwin_amd64 Spark/server diff --git a/build.server.sh b/scripts/build.server.sh similarity index 80% rename from build.server.sh rename to scripts/build.server.sh index 52cf8d0..4c3ecb7 100644 --- a/build.server.sh +++ b/scripts/build.server.sh @@ -26,3 +26,12 @@ export GOARCH=386 go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_windows_i386.exe Spark/server export GOARCH=amd64 go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_windows_amd64.exe Spark/server + + + +export GOOS=darwin + +export GOARCH=arm64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_darwin_arm64 Spark/server +export GOARCH=amd64 +go build -ldflags "-s -w -X 'Spark/server/config.COMMIT=$COMMIT'" -tags=jsoniter -o ./releases/server_darwin_amd64 Spark/server diff --git a/server/common/common.go b/server/common/common.go index fb90ae1..849ffee 100644 --- a/server/common/common.go +++ b/server/common/common.go @@ -10,7 +10,9 @@ import ( "crypto/cipher" "encoding/hex" "github.com/gin-gonic/gin" + "net" "net/http" + "strings" "time" ) @@ -18,7 +20,7 @@ var Melody = melody.New() var Devices = cmap.New() var BuiltFS http.FileSystem -func SendPackUUID(pack modules.Packet, uuid string) bool { +func SendPackByUUID(pack modules.Packet, uuid string) bool { session, ok := Melody.GetSessionByUUID(uuid) if !ok { return false @@ -68,8 +70,7 @@ func Decrypt(data []byte, session *melody.Session) ([]byte, bool) { return dec, true } -func WSHealthCheck(container *melody.Melody) { - const MaxInterval = 90 +func HealthCheckWS(maxIdleSeconds int64, container *melody.Melody) { go func() { // ping client and update latency every 3 seconds ping := func(uuid string, s *melody.Session) { @@ -79,10 +80,8 @@ func WSHealthCheck(container *melody.Melody) { AddEventOnce(func(packet modules.Packet, session *melody.Session) { val, ok := Devices.Get(uuid) if ok { - deviceInfo, ok := val.(*modules.Device) - if ok { - deviceInfo.Latency = uint(time.Now().UnixMilli()-t) / 2 - } + deviceInfo := val.(*modules.Device) + deviceInfo.Latency = uint(time.Now().UnixMilli()-t) / 2 } }, uuid, trigger, 3*time.Second) } @@ -108,7 +107,7 @@ func WSHealthCheck(container *melody.Melody) { queue = append(queue, s) return true } - if timestamp-lastPack > MaxInterval { + if timestamp-lastPack > maxIdleSeconds { queue = append(queue, s) } return true @@ -119,26 +118,71 @@ func WSHealthCheck(container *melody.Melody) { } } -func CheckClientReq(ctx *gin.Context, cb func(*melody.Session)) bool { +func GetRemoteAddr(ctx *gin.Context) string { + if remote, ok := ctx.RemoteIP(); ok { + if remote.IsLoopback() { + forwarded := ctx.GetHeader(`X-Forwarded-For`) + if len(forwarded) > 0 { + return forwarded + } + realIP := ctx.GetHeader(`X-Real-IP`) + if len(realIP) > 0 { + return realIP + } + } else { + if ip := remote.To4(); ip != nil { + return ip.String() + } + if ip := remote.To16(); ip != nil { + return ip.String() + } + } + } + + remote := net.ParseIP(ctx.Request.RemoteAddr) + if remote != nil { + if remote.IsLoopback() { + forwarded := ctx.GetHeader(`X-Forwarded-For`) + if len(forwarded) > 0 { + return forwarded + } + realIP := ctx.GetHeader(`X-Real-IP`) + if len(realIP) > 0 { + return realIP + } + } else { + if ip := remote.To4(); ip != nil { + return ip.String() + } + if ip := remote.To16(); ip != nil { + return ip.String() + } + } + } + addr := ctx.Request.RemoteAddr + if pos := strings.LastIndex(addr, `:`); pos > -1 { + return strings.Trim(addr[:pos], `[]`) + } + return addr +} + +func CheckClientReq(ctx *gin.Context) *melody.Session { secret, err := hex.DecodeString(ctx.GetHeader(`Secret`)) if err != nil || len(secret) != 32 { - return false + return nil } - find := false + var result *melody.Session = nil Melody.IterSessions(func(uuid string, s *melody.Session) bool { if val, ok := s.Get(`Secret`); ok { // Check if there's a connection matches this secret. if b, ok := val.([]byte); ok && bytes.Equal(b, secret) { - find = true - if cb != nil { - cb(s) - } + result = s return false } } return true }) - return find + return result } func CheckDevice(deviceID, connUUID string) (string, bool) { diff --git a/server/common/event.go b/server/common/event.go index b2337fc..2f9e517 100644 --- a/server/common/event.go +++ b/server/common/event.go @@ -7,14 +7,15 @@ import ( "time" ) +type EventCallback func(modules.Packet, *melody.Session) type event struct { connection string callback EventCallback - channel chan bool + finish chan bool + remove chan bool } -type EventCallback func(modules.Packet, *melody.Session) -var eventTable = cmap.New() +var events = cmap.New() // CallEvent tries to call the callback with the given uuid // after that, it will notify the caller via the channel @@ -22,7 +23,7 @@ func CallEvent(pack modules.Packet, session *melody.Session) { if len(pack.Event) == 0 { return } - v, ok := eventTable.Get(pack.Event) + v, ok := events.Get(pack.Event) if !ok { return } @@ -31,12 +32,8 @@ func CallEvent(pack modules.Packet, session *melody.Session) { return } ev.callback(pack, session) - if ev.channel != nil { - defer close(ev.channel) - select { - case ev.channel <- true: - default: - } + if ev.finish != nil { + ev.finish <- true } } @@ -44,17 +41,21 @@ func CallEvent(pack modules.Packet, session *melody.Session) { // can call back the event with the given event trigger. // Event trigger should be uuid to make every event unique. func AddEventOnce(fn EventCallback, connUUID, trigger string, timeout time.Duration) bool { - done := make(chan bool) ev := &event{ connection: connUUID, callback: fn, - channel: done, + finish: make(chan bool), + remove: make(chan bool), } - eventTable.Set(trigger, ev) - defer eventTable.Remove(trigger) + events.Set(trigger, ev) + defer events.Remove(trigger) + defer close(ev.finish) + defer close(ev.remove) select { - case <-done: - return true + case ok := <-ev.finish: + return ok + case ok := <-ev.remove: + return ok case <-time.After(timeout): return false } @@ -66,17 +67,26 @@ func AddEvent(fn EventCallback, connUUID, trigger string) { ev := &event{ connection: connUUID, callback: fn, - channel: nil, } - eventTable.Set(trigger, ev) + events.Set(trigger, ev) } // RemoveEvent deletes the event with the given event trigger. -func RemoveEvent(trigger string) { - eventTable.Remove(trigger) +// The ok will be returned to caller if the event is temp (only once). +func RemoveEvent(trigger string, ok ...bool) { + v, found := events.Get(trigger) + if !found { + return + } + events.Remove(trigger) + if ev := v.(*event); ev.remove != nil { + if len(ok) > 0 { + ev.remove <- ok[0] + } + } } // HasEvent returns if the event exists. func HasEvent(trigger string) bool { - return eventTable.Has(trigger) + return events.Has(trigger) } diff --git a/server/handler/bridge.go b/server/handler/bridge.go new file mode 100644 index 0000000..f3182a7 --- /dev/null +++ b/server/handler/bridge.go @@ -0,0 +1,182 @@ +package handler + +import ( + "Spark/modules" + "Spark/utils/cmap" + "github.com/gin-gonic/gin" + "github.com/kataras/golog" + "io" + "net/http" + "sync" + "time" +) + +// Bridge is a utility that handles the binary flow from the client +// to the browser or flow from the browser to the client. + +type bridge struct { + creation int64 + using bool + uuid string + lock *sync.Mutex + dest *gin.Context + src *gin.Context + ext interface{} + OnPull func(bridge *bridge) + OnPush func(bridge *bridge) + OnFinish func(bridge *bridge) +} + +var bridges = cmap.New() + +func init() { + go func() { + for now := range time.NewTicker(10 * time.Second).C { + var queue []*bridge + bridges.IterCb(func(k string, v interface{}) bool { + b := v.(*bridge) + if b.creation < now.Unix()-60 && !b.using { + queue = append(queue, b) + } + return true + }) + for _, b := range queue { + bridges.Remove(b.uuid) + b.lock.Lock() + if b.src != nil && b.src.Request.Body != nil { + b.src.Request.Body.Close() + } + b.src = nil + b.dest = nil + b.lock.Unlock() + b = nil + } + } + }() +} + +func checkBridge(ctx *gin.Context) *bridge { + var form struct { + Bridge string `json:"bridge" yaml:"bridge" form:"bridge" binding:"required"` + } + if err := ctx.ShouldBind(&form); err != nil { + golog.Error(err) + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) + return nil + } + val, ok := bridges.Get(form.Bridge) + if !ok { + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidBridgeID}`}) + return nil + } + return val.(*bridge) +} + +func bridgePush(ctx *gin.Context) { + bridge := checkBridge(ctx) + if bridge == nil { + return + } + bridge.lock.Lock() + if bridge.using || (bridge.src != nil && bridge.dest != nil) { + bridge.lock.Unlock() + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: 1, Msg: `${i18n|bridgeAlreadyInUse}`}) + return + } + bridge.src = ctx + bridge.using = true + bridge.lock.Unlock() + if bridge.OnPush != nil { + bridge.OnPush(bridge) + } + if bridge.dest != nil && bridge.dest.Writer != nil { + io.Copy(bridge.dest.Writer, bridge.src.Request.Body) + bridge.src.Status(http.StatusOK) + if bridge.OnFinish != nil { + bridge.OnFinish(bridge) + } + removeBridge(bridge.uuid) + bridge = nil + } +} + +func bridgePull(ctx *gin.Context) { + bridge := checkBridge(ctx) + if bridge == nil { + return + } + bridge.lock.Lock() + if bridge.using || (bridge.src != nil && bridge.dest != nil) { + bridge.lock.Unlock() + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: 1, Msg: `${i18n|bridgeAlreadyInUse}`}) + return + } + bridge.dest = ctx + bridge.using = true + bridge.lock.Unlock() + if bridge.OnPull != nil { + bridge.OnPull(bridge) + } + if bridge.src != nil && bridge.src.Request.Body != nil { + io.Copy(bridge.dest.Writer, bridge.src.Request.Body) + bridge.src.Status(http.StatusOK) + if bridge.OnFinish != nil { + bridge.OnFinish(bridge) + } + removeBridge(bridge.uuid) + bridge = nil + } +} + +func addBridge(ext interface{}, uuid string) *bridge { + bridge := &bridge{ + creation: time.Now().Unix(), + uuid: uuid, + using: false, + lock: &sync.Mutex{}, + ext: ext, + } + bridges.Set(uuid, bridge) + return bridge +} + +func addBridgeWithSrc(ext interface{}, uuid string, src *gin.Context) *bridge { + bridge := &bridge{ + creation: time.Now().Unix(), + uuid: uuid, + using: false, + lock: &sync.Mutex{}, + ext: ext, + src: src, + } + bridges.Set(uuid, bridge) + return bridge +} + +func addBridgeWithDest(ext interface{}, uuid string, dest *gin.Context) *bridge { + bridge := &bridge{ + creation: time.Now().Unix(), + uuid: uuid, + using: false, + lock: &sync.Mutex{}, + ext: ext, + dest: dest, + } + bridges.Set(uuid, bridge) + return bridge +} + +func removeBridge(uuid string) { + val, ok := bridges.Get(uuid) + if !ok { + return + } + bridges.Remove(uuid) + b := val.(*bridge) + if b.src != nil && b.src.Request.Body != nil { + b.src.Request.Body.Close() + } + b.src = nil + b.dest = nil + b = nil +} diff --git a/server/handler/file.go b/server/handler/file.go index 426aeb6..c69623b 100644 --- a/server/handler/file.go +++ b/server/handler/file.go @@ -7,7 +7,6 @@ import ( "Spark/utils/melody" "fmt" "github.com/gin-gonic/gin" - "io/ioutil" "net/http" "net/url" "path" @@ -27,7 +26,7 @@ func removeDeviceFile(ctx *gin.Context) { return } trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Code: 0, Act: `removeFile`, Data: gin.H{`file`: form.File}, Event: trigger}, target) + common.SendPackByUUID(modules.Packet{Code: 0, Act: `removeFile`, Data: gin.H{`file`: form.File}, Event: trigger}, target) ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { if p.Code != 0 { ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) @@ -50,7 +49,7 @@ func listDeviceFiles(ctx *gin.Context) { return } trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Act: `listFiles`, Data: gin.H{`path`: form.Path}, Event: trigger}, target) + common.SendPackByUUID(modules.Packet{Act: `listFiles`, Data: gin.H{`path`: form.Path}, Event: trigger}, target) ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { if p.Code != 0 { ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) @@ -73,12 +72,13 @@ func getDeviceFile(ctx *gin.Context) { if !ok { return } + bridgeID := utils.GetStrUUID() trigger := utils.GetStrUUID() var rangeStart, rangeEnd int64 var err error partial := false { - command := gin.H{`file`: form.File} + command := gin.H{`file`: form.File, `bridge`: bridgeID} rangeHeader := ctx.GetHeader(`Range`) if len(rangeHeader) > 6 { if rangeHeader[:6] != `bytes=` { @@ -112,76 +112,57 @@ func getDeviceFile(ctx *gin.Context) { command[`start`] = rangeStart partial = true } - common.SendPackUUID(modules.Packet{Code: 0, Act: `uploadFile`, Data: command, Event: trigger}, target) + common.SendPackByUUID(modules.Packet{Code: 0, Act: `uploadFile`, Data: command, Event: trigger}, target) } - wait := make(chan bool) called := false common.AddEvent(func(p modules.Packet, _ *melody.Session) { + wait <- false called = true + removeBridge(bridgeID) common.RemoveEvent(trigger) - if p.Code != 0 { - wait <- false - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) - return - } else { - val, ok := p.Data[`request`] - if !ok { - wait <- false - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: `${i18n|fileUploadFailed}`}) - return - } - req, ok := val.(*http.Request) - if !ok || req == nil || req.Body == nil { - wait <- false - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: `${i18n|fileUploadFailed}`}) - return - } - - if req.ContentLength > 0 { - ctx.Header(`Content-Length`, strconv.FormatInt(req.ContentLength, 10)) - } - ctx.Header(`Accept-Ranges`, `bytes`) - ctx.Header(`Content-Transfer-Encoding`, `binary`) - ctx.Header(`Content-Type`, `application/octet-stream`) - filename := ctx.GetHeader(`FileName`) - if len(filename) == 0 { - filename = path.Base(strings.ReplaceAll(form.File, `\`, `/`)) - } - filename = url.PathEscape(filename) - ctx.Header(`Content-Disposition`, `attachment; filename* = UTF-8''`+filename+`;`) + ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) + }, target, trigger) + instance := addBridgeWithDest(nil, bridgeID, ctx) + instance.OnPush = func(bridge *bridge) { + called = true + common.RemoveEvent(trigger) + src := bridge.src + if src.Request.ContentLength > 0 { + ctx.Header(`Content-Length`, strconv.FormatInt(src.Request.ContentLength, 10)) + } + ctx.Header(`Accept-Ranges`, `bytes`) + ctx.Header(`Content-Transfer-Encoding`, `binary`) + ctx.Header(`Content-Type`, `application/octet-stream`) + filename := src.GetHeader(`FileName`) + if len(filename) == 0 { + filename = path.Base(strings.ReplaceAll(form.File, `\`, `/`)) + } + filename = url.PathEscape(filename) + ctx.Header(`Content-Disposition`, `attachment; filename* = UTF-8''`+filename+`;`) - if partial { - if rangeEnd == 0 { - rangeEnd, err = strconv.ParseInt(req.Header.Get(`FileSize`), 10, 64) - if err == nil { - ctx.Header(`Content-Range`, fmt.Sprintf(`bytes %d-%d/%d`, rangeStart, rangeEnd-1, rangeEnd)) - } - } else { - ctx.Header(`Content-Range`, fmt.Sprintf(`bytes %d-%d/%v`, rangeStart, rangeEnd, req.Header.Get(`FileSize`))) + if partial { + if rangeEnd == 0 { + rangeEnd, err = strconv.ParseInt(src.GetHeader(`FileSize`), 10, 64) + if err == nil { + ctx.Header(`Content-Range`, fmt.Sprintf(`bytes %d-%d/%d`, rangeStart, rangeEnd-1, rangeEnd)) } - ctx.Status(http.StatusPartialContent) } else { - ctx.Status(http.StatusOK) - } - - for { - buffer := make([]byte, 8192) - n, err := req.Body.Read(buffer) - buffer = buffer[:n] - ctx.Writer.Write(buffer) - ctx.Writer.Flush() - if n == 0 || err != nil { - wait <- false - break - } + ctx.Header(`Content-Range`, fmt.Sprintf(`bytes %d-%d/%v`, rangeStart, rangeEnd, src.GetHeader(`FileSize`))) } + ctx.Status(http.StatusPartialContent) + } else { + ctx.Status(http.StatusOK) } - }, target, trigger) + } + instance.OnFinish = func(bridge *bridge) { + wait <- false + } select { case <-wait: case <-time.After(5 * time.Second): if !called { + removeBridge(bridgeID) common.RemoveEvent(trigger) ctx.JSON(http.StatusGatewayTimeout, modules.Packet{Code: 1, Msg: `${i18n|responseTimeout}`}) } else { @@ -190,36 +171,62 @@ func getDeviceFile(ctx *gin.Context) { } } -// putDeviceFile will be called by client. -// It will transfer binary stream from client to browser. -func putDeviceFile(ctx *gin.Context) { - original := ctx.Request.Body - ctx.Request.Body = ioutil.NopCloser(ctx.Request.Body) - - errMsg := ctx.GetHeader(`Error`) - trigger := ctx.GetHeader(`Trigger`) - if len(trigger) == 0 { - original.Close() - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) +// uploadToDevice handles file from browser +// and transfer it to device. +func uploadToDevice(ctx *gin.Context) { + var form struct { + Path string `json:"path" yaml:"path" form:"path" binding:"required"` + File string `json:"file" yaml:"file" form:"file" binding:"required"` + } + target, ok := checkForm(ctx, &form) + if !ok { return } - if len(errMsg) > 0 { - common.CallEvent(modules.Packet{ - Code: 1, - Msg: fmt.Sprintf(`${i18n|fileUploadFailed}: %v`, errMsg), - Event: trigger, - }, nil) - original.Close() + bridgeID := utils.GetStrUUID() + trigger := utils.GetStrUUID() + wait := make(chan bool) + called := false + common.AddEvent(func(p modules.Packet, _ *melody.Session) { + wait <- false + called = true + removeBridge(bridgeID) + common.RemoveEvent(trigger) + ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) + }, target, trigger) + instance := addBridgeWithSrc(nil, bridgeID, ctx) + instance.OnPull = func(bridge *bridge) { + called = true + common.RemoveEvent(trigger) + dest := bridge.dest + if ctx.Request.ContentLength > 0 { + dest.Header(`Content-Length`, strconv.FormatInt(ctx.Request.ContentLength, 10)) + } + dest.Header(`Accept-Ranges`, `none`) + dest.Header(`Content-Transfer-Encoding`, `binary`) + dest.Header(`Content-Type`, `application/octet-stream`) + filename := form.File + filename = url.PathEscape(filename) + dest.Header(`Content-Disposition`, `attachment; filename* = UTF-8''`+filename+`;`) + } + instance.OnFinish = func(bridge *bridge) { + wait <- false + } + common.SendPackByUUID(modules.Packet{Code: 0, Act: `fetchFile`, Data: gin.H{ + `path`: form.Path, + `file`: form.File, + `bridge`: bridgeID, + }, Event: trigger}, target) + select { + case <-wait: ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - return + case <-time.After(5 * time.Second): + if !called { + removeBridge(bridgeID) + common.RemoveEvent(trigger) + ctx.JSON(http.StatusGatewayTimeout, modules.Packet{Code: 1, Msg: `${i18n|responseTimeout}`}) + } else { + <-wait + ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) + } } - common.CallEvent(modules.Packet{ - Code: 0, - Data: map[string]interface{}{ - `request`: ctx.Request, - }, - Event: trigger, - }, nil) - original.Close() - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) } diff --git a/server/handler/handler.go b/server/handler/handler.go index 35891f9..b09c109 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -3,30 +3,23 @@ package handler import ( "Spark/modules" "Spark/server/common" - "Spark/server/config" - "Spark/utils" - "Spark/utils/melody" - "bytes" - "fmt" "github.com/gin-gonic/gin" - "github.com/kataras/golog" "net/http" - "strconv" - "time" ) -// APIRouter will initialize http and websocket routers. -func APIRouter(ctx *gin.RouterGroup, auth gin.HandlerFunc) { - ctx.PUT(`/device/screenshot/put`, putScreenshot) // Client, upload screenshot and forward to browser. - ctx.PUT(`/device/file/put`, putDeviceFile) // Client, to upload file and forward to browser. - ctx.Any(`/device/terminal`, initTerminal) // Browser, handle websocket events for web terminal. - ctx.Any(`/client/update`, checkUpdate) // Client, for update. +// InitRouter will initialize http and websocket routers. +func InitRouter(ctx *gin.RouterGroup, auth gin.HandlerFunc) { + ctx.Any(`/bridge/push`, bridgePush) + ctx.Any(`/bridge/pull`, bridgePull) + ctx.Any(`/device/terminal`, initTerminal) // Browser, handle websocket events for web terminal. + ctx.Any(`/client/update`, checkUpdate) // Client, for update. group := ctx.Group(`/`, auth) { group.POST(`/device/screenshot/get`, getScreenshot) group.POST(`/device/process/list`, listDeviceProcesses) group.POST(`/device/process/kill`, killDeviceProcess) group.POST(`/device/file/remove`, removeDeviceFile) + group.POST(`/device/file/upload`, uploadToDevice) group.POST(`/device/file/list`, listDeviceFiles) group.POST(`/device/file/get`, getDeviceFile) group.POST(`/device/list`, getDevices) @@ -36,126 +29,6 @@ func APIRouter(ctx *gin.RouterGroup, auth gin.HandlerFunc) { } } -// checkUpdate will check if client need update and return latest client if so. -func checkUpdate(ctx *gin.Context) { - var form struct { - OS string `form:"os" binding:"required"` - Arch string `form:"arch" binding:"required"` - Commit string `form:"commit" binding:"required"` - } - if err := ctx.ShouldBind(&form); err != nil { - golog.Error(err) - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) - return - } - if form.Commit == config.COMMIT { - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - return - } - tpl, err := common.BuiltFS.Open(fmt.Sprintf(`/%v_%v`, form.OS, form.Arch)) - if err != nil { - ctx.JSON(http.StatusNotFound, modules.Packet{Code: 1, Msg: `${i18n|osOrArchNotPrebuilt}`}) - return - } - - const MaxBodySize = 384 // This is size of client config buffer. - if ctx.Request.ContentLength > MaxBodySize { - ctx.JSON(http.StatusRequestEntityTooLarge, modules.Packet{Code: 1}) - return - } - body, err := ctx.GetRawData() - if err != nil { - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: 1}) - return - } - auth := common.CheckClientReq(ctx, nil) - if !auth { - ctx.JSON(http.StatusUnauthorized, modules.Packet{Code: 1}) - } - - ctx.Header(`Accept-Ranges`, `none`) - ctx.Header(`Content-Transfer-Encoding`, `binary`) - ctx.Header(`Content-Type`, `application/octet-stream`) - if stat, err := tpl.Stat(); err == nil { - ctx.Header(`Content-Length`, strconv.FormatInt(stat.Size(), 10)) - } - cfgBuffer := bytes.Repeat([]byte{'\x19'}, 384) - prevBuffer := make([]byte, 0) - for { - thisBuffer := make([]byte, 1024) - n, err := tpl.Read(thisBuffer) - thisBuffer = thisBuffer[:n] - tempBuffer := append(prevBuffer, thisBuffer...) - bufIndex := bytes.Index(tempBuffer, cfgBuffer) - if bufIndex > -1 { - tempBuffer = bytes.Replace(tempBuffer, cfgBuffer, body, -1) - } - ctx.Writer.Write(tempBuffer[:len(prevBuffer)]) - prevBuffer = tempBuffer[len(prevBuffer):] - if err != nil { - break - } - } - if len(prevBuffer) > 0 { - ctx.Writer.Write(prevBuffer) - prevBuffer = []byte{} - } -} - -// getDevices will return all info about all clients. -func getDevices(ctx *gin.Context) { - devices := make(map[string]modules.Device) - common.Devices.IterCb(func(uuid string, v interface{}) bool { - device, ok := v.(*modules.Device) - if ok { - devices[uuid] = *device - } - return true - }) - ctx.JSON(http.StatusOK, modules.CommonPack{Code: 0, Data: devices}) -} - -// callDevice will call client with command from browser. -func callDevice(ctx *gin.Context) { - act := ctx.Param(`act`) - if len(act) == 0 { - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) - return - } - { - actions := []string{`lock`, `logoff`, `hibernate`, `suspend`, `restart`, `shutdown`, `offline`} - ok := false - for _, v := range actions { - if v == act { - ok = true - break - } - } - if !ok { - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) - return - } - } - connUUID, ok := checkForm(ctx, nil) - if !ok { - return - } - trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Act: act, Event: trigger}, connUUID) - ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { - if p.Code != 0 { - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) - } else { - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - } - }, connUUID, trigger, 5*time.Second) - if !ok { - //This means the client is offline. - //So we take this as a success. - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - } -} - // checkForm checks if the form contains the required fields. // Every request must contain connection UUID or device ID. func checkForm(ctx *gin.Context, form interface{}) (string, bool) { @@ -178,75 +51,3 @@ func checkForm(ctx *gin.Context, form interface{}) (string, bool) { } return connUUID, true } - -// WSDevice handles events about device info. -// Such as websocket handshake and update device info. -func WSDevice(data []byte, session *melody.Session) error { - var pack struct { - Code int `json:"code,omitempty"` - Act string `json:"act,omitempty"` - Msg string `json:"msg,omitempty"` - Device modules.Device `json:"data"` - } - err := utils.JSON.Unmarshal(data, &pack) - if err != nil { - golog.Error(err) - session.Close() - return err - } - - addr, ok := session.Get(`Address`) - if ok { - pack.Device.WAN = addr.(string) - } else { - pack.Device.WAN = `Unknown` - } - - if pack.Act == `report` { - // Check if this device has already connected. - // If so, then find the session and let client quit. - // This will keep only one connection remained per device. - exSession := `` - common.Devices.IterCb(func(uuid string, v interface{}) bool { - device := v.(*modules.Device) - if device.ID == pack.Device.ID { - exSession = uuid - target, ok := common.Melody.GetSessionByUUID(uuid) - if ok { - common.SendPack(modules.Packet{Act: `offline`}, target) - target.Close() - } - return false - } - return true - }) - if len(exSession) > 0 { - common.Devices.Remove(exSession) - } - } - common.SendPack(modules.Packet{Code: 0}, session) - - { - val, ok := common.Devices.Get(session.UUID) - if ok { - deviceInfo, ok := val.(*modules.Device) - if ok { - deviceInfo.CPU = pack.Device.CPU - deviceInfo.RAM = pack.Device.RAM - deviceInfo.Net = pack.Device.Net - if pack.Device.Disk.Total > 0 { - deviceInfo.Disk = pack.Device.Disk - } - deviceInfo.Uptime = pack.Device.Uptime - return nil - } - } - common.Devices.Set(session.UUID, &pack.Device) - } - return nil -} - -// WSRouter handles all packets from client. -func WSRouter(pack modules.Packet, session *melody.Session) { - common.CallEvent(pack, session) -} diff --git a/server/handler/process.go b/server/handler/process.go index f347df1..f30896f 100644 --- a/server/handler/process.go +++ b/server/handler/process.go @@ -18,7 +18,7 @@ func listDeviceProcesses(ctx *gin.Context) { return } trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Act: `listProcesses`, Event: trigger}, connUUID) + common.SendPackByUUID(modules.Packet{Act: `listProcesses`, Event: trigger}, connUUID) ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { if p.Code != 0 { ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) @@ -42,7 +42,7 @@ func killDeviceProcess(ctx *gin.Context) { return } trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Code: 0, Act: `killProcess`, Data: gin.H{`pid`: strconv.FormatInt(int64(form.Pid), 10)}, Event: trigger}, target) + common.SendPackByUUID(modules.Packet{Code: 0, Act: `killProcess`, Data: gin.H{`pid`: strconv.FormatInt(int64(form.Pid), 10)}, Event: trigger}, target) ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { if p.Code != 0 { ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) diff --git a/server/handler/screenshot.go b/server/handler/screenshot.go index 5bc36fc..fc6c84f 100644 --- a/server/handler/screenshot.go +++ b/server/handler/screenshot.go @@ -5,82 +5,47 @@ import ( "Spark/server/common" "Spark/utils" "Spark/utils/melody" - "fmt" "github.com/gin-gonic/gin" "net/http" "time" ) -// putScreenshot will forward screenshot image from client to browser. -func putScreenshot(ctx *gin.Context) { - errMsg := ctx.GetHeader(`Error`) - trigger := ctx.GetHeader(`Trigger`) - if len(trigger) == 0 { - ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) - return - } - if len(errMsg) > 0 { - common.CallEvent(modules.Packet{ - Code: 1, - Msg: fmt.Sprintf(`${i18n|screenshotFailed}: %v`, errMsg), - Event: trigger, - }, nil) - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - return - } - data, err := ctx.GetRawData() - if len(data) == 0 { - msg := `` - if err != nil { - msg = fmt.Sprintf(`${i18n|screenshotObtainFailed}: %v`, err) - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: msg}) - } else { - msg = `${i18n|screenshotFailed}: ${i18n|unknownError}` - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) - } - common.CallEvent(modules.Packet{ - Code: 1, - Msg: msg, - Event: trigger, - }, nil) - return - } - common.CallEvent(modules.Packet{ - Code: 0, - Data: map[string]interface{}{ - `screenshot`: data, - }, - Event: trigger, - }, nil) - ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) -} - // getScreenshot will call client to screenshot. func getScreenshot(ctx *gin.Context) { target, ok := checkForm(ctx, nil) if !ok { return } + bridgeID := utils.GetStrUUID() trigger := utils.GetStrUUID() - common.SendPackUUID(modules.Packet{Code: 0, Act: `screenshot`, Event: trigger}, target) - ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { - if p.Code != 0 { - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) + wait := make(chan bool) + called := false + common.SendPackByUUID(modules.Packet{Code: 0, Act: `screenshot`, Data: gin.H{`bridge`: bridgeID}, Event: trigger}, target) + common.AddEvent(func(p modules.Packet, _ *melody.Session) { + wait <- false + called = true + removeBridge(bridgeID) + common.RemoveEvent(trigger) + ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) + }, target, trigger) + instance := addBridgeWithDest(nil, bridgeID, ctx) + instance.OnPush = func(bridge *bridge) { + called = true + common.RemoveEvent(trigger) + ctx.Header(`Content-Type`, `image/png`) + } + instance.OnFinish = func(bridge *bridge) { + wait <- false + } + select { + case <-wait: + case <-time.After(5 * time.Second): + if !called { + removeBridge(bridgeID) + common.RemoveEvent(trigger) + ctx.JSON(http.StatusGatewayTimeout, modules.Packet{Code: 1, Msg: `${i18n|responseTimeout}`}) } else { - data, ok := p.Data[`screenshot`] - if !ok { - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: `${i18n|screenshotObtainFailed}`}) - return - } - screenshot, ok := data.([]byte) - if !ok { - ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: `${i18n|screenshotObtainFailed}`}) - return - } - ctx.Data(200, `image/png`, screenshot) + <-wait } - }, target, trigger, 5*time.Second) - if !ok { - ctx.JSON(http.StatusGatewayTimeout, modules.Packet{Code: 1, Msg: `${i18n|responseTimeout}`}) } } diff --git a/server/handler/terminal.go b/server/handler/terminal.go index f031ec7..5e97127 100644 --- a/server/handler/terminal.go +++ b/server/handler/terminal.go @@ -15,18 +15,18 @@ import ( ) type terminal struct { + uuid string + event string + device string session *melody.Session deviceConn *melody.Session - device string - termUUID string - eventUUID string } var terminals = cmap.New() -var wsTerminals = melody.New() +var wsSessions = melody.New() func init() { - wsTerminals.HandleConnect(func(session *melody.Session) { + wsSessions.HandleConnect(func(session *melody.Session) { device, ok := session.Get(`Device`) if !ok { simpleSendPack(modules.Packet{Act: `warn`, Msg: `${i18n|terminalSessionCreationFailed}`}, session) @@ -59,11 +59,11 @@ func init() { } eventUUID := utils.GetStrUUID() terminal := &terminal{ + uuid: termUUID, + event: eventUUID, + device: device.(string), session: session, deviceConn: deviceConn, - device: device.(string), - termUUID: termUUID, - eventUUID: eventUUID, } terminals.Set(termUUID, terminal) common.AddEvent(eventWrapper(terminal), connUUID, eventUUID) @@ -71,9 +71,9 @@ func init() { `terminal`: termUUID, }, Event: eventUUID}, deviceConn) }) - wsTerminals.HandleMessage(onMessage) - wsTerminals.HandleMessageBinary(onMessage) - wsTerminals.HandleDisconnect(func(session *melody.Session) { + wsSessions.HandleMessage(onMessage) + wsSessions.HandleMessageBinary(onMessage) + wsSessions.HandleDisconnect(func(session *melody.Session) { val, ok := session.Get(`Terminal`) if !ok { return @@ -86,17 +86,14 @@ func init() { if !ok { return } - terminal, ok := val.(*terminal) - if !ok { - return - } + terminal := val.(*terminal) common.SendPack(modules.Packet{Act: `killTerminal`, Data: gin.H{ - `terminal`: terminal.termUUID, - }, Event: terminal.eventUUID}, terminal.deviceConn) + `terminal`: termUUID, + }, Event: terminal.event}, terminal.deviceConn) terminals.Remove(termUUID) - common.RemoveEvent(terminal.eventUUID) + common.RemoveEvent(terminal.event) }) - go common.WSHealthCheck(wsTerminals) + go common.HealthCheckWS(300, wsSessions) } // initTerminal handles terminal websocket handshake event @@ -125,7 +122,7 @@ func initTerminal(ctx *gin.Context) { return } - wsTerminals.HandleRequestWithKeys(ctx.Writer, ctx.Request, nil, gin.H{ + wsSessions.HandleRequestWithKeys(ctx.Writer, ctx.Request, nil, gin.H{ `Secret`: secret, `Device`: device, `LastPack`: time.Now().Unix(), @@ -146,8 +143,8 @@ func eventWrapper(terminal *terminal) common.EventCallback { msg += `${i18n|unknownError}` } simpleSendPack(modules.Packet{Act: `warn`, Msg: msg}, terminal.session) - terminals.Remove(terminal.termUUID) - common.RemoveEvent(terminal.eventUUID) + terminals.Remove(terminal.uuid) + common.RemoveEvent(terminal.event) terminal.session.Close() } return @@ -158,8 +155,8 @@ func eventWrapper(terminal *terminal) common.EventCallback { msg = pack.Msg } simpleSendPack(modules.Packet{Act: `warn`, Msg: msg}, terminal.session) - terminals.Remove(terminal.termUUID) - common.RemoveEvent(terminal.eventUUID) + terminals.Remove(terminal.uuid) + common.RemoveEvent(terminal.event) terminal.session.Close() return } @@ -246,18 +243,15 @@ func onMessage(session *melody.Session, data []byte) { if !ok { return } - terminal, ok := val.(*terminal) - if !ok { - return - } + terminal := val.(*terminal) if pack.Data == nil { return } if input, ok := pack.Data[`input`]; ok { common.SendPack(modules.Packet{Act: `inputTerminal`, Data: gin.H{ `input`: input, - `terminal`: terminal.termUUID, - }, Event: terminal.eventUUID}, terminal.deviceConn) + `terminal`: terminal.uuid, + }, Event: terminal.event}, terminal.deviceConn) } } if pack.Act == `resizeTerminal` { @@ -273,10 +267,7 @@ func onMessage(session *melody.Session, data []byte) { if !ok { return } - terminal, ok := val.(*terminal) - if !ok { - return - } + terminal := val.(*terminal) if pack.Data == nil { return } @@ -285,26 +276,44 @@ func onMessage(session *melody.Session, data []byte) { common.SendPack(modules.Packet{Act: `resizeTerminal`, Data: gin.H{ `width`: width, `height`: height, - `terminal`: terminal.termUUID, - }, Event: terminal.eventUUID}, terminal.deviceConn) + `terminal`: terminal.uuid, + }, Event: terminal.event}, terminal.deviceConn) } } } + if pack.Act == `killTerminal` { + val, ok := session.Get(`Terminal`) + if !ok { + return + } + termUUID, ok := val.(string) + if !ok { + return + } + val, ok = terminals.Get(termUUID) + if !ok { + return + } + terminal := val.(*terminal) + if pack.Data == nil { + return + } + common.SendPack(modules.Packet{Act: `killTerminal`, Data: gin.H{ + `terminal`: termUUID, + }, Event: terminal.event}, terminal.deviceConn) + } } func CloseSessionsByDevice(deviceID string) { var queue []string terminals.IterCb(func(key string, val interface{}) bool { - terminal, ok := val.(*terminal) - if !ok { - return false - } + terminal := val.(*terminal) if terminal.device == deviceID { - common.RemoveEvent(terminal.eventUUID) + common.RemoveEvent(terminal.event) terminal.session.Close() queue = append(queue, key) } - return false + return true }) for _, key := range queue { diff --git a/server/handler/utility.go b/server/handler/utility.go new file mode 100644 index 0000000..ea82f34 --- /dev/null +++ b/server/handler/utility.go @@ -0,0 +1,200 @@ +package handler + +import ( + "Spark/modules" + "Spark/server/common" + "Spark/server/config" + "Spark/utils" + "Spark/utils/melody" + "bytes" + "fmt" + "github.com/gin-gonic/gin" + "github.com/kataras/golog" + "net/http" + "strconv" + "time" +) + +// OnDevicePack handles events about device info. +// Such as websocket handshake and update device info. +func OnDevicePack(data []byte, session *melody.Session) error { + var pack struct { + Code int `json:"code,omitempty"` + Act string `json:"act,omitempty"` + Msg string `json:"msg,omitempty"` + Device modules.Device `json:"data"` + } + err := utils.JSON.Unmarshal(data, &pack) + if err != nil { + golog.Error(err) + session.Close() + return err + } + + addr, ok := session.Get(`Address`) + if ok { + pack.Device.WAN = addr.(string) + } else { + pack.Device.WAN = `Unknown` + } + + if pack.Act == `report` { + // Check if this device has already connected. + // If so, then find the session and let client quit. + // This will keep only one connection remained per device. + exSession := `` + common.Devices.IterCb(func(uuid string, v interface{}) bool { + device := v.(*modules.Device) + if device.ID == pack.Device.ID { + exSession = uuid + target, ok := common.Melody.GetSessionByUUID(uuid) + if ok { + common.SendPack(modules.Packet{Act: `offline`}, target) + target.Close() + } + return false + } + return true + }) + if len(exSession) > 0 { + common.Devices.Remove(exSession) + } + } + common.SendPack(modules.Packet{Code: 0}, session) + + { + val, ok := common.Devices.Get(session.UUID) + if ok { + deviceInfo := val.(*modules.Device) + deviceInfo.CPU = pack.Device.CPU + deviceInfo.RAM = pack.Device.RAM + deviceInfo.Net = pack.Device.Net + if pack.Device.Disk.Total > 0 { + deviceInfo.Disk = pack.Device.Disk + } + deviceInfo.Uptime = pack.Device.Uptime + return nil + } + common.Devices.Set(session.UUID, &pack.Device) + } + return nil +} + +// checkUpdate will check if client need update and return latest client if so. +func checkUpdate(ctx *gin.Context) { + var form struct { + OS string `form:"os" binding:"required"` + Arch string `form:"arch" binding:"required"` + Commit string `form:"commit" binding:"required"` + } + if err := ctx.ShouldBind(&form); err != nil { + golog.Error(err) + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) + return + } + if form.Commit == config.COMMIT { + ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) + return + } + tpl, err := common.BuiltFS.Open(fmt.Sprintf(`/%v_%v`, form.OS, form.Arch)) + if err != nil { + ctx.JSON(http.StatusNotFound, modules.Packet{Code: 1, Msg: `${i18n|osOrArchNotPrebuilt}`}) + return + } + + const MaxBodySize = 384 // This is size of client config buffer. + if ctx.Request.ContentLength > MaxBodySize { + ctx.JSON(http.StatusRequestEntityTooLarge, modules.Packet{Code: 1}) + return + } + body, err := ctx.GetRawData() + if err != nil { + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: 1}) + return + } + session := common.CheckClientReq(ctx) + if session == nil { + ctx.JSON(http.StatusUnauthorized, modules.Packet{Code: 1}) + return + } + + ctx.Header(`Accept-Ranges`, `none`) + ctx.Header(`Content-Transfer-Encoding`, `binary`) + ctx.Header(`Content-Type`, `application/octet-stream`) + if stat, err := tpl.Stat(); err == nil { + ctx.Header(`Content-Length`, strconv.FormatInt(stat.Size(), 10)) + } + cfgBuffer := bytes.Repeat([]byte{'\x19'}, 384) + prevBuffer := make([]byte, 0) + for { + thisBuffer := make([]byte, 1024) + n, err := tpl.Read(thisBuffer) + thisBuffer = thisBuffer[:n] + tempBuffer := append(prevBuffer, thisBuffer...) + bufIndex := bytes.Index(tempBuffer, cfgBuffer) + if bufIndex > -1 { + tempBuffer = bytes.Replace(tempBuffer, cfgBuffer, body, -1) + } + ctx.Writer.Write(tempBuffer[:len(prevBuffer)]) + prevBuffer = tempBuffer[len(prevBuffer):] + if err != nil { + break + } + } + if len(prevBuffer) > 0 { + ctx.Writer.Write(prevBuffer) + prevBuffer = []byte{} + } +} + +// getDevices will return all info about all clients. +func getDevices(ctx *gin.Context) { + devices := map[string]interface{}{} + common.Devices.IterCb(func(uuid string, v interface{}) bool { + device := v.(*modules.Device) + devices[uuid] = *device + return true + }) + ctx.JSON(http.StatusOK, modules.Packet{Code: 0, Data: devices}) +} + +// callDevice will call client with command from browser. +func callDevice(ctx *gin.Context) { + act := ctx.Param(`act`) + if len(act) == 0 { + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) + return + } + { + actions := []string{`lock`, `logoff`, `hibernate`, `suspend`, `restart`, `shutdown`, `offline`} + ok := false + for _, v := range actions { + if v == act { + ok = true + break + } + } + if !ok { + ctx.JSON(http.StatusBadRequest, modules.Packet{Code: -1, Msg: `${i18n|invalidParameter}`}) + return + } + } + connUUID, ok := checkForm(ctx, nil) + if !ok { + return + } + trigger := utils.GetStrUUID() + common.SendPackByUUID(modules.Packet{Act: act, Event: trigger}, connUUID) + ok = common.AddEventOnce(func(p modules.Packet, _ *melody.Session) { + if p.Code != 0 { + ctx.JSON(http.StatusInternalServerError, modules.Packet{Code: 1, Msg: p.Msg}) + } else { + ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) + } + }, connUUID, trigger, 5*time.Second) + if !ok { + //This means the client is offline. + //So we take this as a success. + ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) + } +} diff --git a/server/main.go b/server/main.go index d23a333..ab32c06 100644 --- a/server/main.go +++ b/server/main.go @@ -7,10 +7,8 @@ import ( "Spark/server/handler" "bytes" "context" - "net" "os" "os/signal" - "strings" "syscall" "time" @@ -61,19 +59,21 @@ func main() { return } app := gin.New() - auth := gin.BasicAuth(config.Config.Auth) - handler.APIRouter(app.Group(`/api`), auth) - app.Any(`/ws`, wsHandshake) - app.NoRoute(auth, func(ctx *gin.Context) { - http.FileServer(webFS).ServeHTTP(ctx.Writer, ctx.Request) - }) + { + auth := gin.BasicAuth(config.Config.Auth) + handler.InitRouter(app.Group(`/api`), auth) + app.Any(`/ws`, wsHandshake) + app.NoRoute(auth, func(ctx *gin.Context) { + http.FileServer(webFS).ServeHTTP(ctx.Writer, ctx.Request) + }) + } common.Melody.Config.MaxMessageSize = 1024 common.Melody.HandleConnect(wsOnConnect) common.Melody.HandleMessage(wsOnMessage) common.Melody.HandleMessageBinary(wsOnMessageBinary) common.Melody.HandleDisconnect(wsOnDisconnect) - go common.WSHealthCheck(common.Melody) + go common.HealthCheckWS(90, common.Melody) srv := &http.Server{Addr: config.Config.Listen, Handler: app} go func() { @@ -114,7 +114,7 @@ func wsHandshake(ctx *gin.Context) { }, gin.H{ `Secret`: secret, `LastPack`: time.Now().Unix(), - `Address`: getRemoteAddr(ctx), + `Address`: common.GetRemoteAddr(ctx), }) if err != nil { golog.Error(err) @@ -134,12 +134,12 @@ func wsHandshake(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, modules.Packet{Code: 1}) return } - auth := common.CheckClientReq(ctx, func(s *melody.Session) { - wsOnMessageBinary(s, body) - }) - if !auth { + session := common.CheckClientReq(ctx) + if session == nil { ctx.JSON(http.StatusUnauthorized, modules.Packet{Code: 1}) + return } + wsOnMessageBinary(session, body) ctx.JSON(http.StatusOK, modules.Packet{Code: 0}) } } @@ -161,71 +161,22 @@ func wsOnMessageBinary(session *melody.Session, data []byte) { } if pack.Act == `report` || pack.Act == `setDevice` { session.Set(`LastPack`, time.Now().Unix()) - handler.WSDevice(data, session) + handler.OnDevicePack(data, session) return } if !common.Devices.Has(session.UUID) { session.Close() return } - handler.WSRouter(pack, session) + common.CallEvent(pack, session) session.Set(`LastPack`, time.Now().Unix()) } func wsOnDisconnect(session *melody.Session) { if val, ok := common.Devices.Get(session.UUID); ok { - if deviceInfo, ok := val.(*modules.Device); ok { - handler.CloseSessionsByDevice(deviceInfo.ID) - } + deviceInfo := val.(*modules.Device) + handler.CloseSessionsByDevice(deviceInfo.ID) } common.Devices.Remove(session.UUID) } - -func getRemoteAddr(ctx *gin.Context) string { - if remote, ok := ctx.RemoteIP(); ok { - if remote.IsLoopback() { - forwarded := ctx.GetHeader(`X-Forwarded-For`) - if len(forwarded) > 0 { - return forwarded - } - realIP := ctx.GetHeader(`X-Real-IP`) - if len(realIP) > 0 { - return realIP - } - } else { - if ip := remote.To4(); ip != nil { - return ip.String() - } - if ip := remote.To16(); ip != nil { - return ip.String() - } - } - } - - remote := net.ParseIP(ctx.Request.RemoteAddr) - if remote != nil { - if remote.IsLoopback() { - forwarded := ctx.GetHeader(`X-Forwarded-For`) - if len(forwarded) > 0 { - return forwarded - } - realIP := ctx.GetHeader(`X-Real-IP`) - if len(realIP) > 0 { - return realIP - } - } else { - if ip := remote.To4(); ip != nil { - return ip.String() - } - if ip := remote.To16(); ip != nil { - return ip.String() - } - } - } - addr := ctx.Request.RemoteAddr - if pos := strings.LastIndex(addr, `:`); pos > -1 { - return strings.Trim(addr[:pos], `[]`) - } - return addr -} diff --git a/utils/utils.go b/utils/utils.go index 0915414..f16d32b 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -36,6 +36,7 @@ func GetMD5(data []byte) ([]byte, string) { hash := md5.New() hash.Write(data) result := hash.Sum(nil) + hash.Reset() return result, hex.EncodeToString(result) } @@ -73,9 +74,13 @@ func Decrypt(data []byte, key []byte) ([]byte, error) { hash, _ := GetMD5(decBuffer) if !bytes.Equal(hash, data[:16]) { + data = nil + decBuffer = nil return nil, ErrFailedVerification } + data = nil + decBuffer = decBuffer[:dataLen-16-64] //fmt.Println(`Recv: `, string(decBuffer[:dataLen-16-64])) - return decBuffer[:dataLen-16-64], nil + return decBuffer, nil } diff --git a/web/src/components/explorer.css b/web/src/components/explorer.css index 7ae2ecf..7a9afca 100644 --- a/web/src/components/explorer.css +++ b/web/src/components/explorer.css @@ -6,4 +6,8 @@ .ant-table-body { max-height: 300px; min-height: 300px; +} + +.upload-progress-square > .ant-progress-outer > .ant-progress-inner { + border-radius: 0 !important; } \ No newline at end of file diff --git a/web/src/components/explorer.js b/web/src/components/explorer.js index e7271cd..6da8c8c 100644 --- a/web/src/components/explorer.js +++ b/web/src/components/explorer.js @@ -1,14 +1,19 @@ import React, {useEffect, useRef, useState} from 'react'; -import {message, Modal, Popconfirm} from "antd"; +import {message, Modal, Popconfirm, Progress} from "antd"; import ProTable from '@ant-design/pro-table'; import {formatSize, post, request, waitTime} from "../utils/utils"; import dayjs from "dayjs"; import i18n from "../locale/locale"; import './explorer.css'; +import {ReloadOutlined, UploadOutlined} from "@ant-design/icons"; +import axios from "axios"; +import Qs from "qs"; +let fileList = []; function FileBrowser(props) { const [path, setPath] = useState(`/`); const [loading, setLoading] = useState(false); + const [upload, setUpload] = useState(false); const columns = [ { key: 'Name', @@ -91,7 +96,7 @@ function FileBrowser(props) { function onRowClick(file) { let separator = props.isWindows ? '\\' : '/'; if (file.name === '..') { - listFiles(getLastPath()); + listFiles(getParentPath()); return; } if (file.type !== 0) { @@ -110,7 +115,7 @@ function FileBrowser(props) { tableRef.current.reload(); } - function getLastPath() { + function getParentPath() { let separator = props.isWindows ? '\\' : '/'; // remove the last separator // or there'll be an empty element after split @@ -125,6 +130,52 @@ function FileBrowser(props) { return pathArr.join(separator) + separator; } + function onFileChange(e) { + let file = e.target.files[0]; + if (file === undefined) return; + e.target.value = null; + { + let exists = false; + for (let i = 0; i < fileList.length; i++) { + if (fileList[i].type === 0 && fileList[i].name === file.name) { + exists = true; + break; + } + } + if (exists) { + Modal.confirm({ + autoFocusButton: 'cancel', + content: i18n.t('fileOverwriteConfirm').replace('{0}', file.name), + okText: i18n.t('fileOverwrite'), + onOk: () => { + setUpload(file); + }, + okButtonProps: { + danger: true, + }, + }); + } else { + setUpload(file); + } + } + } + function uploadFile() { + if (path === '/' || path === '\\' || path.length === 0) { + if (props.isWindows) { + message.error(i18n.t('uploadInvalidPath')); + return; + } + } + document.getElementById('uploader').click(); + } + function onUploadSuccess() { + tableRef.current.reload(); + setUpload(false); + } + function onUploadCancel() { + setUpload(false); + } + function downloadFile(file) { post(location.origin + location.pathname + 'api/device/file/get', { file: path + file, @@ -150,6 +201,7 @@ function FileBrowser(props) { if (data.code === 0) { let addParentShortcut = false; data.data.files = data.data.files.sort((first, second) => (second.type - first.type)); + fileList = [].concat(data.data.files); if (path.length > 0 && path !== '/' && path !== '\\') { addParentShortcut = true; data.data.files.unshift({ @@ -165,7 +217,7 @@ function FileBrowser(props) { total: data.data.files.length - (addParentShortcut ? 1 : 0) }); } - setPath(getLastPath()); + setPath(getParentPath()); return ({data: [], success: false, total: 0}); } @@ -188,7 +240,22 @@ function FileBrowser(props) { onDoubleClick: onRowClick.bind(null, file), })} toolbar={{ - actions: [] + settings: [ + { + icon: , + tooltip: i18n.t('upload'), + key: 'upload', + onClick: uploadFile + }, + { + icon: , + tooltip: i18n.t('reload'), + key: 'reload', + onClick: () => { + tableRef.current.reload(); + } + } + ] }} scroll={{scrollToFirstRowOnChange: true, y: 300}} search={false} @@ -203,6 +270,173 @@ function FileBrowser(props) { actionRef={tableRef} > + + + + ) +} + +let abortController = null; +function UploadModal(props) { + const [visible, setVisible] = useState(!!props.file); + const [percent, setPercent] = useState(0); + const [status, setStatus] = useState(0); + // 0: ready, 1: uploading, 2: success, 3: fail, 4: cancel + + useEffect(() => { + if (props.file) { + setVisible(true); + setPercent(0); + setStatus(0); + } + }, [props.file]); + + function onPageUnload(e) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + + function onConfirm() { + if (status !== 0) { + onCancel(); + return; + } + let params = Qs.stringify({ + device: props.device, + path: props.path, + file: props.file.name + }); + setStatus(1); + window.onbeforeunload = onPageUnload; + abortController = new AbortController(); + axios.post( + '/api/device/file/upload?' + params, + props.file, + { + headers: { + 'Content-Type': 'application/octet-stream' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + let percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setPercent(percentCompleted); + }, + signal: abortController.signal + } + ).then(res => { + let data = res.data; + if (data.code === 0) { + setStatus(2); + message.success(i18n.t('uploadSuccess')); + } else { + setStatus(3); + } + }).catch((err) => { + if (axios.isCancel(err)) { + setStatus(4); + message.error(i18n.t('uploadAborted')); + } else { + setStatus(3); + message.error(i18n.t('uploadFailed') + i18n.t('colon') + err.message); + } + }).finally(() => { + abortController = null; + window.onbeforeunload = null; + setTimeout(() => { + setVisible(false); + if (status === 2) { + props.onSuccess(); + } else { + props.onCanel(); + } + }, 1500); + }); + } + function onCancel() { + if (status === 0) { + setVisible(false); + setTimeout(props.onCanel, 300); + return; + } + if (status === 1) { + Modal.confirm({ + autoFocusButton: 'cancel', + content: i18n.t('uploadCancelConfirm'), + onOk: () => { + abortController.abort(); + }, + okButtonProps: { + danger: true, + }, + }); + return; + } + setTimeout(() => { + setVisible(false); + setTimeout(props.onCanel, 300); + }, 1500); + } + + function getDescription() { + switch (status) { + case 1: + return `${percent}%`; + case 2: + return i18n.t('uploadSuccess'); + case 3: + return i18n.t('uploadFailed'); + case 4: + return i18n.t('uploadAborted'); + default: + return i18n.t('upload'); + } + + } + + return ( + 1}} + width={550} + > + <> + + {getDescription()} + + {props.file.name + ` (${formatSize(props.file.size)})`} + + ) } diff --git a/web/src/components/terminal.js b/web/src/components/terminal.js index 7632892..7ee4ec2 100644 --- a/web/src/components/terminal.js +++ b/web/src/components/terminal.js @@ -8,7 +8,7 @@ import CryptoJS from 'crypto-js'; import wcwidth from 'wcwidth'; import "xterm/css/xterm.css"; import i18n from "../locale/locale"; -import {translate} from "../utils/utils"; +import {getBaseURL, translate} from "../utils/utils"; function hex2buf(hex) { if (typeof hex !== 'string') { @@ -59,13 +59,6 @@ function ab2str(buffer) { return out; } -function getBaseURL() { - if (location.protocol === 'https:') { - return `wss://${location.host}${location.pathname}api/device/terminal`; - } - return `ws://${location.host}${location.pathname}api/device/terminal`; -} - function genRandHex(length) { return [...Array(length)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); } @@ -120,7 +113,7 @@ class TerminalModal extends React.Component { ev?.dispose(); let buffer = ''; let termEv = null; - // Windows don't support pty, so we still use traditional way. + // Windows doesn't support pty, so we still use traditional way. if (this.props.device.os === 'windows') { let cmd = ''; termEv = this.term.onData((e) => { @@ -173,7 +166,7 @@ class TerminalModal extends React.Component { }); } - this.ws = new WebSocket(`${getBaseURL()}?device=${this.props.device.id}&secret=${this.secret}`); + this.ws = new WebSocket(`${getBaseURL(true)}?device=${this.props.device.id}&secret=${this.secret}`); this.ws.binaryType = 'arraybuffer'; this.ws.onopen = () => { this.conn = true; @@ -192,7 +185,6 @@ class TerminalModal extends React.Component { data = data.substring(buffer.length); buffer = ''; } - return; } this.term.write(data); return; @@ -263,6 +255,9 @@ class TerminalModal extends React.Component { if (prevProps.visible) { clearInterval(this.ticker); if (this.conn) { + this.sendData({ + act: 'killTerminal' + }); this.ws.close(); } this?.termEv?.dispose(); diff --git a/web/src/index.js b/web/src/index.js index 5eb2c0a..3cc6568 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -27,18 +27,20 @@ axios.interceptors.response.use(async (res) => { } return Promise.resolve(res); }, (err) => { + console.error(err); if (err.code === 'ECONNABORTED') { - message.warn(i18n.t('requestTimeout')); - return Promise.resolve(err); + message.error(i18n.t('requestTimeout')); + return Promise.reject(err); } let res = err.response; - let data = res.data; + let data = res?.data ?? {}; if (data.hasOwnProperty('code')) { if (data.code !== 0){ message.warn(translate(data.msg)); + return Promise.resolve(res); } } - return Promise.resolve(res); + return Promise.reject(err); }); ReactDOM.render( diff --git a/web/src/locale/en.json b/web/src/locale/en.json index a58e7c5..7adc2a6 100644 --- a/web/src/locale/en.json +++ b/web/src/locale/en.json @@ -44,12 +44,22 @@ "modifyTime": "Modify Time", "file": "file", "folder": "folder", + "reload": "Reload", + "upload": "Upload", "delete": "Delete", "download": "Download", + "uploading": "Uploading...", + "uploadFailed": "Upload Failed", + "uploadAborted": "Upload Aborted", + "uploadSuccess": "Upload Success", + "uploadInvalidPath": "Cannot upload here", + "uploadCancelConfirm": "Are you sure to cancel uploading?", "deleteConfirm": "Are you sure to delete this {0}?", "deleteSuccess": "File or folder deleted", "dateTimeFormat": "MMM D, YYYY h:mm A", "fileOrDirNotExist": "File or folder does not exist", + "fileOverwriteConfirm": "File [ {0} ] already exists, overwrite?", + "fileOverwrite": "Overwrite", "host": "Host", "port": "Port", diff --git a/web/src/locale/zh-CN.json b/web/src/locale/zh-CN.json index b7c7513..bdbad00 100644 --- a/web/src/locale/zh-CN.json +++ b/web/src/locale/zh-CN.json @@ -45,12 +45,22 @@ "modifyTime": "修改时间", "file": "文件", "folder": "文件夹", + "reload": "刷新", + "upload": "上传", "delete": "删除", "download": "下载", + "uploading": "上传中...", + "uploadFailed": "上传失败", + "uploadAborted": "取消上传", + "uploadSuccess": "上传完成", + "uploadInvalidPath": "该路径无法上传文件", + "uploadCancelConfirm": "确定要取消上传吗?", "deleteConfirm": "确定要删除该{0}吗?", "deleteSuccess": "文件或目录已被删除", "dateTimeFormat": "YYYY/MM/DD HH:mm", "fileOrDirNotExist": "文件或目录不存在", + "fileOverwriteConfirm": "文件 [ {0} ] 已经存在,是否覆盖?", + "fileOverwrite": "覆盖", "registryEditor": "注册表编辑器", "unknownRegistryKey": "注册表键有误", diff --git a/web/src/utils/utils.js b/web/src/utils/utils.js index e1ae24f..a74aac3 100644 --- a/web/src/utils/utils.js +++ b/web/src/utils/utils.js @@ -24,11 +24,13 @@ function waitTime(time) { }; function formatSize(size) { - if (size === 0) return '0 B'; + size = isNaN(size) ? 0 : (size??0); + size = Math.max(size, 0); let k = 1024, - i = Math.floor(Math.log(size) / Math.log(k)), - units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - return (size / Math.pow(k, i)).toFixed(2) + ' ' + units[i]; + i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(k)), + units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], + result = size / Math.pow(k, i); + return (Math.round(result * 100) / 100) + ' ' + units[i]; } function tsToTime(ts) { @@ -39,6 +41,15 @@ function tsToTime(ts) { return `${String(hours) + i18n.t('hours') + ' ' + String(minutes) + i18n.t('minutes')}`; } +function getBaseURL(ws) { + if (location.protocol === 'https:') { + let scheme = ws ? 'wss' : 'https'; + return scheme + `://${location.host}${location.pathname}api/device/terminal`; + } + let scheme = ws ? 'ws' : 'http'; + return scheme + `://${location.host}${location.pathname}api/device/terminal`; +} + function post(url, data, ext) { let form = document.createElement('form'); form.action = url; @@ -65,4 +76,4 @@ function translate(text) { }); } -export {post, request, waitTime, formatSize, tsToTime, translate}; \ No newline at end of file +export {post, request, waitTime, formatSize, tsToTime, getBaseURL, translate}; \ No newline at end of file