├── .default.env ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── Dockerfile-mini ├── README.md ├── manifests.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── detector │ ├── README.md │ ├── base.d.ts │ ├── base.js │ ├── info.js │ └── nvm.sh ├── dto.ts ├── interface.ts ├── main.ts └── utils │ ├── agent.ts │ ├── kit.ts │ └── nodeSSH.ts ├── tsconfig.build.json └── tsconfig.json /.default.env: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-non-null-assertion': 'off', 24 | '@typescript-eslint/no-var-requires': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | .prod.env 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.15.4 2 | 3 | ADD . /app 4 | WORKDIR /app 5 | 6 | RUN npm run build 7 | 8 | EXPOSE 3000 9 | CMD [ "node", "dist/main" ] 10 | -------------------------------------------------------------------------------- /Dockerfile-mini: -------------------------------------------------------------------------------- 1 | FROM node:16 as base 2 | 3 | WORKDIR /app 4 | COPY package.json \ 5 | yarn.lock \ 6 | .default.env \ 7 | ./ 8 | RUN yarn --production 9 | RUN curl -sf https://gobinaries.com/tj/node-prune | sh 10 | RUN node-prune 11 | 12 | # lint and formatting configs are commented out 13 | # uncomment if you want to add them into the build process 14 | 15 | FROM base AS dev 16 | COPY nest-cli.json \ 17 | tsconfig.* \ 18 | # .eslintrc.js \ 19 | # .prettierrc \ 20 | ./ 21 | # bring in src from context 22 | COPY ./src/ ./src/ 23 | RUN yarn 24 | # RUN yarn lint 25 | RUN yarn build 26 | 27 | # use one of the smallest images possible 28 | FROM node:16-alpine 29 | # get package.json from base 30 | COPY --from=base /app/package.json ./ 31 | # get the dist back 32 | COPY --from=dev /app/dist/ ./dist/ 33 | # get the node_modules from the intial cache 34 | COPY --from=base /app/node_modules/ ./node_modules/ 35 | # expose application port 36 | EXPOSE 3000 37 | # start 38 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### 配置地址 4 | - 格式为 http://IP:PORT 5 | - 例如部署在本地的就是 http://127.0.0.1:3000 (http协议,不是https协议哦) 6 | 7 | ### 方法一(建议):Docker 运行 8 | ``` 9 | # 若未安装 docker,可以一键安装 docker 环境后执行上面的安装命令 10 | $ curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun 11 | 12 | # 已经安装了 docker 的服务器直接执行下面的安装命令 13 | $ docker pull selypan/console-proxy:latest 14 | $ docker run -d -p 3000:3000 --name console-proxy selypan/console-proxy:latest 15 | 16 | 安装完成后,地址为服务器公网IP(局域网IP),端口为3000,例如 http://127.0.0.1:3000 17 | ``` 18 | 19 | 一键安装,适合没有安装过 docker 的 linux 主机 20 | ```shell 21 | curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun && docker pull selypan/console-proxy:latest && docker run -d -p 3000:3000 --name console-proxy selypan/console-proxy:latest 22 | ``` 23 | 24 | 25 | ### 方法二(需要一定Node.js基础):需要预先安装 node14+ 环境 26 | #### windows 和 mac 推荐下载安装包直接安装 [中文官网地址](http://nodejs.cn/download/) 27 | 28 | #### mac 和 linux 推荐使用进行安装nvm进行安装 [github首页](https://github.com/nvm-sh/nvm) 29 | 安装方法如下 30 | ```bash 31 | $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash 32 | # 有时候 raw.githubusercontent.com 被污染,无法访问,可以使用下面个人的 cdn 版本 33 | # curl -o- https://console-hz.selypan.com/assert/install.sh | bash 34 | 35 | $ 36 | $ nvm install --lts 37 | $ nvm alias default 14 38 | ``` 39 | 40 | ### 安装依赖 41 | 42 | ```bash 43 | $ cd console-proxy 44 | $ npm install 45 | # npm install 如果速度慢可以使用淘宝镜像 46 | # npm config set registry https://registry.npm.taobao.org 47 | ``` 48 | 49 | ### 环境变量配置(基本不需要修改即可运行) 50 | - 方法一: 修改 .default.env 文件 51 | - 方法二: 新增 .prod.env 文件覆盖变量 52 | - 方法三: 直接添加环境变量, 如 PORT=4000 53 | 54 | ### 运行 55 | 56 | ```bash 57 | # development 58 | $ npm run start 59 | 60 | # watch mode 61 | $ npm run start:dev 62 | 63 | # production mode 64 | $ npm run start:prod 65 | ``` 66 | 67 | ### 后台运行 68 | ```bash 69 | # 安装 pm2 70 | $ npm i pm2 -g 71 | 72 | # 后台启动 73 | $ npm run build 74 | $ pm2 start dist/main.js --name console-proxy 75 | 76 | # 后台关闭 77 | $ pm2 delete console-proxy 78 | 79 | # 查看程序 80 | $ pm2 ls 81 | 82 | # 查看日志 83 | $ pm2 logs 84 | 85 | # 保存服务自启动 86 | $ pm2 startup 87 | 88 | # 停止 pm2 (不建议,pm2 后台运行几乎没有消耗) 89 | $ pm2 kill 90 | ``` 91 | 92 | ## License 93 | 94 | [MIT licensed](LICENSE). 95 | -------------------------------------------------------------------------------- /manifests.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: console-proxy 5 | labels: 6 | app: console 7 | tier: proxy 8 | spec: 9 | sessionAffinity: ClientIP 10 | ports: 11 | - port: 30002 12 | targetPort: 30002 13 | selector: 14 | app: console 15 | tier: proxy 16 | type: ClusterIP 17 | --- 18 | apiVersion: apps/v1 19 | kind: Deployment 20 | metadata: 21 | name: console-proxy 22 | labels: 23 | app: console 24 | tier: proxy 25 | spec: 26 | selector: 27 | matchLabels: 28 | app: console 29 | tier: proxy 30 | strategy: 31 | type: RollingUpdate 32 | rollingUpdate: 33 | maxSurge: 25% 34 | maxUnavailable: 25% 35 | template: 36 | metadata: 37 | labels: 38 | app: console 39 | tier: proxy 40 | spec: 41 | containers: 42 | - image: ${IMAGE} 43 | name: console-proxy 44 | resources: 45 | limits: 46 | cpu: 500m 47 | memory: 500Mi 48 | requests: 49 | cpu: 100m 50 | memory: 100Mi 51 | env: 52 | - name: NODE_ENV 53 | value: 'production' 54 | - name: RUNTIME_ENV 55 | value: 'OFFICE' 56 | - name: PORT 57 | value: '30002' 58 | - name: TMP_SERVER 59 | valueFrom: 60 | configMapKeyRef: 61 | name: console-proxy 62 | key: tmp-server 63 | ports: 64 | - containerPort: 30002 65 | name: console-proxy 66 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "assets": ["**/*.sh"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console-proxy", 3 | "version": "1.0.0", 4 | "description": "terminal.icu proxy", 5 | "author": "selypan", 6 | "private": "false", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "yarn install && nest build && yarn --production", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "build:electron": "npm install && nest build --webpack && yarn install --production=true", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" 17 | }, 18 | "dependencies": { 19 | "@nestjs/common": "^7.6.15", 20 | "@nestjs/config": "^0.6.3", 21 | "@nestjs/core": "^7.6.15", 22 | "@nestjs/platform-express": "^7.6.15", 23 | "@nestjs/platform-socket.io": "^7.6.15", 24 | "@nestjs/websockets": "^7.6.15", 25 | "@types/redis-info": "^3.0.0", 26 | "assert": "^2.0.0", 27 | "crypto-js": "^4.0.0", 28 | "ioredis": "^4.27.6", 29 | "is-valid-domain": "^0.0.19", 30 | "lodash": "^4.17.21", 31 | "make-dir": "^3.1.0", 32 | "moment": "^2.29.1", 33 | "node-fetch": "^2.6.1", 34 | "redis-info": "^3.0.8", 35 | "reflect-metadata": "^0.1.13", 36 | "rxjs": "^6.6.6", 37 | "sb-promise-queue": "^2.1.0", 38 | "sb-scandir": "^3.1.0", 39 | "shell-escape": "^0.2.0", 40 | "socks": "^2.6.1", 41 | "ssh2": "^1.1.0", 42 | "ssh2-streams": "^0.4.10", 43 | "tsdef": "0.0.14" 44 | }, 45 | "devDependencies": { 46 | "@nestjs/cli": "^7.6.0", 47 | "@nestjs/schematics": "^7.3.0", 48 | "@nestjs/testing": "^7.6.15", 49 | "@types/crypto-js": "^4.0.1", 50 | "@types/express": "^4.17.11", 51 | "@types/ioredis": "^4.26.4", 52 | "@types/jest": "^26.0.22", 53 | "@types/lodash": "^4.14.168", 54 | "@types/node": "^14.14.36", 55 | "@types/node-fetch": "^2.5.10", 56 | "@types/socket.io": "^2.1.13", 57 | "@types/ssh2": "^0.5.46", 58 | "@types/supertest": "^2.0.10", 59 | "@typescript-eslint/eslint-plugin": "^4.19.0", 60 | "@typescript-eslint/parser": "^4.19.0", 61 | "eslint": "^7.22.0", 62 | "eslint-config-prettier": "^8.1.0", 63 | "eslint-plugin-prettier": "^3.3.1", 64 | "prettier": "^2.2.1", 65 | "rimraf": "^3.0.2", 66 | "ts-loader": "^8.0.18", 67 | "ts-node": "^9.1.1", 68 | "tsconfig-paths": "^3.9.0", 69 | "typescript": "^4.2.3" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; 2 | import fetch from 'node-fetch'; 3 | import { ForwardInRequestDto, ForwardInResponseDto } from './dto'; 4 | import { Forward } from './app.service'; 5 | 6 | @Controller() 7 | export class AppController { 8 | constructor(private readonly forwardService: Forward) {} 9 | 10 | @Post('/forward-in') 11 | async newForwardIn( 12 | @Body() body: ForwardInRequestDto, 13 | ): Promise { 14 | return this.forwardService.newForwardIn(body); 15 | } 16 | 17 | @Delete('/forward/:id') 18 | async unForward(@Param('id') id: string) { 19 | if (process.env.RUNTIME_ENV === 'OFFICE') { 20 | return; 21 | } 22 | await this.forwardService.unForward(id); 23 | } 24 | 25 | @Get('/forward-status') 26 | async forwardStatus() { 27 | if (process.env.RUNTIME_ENV === 'OFFICE') { 28 | return; 29 | } 30 | return this.forwardService.forwardStatus(); 31 | } 32 | 33 | @Get('/frpc-status') 34 | async frpcStatus() { 35 | if (process.env.RUNTIME_ENV === 'OFFICE') { 36 | return []; 37 | } 38 | 39 | try { 40 | return await fetch('http://localhost:22335/api/status').then((res) => 41 | res.json(), 42 | ); 43 | } catch (error) { 44 | console.log(error); 45 | return []; 46 | } 47 | } 48 | 49 | @Get('/frpc-reload') 50 | async frpcReload() { 51 | if (process.env.RUNTIME_ENV === 'OFFICE') { 52 | return false; 53 | } 54 | 55 | try { 56 | await fetch('http://localhost:22335/api/reload'); 57 | return true; 58 | } catch (error) { 59 | console.log(error); 60 | return false; 61 | } 62 | } 63 | 64 | @Get() 65 | ping(): string { 66 | return 'pong'; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Provider, Forward } from './app.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { AppController } from './app.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | ConfigModule.forRoot({ 9 | envFilePath: ['.prod.env', '.default.env'], 10 | }), 11 | ], 12 | controllers: [AppController], 13 | providers: [Provider, Forward], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import { 4 | MessageBody, 5 | OnGatewayConnection, 6 | OnGatewayDisconnect, 7 | OnGatewayInit, 8 | SubscribeMessage, 9 | WebSocketGateway, 10 | } from '@nestjs/websockets'; 11 | import { promisify } from 'util'; 12 | import * as Path from 'path'; 13 | import { Config as ConnectionConfig, NodeSSH } from './utils/nodeSSH'; 14 | import { ClientChannel } from 'ssh2'; 15 | import * as _ from 'lodash'; 16 | import * as moment from 'moment'; 17 | import { Undefinable } from 'tsdef'; 18 | import * as isValidDomain from 'is-valid-domain'; 19 | import * as dns from 'dns'; 20 | import * as net from 'net'; 21 | import { ConsoleSocket, SFTP } from './interface'; 22 | import { decrypt, md5, sleep, WsErrorCatch } from './utils/kit'; 23 | import { ForwardInParams } from './dto'; 24 | import * as fs from 'fs'; 25 | import IORedis from 'ioredis'; 26 | import { parse as redisInfoParser } from 'redis-info'; 27 | import * as shellEscape from 'shell-escape'; 28 | 29 | const lookup = promisify(dns.lookup); 30 | const readFile = promisify(fs.readFile); 31 | 32 | enum KEYS { 33 | statusShell = 'statusShell', 34 | connectionSubMap = 'connectionSubMap', 35 | connectionId = 'connectionId', 36 | serverStatusLock = 'serverStatusLock', 37 | } 38 | 39 | @Injectable() 40 | @WebSocketGateway() 41 | export class Provider 42 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 43 | { 44 | private logger: Logger = new Logger('Provider'); 45 | 46 | afterInit(): void { 47 | return this.logger.log( 48 | `Websocket server successfully started port:${process.env.PORT}`, 49 | ); 50 | } 51 | 52 | async handleConnection(socket: ConsoleSocket): Promise { 53 | this.logger.log(`Client connected, socketId: ${socket.id}`); 54 | socket.shellService = new Shell(socket); 55 | socket.sftpService = new Sftp(socket); 56 | socket.redisService = new Redis(socket); 57 | socket.serverStatusService = new ServerStatus(socket); 58 | } 59 | 60 | handleDisconnect(socket: ConsoleSocket) { 61 | this.logger.log(`Client disconnect, socketId: ${socket.id}`); 62 | 63 | socket.shellService.handleDisconnect(); 64 | socket.sftpService.handleDisconnect(); 65 | socket.redisService.handleDisconnect(); 66 | socket.serverStatusService.handleDisconnect(); 67 | socket.removeAllListeners(); 68 | } 69 | 70 | @SubscribeMessage('terminal:ping') 71 | async ping(socket: ConsoleSocket, { host, port }) { 72 | return { success: true, errorMessage: '' }; 73 | } 74 | 75 | @SubscribeMessage('terminal:testConnect') 76 | async testShellConnect(socket: ConsoleSocket, messageBody) { 77 | try { 78 | const connection = await socket.shellService.getConnection(messageBody); 79 | socket.shellService.handleDisconnect( 80 | _.get(connection, KEYS.connectionId), 81 | ); 82 | } catch (e) { 83 | return { 84 | success: false, 85 | errorMessage: e.message, 86 | }; 87 | } 88 | 89 | return { success: true, errorMessage: '' }; 90 | } 91 | 92 | @SubscribeMessage('terminal:preConnect') 93 | async preShellConnect(socket: ConsoleSocket, messageBody) { 94 | return socket.shellService.preConnect(messageBody); 95 | } 96 | 97 | @SubscribeMessage('terminal:new') 98 | async newShell(socket: ConsoleSocket, messageBody) { 99 | return socket.shellService.newShell(messageBody); 100 | } 101 | 102 | @SubscribeMessage('terminal:close') 103 | async closeShell(socket: ConsoleSocket, messageBody) { 104 | return socket.shellService.closeShell(messageBody); 105 | } 106 | 107 | @SubscribeMessage('terminal:disconnect') 108 | async terminalDisconnect(socket: ConsoleSocket, { id }) { 109 | return socket.shellService.handleDisconnect(id); 110 | } 111 | 112 | @SubscribeMessage('terminal:input') 113 | async shellInput(socket: ConsoleSocket, messageBody) { 114 | return socket.shellService.input(messageBody); 115 | } 116 | 117 | @SubscribeMessage('terminal:resize') 118 | async shellResize(socket: ConsoleSocket, messageBody) { 119 | return socket.shellService.resize(messageBody); 120 | } 121 | 122 | @SubscribeMessage('serverStatus:hasInit') 123 | async serverStatusHasInit(socket: ConsoleSocket, MessageBody) { 124 | const { init } = await socket.serverStatusService.hasInit(MessageBody); 125 | return init; 126 | } 127 | 128 | @SubscribeMessage('serverStatus:startFresh') 129 | async serverStatusStartFresh(socket: ConsoleSocket, MessageBody) { 130 | return socket.serverStatusService.startFresh(MessageBody); 131 | } 132 | 133 | @SubscribeMessage('serverStatus:serverStatus') 134 | async serverStatus(socket: ConsoleSocket, { id }) { 135 | return socket.serverStatusService.ServerStatus(id); 136 | } 137 | 138 | @SubscribeMessage('serverStatus:disconnect') 139 | async serverStatusDisConnect(socket: ConsoleSocket, { id }) { 140 | return socket.serverStatusService.handleDisconnect(id); 141 | } 142 | 143 | @SubscribeMessage('file:new') 144 | async newSftp(socket: ConsoleSocket, messageBody) { 145 | return socket.sftpService.newSftp({ ...messageBody, ...messageBody.data }); 146 | } 147 | 148 | @SubscribeMessage('file:close') 149 | async closeSftp(socket: ConsoleSocket, messageBody) { 150 | return socket.sftpService.closeSftp(messageBody); 151 | } 152 | 153 | @SubscribeMessage('file:disconnect') 154 | async disconnectSftp(socket: ConsoleSocket, { id }) { 155 | return socket.sftpService.handleDisconnect(id); 156 | } 157 | 158 | @SubscribeMessage('file:list') 159 | async sftpReaddir(socket: ConsoleSocket, messageBody) { 160 | return socket.sftpService.sftpReaddir(messageBody); 161 | } 162 | 163 | @SubscribeMessage('file:touch') 164 | async touch(socket: ConsoleSocket, messageBody) { 165 | return socket.sftpService.touch(messageBody); 166 | } 167 | 168 | @SubscribeMessage('file:writeFile') 169 | async writeFile(socket: ConsoleSocket, messageBody) { 170 | return socket.sftpService.writeFile(messageBody); 171 | } 172 | 173 | @SubscribeMessage('file:writeFileByPath') 174 | async writeFileByPath(socket: ConsoleSocket, messageBody) { 175 | return socket.sftpService.writeFileByPath(messageBody); 176 | } 177 | 178 | @SubscribeMessage('file:writeFiles') 179 | async writeFiles(socket: ConsoleSocket, messageBody) { 180 | return socket.sftpService.writeFiles(messageBody); 181 | } 182 | 183 | @SubscribeMessage('file:getFile') 184 | async getFile(socket: ConsoleSocket, messageBody) { 185 | return socket.sftpService.getFile(messageBody); 186 | } 187 | 188 | @SubscribeMessage('file:getFiles') 189 | async getFiles(socket: ConsoleSocket, messageBody) { 190 | return socket.sftpService.getFiles(messageBody); 191 | } 192 | 193 | @SubscribeMessage('file:getFileByPath') 194 | async getFileByPath(socket: ConsoleSocket, messageBody) { 195 | return socket.sftpService.getFileByPath(messageBody); 196 | } 197 | 198 | @SubscribeMessage('file:getFilesByPath') 199 | async getFilesByPath(socket: ConsoleSocket, messageBody) { 200 | return socket.sftpService.getFilesByPath(messageBody); 201 | } 202 | 203 | @SubscribeMessage('file:rename') 204 | async rename(socket: ConsoleSocket, messageBody) { 205 | return socket.sftpService.rename(messageBody); 206 | } 207 | 208 | @SubscribeMessage('file:unlink') 209 | async unlink(socket: ConsoleSocket, messageBody) { 210 | return socket.sftpService.unlink(messageBody); 211 | } 212 | 213 | @SubscribeMessage('file:rmdir') 214 | async rmdir(socket: ConsoleSocket, messageBody) { 215 | return socket.sftpService.rmdir(messageBody); 216 | } 217 | 218 | @SubscribeMessage('file:rmrf') 219 | async rmrf(socket: ConsoleSocket, messageBody) { 220 | return socket.sftpService.rmrf(messageBody); 221 | } 222 | 223 | @SubscribeMessage('file:mkdir') 224 | async mkdir(socket: ConsoleSocket, messageBody) { 225 | return socket.sftpService.mkdir(messageBody); 226 | } 227 | 228 | @SubscribeMessage('redis:connect') 229 | async redisConnect(socket: ConsoleSocket, messageBody) { 230 | return socket.redisService.redisConnect(messageBody); 231 | } 232 | 233 | @SubscribeMessage('redis:disConnect') 234 | async redisDisConnect(socket: ConsoleSocket, { id }) { 235 | return socket.redisService.handleDisconnect(id); 236 | } 237 | 238 | @SubscribeMessage('redis:deleteKey') 239 | async deleteRedisKey(socket: ConsoleSocket, messageBody) { 240 | return socket.redisService.deleteRedisKey(messageBody); 241 | } 242 | 243 | @SubscribeMessage('redis:keys') 244 | async redisKeys(socket: ConsoleSocket, messageBody) { 245 | return socket.redisService.redisKeys(messageBody); 246 | } 247 | 248 | @SubscribeMessage('redis:hscan') 249 | async redisHScan(socket: ConsoleSocket, messageBody) { 250 | return socket.redisService.redisHScan(messageBody); 251 | } 252 | 253 | @SubscribeMessage('redis:sscan') 254 | async redisSScan(socket: ConsoleSocket, messageBody) { 255 | return socket.redisService.redisSScan(messageBody); 256 | } 257 | 258 | @SubscribeMessage('redis:command') 259 | async redisCommand(socket: ConsoleSocket, messageBody) { 260 | return socket.redisService.redisCommand(messageBody); 261 | } 262 | 263 | @SubscribeMessage('redis:info') 264 | async redisInfo(socket: ConsoleSocket, messageBody) { 265 | return socket.redisService.redisInfo(messageBody); 266 | } 267 | } 268 | 269 | class Base { 270 | static logger: Logger = new Logger('Base'); 271 | connectionMap: Map = new Map(); 272 | 273 | static exec(connection: NodeSSH, command: string, parameters: string[]) { 274 | return connection.exec(command, parameters); 275 | } 276 | 277 | static execs(connection: NodeSSH, command: string) { 278 | return connection.execCommand(command, { 279 | execOptions: { 280 | env: { 281 | HISTCONTROL: 'ignorespace', 282 | HISTIGNORE: '*', 283 | HISTSIZE: '0', 284 | HISTFILESIZE: '0', 285 | HISTFILE: '/dev/null', 286 | }, 287 | }, 288 | }); 289 | } 290 | 291 | handleConnectionClose(nodeSSH: NodeSSH) { 292 | Base.logger.log('handleConnectionClose'); 293 | } 294 | 295 | @WsErrorCatch() 296 | async preConnect({ ...config }) { 297 | try { 298 | await this.getConnection(config, undefined, 'preConnect'); 299 | 300 | Base.logger.log( 301 | `[preConnect] connected, server: ${config.username}@${config.host}`, 302 | ); 303 | } catch (error) { 304 | Base.logger.error('[preConnect] error', error.stack); 305 | return { 306 | success: false, 307 | errorMessage: error.message, 308 | }; 309 | } 310 | 311 | return { 312 | success: true, 313 | errorMessage: '', 314 | }; 315 | } 316 | 317 | async getConnection( 318 | configOrId?: ConnectionConfig | string, 319 | retryDelay?: number, 320 | debugfrom?: string, 321 | ): Promise> { 322 | if (typeof configOrId === 'string') { 323 | return this.connectionMap.get(configOrId); 324 | } 325 | 326 | const config = configOrId; 327 | 328 | const secretKey = md5( 329 | `${config.host}${config.username}${config.port}`, 330 | ).toString(); 331 | 332 | if (config.password) { 333 | config.password = decrypt(config.password, secretKey); 334 | } 335 | if (config.privateKey) { 336 | config.privateKey = decrypt(config.privateKey, secretKey); 337 | } 338 | 339 | const connectionId = md5( 340 | `${config.host}${config.username}${config.port}${config.password}${config.privateKey}`, 341 | ).toString(); 342 | 343 | const connectExist = this.connectionMap.get(connectionId); 344 | if (connectExist) return connectExist; 345 | 346 | if (retryDelay) { 347 | await sleep(retryDelay); 348 | return this.getConnection(config); 349 | } 350 | 351 | if (config) { 352 | if (isValidDomain(config.host, { allowUnicode: true })) { 353 | try { 354 | const { address } = await lookup(config.host); 355 | config.host = address; 356 | } catch (e) { 357 | // nothing 358 | } 359 | } 360 | 361 | const connection = await new NodeSSH().connect({ 362 | tryKeyboard: true, 363 | keepaliveInterval: 10000, 364 | readyTimeout: 100000, 365 | ...config, 366 | host: 367 | config.host === 'linuxServer' ? process.env.TMP_SERVER : config.host, 368 | privateKey: config.privateKey || undefined, 369 | }); 370 | 371 | // 方便读取 id, 避免重新计算 372 | _.set(connection, KEYS.connectionId, connectionId); 373 | _.set(connection, 'debugfrom', debugfrom); 374 | 375 | this.connectionMap.set(connectionId, connection); 376 | 377 | connection.connection?.on('error', (error) => { 378 | Base.logger.error('connection server error', error.stack); 379 | this.handleConnectionClose(connection); 380 | }); 381 | connection.connection?.on('close', () => { 382 | Base.logger.warn('connection server close'); 383 | this.handleConnectionClose(connection); 384 | }); 385 | 386 | return connection; 387 | } 388 | 389 | return undefined; 390 | } 391 | } 392 | 393 | export class Shell extends Base { 394 | static logger: Logger = new Logger('Shell'); 395 | private shellMap: Map = new Map(); 396 | 397 | constructor(private socket: ConsoleSocket) { 398 | super(); 399 | } 400 | 401 | @WsErrorCatch() 402 | async getShell(id: string, connection?: NodeSSH) { 403 | const sshExist = this.shellMap.get(id); 404 | if (sshExist) return sshExist; 405 | 406 | if (connection) { 407 | const shell = await connection.requestShell({ 408 | term: 'xterm-256color', 409 | }); 410 | this.shellMap.set(id, shell); 411 | 412 | // 可以根据 connection 找到 shell 413 | _.set(connection, `${KEYS.connectionSubMap}.${id}`, shell); 414 | 415 | // 可以根据 shell 获取 connection 416 | _.set(shell, KEYS.connectionId, connection); 417 | return shell; 418 | } 419 | 420 | return undefined; 421 | } 422 | 423 | @WsErrorCatch() 424 | async closeShell({ id }) { 425 | // i love you baby 426 | const shell = await this.getShell(id); 427 | if (shell) { 428 | shell.removeAllListeners(); 429 | shell.close(); 430 | this.shellMap.delete(id); 431 | Shell.logger.log(`[closeShell] shellId: ${id}`); 432 | 433 | // 获取 connection 如果没有其他的 shell 连接了就直接关闭 434 | const connection = _.get(shell, KEYS.connectionId); 435 | const connectionId = _.get(connection, KEYS.connectionId); 436 | const shells = _.get(connection, KEYS.connectionSubMap); 437 | // 剔除本次连接 438 | if (shells) { 439 | delete shells[id]; 440 | } 441 | if (connection && connectionId) { 442 | setTimeout(() => { 443 | const shells = _.get(connection, KEYS.connectionSubMap); 444 | if (!shells) { 445 | // 如果没有直接关闭 446 | this.handleDisconnect(connectionId); 447 | } 448 | 449 | // 检查是否还有其他的连接,没有就关闭连接 450 | if (_.isEmpty(shells)) { 451 | this.handleDisconnect(connectionId); 452 | } 453 | }, 1000); 454 | } 455 | } 456 | } 457 | 458 | @WsErrorCatch() 459 | async newShell({ id, ...config }) { 460 | try { 461 | const connection = (await this.getConnection( 462 | config, 463 | undefined, 464 | 'shell', 465 | ))!; 466 | 467 | // 已存在 468 | if (this.shellMap.get(id)) { 469 | return; 470 | } 471 | 472 | // 初始化 terminal 473 | const shell = await this.getShell(id, connection); 474 | 475 | // @ts-ignore 476 | if (shell.errorMessage) { 477 | // @ts-ignore 478 | throw new Error(shell.errorMessage); 479 | } 480 | 481 | // 建立 terminal 监听 482 | shell.on('data', (data) => { 483 | this.socket.emit('terminal:data', { data: data.toString(), id }); 484 | }); 485 | shell.on('close', () => { 486 | this.closeShell({ id }); 487 | 488 | this.socket.emit('terminal:data', { 489 | data: 'connection close\r\nwill reconnect after 2 second\r\n', 490 | id, 491 | }); 492 | setTimeout(() => { 493 | this.socket.emit('terminal:reconnect', { id }); 494 | }, 2 * 1000); 495 | }); 496 | 497 | shell.on('error', (error) => { 498 | Shell.logger.error( 499 | `[shell]: ${config.host}${config.username} error`, 500 | error.stack(), 501 | ); 502 | }); 503 | 504 | Shell.logger.log( 505 | `[newShell] connected, server: ${config.username}@${config.host}`, 506 | ); 507 | } catch (error) { 508 | Shell.logger.error('[newShell] error', error.stack); 509 | return { 510 | success: false, 511 | errorMessage: error.message, 512 | }; 513 | } 514 | 515 | return { 516 | success: true, 517 | errorMessage: '', 518 | }; 519 | } 520 | 521 | @WsErrorCatch() 522 | async input({ id, data }) { 523 | (await this.getShell(id))?.write(data); 524 | } 525 | 526 | @WsErrorCatch() 527 | async resize({ id, data: { cols, rows, height = 480, width = 640 } }) { 528 | (await this.getShell(id))?.setWindow(rows, cols, height, width); 529 | } 530 | 531 | handleDisconnect(connectionId?: string) { 532 | Shell.logger.log( 533 | `[handleDisconnect] connectionId: ${connectionId ? 'one' : 'all'}`, 534 | ); 535 | if (connectionId) { 536 | const connection = this.connectionMap.get(connectionId); 537 | if (connection) { 538 | this.connectionMap.delete(connectionId); 539 | connection.dispose(true); 540 | } 541 | 542 | return; 543 | } 544 | 545 | this.connectionMap.forEach((connection, id) => { 546 | this.connectionMap.delete(id); 547 | connection.dispose(true); 548 | }); 549 | 550 | this.shellMap.forEach((shell, id) => { 551 | shell.close(); 552 | this.shellMap.delete(id); 553 | }); 554 | } 555 | 556 | handleConnectionClose(connection: NodeSSH) { 557 | const connectionId = _.get(connection, KEYS.connectionId); 558 | Shell.logger.log(`[handleConnectionClose] connectionId: ${connectionId}`); 559 | this.handleDisconnect(connectionId); 560 | 561 | const shells = _.get(connection, KEYS.connectionSubMap); 562 | if (shells) { 563 | for (const [id, shell] of Object.entries(shells)) { 564 | Shell.logger.log(`[handleConnectionClose] shellId: ${id}`); 565 | 566 | // (shell as ClientChannel).emit('close'); 567 | } 568 | } 569 | } 570 | } 571 | 572 | export class Sftp extends Base { 573 | static logger: Logger = new Logger('Sftp'); 574 | private sftpMap: Map = new Map(); 575 | 576 | constructor(private socket: ConsoleSocket) { 577 | super(); 578 | } 579 | 580 | static sftpPromisify(sftpClient) { 581 | ['readdir', 'readFile', 'writeFile', 'rename', 'unlink', 'rmdir'].forEach( 582 | (method) => { 583 | sftpClient[method] = promisify(sftpClient[method]); 584 | }, 585 | ); 586 | 587 | return sftpClient; 588 | } 589 | 590 | @WsErrorCatch() 591 | async closeSftp({ id }) { 592 | const sftp = await this.sftpMap.get(id); 593 | if (sftp) { 594 | sftp.end(); 595 | this.sftpMap.delete(id); 596 | Shell.logger.log(`[closeSftp] sftpId: ${id}`); 597 | } 598 | } 599 | 600 | @WsErrorCatch() 601 | async newSftp({ id, ...config }) { 602 | try { 603 | const connection = (await this.getConnection(config, undefined, 'sftp'))!; 604 | 605 | const sftp: unknown = await connection.requestSFTP(); 606 | this.sftpMap.set(id, Sftp.sftpPromisify(sftp)); 607 | } catch (error) { 608 | Sftp.logger.error('[newSftp] error', error.stack); 609 | return { 610 | success: false, 611 | errorMessage: error.message, 612 | }; 613 | } 614 | 615 | return { 616 | data: true, 617 | errorMessage: '', 618 | }; 619 | } 620 | 621 | @WsErrorCatch() 622 | async sftpReaddir({ id, data }) { 623 | let targetPath = data?.path; 624 | if (!targetPath || targetPath === '~') { 625 | const connection = await this.getConnection(id); 626 | if (!connection) return { errorMessage: '无法连接' }; 627 | 628 | const { stdout } = await Base.execs(connection, 'pwd'); 629 | targetPath = stdout || '/'; 630 | } 631 | 632 | const sftp = this.sftpMap.get(id); 633 | if (!sftp) return { errorMessage: '无法连接' }; 634 | 635 | const originalList = await sftp.readdir(targetPath); 636 | const list = originalList 637 | .map((file: any) => { 638 | const createdAt = new Date(file.attrs.atime * 1000); 639 | const updatedAt = new Date(file.attrs.mtime * 1000); 640 | const isFile = file.attrs.isFile(); 641 | return { 642 | createdAt, 643 | updatedAt, 644 | isFile, 645 | isDir: file.attrs.isDirectory(), 646 | filename: file.filename, 647 | size: file.attrs.size || 0, 648 | id: (targetPath + '/' + file.filename).replace('//', '/'), 649 | }; 650 | }) 651 | .filter((file) => file.isDir || file.isFile); 652 | 653 | return { 654 | data: { 655 | pwd: targetPath, 656 | fileEntries: list, 657 | }, 658 | }; 659 | } 660 | 661 | @WsErrorCatch() 662 | async touch({ id, data: { remotePath } }) { 663 | const sftp = await this.sftpMap.get(id); 664 | if (!sftp) return { errorMessage: '无法连接' }; 665 | 666 | await sftp.writeFile(remotePath, ''); 667 | 668 | return { 669 | data: true, 670 | }; 671 | } 672 | 673 | @WsErrorCatch() 674 | async writeFile({ id, data: { remotePath, buffer } }) { 675 | const sftp = await this.sftpMap.get(id); 676 | if (!sftp) return { errorMessage: '无法连接' }; 677 | 678 | this.socket.emit(`file:uploaded:${id}`, { 679 | filepath: Path.basename(remotePath), 680 | process: 0.01, 681 | }); 682 | 683 | await sftp.writeFile(remotePath, buffer); 684 | 685 | this.socket.emit(`file:uploaded:${id}`, { 686 | filepath: Path.basename(remotePath), 687 | process: 1, 688 | }); 689 | 690 | return { 691 | data: true, 692 | }; 693 | } 694 | 695 | @WsErrorCatch() 696 | async writeFileByPath({ id, data: { localDirectory, remoteDirectory } }) { 697 | const connection = await this.getConnection(id); 698 | if (!connection) return { errorMessage: '无法连接' }; 699 | 700 | await connection.putDirectory(localDirectory, remoteDirectory, { 701 | concurrency: 5, 702 | transferOptions: { 703 | // @ts-ignore 704 | step: ( 705 | total_transferred: number, 706 | chunk: number, 707 | total: number, 708 | localFile: string, 709 | ) => { 710 | this.socket.emit(`file:uploaded:${id}`, { 711 | filepath: localFile, 712 | process: Number.parseFloat((total_transferred / total).toFixed(3)), 713 | }); 714 | }, 715 | }, 716 | }); 717 | 718 | return { 719 | data: true, 720 | }; 721 | } 722 | 723 | @WsErrorCatch() 724 | async writeFiles({ id, data: { files } }) { 725 | const connection = await this.getConnection(id); 726 | if (!connection) return { errorMessage: '无法连接' }; 727 | 728 | await connection.putFiles(files, { 729 | concurrency: 5, 730 | transferOptions: { 731 | // @ts-ignore 732 | step: ( 733 | total_transferred: number, 734 | chunk: number, 735 | total: number, 736 | localFile: string, 737 | ) => { 738 | this.socket.emit(`file:uploaded:${id}`, { 739 | filepath: localFile, 740 | process: Number.parseFloat((total_transferred / total).toFixed(3)), 741 | }); 742 | }, 743 | }, 744 | }); 745 | 746 | return { 747 | data: true, 748 | }; 749 | } 750 | 751 | @WsErrorCatch() 752 | async getFile({ id, data: { remotePath } }) { 753 | const sftp = await this.sftpMap.get(id); 754 | if (!sftp) return { errorMessage: '无法连接' }; 755 | 756 | const buffer = await sftp.readFile(remotePath, {}); 757 | 758 | return { 759 | data: buffer, 760 | }; 761 | } 762 | 763 | @WsErrorCatch() 764 | async getFiles(@MessageBody() { id, data: { remotePaths } }) { 765 | const connection = await this.getConnection(id); 766 | const sftp = await this.sftpMap.get(id); 767 | 768 | if (!sftp || !connection) { 769 | return { errorMessage: '无法连接' }; 770 | } 771 | 772 | const tarFilename = `/tmp/${moment().format('YYYYMMDDHHmmss')}.tar.gz`; 773 | const tarFileStringArr: string[] = ['-czf', tarFilename]; 774 | remotePaths.forEach((item) => { 775 | tarFileStringArr.push('-C'); 776 | tarFileStringArr.push(item.path); 777 | tarFileStringArr.push(item.filename); 778 | }); 779 | await Base.exec(connection, 'tar', tarFileStringArr); 780 | const buffer = await sftp.readFile(tarFilename, {}); 781 | sftp.unlink(tarFilename).then(); 782 | 783 | return { 784 | data: buffer, 785 | }; 786 | } 787 | 788 | @WsErrorCatch() 789 | async getFileByPath({ id, data: { localDirectory, remoteDirectory } }) { 790 | const connection = await this.getConnection(id); 791 | if (!connection) return { errorMessage: '无法连接' }; 792 | 793 | await connection.getDirectory(localDirectory, remoteDirectory, { 794 | concurrency: 5, 795 | transferOptions: { 796 | // @ts-ignore 797 | step: ( 798 | total_transferred: number, 799 | chunk: number, 800 | total: number, 801 | remoteFile: string, 802 | ) => { 803 | this.socket.emit(`file:download:${id}`, { 804 | filepath: remoteFile, 805 | process: Number.parseFloat((total_transferred / total).toFixed(3)), 806 | }); 807 | }, 808 | }, 809 | }); 810 | 811 | return { 812 | data: true, 813 | }; 814 | } 815 | 816 | @WsErrorCatch() 817 | async getFilesByPath({ id, data: { files } }) { 818 | const connection = await this.getConnection(id); 819 | if (!connection) return { errorMessage: '无法连接' }; 820 | 821 | await connection.getFiles(files, { 822 | concurrency: 5, 823 | transferOptions: { 824 | // @ts-ignore 825 | step: ( 826 | total_transferred: number, 827 | chunk: number, 828 | total: number, 829 | remoteFile: string, 830 | ) => { 831 | this.socket.emit(`file:download:${id}`, { 832 | filepath: remoteFile, 833 | process: Number.parseFloat((total_transferred / total).toFixed(3)), 834 | }); 835 | }, 836 | }, 837 | }); 838 | 839 | return { 840 | data: true, 841 | }; 842 | } 843 | 844 | @WsErrorCatch() 845 | async rename({ id, data: { srcPath, destPath } }) { 846 | const sftp = await this.sftpMap.get(id); 847 | if (!sftp) return { errorMessage: '无法连接' }; 848 | 849 | await sftp.rename(srcPath, destPath); 850 | 851 | return { 852 | data: true, 853 | }; 854 | } 855 | 856 | @WsErrorCatch() 857 | async unlink(@MessageBody() { id, data: { remotePath } }) { 858 | const sftp = await this.sftpMap.get(id); 859 | if (!sftp) return { errorMessage: '无法连接' }; 860 | 861 | await sftp.unlink(remotePath); 862 | 863 | return { 864 | data: true, 865 | }; 866 | } 867 | 868 | @WsErrorCatch() 869 | async rmdir(@MessageBody() { id, data: { remotePath } }) { 870 | const sftp = await this.sftpMap.get(id); 871 | if (!sftp) return { errorMessage: '无法连接' }; 872 | 873 | await sftp.rmdir(remotePath); 874 | 875 | return { 876 | data: true, 877 | }; 878 | } 879 | 880 | @WsErrorCatch() 881 | async rmrf(@MessageBody() { id, data: { remotePath } }) { 882 | const connection = await this.getConnection(id); 883 | if (!connection) { 884 | return { errorMessage: '无法连接' }; 885 | } 886 | 887 | const { stderr } = await Base.execs(connection, `rm -rf ${remotePath}`); 888 | if (stderr) { 889 | const sftp = await this.sftpMap.get(id); 890 | if (sftp) { 891 | await sftp.rmdir(remotePath); 892 | } 893 | } 894 | 895 | return { 896 | data: true, 897 | }; 898 | } 899 | 900 | @WsErrorCatch() 901 | async mkdir(@MessageBody() { id, data: { remotePath } }) { 902 | const sftp = await this.sftpMap.get(id); 903 | if (!sftp) return { errorMessage: '无法连接' }; 904 | 905 | await sftp.mkdir(remotePath, {}); 906 | 907 | return { 908 | data: true, 909 | }; 910 | } 911 | 912 | @WsErrorCatch() 913 | async serverStatus({ id }) { 914 | try { 915 | const sftp = await this.sftpMap.get(id); 916 | if (!sftp) return { errorMessage: '无法连接' }; 917 | 918 | const file = await sftp.readFile('.terminal.icu/agent/status.txt', {}); 919 | 920 | return { data: JSON.parse(file.toString()) }; 921 | } catch (e) { 922 | return { data: {} }; 923 | } 924 | } 925 | 926 | handleDisconnect(connectionId?: string) { 927 | Sftp.logger.log( 928 | `[handleDisconnect] connectionId: ${connectionId ? 'one' : 'all'}`, 929 | ); 930 | if (connectionId) { 931 | const connection = this.connectionMap.get(connectionId); 932 | if (connection) { 933 | this.connectionMap.delete(connectionId); 934 | connection.dispose(true); 935 | } 936 | 937 | return; 938 | } 939 | 940 | this.connectionMap.forEach((connection, id) => { 941 | this.connectionMap.delete(id); 942 | connection.dispose(true); 943 | }); 944 | 945 | this.sftpMap.forEach((sftp, id) => { 946 | sftp.end(); 947 | this.sftpMap.delete(id); 948 | }); 949 | } 950 | } 951 | 952 | export class Redis extends Base { 953 | static logger: Logger = new Logger('Redis'); 954 | private redisMap: Map = new Map(); 955 | 956 | constructor(private socket: ConsoleSocket) { 957 | super(); 958 | } 959 | 960 | @WsErrorCatch() 961 | async redisConnect({ 962 | id, 963 | host, 964 | password, 965 | initKeys = true, 966 | port = 6379, 967 | ...config 968 | }) { 969 | Redis.logger.log( 970 | `[redisConnect] start ${id} initKeys: ${initKeys} ${host}`, 971 | ); 972 | let redis: IORedis.Redis; 973 | 974 | redis = this.redisMap.get(id); 975 | if (redis) { 976 | Redis.logger.log('[redisConnect] connecting'); 977 | // 正在连接 978 | } else { 979 | Redis.logger.log('[redisConnect] new redis'); 980 | 981 | // 新建连接 982 | const secretKey = md5(`${host}${port}`).toString(); 983 | if (password) { 984 | password = decrypt(password, secretKey); 985 | } 986 | 987 | try { 988 | await new Promise((resolve, reject) => { 989 | redis = new IORedis({ 990 | ...config, 991 | host, 992 | port, 993 | password, 994 | }); 995 | redis.on('error', async (error) => { 996 | Redis.logger.log(`[redisConnect] error event ${error.message}`); 997 | await redis.quit(); 998 | reject(error); 999 | }); 1000 | redis.on('connect', () => { 1001 | Redis.logger.log(`[redisConnect] connect success event`); 1002 | resolve(redis); 1003 | }); 1004 | redis.on('close', () => { 1005 | Redis.logger.log(`[redisConnect] close event`); 1006 | }); 1007 | }); 1008 | } catch (error) { 1009 | Redis.logger.log(`[redisConnect] error ${error.message}`); 1010 | return { 1011 | success: false, 1012 | errorMessage: error.message, 1013 | }; 1014 | } 1015 | this.redisMap.set(id, redis); 1016 | } 1017 | 1018 | return { 1019 | success: true, 1020 | data: initKeys ? (await this.redisKeys({ match: '*', id })).data : [], 1021 | }; 1022 | } 1023 | 1024 | @WsErrorCatch() 1025 | async deleteRedisKey( 1026 | @MessageBody() { id, refreshKeys = true, keys, match, count, method }, 1027 | ) { 1028 | Redis.logger.log( 1029 | `redis:deleteKey start ${keys.map((v) => v.key).join(',')}`, 1030 | ); 1031 | const redis = this.redisMap.get(id); 1032 | if (!redis) return { errorMessage: 'redis 已断开连接' }; 1033 | 1034 | method = method === 'unlink' ? 'unlink' : 'del'; 1035 | 1036 | await Promise.all([ 1037 | // 普通的 key 1038 | Promise.all( 1039 | keys.filter((v) => v.isLeaf).map((v) => redis[method](v.key).catch()), 1040 | ), 1041 | // 前缀 key 1042 | Promise.all( 1043 | keys 1044 | .filter((v) => !v.isLeaf) 1045 | .map((v) => { 1046 | return new Promise((resolve) => { 1047 | if (!v.key) { 1048 | resolve(true); 1049 | return; 1050 | } 1051 | 1052 | const stream = redis.scanStream({ 1053 | match: `${v.key}:*`, 1054 | count: 50, 1055 | }); 1056 | 1057 | stream.on('data', async (resultKeys) => { 1058 | stream.pause(); 1059 | await Promise.all(resultKeys.map((key) => redis[method](key))); 1060 | stream.resume(); 1061 | }); 1062 | stream.on('end', () => resolve(true)); 1063 | stream.on('error', () => resolve(true)); 1064 | }); 1065 | }), 1066 | ), 1067 | ]); 1068 | 1069 | return { 1070 | success: true, 1071 | data: refreshKeys 1072 | ? (await this.redisKeys({ match, id, count })).data 1073 | : [], 1074 | }; 1075 | } 1076 | 1077 | @WsErrorCatch() 1078 | async redisKeys({ id, match, needType = true, count = 500 }) { 1079 | const redis = this.redisMap.get(id); 1080 | if (!redis) return { errorMessage: 'redis 已断开连接', data: [] }; 1081 | 1082 | let cursor: undefined | string = undefined; 1083 | const result: string[] = []; 1084 | while (cursor !== '0' && result.length < count) { 1085 | const [currentCursor, currentResult] = await redis.scan( 1086 | cursor || '0', 1087 | 'match', 1088 | match || '*', 1089 | 'count', 1090 | 50, 1091 | ); 1092 | 1093 | cursor = currentCursor; 1094 | result.push(...currentResult); 1095 | } 1096 | 1097 | const keys = _.uniq(_.flatten(result)); 1098 | if (!needType) { 1099 | return { 1100 | success: true, 1101 | data: keys.map((v) => ({ key: v })), 1102 | }; 1103 | } 1104 | 1105 | const pipeline = redis.pipeline(); 1106 | keys.forEach((key) => pipeline.type(key)); 1107 | const types = await pipeline.exec(); 1108 | return { 1109 | success: true, 1110 | data: keys.map((key, index) => ({ 1111 | key, 1112 | type: types[index][1], 1113 | })), 1114 | }; 1115 | } 1116 | 1117 | @WsErrorCatch() 1118 | async redisHScan(@MessageBody() { id, match, key, count = 500 }) { 1119 | const redis = this.redisMap.get(id); 1120 | if (!redis) return { errorMessage: 'redis 已断开连接' }; 1121 | 1122 | let cursor: undefined | string = undefined; 1123 | const result: string[] = []; 1124 | while (cursor !== '0' && result.length / 2 < count) { 1125 | const [currentCursor, currentResult] = await redis.hscan( 1126 | key, 1127 | cursor || '0', 1128 | 'match', 1129 | match || '*', 1130 | 'count', 1131 | 50, 1132 | ); 1133 | 1134 | cursor = currentCursor; 1135 | result.push(...currentResult); 1136 | } 1137 | 1138 | return { 1139 | success: true, 1140 | data: result, 1141 | }; 1142 | } 1143 | 1144 | @WsErrorCatch() 1145 | async redisSScan(@MessageBody() { id, match, key, count = 500 }) { 1146 | const redis = this.redisMap.get(id); 1147 | if (!redis) return { errorMessage: 'redis 已断开连接' }; 1148 | 1149 | let cursor: undefined | string = undefined; 1150 | const result: string[] = []; 1151 | while (cursor !== '0' && result.length < count) { 1152 | const [currentCursor, currentResult] = await redis.sscan( 1153 | key, 1154 | cursor || '0', 1155 | 'match', 1156 | match || '*', 1157 | 'count', 1158 | 50, 1159 | ); 1160 | 1161 | cursor = currentCursor; 1162 | result.push(...currentResult); 1163 | } 1164 | 1165 | return { 1166 | success: true, 1167 | data: result, 1168 | }; 1169 | } 1170 | 1171 | @WsErrorCatch() 1172 | async redisCommand(@MessageBody() { id, command, params }) { 1173 | const redis = this.redisMap.get(id); 1174 | if (!redis) return { errorMessage: 'redis disconnect' }; 1175 | 1176 | try { 1177 | const data = await redis[command](...params); 1178 | return { success: true, data }; 1179 | } catch (e) { 1180 | Redis.logger.error(`redis:command ${e.message}`); 1181 | return { errorMessage: e.message }; 1182 | } 1183 | } 1184 | 1185 | @WsErrorCatch() 1186 | async redisInfo(@MessageBody() { id }) { 1187 | const redis = this.redisMap.get(id); 1188 | if (!redis) { 1189 | return { errorMessage: 'redis disconnect' }; 1190 | } 1191 | try { 1192 | const [[, keyspace], [, info], [, [, databases]]] = await redis 1193 | .pipeline() 1194 | .info('keyspace') 1195 | .info() 1196 | .config('get', 'databases') 1197 | .exec(); 1198 | const parseInfo = redisInfoParser(info); 1199 | return { 1200 | success: true, 1201 | data: { 1202 | databases: Number.parseInt(databases), 1203 | keyspace: _.pick(redisInfoParser(keyspace), ['databases']), 1204 | cpu: _.pick(parseInfo, ['used_cpu_sys', 'used_cpu_user']), 1205 | memory: _.pick(parseInfo, [ 1206 | 'maxmemory', 1207 | 'used_memory', 1208 | 'total_system_memory', 1209 | ]), 1210 | server: _.pick(parseInfo, ['redis_version', 'uptime_in_days']), 1211 | clients: _.pick(parseInfo, ['connected_clients', 'blocked_clients']), 1212 | time: Date.now(), 1213 | }, 1214 | }; 1215 | } catch (e) { 1216 | Redis.logger.error(`redis:redisInfo ${e.message}`); 1217 | return { errorMessage: e.message }; 1218 | } 1219 | } 1220 | 1221 | @WsErrorCatch() 1222 | async handleDisconnect(connectionId?: string) { 1223 | Redis.logger.log( 1224 | `[handleDisconnect] connectionId: ${connectionId ? 'one' : 'all'}`, 1225 | ); 1226 | 1227 | if (connectionId) { 1228 | const redis = this.redisMap.get(connectionId); 1229 | if (redis) { 1230 | await redis.quit(); 1231 | redis.removeAllListeners(); 1232 | this.redisMap.delete(connectionId); 1233 | } 1234 | return; 1235 | } 1236 | 1237 | this.redisMap.forEach((redis, id) => { 1238 | redis.quit(); 1239 | redis.removeAllListeners(); 1240 | this.redisMap.delete(id); 1241 | }); 1242 | } 1243 | } 1244 | 1245 | export class ServerStatus extends Base { 1246 | static logger: Logger = new Logger('ServerStatus'); 1247 | static NvmNodePath = '.terminal.icu/versions/node/v14.18.0/bin/node'; 1248 | connectionMap: Map = new Map(); 1249 | 1250 | constructor(private socket: ConsoleSocket) { 1251 | super(); 1252 | } 1253 | 1254 | private static async hasNode(connection: NodeSSH, command?: string) { 1255 | // 检查本机 node 是否已经安装 1256 | const { stdout } = await ServerStatus.execs( 1257 | connection, 1258 | command || 'node -v', 1259 | ); 1260 | 1261 | if ( 1262 | stdout && 1263 | Number.parseInt(stdout.replace('v', '').split('.')[0], 10) >= 8 1264 | ) { 1265 | return true; 1266 | } 1267 | } 1268 | 1269 | private static async hasNvmNode(connection: NodeSSH) { 1270 | // 检查是否已经安装 nvm & node 1271 | const { stdout } = await ServerStatus.execs( 1272 | connection, 1273 | `if [ -f "${ServerStatus.NvmNodePath}" ]; then echo 'exists' ;fi`, 1274 | ); 1275 | 1276 | if (stdout === 'exists') { 1277 | return true; 1278 | } 1279 | } 1280 | 1281 | private static async sendLargeTextFile( 1282 | connection: NodeSSH, 1283 | files: { local: string; remote: string }[], 1284 | ) { 1285 | // 首先使用 sftp 1286 | try { 1287 | await connection.putFiles(files); 1288 | return true; 1289 | } catch (err1) { 1290 | try { 1291 | for (const file of files) { 1292 | // 删除源文件 1293 | await ServerStatus.execs(connection, `rm ${file.remote}`); 1294 | 1295 | const content = (await readFile(file.local)) 1296 | .toString() 1297 | .split('\n') 1298 | .reverse(); 1299 | const chunks: string[] = []; 1300 | let counter = 0; 1301 | let tmpChunk: string[] = []; 1302 | 1303 | while (content.length) { 1304 | const chunk = content.pop(); 1305 | 1306 | counter += chunk.length; 1307 | tmpChunk.push(chunk); 1308 | if (counter >= 1000) { 1309 | chunks.push(tmpChunk.join('\n')); 1310 | tmpChunk = []; 1311 | counter = 0; 1312 | } 1313 | } 1314 | 1315 | if (tmpChunk.length) { 1316 | chunks.push(tmpChunk.join('\n')); 1317 | } 1318 | 1319 | chunks.reverse(); 1320 | while (chunks.length) { 1321 | await ServerStatus.execs( 1322 | connection, 1323 | `echo ${shellEscape([chunks.pop()])} >> ${file.remote}`, 1324 | ); 1325 | } 1326 | } 1327 | } catch (err2) { 1328 | return false; 1329 | } 1330 | } 1331 | } 1332 | 1333 | private static async installNode(connection: NodeSSH) { 1334 | await ServerStatus.execs(connection, `mkdir -p .terminal.icu`); 1335 | 1336 | await ServerStatus.sendLargeTextFile(connection, [ 1337 | { 1338 | local: Path.join(__dirname, 'detector/nvm.sh'), 1339 | remote: '.terminal.icu/nvm.sh', 1340 | }, 1341 | ]); 1342 | 1343 | // 官方 1344 | this.logger.log('开始', 'nvm 官方安装 node'); 1345 | 1346 | const officeResult = await ServerStatus.execs( 1347 | connection, 1348 | `source .terminal.icu/nvm.sh && nvm install 14.18.0`, 1349 | ); 1350 | this.logger.log(officeResult, 'nvm 官方安装 node'); 1351 | 1352 | // 淘宝 1353 | if (!(await ServerStatus.hasNvmNode(connection))) { 1354 | this.logger.log('开始', 'nvm 淘宝安装 node'); 1355 | 1356 | const taobaoResult = await ServerStatus.execs( 1357 | connection, 1358 | `source .terminal.icu/nvm.sh && export NVM_NODEJS_ORG_MIRROR=https://npm.taobao.org/mirrors/node && nvm install 14.18.0`, 1359 | ); 1360 | this.logger.log(taobaoResult, 'nvm 淘宝安装 node'); 1361 | } 1362 | } 1363 | 1364 | private static async installJs(connection: NodeSSH) { 1365 | await ServerStatus.execs(connection, `mkdir -p .terminal.icu`); 1366 | 1367 | await ServerStatus.sendLargeTextFile(connection, [ 1368 | { 1369 | local: Path.join(__dirname, 'detector/base.js'), 1370 | remote: '.terminal.icu/base.js', 1371 | }, 1372 | { 1373 | local: Path.join(__dirname, 'detector/info.js'), 1374 | remote: '.terminal.icu/info.js', 1375 | }, 1376 | ]); 1377 | } 1378 | 1379 | handleDisconnect(connectionId?: string) { 1380 | ServerStatus.logger.log( 1381 | `[handleDisconnect] connectionId: ${connectionId ? 'one' : 'all'}`, 1382 | ); 1383 | if (connectionId) { 1384 | const connection = this.connectionMap.get(connectionId); 1385 | if (connection) { 1386 | this.connectionMap.delete(connectionId); 1387 | connection.dispose(true); 1388 | } 1389 | 1390 | return; 1391 | } 1392 | 1393 | this.connectionMap.forEach((connection, id) => { 1394 | this.connectionMap.delete(id); 1395 | connection.dispose(true); 1396 | }); 1397 | } 1398 | 1399 | @WsErrorCatch() 1400 | async hasInit(config: ConnectionConfig) { 1401 | const connection = await this.getConnection(config); 1402 | 1403 | return { 1404 | init: await ServerStatus.hasNvmNode(connection), 1405 | }; 1406 | } 1407 | 1408 | @WsErrorCatch() 1409 | async startFresh(configOrConnection: ConnectionConfig | NodeSSH) { 1410 | let connection: NodeSSH; 1411 | if (configOrConnection instanceof NodeSSH) { 1412 | connection = configOrConnection; 1413 | } else { 1414 | connection = await this.getConnection(configOrConnection); 1415 | } 1416 | 1417 | if (_.get(connection, KEYS.serverStatusLock)) return; 1418 | _.set(connection, KEYS.serverStatusLock, true); 1419 | 1420 | let nodePath = ''; 1421 | if (await ServerStatus.hasNvmNode(connection)) { 1422 | nodePath = ServerStatus.NvmNodePath; 1423 | } 1424 | 1425 | // 通过 nvm 安装 node 1426 | if (!nodePath) { 1427 | await ServerStatus.installNode(connection); 1428 | // 再次检查是否安装 node 1429 | if (await ServerStatus.hasNvmNode(connection)) { 1430 | nodePath = ServerStatus.NvmNodePath; 1431 | } 1432 | } 1433 | 1434 | // 尝试使用本地 node 1435 | if (!nodePath && (await ServerStatus.hasNode(connection))) { 1436 | nodePath = 'node'; 1437 | } 1438 | 1439 | // 尝试使用旧版本 1440 | if ( 1441 | !nodePath && 1442 | (await ServerStatus.hasNode(connection, '.terminal.icu/node/bin/node -v')) 1443 | ) { 1444 | nodePath = '.terminal.icu/node/bin/node'; 1445 | } 1446 | // TODO 还是不行通过本地直接传送 1447 | 1448 | // 安装客户端 1449 | if (nodePath) { 1450 | await ServerStatus.installJs(connection); 1451 | 1452 | // 启动 1453 | const statusShell = await connection.requestShell({ 1454 | env: { 1455 | HISTIGNORE: '*', 1456 | HISTSIZE: '0', 1457 | HISTFILESIZE: '0', 1458 | HISTCONTROL: 'ignorespace', 1459 | }, 1460 | }); 1461 | statusShell.write(`${nodePath} .terminal.icu/info.js\r\n`); 1462 | _.set(connection, KEYS.statusShell, statusShell); 1463 | // statusShell.on('data', (data) => { 1464 | // console.log(data.toString()); 1465 | // }); 1466 | } 1467 | } 1468 | 1469 | @WsErrorCatch() 1470 | async ServerStatus(connectionId: string) { 1471 | const connection = await this.getConnection(connectionId); 1472 | if (!connection) { 1473 | return { errorMessage: 'connectionNotFound' }; 1474 | } 1475 | 1476 | if (!connection.isConnected()) { 1477 | await connection.reconnect(); 1478 | _.set(connection, KEYS.serverStatusLock, false); 1479 | await this.startFresh(connection); 1480 | } 1481 | 1482 | const { stdout } = await ServerStatus.execs( 1483 | connection, 1484 | 'cat .terminal.icu/status.txt', 1485 | ); 1486 | 1487 | return { data: JSON.parse(stdout || '{}') }; 1488 | } 1489 | } 1490 | 1491 | @Injectable() 1492 | export class Forward { 1493 | private logger: Logger = new Logger('WebsocketGateway'); 1494 | 1495 | private forwardConnectionMap: Map = new Map(); 1496 | 1497 | ping(): string { 1498 | return 'pong'; 1499 | } 1500 | 1501 | async newForwardIn({ 1502 | id, 1503 | host, 1504 | username, 1505 | password = '', 1506 | privateKey = '', 1507 | port = 22, 1508 | remotePort, 1509 | localAddr, 1510 | localPort, 1511 | }) { 1512 | // 已经处理过,不再处理 1513 | if (this.forwardConnectionMap.get(id)) { 1514 | return { success: true, errorMessage: '' }; 1515 | } 1516 | 1517 | try { 1518 | if (isValidDomain(host, { allowUnicode: true })) { 1519 | try { 1520 | const { address } = await lookup(host); 1521 | host = address; 1522 | } catch (e) { 1523 | // nothing 1524 | } 1525 | } 1526 | 1527 | const connection = await this.forwardIn({ 1528 | id, 1529 | config: { 1530 | host, 1531 | username, 1532 | port, 1533 | tryKeyboard: true, 1534 | ...(password && { password }), 1535 | ...(privateKey && { privateKey }), 1536 | keepaliveInterval: 10000, 1537 | }, 1538 | remoteAddr: host, 1539 | remotePort, 1540 | localAddr, 1541 | localPort, 1542 | }); 1543 | 1544 | this.forwardConnectionMap.set(id, connection); 1545 | 1546 | this.logger.log(`[newForwardOut] connected, server: ${username}@${host}`); 1547 | } catch (error) { 1548 | this.logger.error('[newForwardOut] error', error.stack); 1549 | return { success: false, errorMessage: error.message }; 1550 | } 1551 | 1552 | return { success: true, errorMessage: '' }; 1553 | } 1554 | 1555 | async forwardIn(params: ForwardInParams) { 1556 | return new Promise(async (resolve, reject) => { 1557 | try { 1558 | const { id, config, remoteAddr, remotePort, localAddr, localPort } = 1559 | params; 1560 | 1561 | const connection = await new NodeSSH().connect(config); 1562 | 1563 | _.set(connection, '_config', params); 1564 | 1565 | connection.connection?.on('error', (error) => { 1566 | this.logger.error('connection server error', error.stack); 1567 | }); 1568 | connection.connection?.on('close', () => { 1569 | this.logger.warn('connection close, and retry forward'); 1570 | setTimeout(async () => { 1571 | // 移除原来的 1572 | connection.dispose(true); 1573 | this.forwardConnectionMap.delete(id); 1574 | 1575 | // 重新连接 1576 | this.forwardConnectionMap.set(id, await this.forwardIn(params)); 1577 | }, 1000); 1578 | }); 1579 | 1580 | connection.connection.forwardIn(remoteAddr, remotePort, (err) => { 1581 | if (err) { 1582 | if (connection.connection) { 1583 | connection.connection.removeAllListeners('close'); 1584 | } 1585 | connection.dispose(true); 1586 | this.logger.error(err); 1587 | this.forwardConnectionMap.delete(id); 1588 | reject(err); 1589 | return; 1590 | } 1591 | this.logger.log( 1592 | `forwardIn success, server: ${remoteAddr}:${remotePort} => ${localAddr}:${localPort}`, 1593 | ); 1594 | resolve(connection); 1595 | this.forwardConnectionMap.set(id, connection); 1596 | }); 1597 | 1598 | connection.connection.on('tcp connection', (info, accept) => { 1599 | const stream = accept().pause(); 1600 | const socket = net.connect(localPort, localAddr, function () { 1601 | socket.on('error', (error) => { 1602 | console.log('forward tcp error', error); 1603 | }); 1604 | stream.pipe(socket); 1605 | socket.pipe(stream); 1606 | stream.resume(); 1607 | }); 1608 | }); 1609 | } catch (error) { 1610 | reject(error); 1611 | } 1612 | }); 1613 | } 1614 | 1615 | unForward(id: string) { 1616 | const connection = this.forwardConnectionMap.get(id); 1617 | if (connection) { 1618 | const config: ForwardInParams = _.get(connection, '_config'); 1619 | connection.connection.removeAllListeners('close'); 1620 | connection.connection.unforwardIn(config.remoteAddr, config.remotePort); 1621 | connection.dispose(true); 1622 | this.forwardConnectionMap.delete(id); 1623 | this.logger.log('unForward success'); 1624 | } 1625 | } 1626 | 1627 | forwardStatus() { 1628 | const status: Record = {}; 1629 | 1630 | this.forwardConnectionMap.forEach((connection, id) => { 1631 | status[id] = connection.isConnected(); 1632 | }); 1633 | 1634 | return status; 1635 | } 1636 | } 1637 | -------------------------------------------------------------------------------- /src/detector/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | systeminformation logo 4 | 5 |

6 | 7 |

systeminformation

8 |

9 | System and OS information library for node.js 10 |
11 | Explore Systeminformation docs » 12 |
13 |
14 | Report bug 15 | · 16 | Request feature 17 | · 18 | Changelog 19 |

20 | 21 | 22 | > base.js 是 通过 parcel 打包 23 | 24 | > 打包命令 NODE_ENV=production parcel -t node lib/index.js -o base.js --out-dir detector --no-cache --no-source-maps && cp lib/index.d.ts detector/base.d.ts 25 | -------------------------------------------------------------------------------- /src/detector/base.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for systeminformation 2 | // Project: https://github.com/sebhildebrandt/systeminformation 3 | // Definitions by: sebhildebrandt 4 | 5 | export namespace Systeminformation { 6 | 7 | // 1. General 8 | 9 | interface TimeData { 10 | current: string; 11 | uptime: string; 12 | timezone: string; 13 | timezoneName: string; 14 | } 15 | 16 | // 2. System (HW) 17 | 18 | interface RaspberryRevisionData { 19 | manufacturer: string; 20 | processor: string; 21 | type: string; 22 | revision: string; 23 | } 24 | interface SystemData { 25 | manufacturer: string; 26 | model: string; 27 | version: string; 28 | serial: string; 29 | uuid: string; 30 | sku: string; 31 | virtual: boolean; 32 | virtualHost?: string; 33 | raspberry?: RaspberryRevisionData; 34 | } 35 | 36 | interface BiosData { 37 | vendor: string; 38 | version: string; 39 | releaseDate: string; 40 | revision: string; 41 | language?: string; 42 | features?: string[]; 43 | } 44 | 45 | interface BaseboardData { 46 | manufacturer: string; 47 | model: string; 48 | version: string; 49 | serial: string; 50 | assetTag: string; 51 | } 52 | 53 | interface ChassisData { 54 | manufacturer: string; 55 | model: string; 56 | type: string; 57 | version: string; 58 | serial: string; 59 | assetTag: string; 60 | sku: string; 61 | } 62 | 63 | // 3. CPU, Memory, Disks, Battery, Graphics 64 | 65 | interface CpuData { 66 | manufacturer: string; 67 | brand: string; 68 | vendor: string; 69 | family: string; 70 | model: string; 71 | stepping: string; 72 | revision: string; 73 | voltage: string; 74 | speed: number; 75 | speedMin: number; 76 | speedMax: number; 77 | governor: string; 78 | cores: number; 79 | physicalCores: number; 80 | efficiencyCores?: number; 81 | performanceCores?: number; 82 | processors: number; 83 | socket: string; 84 | flags: string; 85 | virtualization: boolean; 86 | cache: CpuCacheData; 87 | } 88 | 89 | interface CpuCacheData { 90 | l1d: number; 91 | l1i: number; 92 | l2: number; 93 | l3: number; 94 | } 95 | 96 | interface CpuCurrentSpeedData { 97 | min: number; 98 | max: number; 99 | avg: number; 100 | cores: number[]; 101 | } 102 | 103 | interface CpuTemperatureData { 104 | main: number; 105 | cores: number[]; 106 | max: number; 107 | socket?: number[]; 108 | chipset?: number; 109 | } 110 | 111 | interface MemData { 112 | total: number; 113 | free: number; 114 | used: number; 115 | active: number; 116 | available: number; 117 | buffcache: number; 118 | buffers: number; 119 | cached: number; 120 | slab: number; 121 | swaptotal: number; 122 | swapused: number; 123 | swapfree: number; 124 | } 125 | 126 | interface MemLayoutData { 127 | size: number; 128 | bank: string; 129 | type: string; 130 | ecc?: boolean; 131 | clockSpeed: number; 132 | formFactor: string; 133 | partNum: string; 134 | serialNum: string; 135 | voltageConfigured: number; 136 | voltageMin: number; 137 | voltageMax: number; 138 | } 139 | 140 | interface SmartData { 141 | smartctl: { 142 | version: number[]; 143 | platform_info: string; 144 | build_info: string; 145 | argv: string[]; 146 | exit_status: number; 147 | }; 148 | json_format_version: number[]; 149 | device: { 150 | name: string; 151 | info_name: string; 152 | type: string; 153 | protocol: string; 154 | } 155 | smart_status: { 156 | passed: boolean; 157 | } 158 | ata_smart_attributes: { 159 | revision: number; 160 | table: { 161 | id: number; 162 | name: string; 163 | value: number; 164 | worst: number; 165 | thresh: number; 166 | when_failed: string; 167 | flags: { 168 | value: number; 169 | string: string; 170 | prefailure: boolean; 171 | updated_online: boolean; 172 | performance: boolean; 173 | error_rate: boolean; 174 | event_count: boolean; 175 | auto_keep: boolean; 176 | }; 177 | raw: { value: number; string: string } 178 | }[]; 179 | }; 180 | power_on_time: { 181 | hours: number; 182 | }; 183 | power_cycle_count: number; 184 | temperature: { 185 | current: number; 186 | }; 187 | ata_smart_error_log: { 188 | summary: { 189 | revision: number; 190 | count: number; 191 | }; 192 | }; 193 | ata_smart_self_test_log: { 194 | standard: { 195 | revision: number; 196 | table: { 197 | type: { 198 | value: number; 199 | string: string; 200 | }, 201 | status: { 202 | value: number; 203 | string: string; 204 | passed: boolean; 205 | }, 206 | lifetime_hours: number; 207 | }[]; 208 | count: number; 209 | error_count_total: number; 210 | error_count_outdated: number; 211 | }; 212 | } 213 | } 214 | 215 | interface DiskLayoutData { 216 | device: string; 217 | type: string; 218 | name: string; 219 | vendor: string; 220 | size: number; 221 | bytesPerSector: number; 222 | totalCylinders: number; 223 | totalHeads: number; 224 | totalSectors: number; 225 | totalTracks: number; 226 | tracksPerCylinder: number; 227 | sectorsPerTrack: number; 228 | firmwareRevision: string; 229 | serialNum: string; 230 | interfaceType: string; 231 | smartStatus: string; 232 | smartData?: SmartData; 233 | } 234 | 235 | interface BatteryData { 236 | hasBattery: boolean; 237 | cycleCount: number; 238 | isCharging: boolean; 239 | voltage: number; 240 | designedCapacity: number; 241 | maxCapacity: number; 242 | currentCapacity: number; 243 | capacityUnit: string; 244 | percent: number; 245 | timeRemaining: number, 246 | acConnected: boolean; 247 | type: string; 248 | model: string; 249 | manufacturer: string; 250 | serial: string; 251 | } 252 | 253 | interface GraphicsData { 254 | controllers: GraphicsControllerData[]; 255 | displays: GraphicsDisplayData[]; 256 | } 257 | 258 | interface GraphicsControllerData { 259 | vendor: string; 260 | model: string; 261 | bus: string; 262 | busAddress?: string; 263 | vram: number; 264 | vramDynamic: boolean; 265 | subDeviceId?: string; 266 | driverVersion?: string; 267 | name?: string; 268 | pciBus?: string; 269 | fanSpeed?: number; 270 | memoryTotal?: number; 271 | memoryUsed?: number; 272 | memoryFree?: number; 273 | utilizationGpu?: number; 274 | utilizationMemory?: number; 275 | temperatureGpu?: number; 276 | temperatureMemory?: number; 277 | powerDraw?: number; 278 | powerLimit?: number; 279 | clockCore?: number; 280 | clockMemory?: number; 281 | } 282 | 283 | interface GraphicsDisplayData { 284 | vendor: string; 285 | model: string; 286 | deviceName: string; 287 | main: boolean; 288 | builtin: boolean; 289 | connection: string; 290 | sizeX: number; 291 | sizeY: number; 292 | pixelDepth: number; 293 | resolutionX: number; 294 | resolutionY: number; 295 | currentResX: number; 296 | currentResY: number; 297 | positionX: number; 298 | positionY: number; 299 | currentRefreshRate: number; 300 | } 301 | 302 | // 4. Operating System 303 | 304 | interface OsData { 305 | platform: string; 306 | distro: string; 307 | release: string; 308 | codename: string; 309 | kernel: string; 310 | arch: string; 311 | hostname: string; 312 | fqdn: string; 313 | codepage: string; 314 | logofile: string; 315 | serial: string; 316 | build: string; 317 | servicepack: string; 318 | uefi: boolean; 319 | hypervizor?: boolean; 320 | remoteSession?: boolean; 321 | } 322 | 323 | interface UuidData { 324 | os: string; 325 | hardware: string; 326 | } 327 | 328 | interface VersionData { 329 | kernel?: string; 330 | openssl?: string; 331 | systemOpenssl?: string; 332 | systemOpensslLib?: string; 333 | node?: string; 334 | v8?: string; 335 | npm?: string; 336 | yarn?: string; 337 | pm2?: string; 338 | gulp?: string; 339 | grunt?: string; 340 | git?: string; 341 | tsc?: string; 342 | mysql?: string; 343 | redis?: string; 344 | mongodb?: string; 345 | nginx?: string; 346 | php?: string; 347 | docker?: string; 348 | postfix?: string; 349 | postgresql?: string; 350 | perl?: string; 351 | python?: string; 352 | python3?: string; 353 | pip?: string; 354 | pip3?: string; 355 | java?: string; 356 | gcc?: string; 357 | virtualbox?: string; 358 | dotnet?: string; 359 | } 360 | 361 | interface UserData { 362 | user: string; 363 | tty: string; 364 | date: string; 365 | time: string; 366 | ip: string; 367 | command: string; 368 | } 369 | 370 | // 5. File System 371 | 372 | interface FsSizeData { 373 | fs: string; 374 | type: string; 375 | size: number; 376 | used: number; 377 | available: number; 378 | use: number; 379 | mount: string; 380 | } 381 | 382 | interface FsOpenFilesData { 383 | max: number; 384 | allocated: number; 385 | available: number; 386 | } 387 | 388 | interface BlockDevicesData { 389 | name: string; 390 | identifier: string; 391 | type: string; 392 | fsType: string; 393 | mount: string; 394 | size: number; 395 | physical: string; 396 | uuid: string; 397 | label: string; 398 | model: string; 399 | serial: string; 400 | removable: boolean; 401 | protocol: string; 402 | } 403 | 404 | interface FsStatsData { 405 | rx: number; 406 | wx: number; 407 | tx: number; 408 | rx_sec: number; 409 | wx_sec: number; 410 | tx_sec: number; 411 | ms: number; 412 | } 413 | 414 | interface DisksIoData { 415 | rIO: number; 416 | wIO: number; 417 | tIO: number; 418 | rIO_sec: number; 419 | wIO_sec: number; 420 | tIO_sec: number; 421 | ms: number; 422 | } 423 | 424 | // 6. Network related functions 425 | 426 | interface NetworkInterfacesData { 427 | iface: string; 428 | ifaceName: string; 429 | ip4: string; 430 | ip4subnet: string; 431 | ip6: string; 432 | ip6subnet: string; 433 | mac: string; 434 | internal: boolean; 435 | virtual: boolean; 436 | operstate: string; 437 | type: string; 438 | duplex: string; 439 | mtu: number; 440 | speed: number; 441 | dhcp: boolean; 442 | dnsSuffix: string; 443 | ieee8021xAuth: string; 444 | ieee8021xState: string; 445 | carrierChanges: number; 446 | } 447 | 448 | interface NetworkStatsData { 449 | iface: string; 450 | operstate: string; 451 | rx_bytes: number; 452 | rx_dropped: number; 453 | rx_errors: number; 454 | tx_bytes: number; 455 | tx_dropped: number; 456 | tx_errors: number; 457 | rx_sec: number; 458 | tx_sec: number; 459 | ms: number; 460 | } 461 | 462 | interface NetworkConnectionsData { 463 | protocol: string; 464 | localAddress: string; 465 | localPort: string; 466 | peerAddress: string; 467 | peerPort: string; 468 | state: string; 469 | pid: number; 470 | process: string; 471 | } 472 | 473 | interface InetChecksiteData { 474 | url: string; 475 | ok: boolean; 476 | status: number; 477 | ms: number; 478 | } 479 | 480 | interface WifiNetworkData { 481 | ssid: string; 482 | bssid: string; 483 | mode: string; 484 | channel: number; 485 | frequency: number; 486 | signalLevel: number; 487 | quality: number; 488 | security: string[]; 489 | wpaFlags: string[]; 490 | rsnFlags: string[]; 491 | } 492 | 493 | interface WifiInterfaceData { 494 | id: string; 495 | iface: string; 496 | model: string; 497 | vendor: string; 498 | } 499 | 500 | interface WifiConnectionData { 501 | id: string; 502 | iface: string; 503 | model: string; 504 | ssid: string; 505 | bssid: string; 506 | channel: number; 507 | type: string; 508 | security: string; 509 | frequency: number; 510 | signalLevel: number; 511 | txRate: number; 512 | } 513 | 514 | // 7. Current Load, Processes & Services 515 | 516 | interface CurrentLoadData { 517 | avgLoad: number; 518 | currentLoad: number; 519 | currentLoadUser: number; 520 | currentLoadSystem: number; 521 | currentLoadNice: number; 522 | currentLoadIdle: number; 523 | currentLoadIrq: number; 524 | rawCurrentLoad: number; 525 | rawCurrentLoadUser: number; 526 | rawCurrentLoadSystem: number; 527 | rawCurrentLoadNice: number; 528 | rawCurrentLoadIdle: number; 529 | rawCurrentLoadIrq: number; 530 | cpus: CurrentLoadCpuData[]; 531 | } 532 | 533 | interface CurrentLoadCpuData { 534 | load: number; 535 | loadUser: number; 536 | loadSystem: number; 537 | loadNice: number; 538 | loadIdle: number; 539 | loadIrq: number; 540 | rawLoad: number; 541 | rawLoadUser: number; 542 | rawLoadSystem: number; 543 | rawLoadNice: number; 544 | rawLoadIdle: number; 545 | rawLoadIrq: number; 546 | } 547 | 548 | interface ProcessesData { 549 | all: number; 550 | running: number; 551 | blocked: number; 552 | sleeping: number; 553 | unknown: number; 554 | list: ProcessesProcessData[]; 555 | } 556 | 557 | interface ProcessesProcessData { 558 | pid: number; 559 | parentPid: number; 560 | name: string, 561 | cpu: number; 562 | cpuu: number; 563 | cpus: number; 564 | mem: number; 565 | priority: number; 566 | memVsz: number; 567 | memRss: number; 568 | nice: number; 569 | started: string, 570 | state: string; 571 | tty: string; 572 | user: string; 573 | command: string; 574 | params: string; 575 | path: string; 576 | } 577 | 578 | interface ProcessesProcessLoadData { 579 | proc: string; 580 | pid: number; 581 | pids: number[]; 582 | cpu: number; 583 | mem: number; 584 | } 585 | 586 | interface ServicesData { 587 | name: string; 588 | running: boolean; 589 | startmode: string; 590 | pids: number[]; 591 | cpu: number; 592 | mem: number; 593 | } 594 | 595 | // 8. Docker 596 | 597 | interface DockerInfoData { 598 | id: string; 599 | containers: number; 600 | containersRunning: number; 601 | containersPaused: number; 602 | containersStopped: number; 603 | images: number; 604 | driver: string; 605 | memoryLimit: boolean; 606 | swapLimit: boolean; 607 | kernelMemory: boolean; 608 | cpuCfsPeriod: boolean; 609 | cpuCfsQuota: boolean; 610 | cpuShares: boolean; 611 | cpuSet: boolean; 612 | ipv4Forwarding: boolean; 613 | bridgeNfIptables: boolean; 614 | bridgeNfIp6tables: boolean; 615 | debug: boolean; 616 | mfd: number; 617 | oomKillDisable: boolean; 618 | ngoroutines: number; 619 | systemTime: string; 620 | loggingDriver: string; 621 | cgroupDriver: string; 622 | nEventsListener: number; 623 | kernelVersion: string; 624 | operatingSystem: string; 625 | osType: string; 626 | architecture: string; 627 | ncpu: number; 628 | memTotal: number; 629 | dockerRootDir: string; 630 | httpProxy: string; 631 | httpsProxy: string; 632 | noProxy: string; 633 | name: string; 634 | labels: string[]; 635 | experimentalBuild: boolean; 636 | serverVersion: string; 637 | clusterStore: string; 638 | clusterAdvertise: string; 639 | defaultRuntime: string; 640 | liveRestoreEnabled: boolean; 641 | isolation: string; 642 | initBinary: string; 643 | productLicense: string; 644 | } 645 | 646 | interface DockerImageData { 647 | id: string; 648 | container: string; 649 | comment: string; 650 | os: string; 651 | architecture: string; 652 | parent: string; 653 | dockerVersion: string; 654 | size: number; 655 | sharedSize: number; 656 | virtualSize: number; 657 | author: string; 658 | created: number; 659 | containerConfig: any; 660 | graphDriver: any; 661 | repoDigests: any; 662 | repoTags: any; 663 | config: any; 664 | rootFS: any; 665 | } 666 | 667 | interface DockerContainerData { 668 | id: string; 669 | name: string; 670 | image: string; 671 | imageID: string; 672 | command: string; 673 | created: number; 674 | started: number; 675 | finished: number; 676 | createdAt: string; 677 | startedAt: string; 678 | finishedAt: string; 679 | state: string; 680 | restartCount: number; 681 | platform: string; 682 | driver: string; 683 | ports: number[]; 684 | mounts: DockerContainerMountData[]; 685 | } 686 | 687 | interface DockerContainerMountData { 688 | Type: string; 689 | Source: string; 690 | Destination: string; 691 | Mode: string; 692 | RW: boolean; 693 | Propagation: string; 694 | } 695 | 696 | interface DockerContainerStatsData { 697 | id: string; 698 | memUsage: number; 699 | memLimit: number; 700 | memPercent: number; 701 | cpuPercent: number; 702 | netIO: { 703 | rx: number; 704 | wx: number; 705 | }; 706 | blockIO: { 707 | r: number; 708 | w: number; 709 | }; 710 | restartCount: number; 711 | cpuStats: any; 712 | precpuStats: any; 713 | memoryStats: any, 714 | networks: any; 715 | } 716 | 717 | interface DockerVolumeData { 718 | name: string; 719 | driver: string; 720 | labels: any; 721 | mountpoint: string; 722 | options: any; 723 | scope: string; 724 | created: number; 725 | } 726 | 727 | // 9. Virtual Box 728 | 729 | interface VboxInfoData { 730 | id: string; 731 | name: string; 732 | running: boolean; 733 | started: string; 734 | runningSince: number; 735 | stopped: string; 736 | stoppedSince: number; 737 | guestOS: string; 738 | hardwareUUID: string; 739 | memory: number; 740 | vram: number; 741 | cpus: number; 742 | cpuExepCap: string; 743 | cpuProfile: string; 744 | chipset: string; 745 | firmware: string; 746 | pageFusion: boolean; 747 | configFile: string; 748 | snapshotFolder: string; 749 | logFolder: string; 750 | hpet: boolean; 751 | pae: boolean; 752 | longMode: boolean; 753 | tripleFaultReset: boolean; 754 | apic: boolean; 755 | x2Apic: boolean; 756 | acpi: boolean; 757 | ioApic: boolean; 758 | biosApicMode: string; 759 | bootMenuMode: string; 760 | bootDevice1: string; 761 | bootDevice2: string; 762 | bootDevice3: string; 763 | bootDevice4: string; 764 | timeOffset: string; 765 | rtc: string; 766 | } 767 | 768 | interface PrinterData { 769 | id: number; 770 | name: string; 771 | model: string; 772 | uri: string; 773 | uuid: string; 774 | local: boolean; 775 | status: string; 776 | default: boolean; 777 | shared: boolean; 778 | } 779 | 780 | interface UsbData { 781 | id: number | string; 782 | bus: number; 783 | deviceId: number; 784 | name: string; 785 | type: string; 786 | removable: boolean; 787 | vendor: string; 788 | manufacturer: string; 789 | maxPower: string; 790 | serialNumber: string; 791 | } 792 | 793 | interface AudioData { 794 | id: number | string; 795 | name: string; 796 | manufacturer: string; 797 | default: boolean; 798 | revision: string; 799 | driver: string; 800 | in: boolean; 801 | out: boolean; 802 | interfaceType: string; 803 | status: string; 804 | } 805 | 806 | interface BluetoothDeviceData { 807 | device: string; 808 | name: string; 809 | macDevice: string; 810 | macHost: string; 811 | batteryPercent: number; 812 | manufacturer: string; 813 | type: string; 814 | connected: boolean; 815 | } 816 | 817 | // 10. "Get All at once" - functions 818 | 819 | interface StaticData { 820 | version: string; 821 | system: SystemData; 822 | bios: BiosData; 823 | baseboard: BaseboardData; 824 | chassis: ChassisData; 825 | os: OsData; 826 | uuid: UuidData; 827 | versions: VersionData; 828 | cpu: CpuData; 829 | graphics: GraphicsData; 830 | net: NetworkInterfacesData[]; 831 | memLayout: MemLayoutData[]; 832 | diskLayout: DiskLayoutData[]; 833 | } 834 | } 835 | 836 | export function version(): string; 837 | export function system(cb?: (data: Systeminformation.SystemData) => any): Promise; 838 | export function bios(cb?: (data: Systeminformation.BiosData) => any): Promise; 839 | export function baseboard(cb?: (data: Systeminformation.BaseboardData) => any): Promise; 840 | export function chassis(cb?: (data: Systeminformation.ChassisData) => any): Promise; 841 | 842 | export function time(): Systeminformation.TimeData; 843 | export function osInfo(cb?: (data: Systeminformation.OsData) => any): Promise; 844 | export function versions(apps?: string, cb?: (data: Systeminformation.VersionData) => any): Promise; 845 | export function shell(cb?: (data: string) => any): Promise; 846 | export function uuid(cb?: (data: Systeminformation.UuidData) => any): Promise; 847 | 848 | export function cpu(cb?: (data: Systeminformation.CpuData) => any): Promise; 849 | export function cpuFlags(cb?: (data: string) => any): Promise; 850 | export function cpuCache(cb?: (data: Systeminformation.CpuCacheData) => any): Promise; 851 | export function cpuCurrentSpeed(cb?: (data: Systeminformation.CpuCurrentSpeedData) => any): Promise; 852 | export function cpuTemperature(cb?: (data: Systeminformation.CpuTemperatureData) => any): Promise; 853 | export function currentLoad(cb?: (data: Systeminformation.CurrentLoadData) => any): Promise; 854 | export function fullLoad(cb?: (data: number) => any): Promise; 855 | 856 | export function mem(cb?: (data: Systeminformation.MemData) => any): Promise; 857 | export function memLayout(cb?: (data: Systeminformation.MemLayoutData[]) => any): Promise; 858 | 859 | export function battery(cb?: (data: Systeminformation.BatteryData) => any): Promise; 860 | export function graphics(cb?: (data: Systeminformation.GraphicsData) => any): Promise; 861 | 862 | export function fsSize(cb?: (data: Systeminformation.FsSizeData[]) => any): Promise; 863 | export function fsOpenFiles(cb?: (data: Systeminformation.FsOpenFilesData[]) => any): Promise; 864 | export function blockDevices(cb?: (data: Systeminformation.BlockDevicesData[]) => any): Promise; 865 | export function fsStats(cb?: (data: Systeminformation.FsStatsData) => any): Promise; 866 | export function disksIO(cb?: (data: Systeminformation.DisksIoData) => any): Promise; 867 | export function diskLayout(cb?: (data: Systeminformation.DiskLayoutData[]) => any): Promise; 868 | 869 | export function networkInterfaceDefault(cb?: (data: string) => any): Promise; 870 | export function networkGatewayDefault(cb?: (data: string) => any): Promise; 871 | export function networkInterfaces(cb?: (data: Systeminformation.NetworkInterfacesData[]) => any): Promise; 872 | 873 | export function networkStats(ifaces?: string, cb?: (data: Systeminformation.NetworkStatsData[]) => any): Promise; 874 | export function networkConnections(cb?: (data: Systeminformation.NetworkConnectionsData[]) => any): Promise; 875 | export function inetChecksite(url: string, cb?: (data: Systeminformation.InetChecksiteData) => any): Promise; 876 | export function inetLatency(host?: string, cb?: (data: number) => any): Promise; 877 | 878 | export function wifiNetworks(cb?: (data: Systeminformation.WifiNetworkData[]) => any): Promise; 879 | export function wifiInterfaces(cb?: (data: Systeminformation.WifiInterfaceData[]) => any): Promise; 880 | export function wifiConnections(cb?: (data: Systeminformation.WifiConnectionData[]) => any): Promise; 881 | 882 | export function users(cb?: (data: Systeminformation.UserData[]) => any): Promise; 883 | 884 | export function processes(cb?: (data: Systeminformation.ProcessesData) => any): Promise; 885 | export function processLoad(processName: string, cb?: (data: Systeminformation.ProcessesProcessLoadData) => any): Promise; 886 | export function services(serviceName: string, cb?: (data: Systeminformation.ServicesData[]) => any): Promise; 887 | 888 | export function dockerInfo(cb?: (data: Systeminformation.DockerInfoData) => any): Promise; 889 | export function dockerImages(all?: boolean, cb?: (data: Systeminformation.DockerImageData[]) => any): Promise; 890 | export function dockerContainers(all?: boolean, cb?: (data: Systeminformation.DockerContainerData[]) => any): Promise; 891 | export function dockerContainerStats(id?: string, cb?: (data: Systeminformation.DockerContainerStatsData[]) => any): Promise; 892 | export function dockerContainerProcesses(id?: string, cb?: (data: any) => any): Promise; 893 | export function dockerVolumes(cb?: (data: Systeminformation.DockerVolumeData[]) => any): Promise; 894 | export function dockerAll(cb?: (data: any) => any): Promise; 895 | 896 | export function vboxInfo(cb?: (data: Systeminformation.VboxInfoData[]) => any): Promise; 897 | 898 | export function printer(cb?: (data: Systeminformation.PrinterData[]) => any): Promise; 899 | 900 | export function usb(cb?: (data: Systeminformation.UsbData[]) => any): Promise; 901 | 902 | export function audio(cb?: (data: Systeminformation.AudioData[]) => any): Promise; 903 | 904 | export function bluetoothDevices(cb?: (data: Systeminformation.BlockDevicesData[]) => any): Promise; 905 | 906 | export function getStaticData(cb?: (data: Systeminformation.StaticData) => any): Promise; 907 | export function getDynamicData(srv?: string, iface?: string, cb?: (data: any) => any): Promise; 908 | export function getAllData(srv?: string, iface?: string, cb?: (data: any) => any): Promise; 909 | export function get(valuesObject: any, cb?: (data: any) => any): Promise; 910 | export function observe(valuesObject: any, interval: number, cb?: (data: any) => any): number; 911 | -------------------------------------------------------------------------------- /src/detector/info.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const util = require('util'); 3 | const path = require('path'); 4 | const agent = require('./base'); 5 | 6 | const writeFile = util.promisify(fs.writeFile); 7 | const stat = util.promisify(fs.stat); 8 | 9 | function round(number, precision = 2) { 10 | if (typeof number === 'number') { 11 | return Number.parseFloat(number.toFixed(precision)); 12 | } 13 | 14 | return number; 15 | } 16 | 17 | async function getCpuInfo(result) { 18 | const currentLoad = await agent.currentLoad(); 19 | result.cpu = { 20 | currentLoad: round(currentLoad.currentLoad), 21 | currentLoadUser: round(currentLoad.currentLoadUser), 22 | currentLoadSystem: round(currentLoad.currentLoadSystem), 23 | currentLoadIdle: round(currentLoad.currentLoadIdle), 24 | cpuList: currentLoad.cpus.map((cpu) => ({ 25 | // load: round(cpu.load), 26 | loadUser: round(cpu.loadUser), 27 | loadSystem: round(cpu.loadSystem), 28 | loadIdle: round(cpu.loadIdle), 29 | })), 30 | }; 31 | } 32 | 33 | async function getMemoryInfo(result) { 34 | const mem = await agent.mem(); 35 | result.mem = { 36 | total: mem.total, 37 | free: mem.free, 38 | used: mem.used, 39 | buffcache: mem.buffcache, 40 | swaptotal: mem.swaptotal, 41 | swapused: mem.swapused, 42 | swapfree: mem.swapfree, 43 | }; 44 | } 45 | 46 | async function getFsSize(result) { 47 | const [fsSize, fsStats] = await Promise.all([ 48 | agent.fsSize(), 49 | agent.fsStats(), 50 | ]); 51 | 52 | result.fsSize = fsSize.map((item) => ({ 53 | fs: item.fs, 54 | type: item.type, 55 | size: item.size, 56 | available: item.available, 57 | mount: item.mount, 58 | })); 59 | 60 | result.fsStats = { 61 | rxSec: round((fsStats && fsStats.rx_sec) || 0), 62 | wxSec: round((fsStats && fsStats.wx_sec) || 0), 63 | }; 64 | } 65 | 66 | let rxInit = 0; 67 | let txInit = 0; 68 | 69 | async function getNetworkStats(result) { 70 | const networkStats = await agent.networkStats(); 71 | if (!rxInit) { 72 | rxInit = networkStats.reduce((a, b) => a + b.rx_bytes, 0); 73 | txInit = networkStats.reduce((a, b) => a + b.tx_bytes, 0); 74 | } 75 | 76 | result.networkStats = { 77 | rxBytes: rxInit 78 | ? networkStats.reduce((a, b) => a + b.rx_bytes, 0) - rxInit 79 | : 0, 80 | txBytes: txInit 81 | ? networkStats.reduce((a, b) => a + b.tx_bytes, 0) - txInit 82 | : 0, 83 | rxSec: round(networkStats.reduce((a, b) => a + b.rx_sec, 0)), 84 | txSec: round(networkStats.reduce((a, b) => a + b.tx_sec, 0)), 85 | }; 86 | } 87 | 88 | async function getProcess(result) { 89 | const process = await agent.processes(); 90 | result.process = { 91 | all: process.all, 92 | running: process.running, 93 | blocked: process.blocked, 94 | sleeping: process.sleeping, 95 | }; 96 | 97 | const list = process.list.filter((v) => v.command !== 'linuxInfo.js'); 98 | 99 | list.sort((a, b) => b.cpu - a.cpu); 100 | result.process.cpuSortList = list.slice(0, 5).map((item) => ({ 101 | cpu: round(item.cpu), 102 | mem: item.memRss, 103 | // started: item.started, 104 | command: item.name, 105 | pid: item.pid, 106 | })); 107 | 108 | list.sort((a, b) => b.memRss - a.memRss); 109 | result.process.memRssSortList = list.slice(0, 5).map((item) => ({ 110 | cpu: round(item.cpu), 111 | mem: item.memRss, 112 | command: item.command, 113 | pid: item.pid, 114 | })); 115 | } 116 | 117 | async function getTime(result) { 118 | const time = await agent.time(); 119 | 120 | result.time = { 121 | timezone: time.timezone, 122 | uptime: time.uptime, 123 | timezoneName: time.timezoneName, 124 | }; 125 | } 126 | 127 | async function getOs(result) { 128 | const osInfo = await agent.osInfo(); 129 | 130 | result.os = { 131 | distro: osInfo.distro, 132 | logofile: osInfo.logofile, 133 | release: osInfo.release, 134 | codename: osInfo.codename, 135 | }; 136 | } 137 | 138 | async function refresh() { 139 | const refreshTimeInterval = 4000; 140 | const statusFilePath = path.join(__dirname, 'status.txt'); 141 | 142 | let needRefresh = true; 143 | try { 144 | const fileStatus = await stat(statusFilePath); 145 | if (Date.now() - fileStatus.mtimeMs < refreshTimeInterval) { 146 | needRefresh = false; 147 | } 148 | } catch (e) { 149 | // ignore 150 | } 151 | 152 | // 防止多个线程启动 153 | if (!needRefresh) { 154 | setTimeout(refresh, refreshTimeInterval); 155 | return; 156 | } 157 | 158 | console.time('refresh'); 159 | const result = {}; 160 | await Promise.all([ 161 | getCpuInfo(result), 162 | getMemoryInfo(result), 163 | getFsSize(result), 164 | getNetworkStats(result), 165 | getProcess(result), 166 | getTime(result), 167 | getOs(result), 168 | ]); 169 | 170 | await writeFile(statusFilePath, JSON.stringify(result)); 171 | console.timeEnd(`refresh`); 172 | setTimeout(refresh, refreshTimeInterval); 173 | } 174 | 175 | refresh().then(); 176 | -------------------------------------------------------------------------------- /src/dto.ts: -------------------------------------------------------------------------------- 1 | import { Config as ConnectionConfig } from './utils/nodeSSH'; 2 | 3 | export interface ForwardInParams { 4 | id: string; 5 | config: ConnectionConfig; 6 | remoteAddr: string; 7 | remotePort: number; 8 | localAddr: string; 9 | localPort: number; 10 | } 11 | 12 | export class ForwardInRequestDto { 13 | id: string; 14 | host: string; 15 | port: number; 16 | username: string; 17 | password: string; 18 | privateKey: string; 19 | remotePort: number; 20 | localAddr: string; 21 | localPort: number; 22 | } 23 | 24 | export class ForwardInResponseDto { 25 | success: boolean; 26 | errorMessage: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { Socket } from 'socket.io'; 3 | import events from 'events'; 4 | import { 5 | FileEntry, 6 | InputAttributes, 7 | ReadFileOptions, 8 | ReadStreamOptions, 9 | Stats, 10 | TransferOptions, 11 | WriteFileOptions, 12 | WriteStreamOptions, 13 | } from 'ssh2-streams'; 14 | import stream from 'stream'; 15 | import { Forward, Sftp, Shell, Redis, ServerStatus } from './app.service'; 16 | 17 | export interface ConsoleSocket extends Socket { 18 | shellService?: Shell; 19 | sftpService?: Sftp; 20 | frowardService?: Forward; 21 | serverStatusService?: ServerStatus; 22 | redisService?: Redis; 23 | } 24 | 25 | export interface SFTP extends events.EventEmitter { 26 | /** 27 | * (Client-only) 28 | * Downloads a file at `remotePath` to `localPath` using parallel reads for faster throughput. 29 | */ 30 | fastGet( 31 | remotePath: string, 32 | localPath: string, 33 | options: TransferOptions, 34 | callback: (err: any) => void, 35 | ): void; 36 | 37 | /** 38 | * (Client-only) 39 | * Downloads a file at `remotePath` to `localPath` using parallel reads for faster throughput. 40 | */ 41 | fastGet( 42 | remotePath: string, 43 | localPath: string, 44 | callback: (err: any) => void, 45 | ): void; 46 | 47 | /** 48 | * (Client-only) 49 | * Uploads a file from `localPath` to `remotePath` using parallel reads for faster throughput. 50 | */ 51 | fastPut( 52 | localPath: string, 53 | remotePath: string, 54 | options: TransferOptions, 55 | callback: (err: any) => void, 56 | ): void; 57 | 58 | /** 59 | * (Client-only) 60 | * Uploads a file from `localPath` to `remotePath` using parallel reads for faster throughput. 61 | */ 62 | fastPut( 63 | localPath: string, 64 | remotePath: string, 65 | callback: (err: any) => void, 66 | ): void; 67 | 68 | /** 69 | * (Client-only) 70 | * Reads a file in memory and returns its contents 71 | */ 72 | readFile(remotePath: string, options: ReadFileOptions): Promise; 73 | 74 | /** 75 | * (Client-only) 76 | * Reads a file in memory and returns its contents 77 | */ 78 | readFile( 79 | remotePath: string, 80 | encoding: string, 81 | callback: (err: any, handle: Buffer) => void, 82 | ): void; 83 | 84 | /** 85 | * (Client-only) 86 | * Reads a file in memory and returns its contents 87 | */ 88 | readFile( 89 | remotePath: string, 90 | callback: (err: any, handle: Buffer) => void, 91 | ): void; 92 | 93 | /** 94 | * (Client-only) 95 | * Returns a new readable stream for `path`. 96 | */ 97 | createReadStream(path: string, options?: ReadStreamOptions): stream.Readable; 98 | 99 | /** 100 | * (Client-only) 101 | * Writes data to a file 102 | */ 103 | writeFile( 104 | remotePath: string, 105 | data: string | Buffer, 106 | options: WriteFileOptions, 107 | callback?: (err: any) => void, 108 | ): void; 109 | 110 | /** 111 | * (Client-only) 112 | * Writes data to a file 113 | */ 114 | writeFile( 115 | remotePath: string, 116 | data: string | Buffer, 117 | encoding: string, 118 | callback?: (err: any) => void, 119 | ): void; 120 | 121 | /** 122 | * (Client-only) 123 | * Writes data to a file 124 | */ 125 | writeFile(remotePath: string, data: string | Buffer): Promise; 126 | 127 | /** 128 | * (Client-only) 129 | * Returns a new writable stream for `path`. 130 | */ 131 | createWriteStream( 132 | path: string, 133 | options?: WriteStreamOptions, 134 | ): stream.Writable; 135 | 136 | /** 137 | * (Client-only) 138 | * Opens a file `filename` for `mode` with optional `attributes`. 139 | * 140 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 141 | */ 142 | open( 143 | filename: string, 144 | mode: string, 145 | attributes: InputAttributes, 146 | callback: (err: any, handle: Buffer) => void, 147 | ): boolean; 148 | 149 | /** 150 | * (Client-only) 151 | * Opens a file `filename` for `mode`. 152 | * 153 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 154 | */ 155 | open( 156 | filename: string, 157 | mode: string, 158 | callback: (err: any, handle: Buffer) => void, 159 | ): boolean; 160 | 161 | /** 162 | * (Client-only) 163 | * Closes the resource associated with `handle` given by `open()` or `opendir()`. 164 | * 165 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 166 | */ 167 | close(handle: Buffer, callback: (err: any) => void): boolean; 168 | 169 | /** 170 | * (Client-only) 171 | * Reads `length` bytes from the resource associated with `handle` starting at `position` 172 | * and stores the bytes in `buffer` starting at `offset`. 173 | * 174 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 175 | */ 176 | read( 177 | handle: Buffer, 178 | buffer: Buffer, 179 | offset: number, 180 | length: number, 181 | position: number, 182 | callback: ( 183 | err: any, 184 | bytesRead: number, 185 | buffer: Buffer, 186 | position: number, 187 | ) => void, 188 | ): boolean; 189 | 190 | /** 191 | * (Client-only) 192 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 193 | */ 194 | write( 195 | handle: Buffer, 196 | buffer: Buffer, 197 | offset: number, 198 | length: number, 199 | position: number, 200 | callback: (err: any) => void, 201 | ): boolean; 202 | 203 | /** 204 | * (Client-only) 205 | * Retrieves attributes for the resource associated with `handle`. 206 | * 207 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 208 | */ 209 | fstat(handle: Buffer, callback: (err: any, stats: Stats) => void): boolean; 210 | 211 | /** 212 | * (Client-only) 213 | * Sets the attributes defined in `attributes` for the resource associated with `handle`. 214 | * 215 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 216 | */ 217 | fsetstat( 218 | handle: Buffer, 219 | attributes: InputAttributes, 220 | callback: (err: any) => void, 221 | ): boolean; 222 | 223 | /** 224 | * (Client-only) 225 | * Sets the access time and modified time for the resource associated with `handle`. 226 | * 227 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 228 | */ 229 | futimes( 230 | handle: Buffer, 231 | atime: number | Date, 232 | mtime: number | Date, 233 | callback: (err: any) => void, 234 | ): boolean; 235 | 236 | /** 237 | * (Client-only) 238 | * Sets the owner for the resource associated with `handle`. 239 | * 240 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 241 | */ 242 | fchown( 243 | handle: Buffer, 244 | uid: number, 245 | gid: number, 246 | callback: (err: any) => void, 247 | ): boolean; 248 | 249 | /** 250 | * (Client-only) 251 | * Sets the mode for the resource associated with `handle`. 252 | * 253 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 254 | */ 255 | fchmod( 256 | handle: Buffer, 257 | mode: number | string, 258 | callback: (err: any) => void, 259 | ): boolean; 260 | 261 | /** 262 | * (Client-only) 263 | * Opens a directory `path`. 264 | * 265 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 266 | */ 267 | opendir(path: string, callback: (err: any, handle: Buffer) => void): boolean; 268 | 269 | /** 270 | * (Client-only) 271 | * Retrieves a directory listing. 272 | * 273 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 274 | */ 275 | readdir(location: string | Buffer): Promise; 276 | 277 | /** 278 | * (Client-only) 279 | * Removes the file/symlink at `path`. 280 | * 281 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 282 | */ 283 | unlink(path: string): Promise; 284 | 285 | /** 286 | * (Client-only) 287 | * Renames/moves `srcPath` to `destPath`. 288 | * 289 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 290 | */ 291 | rename(srcPath: string, destPath: string): Promise; 292 | 293 | /** 294 | * (Client-only) 295 | * Creates a new directory `path`. 296 | * 297 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 298 | */ 299 | mkdir(path: string, attributes: InputAttributes): Promise; 300 | 301 | /** 302 | * (Client-only) 303 | * Creates a new directory `path`. 304 | * 305 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 306 | */ 307 | mkdir(path: string, callback: (err: any) => void): boolean; 308 | 309 | /** 310 | * (Client-only) 311 | * Removes the directory at `path`. 312 | * 313 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 314 | */ 315 | rmdir(path: string): Promise; 316 | 317 | /** 318 | * (Client-only) 319 | * Retrieves attributes for `path`. 320 | * 321 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 322 | */ 323 | stat(path: string, callback: (err: any, stats: Stats) => void): boolean; 324 | 325 | /** 326 | * (Client-only) 327 | * `path` exists. 328 | * 329 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 330 | */ 331 | exists(path: string, callback: (err: any) => void): boolean; 332 | 333 | /** 334 | * (Client-only) 335 | * Retrieves attributes for `path`. If `path` is a symlink, the link itself is stat'ed 336 | * instead of the resource it refers to. 337 | * 338 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 339 | */ 340 | lstat(path: string, callback: (err: any, stats: Stats) => void): boolean; 341 | 342 | /** 343 | * (Client-only) 344 | * Sets the attributes defined in `attributes` for `path`. 345 | * 346 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 347 | */ 348 | setstat( 349 | path: string, 350 | attributes: InputAttributes, 351 | callback: (err: any) => void, 352 | ): boolean; 353 | 354 | /** 355 | * (Client-only) 356 | * Sets the access time and modified time for `path`. 357 | * 358 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 359 | */ 360 | utimes( 361 | path: string, 362 | atime: number | Date, 363 | mtime: number | Date, 364 | callback: (err: any) => void, 365 | ): boolean; 366 | 367 | /** 368 | * (Client-only) 369 | * Sets the owner for `path`. 370 | * 371 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 372 | */ 373 | chown( 374 | path: string, 375 | uid: number, 376 | gid: number, 377 | callback: (err: any) => void, 378 | ): boolean; 379 | 380 | /** 381 | * (Client-only) 382 | * Sets the mode for `path`. 383 | * 384 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 385 | */ 386 | chmod( 387 | path: string, 388 | mode: number | string, 389 | callback: (err: any) => void, 390 | ): boolean; 391 | 392 | /** 393 | * (Client-only) 394 | * Retrieves the target for a symlink at `path`. 395 | * 396 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 397 | */ 398 | readlink(path: string, callback: (err: any, target: string) => void): boolean; 399 | 400 | /** 401 | * (Client-only) 402 | * Creates a symlink at `linkPath` to `targetPath`. 403 | * 404 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 405 | */ 406 | symlink( 407 | targetPath: string, 408 | linkPath: string, 409 | callback: (err: any) => void, 410 | ): boolean; 411 | 412 | /** 413 | * (Client-only) 414 | * Resolves `path` to an absolute path. 415 | * 416 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 417 | */ 418 | realpath( 419 | path: string, 420 | callback: (err: any, absPath: string) => void, 421 | ): boolean; 422 | 423 | /** 424 | * (Client-only, OpenSSH extension) 425 | * Performs POSIX rename(3) from `srcPath` to `destPath`. 426 | * 427 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 428 | */ 429 | ext_openssh_rename( 430 | srcPath: string, 431 | destPath: string, 432 | callback: (err: any) => void, 433 | ): boolean; 434 | 435 | /** 436 | * (Client-only, OpenSSH extension) 437 | * Performs POSIX statvfs(2) on `path`. 438 | * 439 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 440 | */ 441 | ext_openssh_statvfs( 442 | path: string, 443 | callback: (err: any, fsInfo: any) => void, 444 | ): boolean; 445 | 446 | /** 447 | * (Client-only, OpenSSH extension) 448 | * Performs POSIX fstatvfs(2) on open handle `handle`. 449 | * 450 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 451 | */ 452 | ext_openssh_fstatvfs( 453 | handle: Buffer, 454 | callback: (err: any, fsInfo: any) => void, 455 | ): boolean; 456 | 457 | /** 458 | * (Client-only, OpenSSH extension) 459 | * Performs POSIX link(2) to create a hard link to `targetPath` at `linkPath`. 460 | * 461 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 462 | */ 463 | ext_openssh_hardlink( 464 | targetPath: string, 465 | linkPath: string, 466 | callback: (err: any) => void, 467 | ): boolean; 468 | 469 | /** 470 | * (Client-only, OpenSSH extension) 471 | * Performs POSIX fsync(3) on the open handle `handle`. 472 | * 473 | * Returns `false` if you should wait for the `continue` event before sending any more traffic. 474 | */ 475 | ext_openssh_fsync( 476 | handle: Buffer, 477 | callback: (err: any, fsInfo: any) => void, 478 | ): boolean; 479 | 480 | /** 481 | * Ends the stream. 482 | */ 483 | end(): void; 484 | 485 | /** 486 | * Emitted when an error occurred. 487 | */ 488 | on(event: 'error', listener: (err: any) => void): this; 489 | 490 | /** 491 | * Emitted when the session has ended. 492 | */ 493 | on(event: 'end', listener: () => void): this; 494 | 495 | /** 496 | * Emitted when the session has closed. 497 | */ 498 | on(event: 'close', listener: () => void): this; 499 | 500 | /** 501 | * Emitted when more requests/data can be sent to the stream. 502 | */ 503 | on(event: 'continue', listener: () => void): this; 504 | 505 | on(event: string | symbol, listener: any): this; 506 | } 507 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | 7 | app.enableCors({ 8 | origin: (_, callback) => { 9 | callback(null, true); 10 | }, 11 | credentials: true, 12 | }); 13 | 14 | const port = process.env.PORT || 3000; 15 | console.log('listen PORT', port); 16 | await app.listen(port); 17 | } 18 | bootstrap().then(); 19 | 20 | process.setUncaughtExceptionCaptureCallback((error) => { 21 | console.log(error); 22 | }); 23 | -------------------------------------------------------------------------------- /src/utils/agent.ts: -------------------------------------------------------------------------------- 1 | import { SocksClient } from 'socks'; 2 | import { request } from 'http'; 3 | import * as url from 'url'; 4 | 5 | interface ProxyOpt { 6 | targetHost: string; 7 | targetPort: number; 8 | readyTimeout: number; 9 | } 10 | 11 | export const createAgentSockets = async ( 12 | agent: string, 13 | { readyTimeout, targetHost, targetPort }: ProxyOpt, 14 | ) => { 15 | const urlResult = url.parse(agent); 16 | 17 | if (urlResult.protocol.includes('socks')) { 18 | const info = await SocksClient.createConnection({ 19 | proxy: { 20 | userId: urlResult.auth, 21 | password: urlResult.auth, 22 | host: urlResult.hostname, 23 | port: Number.parseInt(urlResult.port), 24 | type: urlResult.protocol.includes('4') ? 4 : 5, 25 | }, 26 | command: 'connect', 27 | timeout: readyTimeout, 28 | destination: { 29 | host: targetHost, 30 | port: targetPort, 31 | }, 32 | }); 33 | 34 | return info.socket; 35 | } 36 | 37 | if (urlResult.protocol.includes('http')) { 38 | return new Promise((resolve, reject) => { 39 | request({ 40 | auth: urlResult.auth, 41 | agent: false, 42 | protocol: urlResult.protocol.includes('https') ? 'https:' : 'http:', 43 | hostname: urlResult.hostname, 44 | port: urlResult.port, 45 | path: `${targetHost}:${targetPort}`, 46 | method: 'CONNECT', 47 | timeout: readyTimeout, 48 | }) 49 | .on('error', (e) => { 50 | console.error(`fail to connect proxy: ${e.message}`); 51 | reject(e); 52 | }) 53 | .on('connect', (res, socket) => { 54 | resolve(socket); 55 | }) 56 | .end(); 57 | }); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/utils/kit.ts: -------------------------------------------------------------------------------- 1 | import { AES, enc, MD5 } from 'crypto-js'; 2 | import { Logger } from '@nestjs/common'; 3 | 4 | const logger: Logger = new Logger('Kit'); 5 | 6 | export const md5 = (str: string): string => { 7 | return MD5(str).toString(); 8 | }; 9 | 10 | export const decrypt = (str: string, secret: string): string => { 11 | return AES.decrypt(str, secret).toString(enc.Utf8); 12 | }; 13 | 14 | export const encrypt = (str: string, secret: string): string => { 15 | return AES.encrypt(str, secret).toString(); 16 | }; 17 | 18 | export function sleep(ms: number): Promise { 19 | if (ms === 0) { 20 | // 直接完成 21 | return Promise.resolve(); 22 | } 23 | return new Promise((resolve) => { 24 | setTimeout(resolve, ms); 25 | }); 26 | } 27 | 28 | export function WsErrorCatch(originError = false): MethodDecorator { 29 | return (_, __, descriptor: TypedPropertyDescriptor): void => { 30 | const originalMethod = descriptor.value; 31 | descriptor.value = async function fn(...args) { 32 | try { 33 | return await originalMethod.apply(this, [...args]); 34 | } catch (e) { 35 | logger.error('[WsErrorCatch] error', e.stack || e.message); 36 | if (originError) { 37 | return e; 38 | } 39 | return { 40 | errorMessage: e.message, 41 | }; 42 | } 43 | }; 44 | 45 | Reflect.getMetadataKeys(originalMethod).forEach((previousMetadataKey) => { 46 | const previousMetadata = Reflect.getMetadata( 47 | previousMetadataKey, 48 | originalMethod, 49 | ); 50 | Reflect.defineMetadata( 51 | previousMetadataKey, 52 | previousMetadata, 53 | descriptor.value, 54 | ); 55 | }); 56 | 57 | Object.defineProperty(descriptor.value, 'name', { 58 | value: originalMethod.name, 59 | writable: false, 60 | }); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/nodeSSH.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import * as fs from 'fs'; 3 | import * as fsPath from 'path'; 4 | import * as makeDir from 'make-dir'; 5 | import * as shellEscape from 'shell-escape'; 6 | import scanDirectory from 'sb-scandir'; 7 | import { PromiseQueue } from 'sb-promise-queue'; 8 | import * as invariant from 'assert'; 9 | import * as SSH2 from 'ssh2'; 10 | import { 11 | ClientChannel, 12 | ConnectConfig, 13 | ExecOptions, 14 | PseudoTtyOptions, 15 | SFTPWrapper, 16 | ShellOptions, 17 | } from 'ssh2'; 18 | 19 | import { Prompt, Stats, TransferOptions } from 'ssh2-streams'; 20 | import { createAgentSockets } from './agent'; 21 | 22 | export type Config = ConnectConfig & { 23 | password?: string; 24 | privateKey?: string; 25 | tryKeyboard?: boolean; 26 | disableAgent?: boolean; 27 | onKeyboardInteractive?: ( 28 | name: string, 29 | instructions: string, 30 | lang: string, 31 | prompts: Prompt[], 32 | finish: (responses: string[]) => void, 33 | ) => void; 34 | }; 35 | 36 | export interface SSHExecCommandOptions { 37 | cwd?: string; 38 | stdin?: string; 39 | execOptions?: ExecOptions; 40 | encoding?: BufferEncoding; 41 | onChannel?: (clientChannel: ClientChannel) => void; 42 | onStdout?: (chunk: Buffer) => void; 43 | onStderr?: (chunk: Buffer) => void; 44 | } 45 | 46 | export interface SSHExecCommandResponse { 47 | stdout: string; 48 | stderr: string; 49 | code: number | null; 50 | signal: string | null; 51 | } 52 | 53 | export interface SSHExecOptions extends SSHExecCommandOptions { 54 | stream?: 'stdout' | 'stderr' | 'both'; 55 | } 56 | 57 | export interface SSHPutFilesOptions { 58 | sftp?: SFTPWrapper | null; 59 | concurrency?: number; 60 | transferOptions?: TransferOptions; 61 | tick?: (localFile: string, remoteFile: string, error: Error | null) => void; 62 | } 63 | 64 | export interface SSHGetPutDirectoryOptions extends SSHPutFilesOptions { 65 | tick?: (localFile: string, remoteFile: string, error: Error | null) => void; 66 | validate?: (path: string) => boolean; 67 | recursive?: boolean; 68 | } 69 | 70 | export type SSHMkdirMethod = 'sftp' | 'exec'; 71 | 72 | const DEFAULT_CONCURRENCY = 1; 73 | const DEFAULT_VALIDATE = (path: string) => 74 | !fsPath.basename(path).startsWith('.'); 75 | const DEFAULT_TICK = () => { 76 | /* No Op */ 77 | }; 78 | 79 | export class SSHError extends Error { 80 | constructor(message: string, public code: string | null = null) { 81 | super(message); 82 | } 83 | } 84 | 85 | function unixifyPath(path: string) { 86 | if (path.includes('\\')) { 87 | return path.split('\\').join('/'); 88 | } 89 | return path; 90 | } 91 | 92 | async function readFile(filePath: string): Promise { 93 | return new Promise((resolve, reject) => { 94 | fs.readFile(filePath, 'utf8', (err, res) => { 95 | if (err) { 96 | reject(err); 97 | } else { 98 | resolve(res); 99 | } 100 | }); 101 | }); 102 | } 103 | 104 | const SFTP_MKDIR_ERR_CODE_REGEXP = /Error: (E[\S]+): /; 105 | 106 | async function makeDirectoryWithSftp(path: string, sftp: SFTPWrapper) { 107 | let stats: Stats | null = null; 108 | try { 109 | stats = await new Promise((resolve, reject) => { 110 | sftp.stat(path, (err, res) => { 111 | if (err) { 112 | reject(err); 113 | } else { 114 | resolve(res); 115 | } 116 | }); 117 | }); 118 | } catch (_) { 119 | /* No Op */ 120 | } 121 | if (stats) { 122 | if (stats.isDirectory()) { 123 | // Already exists, nothing to worry about 124 | return; 125 | } 126 | throw new Error( 127 | 'mkdir() failed, target already exists and is not a directory', 128 | ); 129 | } 130 | try { 131 | await new Promise((resolve, reject) => { 132 | sftp.mkdir(path, (err) => { 133 | if (err) { 134 | reject(err); 135 | } else { 136 | resolve(); 137 | } 138 | }); 139 | }); 140 | } catch (err) { 141 | if (err != null && typeof err.stack === 'string') { 142 | const matches = SFTP_MKDIR_ERR_CODE_REGEXP.exec(err.stack); 143 | if (matches != null) { 144 | throw new SSHError(err.message, matches[1]); 145 | } 146 | throw err; 147 | } 148 | } 149 | } 150 | 151 | export class NodeSSH { 152 | connection: SSH2.Client | null = null; 153 | connectionConfig: Config; 154 | 155 | public async connect(givenConfig: Config): Promise { 156 | this.connectionConfig = givenConfig; 157 | 158 | invariant( 159 | givenConfig != null && typeof givenConfig === 'object', 160 | 'config must be a valid object', 161 | ); 162 | 163 | const config: Config = { ...givenConfig }; 164 | 165 | invariant( 166 | config.username != null && typeof config.username === 'string', 167 | 'config.username must be a valid string', 168 | ); 169 | 170 | if (config.host != null) { 171 | invariant( 172 | typeof config.host === 'string', 173 | 'config.host must be a valid string', 174 | ); 175 | } else if (config.sock != null) { 176 | invariant( 177 | typeof config.sock === 'object', 178 | 'config.sock must be a valid object', 179 | ); 180 | } else { 181 | throw new invariant.AssertionError({ 182 | message: 'Either config.host or config.sock must be provided', 183 | }); 184 | } 185 | 186 | if (config.privateKey != null) { 187 | invariant( 188 | typeof config.privateKey === 'string', 189 | 'config.privateKey must be a valid string', 190 | ); 191 | invariant( 192 | config.passphrase == null || typeof config.passphrase === 'string', 193 | 'config.passphrase must be a valid string', 194 | ); 195 | 196 | if ( 197 | !( 198 | (config.privateKey.includes('BEGIN') && 199 | config.privateKey.includes('KEY')) || 200 | config.privateKey.includes('PuTTY-User-Key-File-2') 201 | ) 202 | ) { 203 | // Must be an fs path 204 | try { 205 | config.privateKey = await readFile(config.privateKey); 206 | } catch (err) { 207 | if (err != null && err.code === 'ENOENT') { 208 | throw new invariant.AssertionError({ 209 | message: 'config.privateKey does not exist at given fs path', 210 | }); 211 | } 212 | throw err; 213 | } 214 | } 215 | } else if (config.password != null) { 216 | invariant( 217 | typeof config.password === 'string', 218 | 'config.password must be a valid string', 219 | ); 220 | } 221 | 222 | if (config.tryKeyboard != null) { 223 | invariant( 224 | typeof config.tryKeyboard === 'boolean', 225 | 'config.tryKeyboard must be a valid boolean', 226 | ); 227 | } 228 | if (config.tryKeyboard) { 229 | const { password } = config; 230 | if (config.onKeyboardInteractive != null) { 231 | invariant( 232 | typeof config.onKeyboardInteractive === 'function', 233 | 'config.onKeyboardInteractive must be a valid function', 234 | ); 235 | } else if (password != null) { 236 | config.onKeyboardInteractive = ( 237 | name, 238 | instructions, 239 | instructionsLang, 240 | prompts, 241 | finish, 242 | ) => { 243 | if ( 244 | prompts.length > 0 && 245 | prompts[0].prompt.toLowerCase().includes('password') 246 | ) { 247 | finish([password]); 248 | } 249 | }; 250 | } 251 | } 252 | 253 | const connection = new SSH2.Client(); 254 | this.connection = connection; 255 | 256 | await new Promise(async (resolve, reject) => { 257 | connection.on('error', reject); 258 | if (config.onKeyboardInteractive) { 259 | connection.on('keyboard-interactive', config.onKeyboardInteractive); 260 | } 261 | connection.on('ready', () => { 262 | connection.removeListener('error', reject); 263 | resolve(); 264 | }); 265 | connection.on('end', () => { 266 | if (this.connection === connection) { 267 | this.connection = null; 268 | } 269 | }); 270 | connection.on('close', () => { 271 | if (this.connection === connection) { 272 | this.connection = null; 273 | } 274 | reject(new SSHError('No response from server', 'ETIMEDOUT')); 275 | }); 276 | 277 | try { 278 | if (config.disableAgent) { 279 | delete config.agent; 280 | } 281 | const proxyAgent = config.agent; 282 | const sock = proxyAgent 283 | ? await createAgentSockets(proxyAgent as string, { 284 | targetHost: config.host, 285 | targetPort: config.port, 286 | readyTimeout: 3000, 287 | }) 288 | : undefined; 289 | delete config.agent; 290 | connection.connect({ 291 | ...config, 292 | // @ts-ignore 293 | sock, 294 | }); 295 | } catch (e) { 296 | reject(e); 297 | } 298 | }); 299 | 300 | return this; 301 | } 302 | 303 | public isConnected(): boolean { 304 | return this.connection != null; 305 | } 306 | 307 | async requestShell( 308 | options?: PseudoTtyOptions | ShellOptions | false, 309 | ): Promise { 310 | const connection = await this.getConnection(); 311 | 312 | return new Promise((resolve, reject) => { 313 | const callback = (err: Error | undefined, res: ClientChannel) => { 314 | if (err) { 315 | reject(err); 316 | } else { 317 | resolve(res); 318 | } 319 | }; 320 | if (options == null) { 321 | connection.shell(callback); 322 | } else { 323 | connection.shell(options as any, callback); 324 | } 325 | }); 326 | } 327 | 328 | async withShell( 329 | callback: (channel: ClientChannel) => Promise, 330 | options?: PseudoTtyOptions | ShellOptions | false, 331 | ): Promise { 332 | invariant( 333 | typeof callback === 'function', 334 | 'callback must be a valid function', 335 | ); 336 | 337 | const shell = await this.requestShell(options); 338 | try { 339 | await callback(shell); 340 | } finally { 341 | // Try to close gracefully 342 | if (!shell.close()) { 343 | // Destroy local socket if it doesn't work 344 | shell.destroy(); 345 | } 346 | } 347 | } 348 | 349 | async requestSFTP(): Promise { 350 | const connection = await this.getConnection(); 351 | 352 | return new Promise((resolve, reject) => { 353 | connection.sftp((err, res) => { 354 | if (err) { 355 | reject(err); 356 | } else { 357 | resolve(res); 358 | } 359 | }); 360 | }); 361 | } 362 | 363 | async withSFTP( 364 | callback: (sftp: SFTPWrapper) => Promise, 365 | ): Promise { 366 | invariant( 367 | typeof callback === 'function', 368 | 'callback must be a valid function', 369 | ); 370 | 371 | const sftp = await this.requestSFTP(); 372 | try { 373 | await callback(sftp); 374 | } finally { 375 | sftp.end(); 376 | } 377 | } 378 | 379 | async execCommand( 380 | givenCommand: string, 381 | options: SSHExecCommandOptions = {}, 382 | ): Promise { 383 | invariant( 384 | typeof givenCommand === 'string', 385 | 'command must be a valid string', 386 | ); 387 | invariant( 388 | options != null && typeof options === 'object', 389 | 'options must be a valid object', 390 | ); 391 | invariant( 392 | options.cwd == null || typeof options.cwd === 'string', 393 | 'options.cwd must be a valid string', 394 | ); 395 | invariant( 396 | options.stdin == null || typeof options.stdin === 'string', 397 | 'options.stdin must be a valid string', 398 | ); 399 | invariant( 400 | options.execOptions == null || typeof options.execOptions === 'object', 401 | 'options.execOptions must be a valid object', 402 | ); 403 | invariant( 404 | options.encoding == null || typeof options.encoding === 'string', 405 | 'options.encoding must be a valid string', 406 | ); 407 | invariant( 408 | options.onChannel == null || typeof options.onChannel === 'function', 409 | 'options.onChannel must be a valid function', 410 | ); 411 | invariant( 412 | options.onStdout == null || typeof options.onStdout === 'function', 413 | 'options.onStdout must be a valid function', 414 | ); 415 | invariant( 416 | options.onStderr == null || typeof options.onStderr === 'function', 417 | 'options.onStderr must be a valid function', 418 | ); 419 | 420 | let command = givenCommand; 421 | 422 | if (options.cwd) { 423 | command = `cd ${shellEscape([options.cwd])} ; ${command}`; 424 | } 425 | const connection = await this.getConnection(); 426 | 427 | const output: { stdout: string[]; stderr: string[] } = { 428 | stdout: [], 429 | stderr: [], 430 | }; 431 | 432 | return new Promise((resolve, reject) => { 433 | connection.exec( 434 | command, 435 | options.execOptions != null ? options.execOptions : {}, 436 | (err, channel) => { 437 | if (err) { 438 | reject(err); 439 | return; 440 | } 441 | if (options.onChannel) { 442 | options.onChannel(channel); 443 | } 444 | channel.on('data', (chunk: Buffer) => { 445 | if (options.onStdout) options.onStdout(chunk); 446 | output.stdout.push(chunk.toString(options.encoding)); 447 | }); 448 | channel.stderr.on('data', (chunk: Buffer) => { 449 | if (options.onStderr) options.onStderr(chunk); 450 | output.stderr.push(chunk.toString(options.encoding)); 451 | }); 452 | if (options.stdin) { 453 | channel.write(options.stdin); 454 | } 455 | // Close stdout: 456 | channel.end(); 457 | 458 | let code: number | null = null; 459 | let signal: string | null = null; 460 | channel.on('exit', (code_, signal_) => { 461 | code = code_ || null; 462 | signal = signal_ || null; 463 | }); 464 | channel.on('close', () => { 465 | resolve({ 466 | code: code != null ? code : null, 467 | signal: signal != null ? signal : null, 468 | stdout: output.stdout.join('').trim(), 469 | stderr: output.stderr.join('').trim(), 470 | }); 471 | }); 472 | }, 473 | ); 474 | }); 475 | } 476 | 477 | exec( 478 | command: string, 479 | parameters: string[], 480 | options?: SSHExecOptions & { stream?: 'stdout' | 'stderr' }, 481 | ): Promise; 482 | 483 | exec( 484 | command: string, 485 | parameters: string[], 486 | options?: SSHExecOptions & { stream: 'both' }, 487 | ): Promise; 488 | 489 | async exec( 490 | command: string, 491 | parameters: string[], 492 | options: SSHExecOptions = {}, 493 | ): Promise { 494 | invariant(typeof command === 'string', 'command must be a valid string'); 495 | invariant(Array.isArray(parameters), 'parameters must be a valid array'); 496 | invariant( 497 | options != null && typeof options === 'object', 498 | 'options must be a valid object', 499 | ); 500 | invariant( 501 | options.stream == null || 502 | ['both', 'stdout', 'stderr'].includes(options.stream), 503 | 'options.stream must be one of both, stdout, stderr', 504 | ); 505 | for (let i = 0, { length } = parameters; i < length; i += 1) { 506 | invariant( 507 | typeof parameters[i] === 'string', 508 | `parameters[${i}] must be a valid string`, 509 | ); 510 | } 511 | 512 | const completeCommand = `${command} ${shellEscape(parameters)}`; 513 | const response = await this.execCommand(completeCommand, options); 514 | 515 | if (options.stream == null || options.stream === 'stdout') { 516 | if (response.stderr) { 517 | throw new Error(response.stderr); 518 | } 519 | return response.stdout; 520 | } 521 | if (options.stream === 'stderr') { 522 | return response.stderr; 523 | } 524 | 525 | return response; 526 | } 527 | 528 | async mkdir( 529 | path: string, 530 | method: SSHMkdirMethod = 'sftp', 531 | givenSftp: SFTPWrapper | null = null, 532 | ): Promise { 533 | invariant(typeof path === 'string', 'path must be a valid string'); 534 | invariant( 535 | typeof method === 'string' && (method === 'sftp' || method === 'exec'), 536 | 'method must be either sftp or exec', 537 | ); 538 | invariant( 539 | givenSftp == null || typeof givenSftp === 'object', 540 | 'sftp must be a valid object', 541 | ); 542 | 543 | if (method === 'exec') { 544 | await this.exec('mkdir', ['-p', unixifyPath(path)]); 545 | return; 546 | } 547 | const sftp = givenSftp || (await this.requestSFTP()); 548 | 549 | const makeSftpDirectory = async (retry: boolean) => 550 | makeDirectoryWithSftp(unixifyPath(path), sftp).catch( 551 | async (error: SSHError) => { 552 | if ( 553 | !retry || 554 | error == null || 555 | (error.message !== 'No such file' && error.code !== 'ENOENT') 556 | ) { 557 | throw error; 558 | } 559 | await this.mkdir(fsPath.dirname(path), 'sftp', sftp); 560 | await makeSftpDirectory(false); 561 | }, 562 | ); 563 | 564 | try { 565 | await makeSftpDirectory(true); 566 | } finally { 567 | if (!givenSftp) { 568 | sftp.end(); 569 | } 570 | } 571 | } 572 | 573 | async getFile( 574 | localFile: string, 575 | remoteFile: string, 576 | givenSftp: SFTPWrapper | null = null, 577 | transferOptions: TransferOptions | null = null, 578 | ): Promise { 579 | invariant( 580 | typeof localFile === 'string', 581 | 'localFile must be a valid string', 582 | ); 583 | invariant( 584 | typeof remoteFile === 'string', 585 | 'remoteFile must be a valid string', 586 | ); 587 | invariant( 588 | givenSftp == null || typeof givenSftp === 'object', 589 | 'sftp must be a valid object', 590 | ); 591 | invariant( 592 | transferOptions == null || typeof transferOptions === 'object', 593 | 'transferOptions must be a valid object', 594 | ); 595 | 596 | const sftp = givenSftp || (await this.requestSFTP()); 597 | 598 | try { 599 | await new Promise((resolve, reject) => { 600 | sftp.fastGet( 601 | unixifyPath(remoteFile), 602 | localFile, 603 | transferOptions || {}, 604 | (err) => { 605 | if (err) { 606 | reject(err); 607 | } else { 608 | resolve(); 609 | } 610 | }, 611 | ); 612 | }); 613 | } finally { 614 | if (!givenSftp) { 615 | sftp.end(); 616 | } 617 | } 618 | } 619 | 620 | async getFiles( 621 | files: { local: string; remote: string }[], 622 | { 623 | concurrency = DEFAULT_CONCURRENCY, 624 | sftp: givenSftp = null, 625 | transferOptions = {}, 626 | tick = DEFAULT_TICK, 627 | }: SSHPutFilesOptions = {}, 628 | ): Promise { 629 | invariant(Array.isArray(files), 'files must be an array'); 630 | 631 | for (let i = 0, { length } = files; i < length; i += 1) { 632 | const file = files[i]; 633 | invariant(file, 'files items must be valid objects'); 634 | invariant( 635 | file.local && typeof file.local === 'string', 636 | `files[${i}].local must be a string`, 637 | ); 638 | invariant( 639 | file.remote && typeof file.remote === 'string', 640 | `files[${i}].remote must be a string`, 641 | ); 642 | } 643 | 644 | const transferred: typeof files = []; 645 | const sftp = givenSftp || (await this.requestSFTP()); 646 | const queue = new PromiseQueue({ concurrency }); 647 | 648 | try { 649 | await new Promise((resolve, reject) => { 650 | files.forEach((file) => { 651 | queue 652 | .add(async () => { 653 | let beforeTotalTransferred = 0; 654 | await this.getFile(file.local, file.remote, sftp, { 655 | ...transferOptions, 656 | step: ( 657 | total_transferred: number, 658 | chunk: number, 659 | total: number, 660 | ) => { 661 | if (transferOptions.step) { 662 | if (total_transferred === total) { 663 | transferOptions.step( 664 | total_transferred, 665 | chunk, 666 | total, 667 | // @ts-ignore 668 | file.local, 669 | ); 670 | return; 671 | } 672 | if ( 673 | (total_transferred - beforeTotalTransferred) / total > 674 | 0.03 675 | ) { 676 | transferOptions.step( 677 | total_transferred, 678 | chunk, 679 | total, 680 | // @ts-ignore 681 | file.local, 682 | ); 683 | beforeTotalTransferred = total_transferred; 684 | } 685 | } 686 | }, 687 | }); 688 | tick(file.local, file.remote, null); 689 | transferred.push(file); 690 | }) 691 | .catch((error) => { 692 | reject(error); 693 | tick(file.local, file.remote, error); 694 | }); 695 | }); 696 | 697 | queue.waitTillIdle().then(resolve); 698 | }); 699 | } catch (error) { 700 | if (error != null) { 701 | error.transferred = transferred; 702 | } 703 | throw error; 704 | } finally { 705 | if (!givenSftp) { 706 | sftp.end(); 707 | } 708 | } 709 | } 710 | 711 | async putFile( 712 | localFile: string, 713 | remoteFile: string, 714 | givenSftp: SFTPWrapper | null = null, 715 | transferOptions: TransferOptions | null = null, 716 | ): Promise { 717 | invariant( 718 | typeof localFile === 'string', 719 | 'localFile must be a valid string', 720 | ); 721 | invariant( 722 | typeof remoteFile === 'string', 723 | 'remoteFile must be a valid string', 724 | ); 725 | invariant( 726 | givenSftp == null || typeof givenSftp === 'object', 727 | 'sftp must be a valid object', 728 | ); 729 | invariant( 730 | transferOptions == null || typeof transferOptions === 'object', 731 | 'transferOptions must be a valid object', 732 | ); 733 | invariant( 734 | await new Promise((resolve) => { 735 | fs.access(localFile, fs.constants.R_OK, (err) => { 736 | resolve(err === null); 737 | }); 738 | }), 739 | `localFile does not exist at ${localFile}`, 740 | ); 741 | const sftp = givenSftp || (await this.requestSFTP()); 742 | 743 | const putFile = (retry: boolean) => { 744 | return new Promise((resolve, reject) => { 745 | sftp.fastPut( 746 | localFile, 747 | unixifyPath(remoteFile), 748 | transferOptions || {}, 749 | (err) => { 750 | if (err == null) { 751 | resolve(); 752 | return; 753 | } 754 | 755 | if (err.message === 'No such file' && retry) { 756 | resolve( 757 | this.mkdir(fsPath.dirname(remoteFile), 'sftp', sftp).then(() => 758 | putFile(false), 759 | ), 760 | ); 761 | } else { 762 | reject(err); 763 | } 764 | }, 765 | ); 766 | }); 767 | }; 768 | 769 | try { 770 | await putFile(true); 771 | } finally { 772 | if (!givenSftp) { 773 | sftp.end(); 774 | } 775 | } 776 | } 777 | 778 | async putFiles( 779 | files: { local: string; remote: string }[], 780 | { 781 | concurrency = DEFAULT_CONCURRENCY, 782 | sftp: givenSftp = null, 783 | transferOptions = {}, 784 | tick = DEFAULT_TICK, 785 | }: SSHPutFilesOptions = {}, 786 | ): Promise { 787 | invariant(Array.isArray(files), 'files must be an array'); 788 | 789 | for (let i = 0, { length } = files; i < length; i += 1) { 790 | const file = files[i]; 791 | invariant(file, 'files items must be valid objects'); 792 | invariant( 793 | file.local && typeof file.local === 'string', 794 | `files[${i}].local must be a string`, 795 | ); 796 | invariant( 797 | file.remote && typeof file.remote === 'string', 798 | `files[${i}].remote must be a string`, 799 | ); 800 | } 801 | 802 | const transferred: typeof files = []; 803 | const sftp = givenSftp || (await this.requestSFTP()); 804 | const queue = new PromiseQueue({ concurrency }); 805 | 806 | try { 807 | await new Promise((resolve, reject) => { 808 | files.forEach((file) => { 809 | queue 810 | .add(async () => { 811 | let beforeTotalTransferred = 0; 812 | await this.putFile(file.local, file.remote, sftp, { 813 | ...transferOptions, 814 | step: ( 815 | total_transferred: number, 816 | chunk: number, 817 | total: number, 818 | ) => { 819 | if (transferOptions.step) { 820 | if (total_transferred === total) { 821 | transferOptions.step( 822 | total_transferred, 823 | chunk, 824 | total, 825 | // @ts-ignore 826 | file.local, 827 | ); 828 | return; 829 | } 830 | if ( 831 | (total_transferred - beforeTotalTransferred) / total > 832 | 0.03 833 | ) { 834 | transferOptions.step( 835 | total_transferred, 836 | chunk, 837 | total, 838 | // @ts-ignore 839 | file.local, 840 | ); 841 | beforeTotalTransferred = total_transferred; 842 | } 843 | } 844 | }, 845 | }); 846 | tick(file.local, file.remote, null); 847 | transferred.push(file); 848 | }) 849 | .catch((error) => { 850 | reject(error); 851 | tick(file.local, file.remote, error); 852 | }); 853 | }); 854 | 855 | queue.waitTillIdle().then(resolve); 856 | }); 857 | } catch (error) { 858 | if (error != null) { 859 | error.transferred = transferred; 860 | } 861 | throw error; 862 | } finally { 863 | if (!givenSftp) { 864 | sftp.end(); 865 | } 866 | } 867 | } 868 | 869 | async putDirectory( 870 | localDirectory: string, 871 | remoteDirectory: string, 872 | { 873 | concurrency = DEFAULT_CONCURRENCY, 874 | sftp: givenSftp = null, 875 | transferOptions = {}, 876 | recursive = true, 877 | tick = DEFAULT_TICK, 878 | validate = DEFAULT_VALIDATE, 879 | }: SSHGetPutDirectoryOptions = {}, 880 | ): Promise { 881 | invariant( 882 | typeof localDirectory === 'string' && localDirectory, 883 | 'localDirectory must be a string', 884 | ); 885 | invariant( 886 | typeof remoteDirectory === 'string' && remoteDirectory, 887 | 'remoteDirectory must be a string', 888 | ); 889 | 890 | const localDirectoryStat: fs.Stats = await new Promise((resolve) => { 891 | fs.stat(localDirectory, (err, stat) => { 892 | resolve(stat || null); 893 | }); 894 | }); 895 | 896 | invariant( 897 | localDirectoryStat != null, 898 | `localDirectory does not exist at ${localDirectory}`, 899 | ); 900 | invariant( 901 | localDirectoryStat.isDirectory(), 902 | `localDirectory is not a directory at ${localDirectory}`, 903 | ); 904 | 905 | const sftp = givenSftp || (await this.requestSFTP()); 906 | 907 | const scanned = await scanDirectory(localDirectory, { 908 | recursive, 909 | validate, 910 | }); 911 | const files = scanned.files.map((item) => 912 | fsPath.relative(localDirectory, item), 913 | ); 914 | const directories = scanned.directories.map((item) => 915 | fsPath.relative(localDirectory, item), 916 | ); 917 | 918 | // Sort shortest to longest 919 | directories.sort((a, b) => a.length - b.length); 920 | 921 | let failed = false; 922 | 923 | try { 924 | // Do the directories first. 925 | await new Promise((resolve, reject) => { 926 | const queue = new PromiseQueue({ concurrency }); 927 | 928 | directories.forEach((directory) => { 929 | queue 930 | .add(async () => { 931 | await this.mkdir( 932 | fsPath.join(remoteDirectory, directory), 933 | 'sftp', 934 | sftp, 935 | ); 936 | }) 937 | .catch(reject); 938 | }); 939 | 940 | resolve(queue.waitTillIdle()); 941 | }); 942 | 943 | // and now the files 944 | await new Promise((resolve, reject) => { 945 | const queue = new PromiseQueue({ concurrency }); 946 | 947 | files.forEach((file) => { 948 | queue 949 | .add(async () => { 950 | const localFile = fsPath.join(localDirectory, file); 951 | const remoteFile = fsPath.join(remoteDirectory, file); 952 | let beforeTotalTransferred = 0; 953 | try { 954 | await this.putFile(localFile, remoteFile, sftp, { 955 | ...transferOptions, 956 | step: ( 957 | total_transferred: number, 958 | chunk: number, 959 | total: number, 960 | ) => { 961 | if (transferOptions.step) { 962 | if (total_transferred === total) { 963 | transferOptions.step( 964 | total_transferred, 965 | chunk, 966 | total, 967 | // @ts-ignore 968 | localFile, 969 | ); 970 | return; 971 | } 972 | if ( 973 | (total_transferred - beforeTotalTransferred) / total > 974 | 0.03 975 | ) { 976 | transferOptions.step( 977 | total_transferred, 978 | chunk, 979 | total, 980 | // @ts-ignore 981 | localFile, 982 | ); 983 | beforeTotalTransferred = total_transferred; 984 | } 985 | } 986 | }, 987 | }); 988 | tick(localFile, remoteFile, null); 989 | } catch (_) { 990 | failed = true; 991 | tick(localFile, remoteFile, _); 992 | } 993 | }) 994 | .catch(reject); 995 | }); 996 | 997 | resolve(queue.waitTillIdle()); 998 | }); 999 | } finally { 1000 | if (!givenSftp) { 1001 | sftp.end(); 1002 | } 1003 | } 1004 | 1005 | return !failed; 1006 | } 1007 | 1008 | async getDirectory( 1009 | localDirectory: string, 1010 | remoteDirectory: string, 1011 | { 1012 | concurrency = DEFAULT_CONCURRENCY, 1013 | sftp: givenSftp = null, 1014 | transferOptions = {}, 1015 | recursive = true, 1016 | tick = DEFAULT_TICK, 1017 | validate = DEFAULT_VALIDATE, 1018 | }: SSHGetPutDirectoryOptions = {}, 1019 | ): Promise { 1020 | invariant( 1021 | typeof localDirectory === 'string' && localDirectory, 1022 | 'localDirectory must be a string', 1023 | ); 1024 | invariant( 1025 | typeof remoteDirectory === 'string' && remoteDirectory, 1026 | 'remoteDirectory must be a string', 1027 | ); 1028 | 1029 | const localDirectoryStat: fs.Stats = await new Promise((resolve) => { 1030 | fs.stat(localDirectory, (err, stat) => { 1031 | resolve(stat || null); 1032 | }); 1033 | }); 1034 | 1035 | invariant( 1036 | localDirectoryStat != null, 1037 | `localDirectory does not exist at ${localDirectory}`, 1038 | ); 1039 | invariant( 1040 | localDirectoryStat.isDirectory(), 1041 | `localDirectory is not a directory at ${localDirectory}`, 1042 | ); 1043 | 1044 | const sftp = givenSftp || (await this.requestSFTP()); 1045 | 1046 | const scanned = await scanDirectory(remoteDirectory, { 1047 | recursive, 1048 | validate, 1049 | concurrency, 1050 | fileSystem: { 1051 | basename(path) { 1052 | return fsPath.posix.basename(path); 1053 | }, 1054 | join(pathA, pathB) { 1055 | return fsPath.posix.join(pathA, pathB); 1056 | }, 1057 | readdir(path) { 1058 | return new Promise((resolve, reject) => { 1059 | sftp.readdir(path, (err, res) => { 1060 | if (err) { 1061 | reject(err); 1062 | } else { 1063 | resolve(res.map((item) => item.filename)); 1064 | } 1065 | }); 1066 | }); 1067 | }, 1068 | stat(path) { 1069 | return new Promise((resolve, reject) => { 1070 | sftp.stat(path, (err, res) => { 1071 | if (err) { 1072 | reject(err); 1073 | } else { 1074 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 1075 | resolve(res as any); 1076 | } 1077 | }); 1078 | }); 1079 | }, 1080 | }, 1081 | }); 1082 | const files = scanned.files.map((item) => 1083 | fsPath.relative(remoteDirectory, item), 1084 | ); 1085 | const directories = scanned.directories.map((item) => 1086 | fsPath.relative(remoteDirectory, item), 1087 | ); 1088 | 1089 | // Sort shortest to longest 1090 | directories.sort((a, b) => a.length - b.length); 1091 | 1092 | let failed = false; 1093 | 1094 | try { 1095 | // Do the directories first. 1096 | await new Promise((resolve, reject) => { 1097 | const queue = new PromiseQueue({ concurrency }); 1098 | 1099 | directories.forEach((directory) => { 1100 | queue 1101 | .add(async () => { 1102 | await makeDir(fsPath.join(localDirectory, directory)); 1103 | }) 1104 | .catch(reject); 1105 | }); 1106 | 1107 | resolve(queue.waitTillIdle()); 1108 | }); 1109 | 1110 | // and now the files 1111 | await new Promise((resolve, reject) => { 1112 | const queue = new PromiseQueue({ concurrency }); 1113 | 1114 | files.forEach((file) => { 1115 | queue 1116 | .add(async () => { 1117 | const localFile = fsPath.join(localDirectory, file); 1118 | const remoteFile = fsPath.join(remoteDirectory, file); 1119 | let beforeTotalTransferred = 0; 1120 | try { 1121 | await this.getFile(localFile, remoteFile, sftp, { 1122 | ...transferOptions, 1123 | step: ( 1124 | total_transferred: number, 1125 | chunk: number, 1126 | total: number, 1127 | ) => { 1128 | if (transferOptions.step) { 1129 | if (total_transferred === total) { 1130 | transferOptions.step( 1131 | total_transferred, 1132 | chunk, 1133 | total, 1134 | // @ts-ignore 1135 | remoteFile, 1136 | ); 1137 | return; 1138 | } 1139 | if ( 1140 | (total_transferred - beforeTotalTransferred) / total > 1141 | 0.03 1142 | ) { 1143 | transferOptions.step( 1144 | total_transferred, 1145 | chunk, 1146 | total, 1147 | // @ts-ignore 1148 | remoteFile, 1149 | ); 1150 | beforeTotalTransferred = total_transferred; 1151 | } 1152 | } 1153 | }, 1154 | }); 1155 | tick(localFile, remoteFile, null); 1156 | } catch (_) { 1157 | failed = true; 1158 | tick(localFile, remoteFile, _); 1159 | } 1160 | }) 1161 | .catch(reject); 1162 | }); 1163 | 1164 | resolve(queue.waitTillIdle()); 1165 | }); 1166 | } finally { 1167 | if (!givenSftp) { 1168 | sftp.end(); 1169 | } 1170 | } 1171 | 1172 | return !failed; 1173 | } 1174 | 1175 | dispose(removeListener = false) { 1176 | if (this.connection) { 1177 | if (removeListener) { 1178 | this.connection.removeAllListeners(); 1179 | } 1180 | this.connection?.end(); 1181 | this.connection = null; 1182 | } 1183 | } 1184 | 1185 | async reconnect() { 1186 | await this.connect(this.connectionConfig); 1187 | } 1188 | 1189 | private async getConnection(): Promise { 1190 | const { connection } = this; 1191 | if (connection == null) { 1192 | throw new Error('Not connected to server'); 1193 | } 1194 | 1195 | return connection; 1196 | } 1197 | } 1198 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "allowJs": true, 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------