├── web
├── .gitignore
├── src
│ ├── components
│ │ ├── access
│ │ │ ├── Access.css
│ │ │ ├── Console.css
│ │ │ ├── Term.css
│ │ │ ├── FileSystem.css
│ │ │ ├── Monitor.js
│ │ │ └── Console.js
│ │ ├── dashboard
│ │ │ ├── Dashboard.css
│ │ │ └── Dashboard.js
│ │ ├── command
│ │ │ ├── Command.css
│ │ │ ├── DynamicCommandModal.js
│ │ │ └── BatchCommand.js
│ │ ├── user
│ │ │ ├── Logout.js
│ │ │ ├── UserGroupModal.js
│ │ │ └── UserModal.js
│ │ ├── credential
│ │ │ └── CredentialModal.js
│ │ ├── Login.js
│ │ └── session
│ │ │ └── Playback.js
│ ├── fonts
│ │ └── Menlo-Regular-1.ttf
│ ├── App.test.js
│ ├── index.css
│ ├── common
│ │ ├── constants.js
│ │ └── request.js
│ ├── index.js
│ ├── service
│ │ └── permission.js
│ ├── App.css
│ ├── logo.svg
│ ├── serviceWorker.js
│ └── utils
│ │ └── utils.js
├── README.md
├── public
│ ├── favicon.ico
│ ├── manifest.json
│ ├── logo.svg
│ ├── asciinema.html
│ └── index.html
└── package.json
├── .gitignore
├── screenshot
├── qq.png
├── rdp.png
├── ssh.png
├── vnc.png
├── assets.png
├── command.png
├── donate.png
└── docker_stats.png
├── config.yml
├── pkg
├── global
│ ├── global.go
│ └── store.go
├── totp
│ └── totp.go
├── model
│ ├── num.go
│ ├── user-group-member.go
│ ├── user-attribute.go
│ ├── property.go
│ ├── command.go
│ ├── login-log.go
│ ├── asset-attribute.go
│ ├── user.go
│ ├── user-group.go
│ ├── credential.go
│ ├── resource-sharer.go
│ ├── session.go
│ └── asset.go
├── term
│ ├── next_writer.go
│ ├── ssh.go
│ ├── next_terminal.go
│ ├── recording.go
│ └── test
│ │ └── test_ssh.go
├── api
│ ├── property.go
│ ├── login-log.go
│ ├── overview.go
│ ├── resource-sharer.go
│ ├── middleware.go
│ ├── command.go
│ ├── user.go
│ ├── user-group.go
│ ├── credential.go
│ ├── asset.go
│ ├── account.go
│ ├── routes.go
│ └── ssh.go
├── config
│ └── config.go
├── utils
│ └── utils.go
└── log
│ └── logger.go
├── docs
├── screenshot.md
├── faq.md
├── install-docker.md
└── install-naive.md
├── .dockerignore
├── supervisord.conf
├── go.mod
├── README.md
├── .github
└── workflows
│ └── docker.yml
├── Dockerfile
└── main.go
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Project exclude paths
2 | /node_modules/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | yarn.lock
2 | web/build
3 | *.log
4 | *.db
5 | .DS_Store
6 | .eslintcache
--------------------------------------------------------------------------------
/web/src/components/access/Access.css:
--------------------------------------------------------------------------------
1 | .container div {
2 | margin: 0 auto;
3 | }
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # Next Terminal dashboard
2 | just do go dashboard
3 |
4 | ## 主要功能
5 |
--------------------------------------------------------------------------------
/screenshot/qq.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/qq.png
--------------------------------------------------------------------------------
/screenshot/rdp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/rdp.png
--------------------------------------------------------------------------------
/screenshot/ssh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/ssh.png
--------------------------------------------------------------------------------
/screenshot/vnc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/vnc.png
--------------------------------------------------------------------------------
/web/src/components/access/Console.css:
--------------------------------------------------------------------------------
1 | .console-card .ant-card-body {
2 | padding: 0;
3 | }
--------------------------------------------------------------------------------
/screenshot/assets.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/assets.png
--------------------------------------------------------------------------------
/screenshot/command.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/command.png
--------------------------------------------------------------------------------
/screenshot/donate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/donate.png
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/web/public/favicon.ico
--------------------------------------------------------------------------------
/screenshot/docker_stats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/screenshot/docker_stats.png
--------------------------------------------------------------------------------
/web/src/components/dashboard/Dashboard.css:
--------------------------------------------------------------------------------
1 | .text-center{
2 | width: 100px;
3 | text-align: center;
4 | }
--------------------------------------------------------------------------------
/web/src/fonts/Menlo-Regular-1.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gytai/next-terminal/master/web/src/fonts/Menlo-Regular-1.ttf
--------------------------------------------------------------------------------
/web/src/components/command/Command.css:
--------------------------------------------------------------------------------
1 | .command-active {
2 | box-shadow: 0 0 0 2px red;
3 | outline: 2px solid red;
4 | }
--------------------------------------------------------------------------------
/config.yml:
--------------------------------------------------------------------------------
1 | db: mysql
2 | mysql:
3 | hostname: 172.16.101.32
4 | port: 3306
5 | username: root
6 | password: mysql
7 | database: next-terminal
8 | sqlite:
9 | file: 'next-terminal.db'
10 | server:
11 | addr: 0.0.0.0:8088
--------------------------------------------------------------------------------
/pkg/global/global.go:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import (
4 | "github.com/patrickmn/go-cache"
5 | "gorm.io/gorm"
6 | "next-terminal/pkg/config"
7 | )
8 |
9 | var DB *gorm.DB
10 |
11 | var Cache *cache.Cache
12 |
13 | var Config *config.Config
14 |
15 | var Store *TunStore
16 |
--------------------------------------------------------------------------------
/web/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/docs/screenshot.md:
--------------------------------------------------------------------------------
1 | 资源占用截图
2 |
3 | 
4 |
5 | 资产管理
6 |
7 | 
8 |
9 | rdp
10 |
11 | 
12 |
13 | vnc
14 |
15 | 
16 |
17 | ssh
18 |
19 | 
20 |
21 | 批量执行命令
22 |
23 | 
--------------------------------------------------------------------------------
/web/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Go template
3 | # Binaries for programs and plugins
4 | *.exe
5 | *.exe~
6 | *.dll
7 | *.so
8 | *.dylib
9 |
10 | # Test binary, built with `go test -c`
11 | *.test
12 |
13 | # Output of the go coverage tool, specifically when used with LiteIDE
14 | *.out
15 |
16 | # Dependency directories (remove the comment below to include it)
17 | # vendor/
18 |
19 | .gitignore
20 | web/node_modules/
21 |
--------------------------------------------------------------------------------
/pkg/totp/totp.go:
--------------------------------------------------------------------------------
1 | package totp
2 |
3 | import (
4 | otp_t "github.com/pquerna/otp"
5 | totp_t "github.com/pquerna/otp/totp"
6 | )
7 |
8 | type GenerateOpts totp_t.GenerateOpts
9 |
10 | func NewTOTP(opt GenerateOpts) (*otp_t.Key, error) {
11 | return totp_t.Generate(totp_t.GenerateOpts(opt))
12 | }
13 |
14 | func Validate(code string, secret string) bool {
15 | if secret == "" {
16 | return true
17 | }
18 | return totp_t.Validate(code, secret)
19 | }
20 |
--------------------------------------------------------------------------------
/pkg/model/num.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | )
6 |
7 | type Num struct {
8 | I string `gorm:"primary_key" json:"i"`
9 | }
10 |
11 | func (r *Num) TableName() string {
12 | return "nums"
13 | }
14 |
15 | func FindAllTemp() (o []Num) {
16 | if global.DB.Find(&o).Error != nil {
17 | return nil
18 | }
19 | return
20 | }
21 |
22 | func CreateNewTemp(o *Num) (err error) {
23 | err = global.DB.Create(o).Error
24 | return
25 | }
26 |
--------------------------------------------------------------------------------
/supervisord.conf:
--------------------------------------------------------------------------------
1 | [supervisord]
2 | nodaemon=true
3 | [program:guacd]
4 | command=/usr/local/guacamole/sbin/guacd -b 0.0.0.0 -L info -f
5 |
6 | [program:next-terminal]
7 | ; command=/usr/local/next-terminal/next-terminal --mysql.hostname %(ENV_MYSQL_HOSTNAME)s --mysql.port %(ENV_MYSQL_PORT)s --mysql.username %(ENV_MYSQL_USERNAME)s --mysql.password %(ENV_MYSQL_PASSWORD)s --mysql.database %(ENV_MYSQL_DATABASE)s --server.addr 0.0.0.0:%(ENV_SERVER_PORT)s
8 | command=/usr/local/next-terminal/next-terminal --server.addr 0.0.0.0:%(ENV_SERVER_PORT)s
--------------------------------------------------------------------------------
/pkg/term/next_writer.go:
--------------------------------------------------------------------------------
1 | package term
2 |
3 | import (
4 | "bytes"
5 | "sync"
6 | )
7 |
8 | type NextWriter struct {
9 | b bytes.Buffer
10 | mu sync.Mutex
11 | }
12 |
13 | func (w *NextWriter) Write(p []byte) (int, error) {
14 | w.mu.Lock()
15 | defer w.mu.Unlock()
16 | return w.b.Write(p)
17 | }
18 |
19 | func (w *NextWriter) Read() ([]byte, int, error) {
20 | w.mu.Lock()
21 | defer w.mu.Unlock()
22 | p := w.b.Bytes()
23 | buf := make([]byte, len(p))
24 | read, err := w.b.Read(buf)
25 | w.b.Reset()
26 | if err != nil {
27 | return nil, 0, err
28 | }
29 | return buf, read, err
30 | }
31 |
--------------------------------------------------------------------------------
/pkg/model/user-group-member.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "next-terminal/pkg/global"
4 |
5 | type UserGroupMember struct {
6 | ID string `gorm:"primary_key" json:"name"`
7 | UserId string `gorm:"index" json:"userId"`
8 | UserGroupId string `gorm:"index" json:"userGroupId"`
9 | }
10 |
11 | func (r *UserGroupMember) TableName() string {
12 | return "user_group_members"
13 | }
14 |
15 | func FindUserGroupMembersByUserGroupId(id string) (o []string, err error) {
16 | err = global.DB.Table("user_group_members").Select("user_id").Where("user_group_id = ?", id).Find(&o).Error
17 | return
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/components/access/Term.css:
--------------------------------------------------------------------------------
1 | .xterm .xterm-viewport {
2 | /* On OS X this is required in order for the scroll bar to appear fully opaque */
3 | background-color: transparent;
4 | overflow-y: scroll;
5 | cursor: default;
6 | position: absolute;
7 | right: 0;
8 | left: 0;
9 | top: 0;
10 | bottom: 0;
11 | --scrollbar-color: var(--highlight) var(--dark);
12 | --scrollbar-width: thin;
13 | }
14 |
15 | .xterm-viewport::-webkit-scrollbar {
16 | background-color: var(--dark);
17 | width: 5px;
18 | }
19 |
20 | .xterm-viewport::-webkit-scrollbar-thumb {
21 | background: var(--highlight);
22 | }
--------------------------------------------------------------------------------
/web/src/common/constants.js:
--------------------------------------------------------------------------------
1 | // prod
2 | let wsPrefix;
3 | if (window.location.protocol === 'https:') {
4 | wsPrefix = 'wss:'
5 | } else {
6 | wsPrefix = 'ws:'
7 | }
8 |
9 | export const server = '';
10 | export const wsServer = wsPrefix + window.location.host;
11 | export const prefix = window.location.protocol + '//' + window.location.host;
12 |
13 | // dev
14 | // export const server = '//127.0.0.1:8088';
15 | // export const wsServer = 'ws://127.0.0.1:8088';
16 | // export const prefix = '';
17 |
18 | export const PROTOCOL_COLORS = {
19 | 'rdp': 'red',
20 | 'ssh': 'blue',
21 | 'telnet': 'geekblue',
22 | 'vnc': 'purple'
23 | }
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module next-terminal
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/antonfisher/nested-logrus-formatter v1.3.0
7 | github.com/gofrs/uuid v3.3.0+incompatible
8 | github.com/gorilla/websocket v1.4.2
9 | github.com/labstack/echo/v4 v4.1.17
10 | github.com/labstack/gommon v0.3.0
11 | github.com/patrickmn/go-cache v2.1.0+incompatible
12 | github.com/pkg/sftp v1.12.0
13 | github.com/pquerna/otp v1.3.0
14 | github.com/robfig/cron/v3 v3.0.1
15 | github.com/sirupsen/logrus v1.4.2
16 | github.com/spf13/pflag v1.0.3
17 | github.com/spf13/viper v1.7.1
18 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
19 | gorm.io/driver/mysql v1.0.3
20 | gorm.io/driver/sqlite v1.1.4
21 | gorm.io/gorm v1.20.7
22 | )
23 |
--------------------------------------------------------------------------------
/pkg/model/user-attribute.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "next-terminal/pkg/global"
4 |
5 | const (
6 | FontSize = "font-size"
7 | )
8 |
9 | type UserAttribute struct {
10 | Id string `gorm:"index" json:"id"`
11 | UserId string `gorm:"index" json:"userId"`
12 | Name string `gorm:"index" json:"name"`
13 | Value string `json:"value"`
14 | }
15 |
16 | func (r *UserAttribute) TableName() string {
17 | return "user_attributes"
18 | }
19 |
20 | func CreateUserAttribute(o *UserAttribute) error {
21 | return global.DB.Create(o).Error
22 | }
23 |
24 | func FindUserAttributeByUserId(userId string) (o []UserAttribute, err error) {
25 | err = global.DB.Where("user_id = ?", userId).Find(&o).Error
26 | return o, err
27 | }
28 |
--------------------------------------------------------------------------------
/web/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 | import zhCN from 'antd/es/locale-provider/zh_CN';
7 | import {ConfigProvider} from 'antd';
8 | import {HashRouter as Router} from "react-router-dom";
9 |
10 | ReactDOM.render(
11 |
12 |
13 |
14 |
15 | ,
16 | document.getElementById('root')
17 | );
18 |
19 | // If you want your app to work offline and load faster, you can change
20 | // unregister() to register() below. Note this comes with some pitfalls.
21 | // Learn more about service workers: https://bit.ly/CRA-PWA
22 | serviceWorker.unregister();
23 |
24 |
--------------------------------------------------------------------------------
/web/src/service/permission.js:
--------------------------------------------------------------------------------
1 | import {isEmpty} from "../utils/utils";
2 |
3 | export function hasPermission(owner) {
4 | let userJsonStr = sessionStorage.getItem('user');
5 | if (isEmpty(userJsonStr)) {
6 | return false;
7 | }
8 | let user = JSON.parse(userJsonStr);
9 | if (user['type'] === 'admin') {
10 | return true;
11 | }
12 |
13 | return user['id'] === owner;
14 | }
15 |
16 | export function isAdmin() {
17 | let userJsonStr = sessionStorage.getItem('user');
18 | if (isEmpty(userJsonStr)) {
19 | return false;
20 | }
21 | let user = JSON.parse(userJsonStr);
22 | return user['type'] === 'admin';
23 | }
24 |
25 | export function getCurrentUser() {
26 | let userJsonStr = sessionStorage.getItem('user');
27 | if (isEmpty(userJsonStr)) {
28 | return {};
29 | }
30 |
31 | return JSON.parse(userJsonStr);
32 | }
--------------------------------------------------------------------------------
/pkg/api/property.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/labstack/echo/v4"
7 | "gorm.io/gorm"
8 | "next-terminal/pkg/model"
9 | )
10 |
11 | func PropertyGetEndpoint(c echo.Context) error {
12 | properties := model.FindAllPropertiesMap()
13 | return Success(c, properties)
14 | }
15 |
16 | func PropertyUpdateEndpoint(c echo.Context) error {
17 | var item map[string]interface{}
18 | if err := c.Bind(&item); err != nil {
19 | return err
20 | }
21 |
22 | for key := range item {
23 | value := fmt.Sprintf("%v", item[key])
24 | if value == "" {
25 | value = "-"
26 | }
27 |
28 | property := model.Property{
29 | Name: key,
30 | Value: value,
31 | }
32 |
33 | _, err := model.FindPropertyByName(key)
34 | if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
35 | if err := model.CreateNewProperty(&property); err != nil {
36 | return err
37 | }
38 | } else {
39 | model.UpdatePropertyByName(&property, key)
40 | }
41 | }
42 | return Success(c, nil)
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/api/login-log.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/model"
7 | "strconv"
8 | "strings"
9 | )
10 |
11 | func LoginLogPagingEndpoint(c echo.Context) error {
12 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
13 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
14 | userId := c.QueryParam("userId")
15 | clientIp := c.QueryParam("clientIp")
16 |
17 | items, total, err := model.FindPageLoginLog(pageIndex, pageSize, userId, clientIp)
18 |
19 | if err != nil {
20 | return err
21 | }
22 |
23 | return Success(c, H{
24 | "total": total,
25 | "items": items,
26 | })
27 | }
28 |
29 | func LoginLogDeleteEndpoint(c echo.Context) error {
30 | ids := c.Param("id")
31 | split := strings.Split(ids, ",")
32 | for i := range split {
33 | token := split[i]
34 | global.Cache.Delete(token)
35 | model.Logout(token)
36 | }
37 | if err := model.DeleteLoginLogByIdIn(split); err != nil {
38 | return err
39 | }
40 |
41 | return Success(c, nil)
42 | }
43 |
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | # 常见问题
2 |
3 |
4 | 如何进行反向代理?
5 |
6 | 主要是反向代理websocket,示例如下
7 | ```shell
8 | location / {
9 | proxy_pass http://127.0.0.1:8088/;
10 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
11 | proxy_set_header Upgrade $http_upgrade;
12 | proxy_set_header Connection $http_connection;
13 | }
14 |
15 | ```
16 |
17 |
18 |
19 | 访问realvnc提示验证失败?
20 |
21 | 把加密类型修改为 Prefer On
22 |
23 |
24 |
25 |
26 |
27 | docker安装如何更新?
28 |
29 | 推荐使用`watchtower`自动更新
30 |
31 | 手动更新需要先拉取最新的镜像
32 |
33 | ```shell
34 | docker pull dushixiang/next-terminal:latest
35 | ```
36 |
37 | 删除掉原来的容器
38 | > 如果是使用sqlite方式启动的,记得备份`next-terminal.db`文件哦
39 | ```shell
40 | docker rm -f
41 | ```
42 | 再重新执行一次 [docker方式安装命令](install-naive.md)
43 |
44 |
45 |
46 |
47 | 连接rdp协议的windows7或者windows server 2008直接断开?
48 |
49 | 因为freerdp的一个问题导致的,把 设置>RDP 下面的禁用字形缓存打开即可。
50 | 详情可参考 https://issues.apache.org/jira/browse/GUACAMOLE-1191
51 |
52 |
--------------------------------------------------------------------------------
/web/public/asciinema.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Title
6 |
7 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
38 |
--------------------------------------------------------------------------------
/web/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 | Next Terminal
16 |
17 |
18 |
19 |
20 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next Terminal
2 |
3 | 你的下一个终端。
4 |
5 | 
6 |
7 | ## 快速了解
8 |
9 | Next Terminal是使用Golang和React开发的一款HTML5的远程桌面网关,具有小巧、易安装、易使用、资源占用小的特点,支持RDP、SSH、VNC和Telnet协议的连接和管理。
10 |
11 | Next Terminal基于 [Apache Guacamole](https://guacamole.apache.org/) 开发,使用到了guacd服务。
12 |
13 | 目前支持的功能有:
14 |
15 | - 授权凭证管理
16 | - 资产管理(支持RDP、SSH、VNC、TELNET协议)
17 | - 指令管理
18 | - 批量执行命令
19 | - 在线会话管理(监控、强制断开)
20 | - 离线会话管理(查看录屏)
21 | - 双因素认证 感谢 [naiba](https://github.com/naiba) 贡献
22 | - 资产标签
23 | - 资产授权
24 | - 多用户&用户分组
25 |
26 | ## 在线体验
27 |
28 | https://next-terminal.typesafe.cn/
29 |
30 | test/test
31 |
32 | ## 快速安装
33 |
34 | - [使用docker安装](docs/install-docker.md)
35 | - [原生安装](docs/install-naive.md)
36 | - [FAQ](docs/faq.md)
37 |
38 | 默认账号密码为 admin/admin
39 |
40 | ## 相关截图
41 |
42 | [截图](docs/screenshot.md)
43 |
44 | ## 捐赠
45 |
46 | 如果您觉得 next-terminal 对你有帮助,欢迎给予我们一定的捐助来维持项目的长期发展。
47 |
48 | 
49 |
50 | ## 联系方式
51 |
52 | - 邮箱 helloworld1024@foxmail.com
53 |
54 | - QQ群 938145268
55 |
56 |
57 |
58 | - Telegram
59 |
60 | https://t.me/next_terminal
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - "master"
7 | paths-ignore:
8 | - ".gitignore"
9 | - "*.md"
10 | - "*.png"
11 |
12 | jobs:
13 | deploy:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@master
17 |
18 | - uses: actions/setup-node@v2
19 | with:
20 | node-version: '14'
21 |
22 | - run: |
23 | cd web
24 | npm install
25 | npm run build
26 |
27 | - name: Log into registry
28 | run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
29 |
30 | - name: Build and push image
31 | run: |
32 | docker build -t ghcr.io/${{ github.repository_owner }}/next-terminal -f Dockerfile .
33 | docker push ghcr.io/${{ github.repository_owner }}/next-terminal
34 |
35 | - name: Push to Docker Hub
36 | uses: docker/build-push-action@v1
37 | with:
38 | username: ${{ secrets.DOCKER_USERNAME }}
39 | password: ${{ secrets.DOCKER_PASSWORD }}
40 | repository: ${{ github.repository_owner }}/next-terminal
41 | tag_with_ref: true
42 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-terminal",
3 | "version": "0.2.4",
4 | "private": true,
5 | "dependencies": {
6 | "@ant-design/icons": "^4.3.0",
7 | "antd": "^4.8.4",
8 | "axios": "^0.21.1",
9 | "guacamole-common-js": "^1.2.0",
10 | "qs": "^6.9.4",
11 | "react": "^16.14.0",
12 | "react-contexify": "^4.1.1",
13 | "react-dom": "^16.14.0",
14 | "react-draggable": "^4.4.3",
15 | "react-router": "^5.2.0",
16 | "react-router-dom": "^5.2.0",
17 | "react-scripts": "^4.0.0",
18 | "typescript": "^3.9.7",
19 | "xterm": "^4.9.0",
20 | "xterm-addon-fit": "^0.4.0",
21 | "xterm-addon-web-links": "^0.4.0"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": "react-app"
31 | },
32 | "browserslist": [
33 | ">0.2%",
34 | "not dead",
35 | "not ie <= 11",
36 | "not op_mini all"
37 | ],
38 | "homepage": ".",
39 | "devDependencies": {
40 | "@ant-design/charts": "^1.0.13",
41 | "g2": "^2.3.13",
42 | "g2-react": "^1.3.2",
43 | "umi-request": "^1.3.5",
44 | "xterm-addon-attach": "^0.6.0",
45 | "xterm-addon-fit": "^0.4.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/term/ssh.go:
--------------------------------------------------------------------------------
1 | package term
2 |
3 | import (
4 | "fmt"
5 | "golang.org/x/crypto/ssh"
6 | "time"
7 | )
8 |
9 | func NewSshClient(ip string, port int, username, password, privateKey, passphrase string) (*ssh.Client, error) {
10 | var authMethod ssh.AuthMethod
11 | if username == "-" || username == "" {
12 | username = "root"
13 | }
14 | if password == "-" {
15 | password = ""
16 | }
17 | if privateKey == "-" {
18 | privateKey = ""
19 | }
20 | if passphrase == "-" {
21 | passphrase = ""
22 | }
23 |
24 | var err error
25 | if privateKey != "" {
26 | var key ssh.Signer
27 | if len(passphrase) > 0 {
28 | key, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
29 | if err != nil {
30 | return nil, err
31 | }
32 | } else {
33 | key, err = ssh.ParsePrivateKey([]byte(privateKey))
34 | if err != nil {
35 | return nil, err
36 | }
37 | }
38 | authMethod = ssh.PublicKeys(key)
39 | } else {
40 | authMethod = ssh.Password(password)
41 | }
42 |
43 | config := &ssh.ClientConfig{
44 | Timeout: 1 * time.Second,
45 | User: username,
46 | Auth: []ssh.AuthMethod{authMethod},
47 | HostKeyCallback: ssh.InsecureIgnoreHostKey(),
48 | }
49 |
50 | addr := fmt.Sprintf("%s:%d", ip, port)
51 |
52 | return ssh.Dial("tcp", addr, config)
53 | }
54 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:alpine as builder
2 |
3 | ENV GO111MODULE=on
4 |
5 | ENV GOPROXY=https://goproxy.cn,direct
6 |
7 | WORKDIR /app
8 |
9 | COPY . .
10 |
11 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
12 | RUN apk add gcc g++
13 | RUN go env && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -ldflags '-linkmode external -extldflags "-static"' -o next-terminal main.go
14 |
15 | FROM guacamole/guacd:1.2.0
16 |
17 | LABEL MAINTAINER="helloworld1024@foxmail.com"
18 |
19 | RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
20 | RUN apt-get update && apt-get -y install supervisor
21 | RUN mkdir -p /var/log/supervisor
22 | COPY --from=builder /app/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
23 |
24 | ENV DB sqlite
25 | ENV SQLITE_FILE 'next-terminal.db'
26 | ENV SERVER_PORT 8088
27 | ENV SERVER_ADDR 0.0.0.0:$SERVER_PORT
28 | ENV TIME_ZONE=Asia/Shanghai
29 | RUN ln -snf /usr/share/zoneinfo/$TIME_ZONE /etc/localtime && echo $TIME_ZONE > /etc/timezone
30 |
31 | WORKDIR /usr/local/next-terminal
32 |
33 | COPY --from=builder /app/next-terminal ./
34 | COPY --from=builder /app/web/build ./web/build
35 | COPY --from=builder /app/web/src/fonts/Menlo-Regular-1.ttf /usr/share/fonts/
36 |
37 | RUN mkfontscale && mkfontdir && fc-cache
38 |
39 | EXPOSE $SERVER_PORT
40 |
41 | RUN mkdir recording && mkdir drive
42 | ENTRYPOINT /usr/bin/supervisord
--------------------------------------------------------------------------------
/web/src/components/access/FileSystem.css:
--------------------------------------------------------------------------------
1 | .dode {
2 | -webkit-user-select: none;
3 | -moz-user-select: none;
4 | -o-user-select: none;
5 | -ms-user-select: none;
6 | }
7 |
8 | @-webkit-keyframes fadeIn {
9 | from {
10 | opacity: 0;
11 | }
12 |
13 | to {
14 | opacity: 1;
15 | }
16 | }
17 |
18 | @keyframes fadeIn {
19 | from {
20 | opacity: 0;
21 | }
22 |
23 | to {
24 | opacity: 1;
25 | }
26 | }
27 |
28 | .popup {
29 | -webkit-animation-name: fadeIn;
30 | animation-name: fadeIn;
31 | animation-duration: 0.4s;
32 | background-clip: padding-box;
33 | background-color: #fff;
34 | border-radius: 4px;
35 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.75);
36 | left: 0;
37 | list-style-type: none;
38 | margin: 0;
39 | outline: none;
40 | padding: 0;
41 | position: fixed;
42 | text-align: left;
43 | top: 0;
44 | overflow: hidden;
45 | -webkit-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.75);
46 | }
47 |
48 | .popup li {
49 | clear: both;
50 | /*color: rgba(0, 0, 0, 0.65);*/
51 | cursor: pointer;
52 | font-size: 14px;
53 | font-weight: normal;
54 | line-height: 22px;
55 | margin: 0;
56 | padding: 5px 12px;
57 | transition: all .3s;
58 | white-space: nowrap;
59 | -webkit-transition: all .3s;
60 | }
61 |
62 | .popup li:hover {
63 | background-color: #e6f7ff;
64 | }
65 |
66 | .popup li > i {
67 | margin-right: 8px;
68 | }
--------------------------------------------------------------------------------
/pkg/model/property.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/guacd"
6 | )
7 |
8 | const (
9 | SshMode = "ssh-mode"
10 | )
11 |
12 | type Property struct {
13 | Name string `gorm:"primary_key" json:"name"`
14 | Value string `json:"value"`
15 | }
16 |
17 | func (r *Property) TableName() string {
18 | return "properties"
19 | }
20 |
21 | func FindAllProperties() (o []Property) {
22 | if global.DB.Find(&o).Error != nil {
23 | return nil
24 | }
25 | return
26 | }
27 |
28 | func CreateNewProperty(o *Property) (err error) {
29 | err = global.DB.Create(o).Error
30 | return
31 | }
32 |
33 | func UpdatePropertyByName(o *Property, name string) {
34 | o.Name = name
35 | global.DB.Updates(o)
36 | }
37 |
38 | func FindPropertyByName(name string) (o Property, err error) {
39 | err = global.DB.Where("name = ?", name).First(&o).Error
40 | return
41 | }
42 |
43 | func FindAllPropertiesMap() map[string]string {
44 | properties := FindAllProperties()
45 | propertyMap := make(map[string]string)
46 | for i := range properties {
47 | propertyMap[properties[i].Name] = properties[i].Value
48 | }
49 | return propertyMap
50 | }
51 |
52 | func GetDrivePath() (string, error) {
53 | property, err := FindPropertyByName(guacd.DrivePath)
54 | if err != nil {
55 | return "", err
56 | }
57 | return property.Value, nil
58 | }
59 |
60 | func GetRecordingPath() (string, error) {
61 | property, err := FindPropertyByName(guacd.RecordingPath)
62 | if err != nil {
63 | return "", err
64 | }
65 | return property.Value, nil
66 | }
67 |
--------------------------------------------------------------------------------
/pkg/api/overview.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "next-terminal/pkg/model"
6 | )
7 |
8 | type Counter struct {
9 | User int64 `json:"user"`
10 | Asset int64 `json:"asset"`
11 | Credential int64 `json:"credential"`
12 | OnlineSession int64 `json:"onlineSession"`
13 | }
14 |
15 | func OverviewCounterEndPoint(c echo.Context) error {
16 | account, _ := GetCurrentAccount(c)
17 |
18 | var (
19 | countUser int64
20 | countOnlineSession int64
21 | credential int64
22 | asset int64
23 | )
24 | if model.TypeUser == account.Type {
25 | countUser, _ = model.CountUser()
26 | countOnlineSession, _ = model.CountOnlineSession()
27 | credential, _ = model.CountCredentialByUserId(account.ID)
28 | asset, _ = model.CountAssetByUserId(account.ID)
29 | } else {
30 | countUser, _ = model.CountUser()
31 | countOnlineSession, _ = model.CountOnlineSession()
32 | credential, _ = model.CountCredential()
33 | asset, _ = model.CountAsset()
34 | }
35 | counter := Counter{
36 | User: countUser,
37 | OnlineSession: countOnlineSession,
38 | Credential: credential,
39 | Asset: asset,
40 | }
41 |
42 | return Success(c, counter)
43 | }
44 |
45 | func OverviewSessionPoint(c echo.Context) (err error) {
46 | d := c.QueryParam("d")
47 | var results []model.D
48 | if d == "m" {
49 | results, err = model.CountSessionByDay(30)
50 | } else {
51 | results, err = model.CountSessionByDay(7)
52 | }
53 | if err != nil {
54 | return err
55 | }
56 | return Success(c, results)
57 | }
58 |
--------------------------------------------------------------------------------
/web/src/components/user/Logout.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Button, Dropdown, Menu, message, Popconfirm} from "antd";
3 | import request from "../../common/request";
4 | import {getCurrentUser} from "../../service/permission";
5 |
6 | class Logout extends Component {
7 |
8 | confirm = async (e) => {
9 | let result = await request.post('/logout');
10 | if (result['code'] !== 1) {
11 | message.error(result['message']);
12 | } else {
13 | message.success('退出登录成功,即将跳转至登录页面。');
14 | window.location.reload();
15 | }
16 | }
17 |
18 |
19 | render() {
20 |
21 | const menu = (
22 |
39 | );
40 |
41 | return (
42 |
43 |
44 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | export default Logout;
--------------------------------------------------------------------------------
/pkg/global/store.go:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import (
4 | "github.com/gorilla/websocket"
5 | "next-terminal/pkg/guacd"
6 | "next-terminal/pkg/term"
7 | "strconv"
8 | "sync"
9 | )
10 |
11 | type Tun struct {
12 | Protocol string
13 | Mode string
14 | WebSocket *websocket.Conn
15 | Tunnel *guacd.Tunnel
16 | NextTerminal *term.NextTerminal
17 | }
18 |
19 | func (r *Tun) Close(code int, reason string) {
20 | if r.Tunnel != nil {
21 | _ = r.Tunnel.Close()
22 | }
23 | if r.NextTerminal != nil {
24 | _ = r.NextTerminal.Close()
25 | }
26 |
27 | ws := r.WebSocket
28 | if ws != nil {
29 | if r.Mode == "guacd" {
30 | err := guacd.NewInstruction("error", reason, strconv.Itoa(code))
31 | _ = ws.WriteMessage(websocket.TextMessage, []byte(err.String()))
32 | disconnect := guacd.NewInstruction("disconnect")
33 | _ = ws.WriteMessage(websocket.TextMessage, []byte(disconnect.String()))
34 | } else {
35 | msg := `{"type":"closed","content":"` + reason + `"}`
36 | _ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
37 | }
38 | }
39 | }
40 |
41 | type Observable struct {
42 | Subject *Tun
43 | Observers []Tun
44 | }
45 |
46 | type TunStore struct {
47 | m sync.Map
48 | }
49 |
50 | func (s *TunStore) Set(k string, v *Observable) {
51 | s.m.Store(k, v)
52 | }
53 |
54 | func (s *TunStore) Del(k string) {
55 | s.m.Delete(k)
56 | }
57 |
58 | func (s *TunStore) Get(k string) (item *Observable, ok bool) {
59 | value, ok := s.m.Load(k)
60 | if ok {
61 | return value.(*Observable), true
62 | }
63 | return item, false
64 | }
65 |
66 | func NewStore() *TunStore {
67 | store := TunStore{sync.Map{}}
68 | return &store
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/api/resource-sharer.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "next-terminal/pkg/model"
6 | )
7 |
8 | type RU struct {
9 | UserGroupId string `json:"userGroupId"`
10 | UserId string `json:"userId"`
11 | ResourceType string `json:"resourceType"`
12 | ResourceIds []string `json:"resourceIds"`
13 | }
14 |
15 | type UR struct {
16 | ResourceId string `json:"resourceId"`
17 | ResourceType string `json:"resourceType"`
18 | UserIds []string `json:"userIds"`
19 | }
20 |
21 | func RSGetSharersEndPoint(c echo.Context) error {
22 | resourceId := c.QueryParam("resourceId")
23 | userIds, err := model.FindUserIdsByResourceId(resourceId)
24 | if err != nil {
25 | return err
26 | }
27 | return Success(c, userIds)
28 | }
29 |
30 | func RSOverwriteSharersEndPoint(c echo.Context) error {
31 | var ur UR
32 | if err := c.Bind(&ur); err != nil {
33 | return err
34 | }
35 |
36 | if err := model.OverwriteUserIdsByResourceId(ur.ResourceId, ur.ResourceType, ur.UserIds); err != nil {
37 | return err
38 | }
39 |
40 | return Success(c, "")
41 | }
42 |
43 | func ResourceRemoveByUserIdAssignEndPoint(c echo.Context) error {
44 | var ru RU
45 | if err := c.Bind(&ru); err != nil {
46 | return err
47 | }
48 |
49 | if err := model.DeleteByUserIdAndResourceTypeAndResourceIdIn(ru.UserGroupId, ru.UserId, ru.ResourceType, ru.ResourceIds); err != nil {
50 | return err
51 | }
52 |
53 | return Success(c, "")
54 | }
55 |
56 | func ResourceAddByUserIdAssignEndPoint(c echo.Context) error {
57 | var ru RU
58 | if err := c.Bind(&ru); err != nil {
59 | return err
60 | }
61 |
62 | if err := model.AddSharerResources(ru.UserGroupId, ru.UserId, ru.ResourceType, ru.ResourceIds); err != nil {
63 | return err
64 | }
65 |
66 | return Success(c, "")
67 | }
68 |
--------------------------------------------------------------------------------
/pkg/api/middleware.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "github.com/labstack/echo/v4"
6 | "next-terminal/pkg/global"
7 | "next-terminal/pkg/model"
8 | "strings"
9 | "time"
10 | )
11 |
12 | func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
13 | return func(c echo.Context) error {
14 |
15 | if err := next(c); err != nil {
16 |
17 | if he, ok := err.(*echo.HTTPError); ok {
18 | message := fmt.Sprintf("%v", he.Message)
19 | return Fail(c, he.Code, message)
20 | }
21 |
22 | return Fail(c, 0, err.Error())
23 | }
24 | return nil
25 | }
26 | }
27 |
28 | func Auth(next echo.HandlerFunc) echo.HandlerFunc {
29 |
30 | urls := []string{"download", "recording", "login", "static", "favicon", "logo"}
31 |
32 | return func(c echo.Context) error {
33 | // 路由拦截 - 登录身份、资源权限判断等
34 | for i := range urls {
35 | if c.Request().RequestURI == "/" || strings.HasPrefix(c.Request().RequestURI, "/#") {
36 | return next(c)
37 | }
38 | if strings.Contains(c.Request().RequestURI, urls[i]) {
39 | return next(c)
40 | }
41 | }
42 |
43 | token := GetToken(c)
44 | authorization, found := global.Cache.Get(token)
45 | if !found {
46 | return Fail(c, 401, "您的登录信息已失效,请重新登录后再试。")
47 | }
48 |
49 | if authorization.(Authorization).Remember {
50 | // 记住登录有效期两周
51 | global.Cache.Set(token, authorization, time.Hour*time.Duration(24*14))
52 | } else {
53 | global.Cache.Set(token, authorization, time.Hour*time.Duration(2))
54 | }
55 |
56 | return next(c)
57 | }
58 | }
59 |
60 | func Admin(next echo.HandlerFunc) echo.HandlerFunc {
61 | return func(c echo.Context) error {
62 |
63 | account, found := GetCurrentAccount(c)
64 | if !found {
65 | return Fail(c, 401, "您的登录信息已失效,请重新登录后再试。")
66 | }
67 |
68 | if account.Type != model.TypeAdmin {
69 | return Fail(c, 403, "permission denied")
70 | }
71 |
72 | return next(c)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/web/src/components/command/DynamicCommandModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Form, Input, Modal} from "antd/lib/index";
3 |
4 | const {TextArea} = Input;
5 |
6 | // 子级页面
7 | // Ant form create 表单内置方法
8 |
9 | const DynamicCommandModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => {
10 |
11 | const [form] = Form.useForm();
12 |
13 | const formItemLayout = {
14 | labelCol: {span: 6},
15 | wrapperCol: {span: 18},
16 | };
17 |
18 | return (
19 |
20 | {
26 | form
27 | .validateFields()
28 | .then(values => {
29 | form.resetFields();
30 | handleOk(values);
31 | })
32 | .catch(info => {
33 |
34 | });
35 | }}
36 | onCancel={handleCancel}
37 | confirmLoading={confirmLoading}
38 | okText='确定'
39 | cancelText='取消'
40 | >
41 |
42 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | )
58 | };
59 |
60 | export default DynamicCommandModal;
61 |
--------------------------------------------------------------------------------
/web/src/App.css:
--------------------------------------------------------------------------------
1 | .trigger {
2 | font-size: 18px;
3 | line-height: 64px;
4 | padding: 0 24px;
5 | cursor: pointer;
6 | transition: color 0.3s;
7 | }
8 |
9 | .trigger:hover {
10 | color: #1890ff;
11 | }
12 |
13 | .logo {
14 | height: 32px;
15 | margin: 16px;
16 | text-align: center;
17 | }
18 |
19 | .logo > h1 {
20 | color: white;
21 | font-weight: bold;
22 | line-height: 32px; /*设置line-height与父级元素的height相等*/
23 | text-align: center; /*设置文本水平居中*/
24 | display: inline-block;
25 | }
26 |
27 | .site-layout .site-layout-background {
28 | background: #fff;
29 | }
30 |
31 | .site-page-header-ghost-wrapper {
32 | background-color: #FFF;
33 | }
34 |
35 | .global-header {
36 | position: relative;
37 | display: flex;
38 | align-items: center;
39 | height: 48px;
40 | padding: 0 16px 0 0;
41 | background: #fff;
42 | box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
43 | }
44 |
45 | .page-herder {
46 | margin: 16px 16px 0 16px;
47 | }
48 |
49 | .page-search {
50 | background-color: white;
51 | margin: 16px 16px 0 16px;
52 | padding: 16px;
53 | }
54 |
55 | .page-search label {
56 | font-weight: bold;
57 | }
58 |
59 | .page-search .ant-form-item {
60 | margin-bottom: 0;
61 | }
62 |
63 | .page-content {
64 | margin: 16px;
65 | padding: 24px;
66 | min-height: 280px;
67 | }
68 |
69 | .page-card {
70 | margin: 16px;
71 | }
72 |
73 | .user-in-menu {
74 | align-items: center;
75 | text-align: center;
76 | margin: 10px auto;
77 | color: white;
78 | }
79 |
80 | .user-in-menu>.nickname {
81 | margin-top: 20px;
82 | margin-right: auto;
83 | margin-left: auto;
84 | font-weight: bold;
85 | padding: 2px 5px;
86 | border-style: solid;
87 | border-width: 1px;
88 | border-color: white;
89 | width: fit-content;
90 | border-radius: 5%;
91 | }
92 |
93 | .monitor .ant-modal-body{
94 | padding: 0;
95 | }
--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/spf13/pflag"
5 | "strings"
6 |
7 | "github.com/spf13/viper"
8 | )
9 |
10 | type Config struct {
11 | DB string
12 | Server *Server
13 | Mysql *Mysql
14 | Sqlite *Sqlite
15 | }
16 |
17 | type Mysql struct {
18 | Hostname string
19 | Port int
20 | Username string
21 | Password string
22 | Database string
23 | }
24 |
25 | type Sqlite struct {
26 | File string
27 | }
28 |
29 | type Server struct {
30 | Addr string
31 | Cert string
32 | Key string
33 | }
34 |
35 | func SetupConfig() (*Config, error) {
36 |
37 | viper.SetConfigName("config")
38 | viper.SetConfigType("yml")
39 | viper.AddConfigPath("/etc/next-terminal/")
40 | viper.AddConfigPath("$HOME/.next-terminal")
41 | viper.AddConfigPath(".")
42 | viper.AutomaticEnv()
43 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
44 |
45 | pflag.String("db", "sqlite", "db mode")
46 | pflag.String("sqlite.file", "next-terminal.db", "sqlite db file")
47 | pflag.String("mysql.hostname", "127.0.0.1", "mysql hostname")
48 | pflag.Int("mysql.port", 3306, "mysql port")
49 | pflag.String("mysql.username", "mysql", "mysql username")
50 | pflag.String("mysql.password", "mysql", "mysql password")
51 | pflag.String("mysql.database", "next_terminal", "mysql database")
52 |
53 | pflag.String("server.addr", "", "server listen addr")
54 | pflag.String("server.cert", "", "tls cert file")
55 | pflag.String("server.key", "", "tls key file")
56 |
57 | pflag.Parse()
58 | _ = viper.BindPFlags(pflag.CommandLine)
59 | _ = viper.ReadInConfig()
60 |
61 | var config = &Config{
62 | DB: viper.GetString("db"),
63 | Mysql: &Mysql{
64 | Hostname: viper.GetString("mysql.hostname"),
65 | Port: viper.GetInt("mysql.port"),
66 | Username: viper.GetString("mysql.username"),
67 | Password: viper.GetString("mysql.password"),
68 | Database: viper.GetString("mysql.database"),
69 | },
70 | Sqlite: &Sqlite{
71 | File: viper.GetString("sqlite.file"),
72 | },
73 | Server: &Server{
74 | Addr: viper.GetString("server.addr"),
75 | Cert: viper.GetString("server.cert"),
76 | Key: viper.GetString("server.key"),
77 | },
78 | }
79 |
80 | return config, nil
81 | }
82 |
--------------------------------------------------------------------------------
/web/src/components/user/UserGroupModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Form, Input, Modal, Select} from "antd/lib/index";
3 |
4 | const UserGroupModal = ({
5 | title,
6 | visible,
7 | handleOk,
8 | handleCancel,
9 | confirmLoading,
10 | model,
11 | users,
12 | }) => {
13 |
14 | const [form] = Form.useForm();
15 |
16 | const formItemLayout = {
17 | labelCol: {span: 6},
18 | wrapperCol: {span: 14},
19 | };
20 |
21 | return (
22 | {
29 | form
30 | .validateFields()
31 | .then(values => {
32 | form.resetFields();
33 | handleOk(values);
34 | })
35 | .catch(info => {
36 | });
37 | }}
38 | onCancel={handleCancel}
39 | confirmLoading={confirmLoading}
40 | okText='确定'
41 | cancelText='取消'
42 | >
43 |
44 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
64 |
65 |
66 |
67 | )
68 | };
69 |
70 | export default UserGroupModal;
71 |
--------------------------------------------------------------------------------
/web/src/components/user/UserModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Form, Input, Modal, Radio} from "antd/lib/index";
3 |
4 | const UserModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => {
5 |
6 | const [form] = Form.useForm();
7 |
8 | const formItemLayout = {
9 | labelCol: {span: 6},
10 | wrapperCol: {span: 14},
11 | };
12 |
13 | return (
14 | {
20 | form
21 | .validateFields()
22 | .then(values => {
23 | form.resetFields();
24 | handleOk(values);
25 | })
26 | .catch(info => {
27 | });
28 | }}
29 | onCancel={handleCancel}
30 | confirmLoading={confirmLoading}
31 | okText='确定'
32 | cancelText='取消'
33 | >
34 |
35 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | 普通用户
51 | 管理用户
52 |
53 |
54 |
55 | {
56 | title.indexOf('新增') > -1 ?
57 | (
58 |
59 | ) : null
60 | }
61 |
62 |
63 |
64 | )
65 | };
66 |
67 | export default UserModal;
68 |
--------------------------------------------------------------------------------
/web/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/pkg/term/next_terminal.go:
--------------------------------------------------------------------------------
1 | package term
2 |
3 | import (
4 | "github.com/pkg/sftp"
5 | "golang.org/x/crypto/ssh"
6 | "io"
7 | )
8 |
9 | type NextTerminal struct {
10 | SshClient *ssh.Client
11 | SshSession *ssh.Session
12 | StdinPipe io.WriteCloser
13 | SftpClient *sftp.Client
14 | Recorder *Recorder
15 | NextWriter *NextWriter
16 | }
17 |
18 | func NewNextTerminal(ip string, port int, username, password, privateKey, passphrase string, rows, cols int, recording string) (*NextTerminal, error) {
19 |
20 | sshClient, err := NewSshClient(ip, port, username, password, privateKey, passphrase)
21 | if err != nil {
22 | return nil, err
23 | }
24 |
25 | sshSession, err := sshClient.NewSession()
26 | if err != nil {
27 | return nil, err
28 | }
29 | //defer sshSession.Close()
30 |
31 | modes := ssh.TerminalModes{
32 | ssh.ECHO: 1,
33 | ssh.TTY_OP_ISPEED: 14400,
34 | ssh.TTY_OP_OSPEED: 14400,
35 | }
36 |
37 | if err := sshSession.RequestPty("xterm-256color", rows, cols, modes); err != nil {
38 | return nil, err
39 | }
40 |
41 | var nextWriter NextWriter
42 | sshSession.Stdout = &nextWriter
43 | sshSession.Stderr = &nextWriter
44 |
45 | stdinPipe, err := sshSession.StdinPipe()
46 | if err != nil {
47 | return nil, err
48 | }
49 |
50 | if err := sshSession.Shell(); err != nil {
51 | return nil, err
52 | }
53 |
54 | var recorder *Recorder
55 | if recording != "" {
56 | recorder, err = CreateRecording(recording, rows, cols)
57 | if err != nil {
58 | return nil, err
59 | }
60 | }
61 |
62 | terminal := NextTerminal{
63 | SshClient: sshClient,
64 | SshSession: sshSession,
65 | Recorder: recorder,
66 | StdinPipe: stdinPipe,
67 | NextWriter: &nextWriter,
68 | }
69 |
70 | return &terminal, nil
71 | }
72 |
73 | func (ret *NextTerminal) Write(p []byte) (int, error) {
74 | return ret.StdinPipe.Write(p)
75 | }
76 |
77 | func (ret *NextTerminal) Read() ([]byte, int, error) {
78 | bytes, n, err := ret.NextWriter.Read()
79 | if err != nil {
80 | return nil, 0, err
81 | }
82 | if ret.Recorder != nil && n > 0 {
83 | _ = ret.Recorder.WriteData(string(bytes))
84 | }
85 | return bytes, n, nil
86 | }
87 |
88 | func (ret *NextTerminal) Close() error {
89 | if ret.SshSession != nil {
90 | return ret.SshSession.Close()
91 | }
92 |
93 | if ret.SshClient != nil {
94 | return ret.SshClient.Close()
95 | }
96 |
97 | if ret.Recorder != nil {
98 | return ret.Close()
99 | }
100 |
101 | return nil
102 | }
103 |
104 | func (ret *NextTerminal) WindowChange(h int, w int) error {
105 | return ret.SshSession.WindowChange(h, w)
106 | }
107 |
--------------------------------------------------------------------------------
/pkg/model/command.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/utils"
6 | )
7 |
8 | type Command struct {
9 | ID string `gorm:"primary_key" json:"id"`
10 | Name string `json:"name"`
11 | Content string `json:"content"`
12 | Created utils.JsonTime `json:"created"`
13 | Owner string `gorm:"index" json:"owner"`
14 | }
15 |
16 | type CommandVo struct {
17 | ID string `gorm:"primary_key" json:"id"`
18 | Name string `json:"name"`
19 | Content string `json:"content"`
20 | Created utils.JsonTime `json:"created"`
21 | Owner string `json:"owner"`
22 | OwnerName string `json:"ownerName"`
23 | SharerCount int64 `json:"sharerCount"`
24 | }
25 |
26 | func (r *Command) TableName() string {
27 | return "commands"
28 | }
29 |
30 | func FindPageCommand(pageIndex, pageSize int, name, content string, account User) (o []CommandVo, total int64, err error) {
31 |
32 | db := global.DB.Table("commands").Select("commands.id,commands.name,commands.content,commands.owner,commands.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on commands.owner = users.id").Joins("left join resource_sharers on commands.id = resource_sharers.resource_id").Group("commands.id")
33 | dbCounter := global.DB.Table("commands").Select("DISTINCT commands.id").Joins("left join resource_sharers on commands.id = resource_sharers.resource_id").Group("commands.id")
34 |
35 | if TypeUser == account.Type {
36 | owner := account.ID
37 | db = db.Where("commands.owner = ? or resource_sharers.user_id = ?", owner, owner)
38 | dbCounter = dbCounter.Where("commands.owner = ? or resource_sharers.user_id = ?", owner, owner)
39 | }
40 |
41 | if len(name) > 0 {
42 | db = db.Where("commands.name like ?", "%"+name+"%")
43 | dbCounter = dbCounter.Where("commands.name like ?", "%"+name+"%")
44 | }
45 |
46 | if len(content) > 0 {
47 | db = db.Where("commands.content like ?", "%"+content+"%")
48 | dbCounter = dbCounter.Where("commands.content like ?", "%"+content+"%")
49 | }
50 |
51 | err = dbCounter.Count(&total).Error
52 | if err != nil {
53 | return nil, 0, err
54 | }
55 |
56 | err = db.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error
57 | if o == nil {
58 | o = make([]CommandVo, 0)
59 | }
60 | return
61 | }
62 |
63 | func CreateNewCommand(o *Command) (err error) {
64 | if err = global.DB.Create(o).Error; err != nil {
65 | return err
66 | }
67 | return nil
68 | }
69 |
70 | func FindCommandById(id string) (o Command, err error) {
71 | err = global.DB.Where("id = ?", id).First(&o).Error
72 | return
73 | }
74 |
75 | func UpdateCommandById(o *Command, id string) {
76 | o.ID = id
77 | global.DB.Updates(o)
78 | }
79 |
80 | func DeleteCommandById(id string) error {
81 | return global.DB.Where("id = ?", id).Delete(&Command{}).Error
82 | }
83 |
--------------------------------------------------------------------------------
/pkg/api/command.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "github.com/labstack/echo/v4"
6 | "next-terminal/pkg/model"
7 | "next-terminal/pkg/utils"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func CommandCreateEndpoint(c echo.Context) error {
13 | var item model.Command
14 | if err := c.Bind(&item); err != nil {
15 | return err
16 | }
17 |
18 | account, _ := GetCurrentAccount(c)
19 | item.Owner = account.ID
20 | item.ID = utils.UUID()
21 | item.Created = utils.NowJsonTime()
22 |
23 | if err := model.CreateNewCommand(&item); err != nil {
24 | return err
25 | }
26 |
27 | return Success(c, item)
28 | }
29 |
30 | func CommandPagingEndpoint(c echo.Context) error {
31 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
32 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
33 | name := c.QueryParam("name")
34 | content := c.QueryParam("content")
35 | account, _ := GetCurrentAccount(c)
36 |
37 | items, total, err := model.FindPageCommand(pageIndex, pageSize, name, content, account)
38 | if err != nil {
39 | return err
40 | }
41 |
42 | return Success(c, H{
43 | "total": total,
44 | "items": items,
45 | })
46 | }
47 |
48 | func CommandUpdateEndpoint(c echo.Context) error {
49 | id := c.Param("id")
50 | if err := PreCheckCommandPermission(c, id); err != nil {
51 | return err
52 | }
53 |
54 | var item model.Command
55 | if err := c.Bind(&item); err != nil {
56 | return err
57 | }
58 |
59 | model.UpdateCommandById(&item, id)
60 |
61 | return Success(c, nil)
62 | }
63 |
64 | func CommandDeleteEndpoint(c echo.Context) error {
65 | id := c.Param("id")
66 | split := strings.Split(id, ",")
67 | for i := range split {
68 | if err := PreCheckCommandPermission(c, split[i]); err != nil {
69 | return err
70 | }
71 | if err := model.DeleteCommandById(split[i]); err != nil {
72 | return err
73 | }
74 | // 删除资产与用户的关系
75 | if err := model.DeleteResourceSharerByResourceId(split[i]); err != nil {
76 | return err
77 | }
78 | }
79 | return Success(c, nil)
80 | }
81 |
82 | func CommandGetEndpoint(c echo.Context) (err error) {
83 | id := c.Param("id")
84 | var item model.Command
85 | if item, err = model.FindCommandById(id); err != nil {
86 | return err
87 | }
88 | return Success(c, item)
89 | }
90 |
91 | func CommandChangeOwnerEndpoint(c echo.Context) (err error) {
92 | id := c.Param("id")
93 |
94 | if err := PreCheckCommandPermission(c, id); err != nil {
95 | return err
96 | }
97 |
98 | owner := c.QueryParam("owner")
99 | model.UpdateCommandById(&model.Command{Owner: owner}, id)
100 | return Success(c, "")
101 | }
102 |
103 | func PreCheckCommandPermission(c echo.Context, id string) error {
104 | item, err := model.FindCommandById(id)
105 | if err != nil {
106 | return err
107 | }
108 |
109 | if !HasPermission(c, item.Owner) {
110 | return errors.New("permission denied")
111 | }
112 | return nil
113 | }
114 |
--------------------------------------------------------------------------------
/pkg/term/recording.go:
--------------------------------------------------------------------------------
1 | package term
2 |
3 | import (
4 | "encoding/json"
5 | "next-terminal/pkg/utils"
6 | "os"
7 | "time"
8 | )
9 |
10 | type Env struct {
11 | Shell string `json:"SHELL"`
12 | Term string `json:"TERM"`
13 | }
14 |
15 | type Header struct {
16 | Title string `json:"title"`
17 | Version int `json:"version"`
18 | Height int `json:"height"`
19 | Width int `json:"width"`
20 | Env Env `json:"env"`
21 | Timestamp int `json:"Timestamp"`
22 | }
23 |
24 | type Recorder struct {
25 | File *os.File
26 | Timestamp int
27 | }
28 |
29 | func NewRecorder(recoding string) (recorder *Recorder, err error) {
30 | recorder = &Recorder{}
31 |
32 | parentDirectory := utils.GetParentDirectory(recoding)
33 |
34 | if utils.FileExists(parentDirectory) {
35 | if err := os.RemoveAll(parentDirectory); err != nil {
36 | return nil, err
37 | }
38 | }
39 |
40 | if err = os.MkdirAll(parentDirectory, 0777); err != nil {
41 | return
42 | }
43 |
44 | var file *os.File
45 | file, err = os.Create(recoding)
46 | if err != nil {
47 | return nil, err
48 | }
49 |
50 | recorder.File = file
51 | return recorder, nil
52 | }
53 |
54 | func (recorder *Recorder) Close() {
55 | if recorder.File != nil {
56 | recorder.File.Close()
57 | }
58 | }
59 |
60 | func (recorder *Recorder) WriteHeader(header *Header) (err error) {
61 | var p []byte
62 |
63 | if p, err = json.Marshal(header); err != nil {
64 | return
65 | }
66 |
67 | if _, err := recorder.File.Write(p); err != nil {
68 | return err
69 | }
70 | if _, err := recorder.File.Write([]byte("\n")); err != nil {
71 | return err
72 | }
73 |
74 | recorder.Timestamp = header.Timestamp
75 |
76 | return
77 | }
78 |
79 | func (recorder *Recorder) WriteData(data string) (err error) {
80 | now := int(time.Now().UnixNano())
81 |
82 | delta := float64(now-recorder.Timestamp*1000*1000*1000) / 1000 / 1000 / 1000
83 |
84 | row := make([]interface{}, 0)
85 | row = append(row, delta)
86 | row = append(row, "o")
87 | row = append(row, data)
88 |
89 | var s []byte
90 | if s, err = json.Marshal(row); err != nil {
91 | return
92 | }
93 | if _, err := recorder.File.Write(s); err != nil {
94 | return err
95 | }
96 | if _, err := recorder.File.Write([]byte("\n")); err != nil {
97 | return err
98 | }
99 | return
100 | }
101 |
102 | func CreateRecording(recordingPath string, h int, w int) (*Recorder, error) {
103 | recorder, err := NewRecorder(recordingPath)
104 | if err != nil {
105 | return nil, err
106 | }
107 |
108 | header := &Header{
109 | Title: "",
110 | Version: 2,
111 | Height: 42,
112 | Width: 150,
113 | Env: Env{Shell: "/bin/bash", Term: "xterm-256color"},
114 | Timestamp: int(time.Now().Unix()),
115 | }
116 |
117 | if err := recorder.WriteHeader(header); err != nil {
118 | return nil, err
119 | }
120 |
121 | return recorder, nil
122 | }
123 |
--------------------------------------------------------------------------------
/pkg/api/user.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/model"
7 | "next-terminal/pkg/utils"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func UserCreateEndpoint(c echo.Context) error {
13 | var item model.User
14 | if err := c.Bind(&item); err != nil {
15 | return err
16 | }
17 |
18 | var pass []byte
19 | var err error
20 | if pass, err = utils.Encoder.Encode([]byte(item.Password)); err != nil {
21 | return err
22 | }
23 | item.Password = string(pass)
24 |
25 | item.ID = utils.UUID()
26 | item.Created = utils.NowJsonTime()
27 |
28 | if err := model.CreateNewUser(&item); err != nil {
29 | return err
30 | }
31 | return Success(c, item)
32 | }
33 |
34 | func UserPagingEndpoint(c echo.Context) error {
35 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
36 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
37 | username := c.QueryParam("username")
38 | nickname := c.QueryParam("nickname")
39 |
40 | items, total, err := model.FindPageUser(pageIndex, pageSize, username, nickname)
41 | if err != nil {
42 | return err
43 | }
44 |
45 | return Success(c, H{
46 | "total": total,
47 | "items": items,
48 | })
49 | }
50 |
51 | func UserUpdateEndpoint(c echo.Context) error {
52 | id := c.Param("id")
53 |
54 | var item model.User
55 | if err := c.Bind(&item); err != nil {
56 | return err
57 | }
58 |
59 | model.UpdateUserById(&item, id)
60 |
61 | return Success(c, nil)
62 | }
63 |
64 | func UserDeleteEndpoint(c echo.Context) error {
65 | ids := c.Param("id")
66 | account, found := GetCurrentAccount(c)
67 | if !found {
68 | return Fail(c, -1, "获取当前登录账户失败")
69 | }
70 | split := strings.Split(ids, ",")
71 | for i := range split {
72 | userId := split[i]
73 | if account.ID == userId {
74 | return Fail(c, -1, "不允许删除自身账户")
75 | }
76 | // 将用户强制下线
77 | loginLogs, err := model.FindAliveLoginLogsByUserId(userId)
78 | if err != nil {
79 | return err
80 | }
81 | if loginLogs != nil && len(loginLogs) > 0 {
82 | for j := range loginLogs {
83 | global.Cache.Delete(loginLogs[j].ID)
84 | model.Logout(loginLogs[j].ID)
85 | }
86 | }
87 | // 删除用户
88 | model.DeleteUserById(userId)
89 | }
90 |
91 | return Success(c, nil)
92 | }
93 |
94 | func UserGetEndpoint(c echo.Context) error {
95 | id := c.Param("id")
96 |
97 | item, err := model.FindUserById(id)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | return Success(c, item)
103 | }
104 |
105 | func UserChangePasswordEndpoint(c echo.Context) error {
106 | id := c.Param("id")
107 | password := c.QueryParam("password")
108 |
109 | passwd, err := utils.Encoder.Encode([]byte(password))
110 | if err != nil {
111 | return err
112 | }
113 | u := &model.User{
114 | Password: string(passwd),
115 | }
116 | model.UpdateUserById(u, id)
117 | return Success(c, "")
118 | }
119 |
120 | func UserResetTotpEndpoint(c echo.Context) error {
121 | id := c.Param("id")
122 | u := &model.User{
123 | TOTPSecret: "-",
124 | }
125 | model.UpdateUserById(u, id)
126 | return Success(c, "")
127 | }
128 |
--------------------------------------------------------------------------------
/pkg/api/user-group.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/model"
7 | "next-terminal/pkg/utils"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | type UserGroup struct {
13 | Id string `json:"id"`
14 | Name string `json:"name"`
15 | Members []string `json:"members"`
16 | }
17 |
18 | func UserGroupCreateEndpoint(c echo.Context) error {
19 | var item UserGroup
20 | if err := c.Bind(&item); err != nil {
21 | return err
22 | }
23 |
24 | userGroup := model.UserGroup{
25 | ID: utils.UUID(),
26 | Created: utils.NowJsonTime(),
27 | Name: item.Name,
28 | }
29 |
30 | if err := model.CreateNewUserGroup(&userGroup, item.Members); err != nil {
31 | return err
32 | }
33 |
34 | return Success(c, item)
35 | }
36 |
37 | func UserGroupPagingEndpoint(c echo.Context) error {
38 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
39 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
40 | name := c.QueryParam("name")
41 |
42 | items, total, err := model.FindPageUserGroup(pageIndex, pageSize, name)
43 | if err != nil {
44 | return err
45 | }
46 |
47 | return Success(c, H{
48 | "total": total,
49 | "items": items,
50 | })
51 | }
52 |
53 | func UserGroupUpdateEndpoint(c echo.Context) error {
54 | id := c.Param("id")
55 |
56 | var item UserGroup
57 | if err := c.Bind(&item); err != nil {
58 | return err
59 | }
60 | userGroup := model.UserGroup{
61 | Name: item.Name,
62 | }
63 |
64 | if err := model.UpdateUserGroupById(&userGroup, item.Members, id); err != nil {
65 | return err
66 | }
67 |
68 | return Success(c, nil)
69 | }
70 |
71 | func UserGroupDeleteEndpoint(c echo.Context) error {
72 | ids := c.Param("id")
73 | split := strings.Split(ids, ",")
74 | for i := range split {
75 | userId := split[i]
76 | model.DeleteUserGroupById(userId)
77 | }
78 |
79 | return Success(c, nil)
80 | }
81 |
82 | func UserGroupGetEndpoint(c echo.Context) error {
83 | id := c.Param("id")
84 |
85 | item, err := model.FindUserGroupById(id)
86 | if err != nil {
87 | return err
88 | }
89 |
90 | members, err := model.FindUserGroupMembersByUserGroupId(id)
91 | if err != nil {
92 | return err
93 | }
94 |
95 | userGroup := UserGroup{
96 | Id: item.ID,
97 | Name: item.Name,
98 | Members: members,
99 | }
100 |
101 | return Success(c, userGroup)
102 | }
103 |
104 | func UserGroupAddMembersEndpoint(c echo.Context) error {
105 | id := c.Param("id")
106 |
107 | var items []string
108 | if err := c.Bind(&items); err != nil {
109 | return err
110 | }
111 |
112 | if err := model.AddUserGroupMembers(global.DB, items, id); err != nil {
113 | return err
114 | }
115 | return Success(c, "")
116 | }
117 |
118 | func UserGroupDelMembersEndpoint(c echo.Context) (err error) {
119 | id := c.Param("id")
120 | memberIdsStr := c.Param("memberId")
121 | memberIds := strings.Split(memberIdsStr, ",")
122 | for i := range memberIds {
123 | memberId := memberIds[i]
124 | err = global.DB.Where("user_group_id = ? and user_id = ?", id, memberId).Delete(&model.UserGroupMember{}).Error
125 | if err != nil {
126 | return err
127 | }
128 | }
129 |
130 | return Success(c, "")
131 | }
132 |
--------------------------------------------------------------------------------
/docs/install-docker.md:
--------------------------------------------------------------------------------
1 | # docker安装
2 |
3 | ### 使用`sqlite`存储数据
4 |
5 | ```shell
6 | docker run -d \
7 | -p 8088:8088 \
8 | --name next-terminal \
9 | --restart always ghcr.io/dushixiang/next-terminal:latest
10 | ```
11 |
12 | 或者从Docker Hub拉取
13 |
14 | ```shell
15 | docker run -d \
16 | -p 8088:8088 \
17 | --name next-terminal \
18 | --restart always dushixiang/next-terminal:latest
19 | ```
20 |
21 | ### 使用`mysql`存储数据
22 |
23 | ```shell
24 | docker run -d \
25 | -p 8088:8088 \
26 | -e DB=mysql \
27 | -e MYSQL_HOSTNAME=172.1.0.1 \
28 | -e MYSQL_PORT=3306 \
29 | -e MYSQL_USERNAME=root \
30 | -e MYSQL_PASSWORD=mysql \
31 | -e MYSQL_DATABASE=next_terminal \
32 | --name next-terminal \
33 | --restart always ghcr.io/dushixiang/next-terminal:latest
34 | ```
35 |
36 | 或者从Docker Hub拉取
37 |
38 | ```shell
39 | docker run -d \
40 | -p 8088:8088 \
41 | -e DB=mysql \
42 | -e MYSQL_HOSTNAME=172.1.0.1 \
43 | -e MYSQL_PORT=3306 \
44 | -e MYSQL_USERNAME=root \
45 | -e MYSQL_PASSWORD=mysql \
46 | -e MYSQL_DATABASE=next_terminal \
47 | --name next-terminal \
48 | --restart always dushixiang/next-terminal:latest
49 | ```
50 |
51 | 或者使用docker-compose构建
52 |
53 | 示例:
54 |
55 | 1. 在root目录下创建文件夹 `next-terminal`
56 | 2. 在`/root/next-terminal`文件夹下创建`docker-compose.yml`文件
57 |
58 | ```yaml
59 | version: '3.3'
60 | services:
61 | mysql:
62 | image: mysql:8.0
63 | environment:
64 | MYSQL_DATABASE: next-terminal
65 | MYSQL_USER: next-terminal
66 | MYSQL_PASSWORD: next-terminal
67 | MYSQL_ROOT_PASSWORD: next-terminal
68 | ports:
69 | - "3306:3306"
70 | next-terminal:
71 | image: "dushixiang/next-terminal:latest"
72 | environment:
73 | DB: "mysql"
74 | MYSQL_HOSTNAME: "mysql"
75 | MYSQL_PORT: 3306
76 | MYSQL_USERNAME: "next-terminal"
77 | MYSQL_PASSWORD: "next-terminal"
78 | MYSQL_DATABASE: "next-terminal"
79 | ports:
80 | - "8088:8088"
81 | volumes:
82 | - /root/next-terminal/drive:/usr/local/next-terminal/drive
83 | - /root/next-terminal/recording:/usr/local/next-terminal/recording
84 | depends_on:
85 | - mysql
86 | ```
87 |
88 | 3. 在`/root/next-terminal`文件夹下执行命令`docker-compose up`
89 |
90 |
91 | ### 注意事项 ⚠️
92 |
93 | 1. docker连接宿主机器上的`mysql`时连接地址不是`127.0.0.1`,请使用`ipconfig`或`ifconfig`确认宿主机器的IP。
94 | 2. 使用其他容器内部的`mysql`时请使用`--link `,环境变量参数为`-e MYSQL_HOSTNAME=`
95 | 3. 使用独立数据库的需要手动创建数据库,使用docker-compose不需要。
96 |
97 | ## 环境变量
98 |
99 | | 参数 | 含义 |
100 | |---|---|
101 | | DB | 数据库类型,默认 `sqlite`,可选`['sqlite','mysql']` |
102 | | SQLITE_FILE | `sqlite`数据库文件存放地址,默认 `'next-terminal.db'` |
103 | | MYSQL_HOSTNAME | `mysql`数据库地址 |
104 | | MYSQL_PORT | `mysql`数据库端口 |
105 | | MYSQL_USERNAME | `mysql`数据库用户 |
106 | | MYSQL_PASSWORD | `mysql`数据库密码 |
107 | | MYSQL_DATABASE | `mysql`数据库名称 |
108 | | SERVER_ADDR | 服务器监听地址,默认`0.0.0.0:8088` |
109 |
110 | ## 其他
111 |
112 | `next-terminal` 使用了`supervisord`来管理服务,因此相关日志在 `/var/log/supervisor/next-terminal-*.log`
113 |
114 | 程序安装目录地址为:`/usr/local/next-terminal`
115 |
116 | 录屏文件存放地址为:`/usr/local/next-terminal/recording`
117 |
118 | 远程桌面挂载地址为:`/usr/local/next-terminal/drive`
--------------------------------------------------------------------------------
/pkg/model/login-log.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/utils"
7 | )
8 |
9 | type LoginLog struct {
10 | ID string `gorm:"primary_key" json:"id"`
11 | UserId string `gorm:"index" json:"userId"`
12 | ClientIP string `json:"clientIp"`
13 | ClientUserAgent string `json:"clientUserAgent"`
14 | LoginTime utils.JsonTime `json:"loginTime"`
15 | LogoutTime utils.JsonTime `json:"logoutTime"`
16 | Remember bool `json:"remember"`
17 | }
18 |
19 | type LoginLogVo struct {
20 | ID string `json:"id"`
21 | UserId string `json:"userId"`
22 | UserName string `json:"userName"`
23 | ClientIP string `json:"clientIp"`
24 | ClientUserAgent string `json:"clientUserAgent"`
25 | LoginTime utils.JsonTime `json:"loginTime"`
26 | LogoutTime utils.JsonTime `json:"logoutTime"`
27 | Remember bool `json:"remember"`
28 | }
29 |
30 | func (r *LoginLog) TableName() string {
31 | return "login_logs"
32 | }
33 |
34 | func FindPageLoginLog(pageIndex, pageSize int, userId, clientIp string) (o []LoginLogVo, total int64, err error) {
35 |
36 | db := global.DB.Table("login_logs").Select("login_logs.id,login_logs.user_id,login_logs.client_ip,login_logs.client_user_agent,login_logs.login_time, login_logs.logout_time, users.nickname as user_name").Joins("left join users on login_logs.user_id = users.id")
37 | dbCounter := global.DB.Table("login_logs").Select("DISTINCT login_logs.id")
38 |
39 | if userId != "" {
40 | db = db.Where("login_logs.user_id = ?", userId)
41 | dbCounter = dbCounter.Where("login_logs.user_id = ?", userId)
42 | }
43 |
44 | if clientIp != "" {
45 | db = db.Where("login_logs.client_ip like ?", "%"+clientIp+"%")
46 | dbCounter = dbCounter.Where("login_logs.client_ip like ?", "%"+clientIp+"%")
47 | }
48 |
49 | err = dbCounter.Count(&total).Error
50 | if err != nil {
51 | return nil, 0, err
52 | }
53 |
54 | err = db.Order("login_logs.login_time desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error
55 | if o == nil {
56 | o = make([]LoginLogVo, 0)
57 | }
58 | return
59 | }
60 |
61 | func FindAliveLoginLogs() (o []LoginLog, err error) {
62 | err = global.DB.Where("logout_time is null").Find(&o).Error
63 | return
64 | }
65 |
66 | func FindAliveLoginLogsByUserId(userId string) (o []LoginLog, err error) {
67 | err = global.DB.Where("logout_time is null and user_id = ?", userId).Find(&o).Error
68 | return
69 | }
70 |
71 | func CreateNewLoginLog(o *LoginLog) (err error) {
72 | return global.DB.Create(o).Error
73 | }
74 |
75 | func DeleteLoginLogByIdIn(ids []string) (err error) {
76 | return global.DB.Where("id in ?", ids).Delete(&LoginLog{}).Error
77 | }
78 |
79 | func FindLoginLogById(id string) (o LoginLog, err error) {
80 | err = global.DB.Where("id = ?", id).First(&o).Error
81 | return
82 | }
83 |
84 | func Logout(token string) {
85 |
86 | loginLog, err := FindLoginLogById(token)
87 | if err != nil {
88 | logrus.Warnf("登录日志「%v」获取失败", token)
89 | return
90 | }
91 |
92 | global.DB.Table("login_logs").Where("token = ?", token).Update("logout_time", utils.NowJsonTime())
93 |
94 | loginLogs, err := FindAliveLoginLogsByUserId(loginLog.UserId)
95 | if err != nil {
96 | return
97 | }
98 |
99 | if len(loginLogs) == 0 {
100 | UpdateUserById(&User{Online: false}, loginLog.UserId)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/pkg/model/asset-attribute.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 | "github.com/labstack/echo/v4"
6 | "gorm.io/gorm"
7 | "next-terminal/pkg/global"
8 | "next-terminal/pkg/guacd"
9 | "next-terminal/pkg/utils"
10 | )
11 |
12 | type AssetAttribute struct {
13 | Id string `gorm:"index" json:"id"`
14 | AssetId string `gorm:"index" json:"assetId"`
15 | Name string `gorm:"index" json:"name"`
16 | Value string `json:"value"`
17 | }
18 |
19 | func (r *AssetAttribute) TableName() string {
20 | return "asset_attributes"
21 | }
22 |
23 | var SSHParameterNames = []string{guacd.FontName, guacd.FontSize, guacd.ColorScheme, guacd.Backspace, guacd.TerminalType, SshMode}
24 | var RDPParameterNames = []string{guacd.RemoteApp, guacd.RemoteAppDir, guacd.RemoteAppArgs}
25 | var VNCParameterNames = []string{guacd.ColorDepth, guacd.Cursor, guacd.SwapRedBlue, guacd.DestHost, guacd.DestPort}
26 | var TelnetParameterNames = []string{guacd.FontName, guacd.FontSize, guacd.ColorScheme, guacd.Backspace, guacd.TerminalType, guacd.UsernameRegex, guacd.PasswordRegex, guacd.LoginSuccessRegex, guacd.LoginFailureRegex}
27 |
28 | func UpdateAssetAttributes(assetId, protocol string, m echo.Map) error {
29 | var data []AssetAttribute
30 | var parameterNames []string
31 | switch protocol {
32 | case "ssh":
33 | parameterNames = SSHParameterNames
34 | case "rdp":
35 | parameterNames = RDPParameterNames
36 | case "vnc":
37 | parameterNames = VNCParameterNames
38 | case "telnet":
39 | parameterNames = TelnetParameterNames
40 | }
41 |
42 | for i := range parameterNames {
43 | name := parameterNames[i]
44 | if m[name] != nil && m[name] != "" {
45 | data = append(data, genAttribute(assetId, name, m))
46 | }
47 | }
48 |
49 | return global.DB.Transaction(func(tx *gorm.DB) error {
50 | err := tx.Where("asset_id = ?", assetId).Delete(&AssetAttribute{}).Error
51 | if err != nil {
52 | return err
53 | }
54 | return tx.CreateInBatches(&data, len(data)).Error
55 | })
56 | }
57 |
58 | func genAttribute(assetId, name string, m echo.Map) AssetAttribute {
59 | value := fmt.Sprintf("%v", m[name])
60 | attribute := AssetAttribute{
61 | Id: utils.Sign([]string{assetId, name}),
62 | AssetId: assetId,
63 | Name: name,
64 | Value: value,
65 | }
66 | return attribute
67 | }
68 |
69 | func FindAssetAttributeByAssetId(assetId string) (o []AssetAttribute, err error) {
70 | err = global.DB.Where("asset_id = ?", assetId).Find(&o).Error
71 | if o == nil {
72 | o = make([]AssetAttribute, 0)
73 | }
74 | return o, err
75 | }
76 |
77 | func FindAssetAttrMapByAssetId(assetId string) (map[string]interface{}, error) {
78 | asset, err := FindAssetById(assetId)
79 | if err != nil {
80 | return nil, err
81 | }
82 | attributes, err := FindAssetAttributeByAssetId(assetId)
83 | if err != nil {
84 | return nil, err
85 | }
86 |
87 | var parameterNames []string
88 | switch asset.Protocol {
89 | case "ssh":
90 | parameterNames = SSHParameterNames
91 | case "rdp":
92 | parameterNames = RDPParameterNames
93 | case "vnc":
94 | parameterNames = VNCParameterNames
95 | case "telnet":
96 | parameterNames = TelnetParameterNames
97 | }
98 | propertiesMap := FindAllPropertiesMap()
99 | var attributeMap = make(map[string]interface{})
100 | for name := range propertiesMap {
101 | if utils.Contains(parameterNames, name) {
102 | attributeMap[name] = propertiesMap[name]
103 | }
104 | }
105 |
106 | for i := range attributes {
107 | attributeMap[attributes[i].Name] = attributes[i].Value
108 | }
109 | return attributeMap, nil
110 | }
111 |
--------------------------------------------------------------------------------
/pkg/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/utils"
6 | "reflect"
7 | )
8 |
9 | const (
10 | TypeUser = "user"
11 | TypeAdmin = "admin"
12 | )
13 |
14 | type User struct {
15 | ID string `gorm:"primary_key" json:"id"`
16 | Username string `gorm:"index" json:"username"`
17 | Password string `json:"password"`
18 | Nickname string `json:"nickname"`
19 | TOTPSecret string `json:"-"`
20 | Online bool `json:"online"`
21 | Enabled bool `json:"enabled"`
22 | Created utils.JsonTime `json:"created"`
23 | Type string `json:"type"`
24 | }
25 |
26 | type UserVo struct {
27 | ID string `json:"id"`
28 | Username string `json:"username"`
29 | Nickname string `json:"nickname"`
30 | Online bool `json:"online"`
31 | Enabled bool `json:"enabled"`
32 | Created utils.JsonTime `json:"created"`
33 | Type string `json:"type"`
34 | SharerAssetCount int64 `json:"sharerAssetCount"`
35 | }
36 |
37 | func (r *User) TableName() string {
38 | return "users"
39 | }
40 |
41 | func (r *User) IsEmpty() bool {
42 | return reflect.DeepEqual(r, User{})
43 | }
44 |
45 | func FindAllUser() (o []User) {
46 | if global.DB.Find(&o).Error != nil {
47 | return nil
48 | }
49 | return
50 | }
51 |
52 | func FindPageUser(pageIndex, pageSize int, username, nickname string) (o []UserVo, total int64, err error) {
53 | db := global.DB.Table("users").Select("users.id,users.username,users.nickname,users.online,users.enabled,users.created,users.type, count(resource_sharers.user_id) as sharer_asset_count").Joins("left join resource_sharers on users.id = resource_sharers.user_id and resource_sharers.resource_type = 'asset'").Group("users.id")
54 | dbCounter := global.DB.Table("users")
55 | if len(username) > 0 {
56 | db = db.Where("users.username like ?", "%"+username+"%")
57 | dbCounter = dbCounter.Where("username like ?", "%"+username+"%")
58 | }
59 |
60 | if len(nickname) > 0 {
61 | db = db.Where("users.nickname like ?", "%"+nickname+"%")
62 | dbCounter = dbCounter.Where("nickname like ?", "%"+nickname+"%")
63 | }
64 |
65 | err = dbCounter.Count(&total).Error
66 | if err != nil {
67 | return nil, 0, err
68 | }
69 |
70 | err = db.Order("users.created desc").Find(&o).Offset((pageIndex - 1) * pageSize).Limit(pageSize).Error
71 | if o == nil {
72 | o = make([]UserVo, 0)
73 | }
74 | return
75 | }
76 |
77 | func CreateNewUser(o *User) (err error) {
78 | err = global.DB.Create(o).Error
79 | return
80 | }
81 |
82 | func FindUserById(id string) (o User, err error) {
83 | err = global.DB.Where("id = ?", id).First(&o).Error
84 | return
85 | }
86 |
87 | func FindUserByIdIn(ids []string) (o []User, err error) {
88 | err = global.DB.Where("id in ?", ids).First(&o).Error
89 | return
90 | }
91 |
92 | func FindUserByUsername(username string) (o User, err error) {
93 | err = global.DB.Where("username = ?", username).First(&o).Error
94 | return
95 | }
96 |
97 | func UpdateUserById(o *User, id string) {
98 | o.ID = id
99 | global.DB.Updates(o)
100 | }
101 |
102 | func DeleteUserById(id string) {
103 | global.DB.Where("id = ?", id).Delete(&User{})
104 | // 删除用户组中的用户关系
105 | global.DB.Where("user_id = ?", id).Delete(&UserGroupMember{})
106 | // 删除用户分享到的资产
107 | global.DB.Where("user_id = ?", id).Delete(&ResourceSharer{})
108 | }
109 |
110 | func CountUser() (total int64, err error) {
111 | err = global.DB.Find(&User{}).Count(&total).Error
112 | return
113 | }
114 |
--------------------------------------------------------------------------------
/pkg/model/user-group.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/utils"
7 | )
8 |
9 | type UserGroup struct {
10 | ID string `gorm:"primary_key" json:"id"`
11 | Name string `json:"name"`
12 | Created utils.JsonTime `json:"created"`
13 | }
14 |
15 | type UserGroupVo struct {
16 | ID string `json:"id"`
17 | Name string `json:"name"`
18 | Created utils.JsonTime `json:"created"`
19 | AssetCount int64 `json:"assetCount"`
20 | }
21 |
22 | func (r *UserGroup) TableName() string {
23 | return "user_groups"
24 | }
25 |
26 | func FindPageUserGroup(pageIndex, pageSize int, name string) (o []UserGroupVo, total int64, err error) {
27 | db := global.DB.Table("user_groups").Select("user_groups.id, user_groups.name, user_groups.created, count(resource_sharers.user_group_id) as asset_count").Joins("left join resource_sharers on user_groups.id = resource_sharers.user_group_id and resource_sharers.resource_type = 'asset'").Group("user_groups.id")
28 | dbCounter := global.DB.Table("user_groups")
29 | if len(name) > 0 {
30 | db = db.Where("user_groups.name like ?", "%"+name+"%")
31 | dbCounter = dbCounter.Where("name like ?", "%"+name+"%")
32 | }
33 |
34 | err = dbCounter.Count(&total).Error
35 | if err != nil {
36 | return nil, 0, err
37 | }
38 | err = db.Order("user_groups.created desc").Find(&o).Offset((pageIndex - 1) * pageSize).Limit(pageSize).Error
39 | if o == nil {
40 | o = make([]UserGroupVo, 0)
41 | }
42 | return
43 | }
44 |
45 | func CreateNewUserGroup(o *UserGroup, members []string) (err error) {
46 | return global.DB.Transaction(func(tx *gorm.DB) error {
47 | err = tx.Create(o).Error
48 | if err != nil {
49 | return err
50 | }
51 |
52 | if members != nil {
53 | userGroupId := o.ID
54 | err = AddUserGroupMembers(tx, members, userGroupId)
55 | if err != nil {
56 | return err
57 | }
58 | }
59 | return err
60 | })
61 | }
62 |
63 | func AddUserGroupMembers(tx *gorm.DB, userIds []string, userGroupId string) error {
64 | for i := range userIds {
65 | userId := userIds[i]
66 | _, err := FindUserById(userId)
67 | if err != nil {
68 | return err
69 | }
70 |
71 | userGroupMember := UserGroupMember{
72 | ID: utils.Sign([]string{userGroupId, userId}),
73 | UserId: userId,
74 | UserGroupId: userGroupId,
75 | }
76 | err = tx.Create(&userGroupMember).Error
77 | if err != nil {
78 | return err
79 | }
80 | }
81 | return nil
82 | }
83 |
84 | func FindUserGroupById(id string) (o UserGroup, err error) {
85 | err = global.DB.Where("id = ?", id).First(&o).Error
86 | return
87 | }
88 |
89 | func FindUserGroupIdsByUserId(userId string) (o []string, err error) {
90 | // 先查询用户所在的用户
91 | err = global.DB.Table("user_group_members").Select("user_group_id").Where("user_id = ?", userId).Find(&o).Error
92 | return
93 | }
94 |
95 | func UpdateUserGroupById(o *UserGroup, members []string, id string) error {
96 | return global.DB.Transaction(func(tx *gorm.DB) error {
97 | o.ID = id
98 | err := tx.Updates(o).Error
99 | if err != nil {
100 | return err
101 | }
102 |
103 | err = tx.Where("user_group_id = ?", id).Delete(&UserGroupMember{}).Error
104 | if err != nil {
105 | return err
106 | }
107 | if members != nil {
108 | userGroupId := o.ID
109 | err = AddUserGroupMembers(tx, members, userGroupId)
110 | if err != nil {
111 | return err
112 | }
113 | }
114 | return err
115 | })
116 |
117 | }
118 |
119 | func DeleteUserGroupById(id string) {
120 | global.DB.Where("id = ?", id).Delete(&UserGroup{})
121 | global.DB.Where("user_group_id = ?", id).Delete(&UserGroupMember{})
122 | }
123 |
--------------------------------------------------------------------------------
/pkg/term/test/test_ssh.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "time"
8 |
9 | "golang.org/x/crypto/ssh"
10 | "golang.org/x/crypto/ssh/terminal"
11 | )
12 |
13 | type SSHTerminal struct {
14 | Session *ssh.Session
15 | exitMsg string
16 | stdout io.Reader
17 | stdin io.Writer
18 | stderr io.Reader
19 | }
20 |
21 | func main() {
22 | sshConfig := &ssh.ClientConfig{
23 | User: "root",
24 | Auth: []ssh.AuthMethod{
25 | ssh.Password("root"),
26 | },
27 | HostKeyCallback: ssh.InsecureIgnoreHostKey(),
28 | }
29 |
30 | client, err := ssh.Dial("tcp", "172.16.101.32:22", sshConfig)
31 | if err != nil {
32 | fmt.Println(err)
33 | }
34 | defer client.Close()
35 |
36 | err = New(client)
37 | if err != nil {
38 | fmt.Println(err)
39 | }
40 | }
41 |
42 | func (t *SSHTerminal) updateTerminalSize() {
43 |
44 | go func() {
45 | // SIGWINCH is sent to the process when the window size of the terminal has
46 | // changed.
47 | sigwinchCh := make(chan os.Signal, 1)
48 | //signal.Notify(sigwinchCh, syscall.SIN)
49 |
50 | fd := int(os.Stdin.Fd())
51 | termWidth, termHeight, err := terminal.GetSize(fd)
52 | if err != nil {
53 | fmt.Println(err)
54 | }
55 |
56 | for {
57 | select {
58 | // The client updated the size of the local PTY. This change needs to occur
59 | // on the server side PTY as well.
60 | case sigwinch := <-sigwinchCh:
61 | if sigwinch == nil {
62 | return
63 | }
64 | currTermWidth, currTermHeight, err := terminal.GetSize(fd)
65 |
66 | // Terminal size has not changed, don't do anything.
67 | if currTermHeight == termHeight && currTermWidth == termWidth {
68 | continue
69 | }
70 |
71 | t.Session.WindowChange(currTermHeight, currTermWidth)
72 | if err != nil {
73 | fmt.Printf("Unable to send window-change reqest: %s.", err)
74 | continue
75 | }
76 |
77 | termWidth, termHeight = currTermWidth, currTermHeight
78 |
79 | }
80 | }
81 | }()
82 |
83 | }
84 |
85 | func (t *SSHTerminal) interactiveSession() error {
86 |
87 | defer func() {
88 | if t.exitMsg == "" {
89 | fmt.Fprintln(os.Stdout, "the connection was closed on the remote side on ", time.Now().Format(time.RFC822))
90 | } else {
91 | fmt.Fprintln(os.Stdout, t.exitMsg)
92 | }
93 | }()
94 |
95 | fd := int(os.Stdin.Fd())
96 | state, err := terminal.MakeRaw(fd)
97 | if err != nil {
98 | return err
99 | }
100 | defer terminal.Restore(fd, state)
101 |
102 | termWidth, termHeight, err := terminal.GetSize(fd)
103 | if err != nil {
104 | return err
105 | }
106 |
107 | termType := os.Getenv("TERM")
108 | if termType == "" {
109 | termType = "xterm-256color"
110 | }
111 |
112 | err = t.Session.RequestPty(termType, termHeight, termWidth, ssh.TerminalModes{})
113 | if err != nil {
114 | return err
115 | }
116 |
117 | t.updateTerminalSize()
118 |
119 | t.stdin, err = t.Session.StdinPipe()
120 | if err != nil {
121 | return err
122 | }
123 | t.stdout, err = t.Session.StdoutPipe()
124 | if err != nil {
125 | return err
126 | }
127 | t.stderr, err = t.Session.StderrPipe()
128 |
129 | go io.Copy(os.Stderr, t.stderr)
130 | go io.Copy(os.Stdout, t.stdout)
131 | go func() {
132 | buf := make([]byte, 128)
133 | for {
134 | n, err := os.Stdin.Read(buf)
135 | if err != nil {
136 | fmt.Println(err)
137 | return
138 | }
139 | if n > 0 {
140 | _, err = t.stdin.Write(buf[:n])
141 | if err != nil {
142 | fmt.Println(err)
143 | t.exitMsg = err.Error()
144 | return
145 | }
146 | }
147 | }
148 | }()
149 |
150 | err = t.Session.Shell()
151 | if err != nil {
152 | return err
153 | }
154 | err = t.Session.Wait()
155 | if err != nil {
156 | return err
157 | }
158 | return nil
159 | }
160 |
161 | func New(client *ssh.Client) error {
162 |
163 | session, err := client.NewSession()
164 | if err != nil {
165 | return err
166 | }
167 | defer session.Close()
168 |
169 | s := SSHTerminal{
170 | Session: session,
171 | }
172 |
173 | return s.interactiveSession()
174 | }
175 |
--------------------------------------------------------------------------------
/web/src/components/credential/CredentialModal.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {Form, Input, Modal, Select} from "antd/lib/index";
3 | import {isEmpty} from "../../utils/utils";
4 |
5 | const {TextArea} = Input;
6 |
7 | const accountTypes = [
8 | {text: '密码', value: 'custom'},
9 | {text: '密钥', value: 'private-key'},
10 | ];
11 |
12 | const CredentialModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => {
13 |
14 | const [form] = Form.useForm();
15 |
16 | const formItemLayout = {
17 | labelCol: {span: 6},
18 | wrapperCol: {span: 14},
19 | };
20 |
21 | if (model === null || model === undefined) {
22 | model = {}
23 | }
24 |
25 | if (isEmpty(model.type)) {
26 | model.type = 'custom';
27 | }
28 |
29 | for (let key in model) {
30 | if (model.hasOwnProperty(key)) {
31 | if (model[key] === '-') {
32 | model[key] = '';
33 | }
34 | }
35 | }
36 |
37 | let [type, setType] = useState(model.type);
38 |
39 | const handleAccountTypeChange = v => {
40 | setType(v);
41 | model.type = v;
42 | }
43 |
44 | return (
45 |
46 | {
52 | form
53 | .validateFields()
54 | .then(values => {
55 | form.resetFields();
56 | handleOk(values);
57 | })
58 | .catch(info => {
59 |
60 | });
61 | }}
62 | onCancel={handleCancel}
63 | confirmLoading={confirmLoading}
64 | okText='确定'
65 | cancelText='取消'
66 | >
67 |
68 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
83 |
84 |
85 | {
86 | type === 'private-key' ?
87 | <>
88 |
89 |
90 |
91 |
92 |
93 |
95 |
96 |
98 | >
99 | :
100 | <>
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | >
109 |
110 | }
111 |
112 |
113 |
114 | )
115 | };
116 |
117 | export default CredentialModal;
118 |
--------------------------------------------------------------------------------
/pkg/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "crypto/md5"
6 | "database/sql/driver"
7 | "encoding/base64"
8 | "fmt"
9 | "image"
10 | "image/png"
11 | "net"
12 | "os"
13 | "path/filepath"
14 | "reflect"
15 | "sort"
16 | "strconv"
17 | "strings"
18 | "time"
19 |
20 | "github.com/gofrs/uuid"
21 | "golang.org/x/crypto/bcrypt"
22 | )
23 |
24 | type JsonTime struct {
25 | time.Time
26 | }
27 |
28 | func NewJsonTime(t time.Time) JsonTime {
29 | return JsonTime{
30 | Time: t,
31 | }
32 | }
33 |
34 | func NowJsonTime() JsonTime {
35 | return JsonTime{
36 | Time: time.Now(),
37 | }
38 | }
39 |
40 | func (t JsonTime) MarshalJSON() ([]byte, error) {
41 | var stamp = fmt.Sprintf("\"%s\"", t.Format("2006-01-02 15:04:05"))
42 | return []byte(stamp), nil
43 | }
44 |
45 | func (t JsonTime) Value() (driver.Value, error) {
46 | var zeroTime time.Time
47 | if t.Time.UnixNano() == zeroTime.UnixNano() {
48 | return nil, nil
49 | }
50 | return t.Time, nil
51 | }
52 |
53 | func (t *JsonTime) Scan(v interface{}) error {
54 | value, ok := v.(time.Time)
55 | if ok {
56 | *t = JsonTime{Time: value}
57 | return nil
58 | }
59 | return fmt.Errorf("can not convert %v to timestamp", v)
60 | }
61 |
62 | type Bcrypt struct {
63 | cost int
64 | }
65 |
66 | func (b *Bcrypt) Encode(password []byte) ([]byte, error) {
67 | return bcrypt.GenerateFromPassword(password, b.cost)
68 | }
69 |
70 | func (b *Bcrypt) Match(hashedPassword, password []byte) error {
71 | return bcrypt.CompareHashAndPassword(hashedPassword, password)
72 | }
73 |
74 | var Encoder = Bcrypt{
75 | cost: bcrypt.DefaultCost,
76 | }
77 |
78 | func UUID() string {
79 | v4, _ := uuid.NewV4()
80 | return v4.String()
81 | }
82 |
83 | func Tcping(ip string, port int) bool {
84 | var conn net.Conn
85 | var err error
86 |
87 | if conn, err = net.DialTimeout("tcp", ip+":"+strconv.Itoa(port), 2*time.Second); err != nil {
88 | return false
89 | }
90 | defer conn.Close()
91 | return true
92 | }
93 |
94 | func ImageToBase64Encode(img image.Image) (string, error) {
95 | var buf bytes.Buffer
96 | if err := png.Encode(&buf, img); err != nil {
97 | return "", err
98 | }
99 | return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
100 | }
101 |
102 | // 判断所给路径文件/文件夹是否存在
103 | func FileExists(path string) bool {
104 | _, err := os.Stat(path) //os.Stat获取文件信息
105 | if err != nil {
106 | if os.IsExist(err) {
107 | return true
108 | }
109 | return false
110 | }
111 | return true
112 | }
113 |
114 | // 判断所给路径是否为文件夹
115 | func IsDir(path string) bool {
116 | s, err := os.Stat(path)
117 | if err != nil {
118 | return false
119 | }
120 | return s.IsDir()
121 | }
122 |
123 | // 判断所给路径是否为文件
124 | func IsFile(path string) bool {
125 | return !IsDir(path)
126 | }
127 |
128 | func GetParentDirectory(directory string) string {
129 | return filepath.Dir(directory)
130 | }
131 |
132 | // 去除重复元素
133 | func Distinct(a []string) []string {
134 | result := make([]string, 0, len(a))
135 | temp := map[string]struct{}{}
136 | for _, item := range a {
137 | if _, ok := temp[item]; !ok {
138 | temp[item] = struct{}{}
139 | result = append(result, item)
140 | }
141 | }
142 | return result
143 | }
144 |
145 | // 排序+拼接+摘要
146 | func Sign(a []string) string {
147 | sort.Strings(a)
148 | data := []byte(strings.Join(a, ""))
149 | has := md5.Sum(data)
150 | return fmt.Sprintf("%x", has)
151 | }
152 |
153 | func Contains(s []string, str string) bool {
154 | for _, v := range s {
155 | if v == str {
156 | return true
157 | }
158 | }
159 | return false
160 | }
161 |
162 | func StructToMap(obj interface{}) map[string]interface{} {
163 | t := reflect.TypeOf(obj)
164 | v := reflect.ValueOf(obj)
165 | if t.Kind() == reflect.Ptr {
166 | // 如果是指针,则获取其所指向的元素
167 | t = t.Elem()
168 | v = v.Elem()
169 | }
170 |
171 | var data = make(map[string]interface{})
172 | if t.Kind() == reflect.Struct {
173 | // 只有结构体可以获取其字段信息
174 | for i := 0; i < t.NumField(); i++ {
175 | jsonName := t.Field(i).Tag.Get("json")
176 | if jsonName != "" {
177 | data[jsonName] = v.Field(i).Interface()
178 | } else {
179 | data[t.Field(i).Name] = v.Field(i).Interface()
180 | }
181 | }
182 | }
183 | return data
184 | }
185 |
--------------------------------------------------------------------------------
/docs/install-naive.md:
--------------------------------------------------------------------------------
1 | # 原生安装
2 |
3 | ## 安装 Apache Guacamole-Server
4 |
5 | ### Centos 安装Apache Guacamole-Server依赖文件
6 |
7 | ```shell
8 | yum install -y gcc cairo-devel libjpeg-turbo-devel libpng-devel uuid-devel freerdp-devel pango-devel libssh2-devel libtelnet-devel libvncserver-devel pulseaudio-libs-devel openssl-devel libvorbis-devel libwebp-devel libwebsockets-devel libtool
9 | ```
10 |
11 | ### Ubuntu 安装Apache Guacamole-Server依赖文件
12 | ```shell
13 | sudo apt-get install libcairo2-dev libjpeg-turbo8-dev libpng12-dev libtool-bin libossp-uuid-dev freerdp2-dev libpango1.0-dev libssh2-1-dev libtelnet-dev libvncserver-dev libwebsockets-dev libpulse-dev libssl-dev libvorbis-dev libwebp-dev
14 | ```
15 |
16 | ### Debian 安装Apache Guacamole-Server依赖文件
17 | ```shell
18 | sudo apt-get install libcairo2-dev libjpeg62-turbo-dev libpng-dev libtool-bin libossp-uuid-dev freerdp2-dev libpango1.0-dev libssh2-1-dev libtelnet-dev libvncserver-dev libwebsockets-dev libpulse-dev libssl-dev libvorbis-dev libwebp-dev
19 | ```
20 |
21 | 如有疑问可参考 [Guacamole官方安装文档](!https://guacamole.apache.org/doc/gug/installing-guacamole.html)
22 |
23 | 下载&解压&configure
24 | ```shell
25 | wget https://mirror.bit.edu.cn/apache/guacamole/1.2.0/source/guacamole-server-1.2.0.tar.gz
26 | tar -xzf guacamole-server-1.2.0.tar.gz
27 | cd guacamole-server-1.2.0
28 | ./configure --with-init-dir=/etc/init.d
29 | ```
30 |
31 | 如果安装的依赖文件没有缺失的话,会看到`RDP` `SSH` `VNC` 都是 `yes`
32 |
33 | ```shell
34 | ------------------------------------------------
35 | guacamole-server version 1.2.0
36 | ------------------------------------------------
37 |
38 | Library status:
39 |
40 | freerdp2 ............ yes
41 | pango ............... yes
42 | libavcodec .......... no
43 | libavformat.......... no
44 | libavutil ........... no
45 | libssh2 ............. yes
46 | libssl .............. yes
47 | libswscale .......... no
48 | libtelnet ........... yes
49 | libVNCServer ........ yes
50 | libvorbis ........... yes
51 | libpulse ............ yes
52 | libwebsockets ....... no
53 | libwebp ............. yes
54 | wsock32 ............. no
55 |
56 | Protocol support:
57 |
58 | Kubernetes .... no
59 | RDP ........... yes
60 | SSH ........... yes
61 | Telnet ........ yes
62 | VNC ........... yes
63 |
64 | Services / tools:
65 |
66 | guacd ...... yes
67 | guacenc .... no
68 | guaclog .... yes
69 |
70 | FreeRDP plugins: /usr/lib64/freerdp2
71 | Init scripts: /etc/init.d
72 | Systemd units: no
73 |
74 | Type "make" to compile guacamole-server.
75 |
76 | ```
77 |
78 | 编译和安装
79 |
80 | ```shell
81 | make && make install && ldconfig
82 | ```
83 |
84 | 配置guacamole-server
85 | ```shell
86 | mkdir /etc/guacamole/ && cat <> /etc/guacamole/guacd.conf
87 | [daemon]
88 | pid_file = /var/run/guacd.pid
89 | log_level = info
90 |
91 | [server]
92 | bind_host = 0.0.0.0
93 | bind_port = 4822
94 | EOF
95 | ```
96 |
97 | 启动 guacamole-server
98 | ```shell
99 | /etc/init.d/guacd start
100 | ```
101 |
102 | ### 安装字体(SSH使用)
103 |
104 | 安装字体管理软件
105 | ```shell
106 | yum install -y fontconfig mkfontscale
107 | ```
108 |
109 | 下载字体文件并移动到` /usr/share/fonts/`目录下
110 | ```shell
111 | cd /usr/share/fonts/
112 | wget https://raw.githubusercontent.com/dushixiang/next-terminal/master/web/src/fonts/Menlo-Regular-1.ttf
113 | ```
114 |
115 | 更新字体
116 | ```shell
117 | mkfontscale
118 | mkfontdir
119 | fc-cache
120 | ```
121 | ### 安装 Next Terminal
122 | 建立next-terminal目录
123 | ```shell
124 | mkdir ~/next-terminal && cd ~/next-terminal
125 | ```
126 |
127 | 下载
128 | ```shell
129 | wget https://github.com/dushixiang/next-terminal/releases/latest/download/next-terminal.tgz
130 | ```
131 |
132 | 解压
133 | ```shell
134 | tar -xvf next-terminal.tgz
135 | cd next-terminal
136 | ```
137 |
138 | 修改配置文件`config.yml`
139 | ```shell
140 | db: sqlite
141 | # 当db为sqlite时mysql的配置无效
142 | #mysql:
143 | # hostname: 172.16.101.32
144 | # port: 3306
145 | # username: root
146 | # password: mysql
147 | # database: next-terminal
148 |
149 | # 当db为mysql时sqlite的配置无效
150 | sqlite:
151 | file: 'next-terminal.db'
152 | server:
153 | addr: 0.0.0.0:8088
154 | # 当设置下面两个参数时会自动开启https模式
155 | # cert: /root/next-terminal/cert.pem
156 | # key: /root/next-terminal/key.pem
157 | ```
158 |
159 | 启动
160 | ```shell
161 | ./next-terminal
162 | ```
163 |
--------------------------------------------------------------------------------
/pkg/api/credential.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "github.com/labstack/echo/v4"
6 | "next-terminal/pkg/model"
7 | "next-terminal/pkg/utils"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func CredentialAllEndpoint(c echo.Context) error {
13 | account, _ := GetCurrentAccount(c)
14 | items, _ := model.FindAllCredential(account)
15 | return Success(c, items)
16 | }
17 | func CredentialCreateEndpoint(c echo.Context) error {
18 | var item model.Credential
19 | if err := c.Bind(&item); err != nil {
20 | return err
21 | }
22 |
23 | account, _ := GetCurrentAccount(c)
24 | item.Owner = account.ID
25 | item.ID = utils.UUID()
26 | item.Created = utils.NowJsonTime()
27 |
28 | switch item.Type {
29 | case model.Custom:
30 | item.PrivateKey = "-"
31 | item.Passphrase = "-"
32 | if len(item.Username) == 0 {
33 | item.Username = "-"
34 | }
35 | if len(item.Password) == 0 {
36 | item.Password = "-"
37 | }
38 | case model.PrivateKey:
39 | item.Password = "-"
40 | if len(item.Username) == 0 {
41 | item.Username = "-"
42 | }
43 | if len(item.PrivateKey) == 0 {
44 | item.PrivateKey = "-"
45 | }
46 | if len(item.Passphrase) == 0 {
47 | item.Passphrase = "-"
48 | }
49 | default:
50 | return Fail(c, -1, "类型错误")
51 | }
52 |
53 | if err := model.CreateNewCredential(&item); err != nil {
54 | return err
55 | }
56 |
57 | return Success(c, item)
58 | }
59 |
60 | func CredentialPagingEndpoint(c echo.Context) error {
61 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
62 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
63 | name := c.QueryParam("name")
64 |
65 | account, _ := GetCurrentAccount(c)
66 | items, total, err := model.FindPageCredential(pageIndex, pageSize, name, account)
67 | if err != nil {
68 | return err
69 | }
70 |
71 | return Success(c, H{
72 | "total": total,
73 | "items": items,
74 | })
75 | }
76 |
77 | func CredentialUpdateEndpoint(c echo.Context) error {
78 | id := c.Param("id")
79 |
80 | if err := PreCheckCredentialPermission(c, id); err != nil {
81 | return err
82 | }
83 |
84 | var item model.Credential
85 | if err := c.Bind(&item); err != nil {
86 | return err
87 | }
88 |
89 | switch item.Type {
90 | case model.Custom:
91 | item.PrivateKey = "-"
92 | item.Passphrase = "-"
93 | if len(item.Username) == 0 {
94 | item.Username = "-"
95 | }
96 | if len(item.Password) == 0 {
97 | item.Password = "-"
98 | }
99 | case model.PrivateKey:
100 | item.Password = "-"
101 | if len(item.Username) == 0 {
102 | item.Username = "-"
103 | }
104 | if len(item.PrivateKey) == 0 {
105 | item.PrivateKey = "-"
106 | }
107 | if len(item.Passphrase) == 0 {
108 | item.Passphrase = "-"
109 | }
110 | default:
111 | return Fail(c, -1, "类型错误")
112 | }
113 |
114 | model.UpdateCredentialById(&item, id)
115 |
116 | return Success(c, nil)
117 | }
118 |
119 | func CredentialDeleteEndpoint(c echo.Context) error {
120 | id := c.Param("id")
121 | split := strings.Split(id, ",")
122 | for i := range split {
123 | if err := PreCheckCredentialPermission(c, split[i]); err != nil {
124 | return err
125 | }
126 | if err := model.DeleteCredentialById(split[i]); err != nil {
127 | return err
128 | }
129 | // 删除资产与用户的关系
130 | if err := model.DeleteResourceSharerByResourceId(split[i]); err != nil {
131 | return err
132 | }
133 | }
134 |
135 | return Success(c, nil)
136 | }
137 |
138 | func CredentialGetEndpoint(c echo.Context) error {
139 | id := c.Param("id")
140 |
141 | item, err := model.FindCredentialById(id)
142 | if err != nil {
143 | return err
144 | }
145 |
146 | if !HasPermission(c, item.Owner) {
147 | return errors.New("permission denied")
148 | }
149 |
150 | return Success(c, item)
151 | }
152 |
153 | func CredentialChangeOwnerEndpoint(c echo.Context) error {
154 | id := c.Param("id")
155 |
156 | if err := PreCheckCredentialPermission(c, id); err != nil {
157 | return err
158 | }
159 |
160 | owner := c.QueryParam("owner")
161 | model.UpdateCredentialById(&model.Credential{Owner: owner}, id)
162 | return Success(c, "")
163 | }
164 |
165 | func PreCheckCredentialPermission(c echo.Context, id string) error {
166 | item, err := model.FindCredentialById(id)
167 | if err != nil {
168 | return err
169 | }
170 |
171 | if !HasPermission(c, item.Owner) {
172 | return errors.New("permission denied")
173 | }
174 | return nil
175 | }
176 |
--------------------------------------------------------------------------------
/web/src/common/request.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import {server} from "./constants";
3 | import {message} from 'antd';
4 | import {getHeaders} from "../utils/utils";
5 |
6 | // 测试地址
7 | // axios.defaults.baseURL = server;
8 | // 线上地址
9 | axios.defaults.baseURL = server;
10 |
11 | const handleError = (error) => {
12 | if ("Network Error" === error.toString()) {
13 | message.error('网络异常');
14 | return false;
15 | }
16 | if (error.response !== undefined && error.response.status === 401) {
17 | window.location.href = '#/login';
18 | return false;
19 | }
20 | if (error.response !== undefined) {
21 | message.error(error.response.data.message);
22 | return false;
23 | }
24 | return true;
25 | };
26 |
27 | const handleResult = (result) => {
28 | if (result['code'] === 401) {
29 | window.location.href = '#/login';
30 | return false;
31 | }
32 | return true;
33 | }
34 |
35 | const request = {
36 |
37 | get: function (url) {
38 | const headers = getHeaders();
39 |
40 | return new Promise((resolve, reject) => {
41 | axios.get(url, {headers: headers})
42 | .then((response) => {
43 | if (!handleResult(response.data)) {
44 | return;
45 | }
46 | resolve(response.data);
47 | })
48 | .catch((error) => {
49 | if (!handleError(error)) {
50 | return;
51 | }
52 | reject(error);
53 | });
54 | })
55 | },
56 |
57 | post: function (url, params) {
58 |
59 | const headers = getHeaders();
60 |
61 | return new Promise((resolve, reject) => {
62 | axios.post(url, params, {headers: headers})
63 | .then((response) => {
64 | if (!handleResult(response.data)) {
65 | return;
66 | }
67 | resolve(response.data);
68 | })
69 | .catch((error) => {
70 | if (!handleError(error)) {
71 | return;
72 | }
73 | reject(error);
74 | });
75 | })
76 | },
77 |
78 | put: function (url, params) {
79 |
80 | const headers = getHeaders();
81 |
82 | return new Promise((resolve, reject) => {
83 | axios.put(url, params, {headers: headers})
84 | .then((response) => {
85 | if (!handleResult(response.data)) {
86 | return;
87 | }
88 | resolve(response.data);
89 | })
90 | .catch((error) => {
91 | if (!handleError(error)) {
92 | return;
93 | }
94 | reject(error);
95 | });
96 | })
97 | },
98 |
99 | delete: function (url) {
100 | const headers = getHeaders();
101 |
102 | return new Promise((resolve, reject) => {
103 | axios.delete(url, {headers: headers})
104 | .then((response) => {
105 | if (!handleResult(response.data)) {
106 | return;
107 | }
108 | resolve(response.data);
109 | })
110 | .catch((error) => {
111 | if (!handleError(error)) {
112 | return;
113 | }
114 | reject(error);
115 | });
116 | })
117 | },
118 |
119 | patch: function (url, params) {
120 | const headers = getHeaders();
121 |
122 | return new Promise((resolve, reject) => {
123 | axios.patch(url, params, {headers: headers})
124 | .then((response) => {
125 | if (!handleResult(response.data)) {
126 | return;
127 | }
128 | resolve(response.data);
129 | })
130 | .catch((error) => {
131 | if (!handleError(error)) {
132 | return;
133 | }
134 | reject(error);
135 | });
136 | })
137 | },
138 | };
139 | export default request
140 |
--------------------------------------------------------------------------------
/pkg/model/credential.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/utils"
6 | )
7 |
8 | // 密码
9 | const Custom = "custom"
10 |
11 | // 密钥
12 | const PrivateKey = "private-key"
13 |
14 | type Credential struct {
15 | ID string `gorm:"primary_key" json:"id"`
16 | Name string `json:"name"`
17 | Type string `json:"type"`
18 | Username string `json:"username"`
19 | Password string `json:"password"`
20 | PrivateKey string `json:"privateKey"`
21 | Passphrase string `json:"passphrase"`
22 | Created utils.JsonTime `json:"created"`
23 | Owner string `gorm:"index" json:"owner"`
24 | }
25 |
26 | func (r *Credential) TableName() string {
27 | return "credentials"
28 | }
29 |
30 | type CredentialVo struct {
31 | ID string `json:"id"`
32 | Name string `json:"name"`
33 | Type string `json:"type"`
34 | Username string `json:"username"`
35 | Created utils.JsonTime `json:"created"`
36 | Owner string `json:"owner"`
37 | OwnerName string `json:"ownerName"`
38 | SharerCount int64 `json:"sharerCount"`
39 | }
40 |
41 | type CredentialSimpleVo struct {
42 | ID string `json:"id"`
43 | Name string `json:"name"`
44 | }
45 |
46 | func FindAllCredential(account User) (o []CredentialSimpleVo, err error) {
47 | db := global.DB.Table("credentials").Select("DISTINCT credentials.id,credentials.name").Joins("left join resource_sharers on credentials.id = resource_sharers.resource_id")
48 | if account.Type == TypeUser {
49 | db = db.Where("credentials.owner = ? or resource_sharers.user_id = ?", account.ID, account.ID)
50 | }
51 | err = db.Find(&o).Error
52 | return
53 | }
54 |
55 | func FindPageCredential(pageIndex, pageSize int, name string, account User) (o []CredentialVo, total int64, err error) {
56 | db := global.DB.Table("credentials").Select("credentials.id,credentials.name,credentials.type,credentials.username,credentials.owner,credentials.created,users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on credentials.owner = users.id").Joins("left join resource_sharers on credentials.id = resource_sharers.resource_id").Group("credentials.id")
57 | dbCounter := global.DB.Table("credentials").Select("DISTINCT credentials.id").Joins("left join resource_sharers on credentials.id = resource_sharers.resource_id").Group("credentials.id")
58 |
59 | if TypeUser == account.Type {
60 | owner := account.ID
61 | db = db.Where("credentials.owner = ? or resource_sharers.user_id = ?", owner, owner)
62 | dbCounter = dbCounter.Where("credentials.owner = ? or resource_sharers.user_id = ?", owner, owner)
63 | }
64 |
65 | if len(name) > 0 {
66 | db = db.Where("credentials.name like ?", "%"+name+"%")
67 | dbCounter = dbCounter.Where("credentials.name like ?", "%"+name+"%")
68 | }
69 |
70 | err = dbCounter.Count(&total).Error
71 | if err != nil {
72 | return nil, 0, err
73 | }
74 | err = db.Order("credentials.created desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error
75 | if o == nil {
76 | o = make([]CredentialVo, 0)
77 | }
78 | return
79 | }
80 |
81 | func CreateNewCredential(o *Credential) (err error) {
82 | if err = global.DB.Create(o).Error; err != nil {
83 | return err
84 | }
85 | return nil
86 | }
87 |
88 | func FindCredentialById(id string) (o Credential, err error) {
89 | err = global.DB.Where("id = ?", id).First(&o).Error
90 | return
91 | }
92 |
93 | func UpdateCredentialById(o *Credential, id string) {
94 | o.ID = id
95 | global.DB.Updates(o)
96 | }
97 |
98 | func DeleteCredentialById(id string) error {
99 | return global.DB.Where("id = ?", id).Delete(&Credential{}).Error
100 | }
101 |
102 | func CountCredential() (total int64, err error) {
103 | err = global.DB.Find(&Credential{}).Count(&total).Error
104 | return
105 | }
106 |
107 | func CountCredentialByUserId(userId string) (total int64, err error) {
108 | db := global.DB.Joins("left join resource_sharers on credentials.id = resource_sharers.resource_id")
109 |
110 | db = db.Where("credentials.owner = ? or resource_sharers.user_id = ?", userId, userId)
111 |
112 | // 查询用户所在用户组列表
113 | userGroupIds, err := FindUserGroupIdsByUserId(userId)
114 | if err != nil {
115 | return 0, err
116 | }
117 |
118 | if userGroupIds != nil && len(userGroupIds) > 0 {
119 | db = db.Or("resource_sharers.user_group_id in ?", userGroupIds)
120 | }
121 | err = db.Find(&Credential{}).Count(&total).Error
122 | return
123 | }
124 |
--------------------------------------------------------------------------------
/pkg/log/logger.go:
--------------------------------------------------------------------------------
1 | package log
2 |
3 | import (
4 | "io"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/labstack/echo/v4"
9 | "github.com/labstack/gommon/log"
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | // Logrus : implement Logger
14 | type Logrus struct {
15 | *logrus.Logger
16 | }
17 |
18 | // Logger ...
19 | var Logger = logrus.New()
20 |
21 | // GetEchoLogger for e.Logger
22 | func GetEchoLogger() Logrus {
23 | return Logrus{Logger}
24 | }
25 |
26 | // Level returns logger level
27 | func (l Logrus) Level() log.Lvl {
28 | switch l.Logger.Level {
29 | case logrus.DebugLevel:
30 | return log.DEBUG
31 | case logrus.WarnLevel:
32 | return log.WARN
33 | case logrus.ErrorLevel:
34 | return log.ERROR
35 | case logrus.InfoLevel:
36 | return log.INFO
37 | default:
38 | l.Panic("Invalid level")
39 | }
40 |
41 | return log.OFF
42 | }
43 |
44 | // SetHeader is a stub to satisfy interface
45 | // It's controlled by Logger
46 | func (l Logrus) SetHeader(_ string) {}
47 |
48 | // SetPrefix It's controlled by Logger
49 | func (l Logrus) SetPrefix(s string) {}
50 |
51 | // Prefix It's controlled by Logger
52 | func (l Logrus) Prefix() string {
53 | return ""
54 | }
55 |
56 | // SetLevel set level to logger from given log.Lvl
57 | func (l Logrus) SetLevel(lvl log.Lvl) {
58 | switch lvl {
59 | case log.DEBUG:
60 | Logger.SetLevel(logrus.DebugLevel)
61 | case log.WARN:
62 | Logger.SetLevel(logrus.WarnLevel)
63 | case log.ERROR:
64 | Logger.SetLevel(logrus.ErrorLevel)
65 | case log.INFO:
66 | Logger.SetLevel(logrus.InfoLevel)
67 | default:
68 | l.Panic("Invalid level")
69 | }
70 | }
71 |
72 | // Output logger output func
73 | func (l Logrus) Output() io.Writer {
74 | return l.Out
75 | }
76 |
77 | // SetOutput change output, default os.Stdout
78 | func (l Logrus) SetOutput(w io.Writer) {
79 | Logger.SetOutput(w)
80 | }
81 |
82 | // Printj print json log
83 | func (l Logrus) Printj(j log.JSON) {
84 | Logger.WithFields(logrus.Fields(j)).Print()
85 | }
86 |
87 | // Debugj debug json log
88 | func (l Logrus) Debugj(j log.JSON) {
89 | Logger.WithFields(logrus.Fields(j)).Debug()
90 | }
91 |
92 | // Infoj info json log
93 | func (l Logrus) Infoj(j log.JSON) {
94 | Logger.WithFields(logrus.Fields(j)).Info()
95 | }
96 |
97 | // Warnj warning json log
98 | func (l Logrus) Warnj(j log.JSON) {
99 | Logger.WithFields(logrus.Fields(j)).Warn()
100 | }
101 |
102 | // Errorj error json log
103 | func (l Logrus) Errorj(j log.JSON) {
104 | Logger.WithFields(logrus.Fields(j)).Error()
105 | }
106 |
107 | // Fatalj fatal json log
108 | func (l Logrus) Fatalj(j log.JSON) {
109 | Logger.WithFields(logrus.Fields(j)).Fatal()
110 | }
111 |
112 | // Panicj panic json log
113 | func (l Logrus) Panicj(j log.JSON) {
114 | Logger.WithFields(logrus.Fields(j)).Panic()
115 | }
116 |
117 | // Print string log
118 | func (l Logrus) Print(i ...interface{}) {
119 | Logger.Print(i[0].(string))
120 | }
121 |
122 | // Debug string log
123 | func (l Logrus) Debug(i ...interface{}) {
124 | Logger.Debug(i[0].(string))
125 | }
126 |
127 | // Info string log
128 | func (l Logrus) Info(i ...interface{}) {
129 | Logger.Info(i[0].(string))
130 | }
131 |
132 | // Warn string log
133 | func (l Logrus) Warn(i ...interface{}) {
134 | Logger.Warn(i[0].(string))
135 | }
136 |
137 | // Error string log
138 | func (l Logrus) Error(i ...interface{}) {
139 | Logger.Error(i[0].(string))
140 | }
141 |
142 | // Fatal string log
143 | func (l Logrus) Fatal(i ...interface{}) {
144 | Logger.Fatal(i[0].(string))
145 | }
146 |
147 | // Panic string log
148 | func (l Logrus) Panic(i ...interface{}) {
149 | Logger.Panic(i[0].(string))
150 | }
151 |
152 | func logrusMiddlewareHandler(c echo.Context, next echo.HandlerFunc) error {
153 | req := c.Request()
154 | res := c.Response()
155 | start := time.Now()
156 | if err := next(c); err != nil {
157 | c.Error(err)
158 | }
159 | stop := time.Now()
160 |
161 | p := req.URL.Path
162 |
163 | bytesIn := req.Header.Get(echo.HeaderContentLength)
164 |
165 | Logger.WithFields(map[string]interface{}{
166 | "time_rfc3339": time.Now().Format(time.RFC3339),
167 | "remote_ip": c.RealIP(),
168 | "host": req.Host,
169 | "uri": req.RequestURI,
170 | "method": req.Method,
171 | "path": p,
172 | "referer": req.Referer(),
173 | "user_agent": req.UserAgent(),
174 | "status": res.Status,
175 | "latency": strconv.FormatInt(stop.Sub(start).Nanoseconds()/1000, 10),
176 | "latency_human": stop.Sub(start).String(),
177 | "bytes_in": bytesIn,
178 | "bytes_out": strconv.FormatInt(res.Size, 10),
179 | }).Info("Handled request")
180 |
181 | return nil
182 | }
183 |
184 | func logger(next echo.HandlerFunc) echo.HandlerFunc {
185 | return func(c echo.Context) error {
186 | return logrusMiddlewareHandler(c, next)
187 | }
188 | }
189 |
190 | // Hook is a function to process log.
191 | func Hook() echo.MiddlewareFunc {
192 | return logger
193 | }
194 |
--------------------------------------------------------------------------------
/pkg/model/resource-sharer.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "gorm.io/gorm"
6 | "next-terminal/pkg/global"
7 | "next-terminal/pkg/utils"
8 | )
9 |
10 | type ResourceSharer struct {
11 | ID string `gorm:"primary_key" json:"id"`
12 | ResourceId string `gorm:"index" json:"resourceId"`
13 | ResourceType string `gorm:"index" json:"resourceType"`
14 | UserId string `gorm:"index" json:"userId"`
15 | UserGroupId string `gorm:"index" json:"userGroupId"`
16 | }
17 |
18 | func (r *ResourceSharer) TableName() string {
19 | return "resource_sharers"
20 | }
21 |
22 | func FindUserIdsByResourceId(resourceId string) (r []string, err error) {
23 | db := global.DB
24 | err = db.Table("resource_sharers").Select("user_id").Where("resource_id = ?", resourceId).Find(&r).Error
25 | if r == nil {
26 | r = make([]string, 0)
27 | }
28 | return
29 | }
30 |
31 | func OverwriteUserIdsByResourceId(resourceId, resourceType string, userIds []string) (err error) {
32 | db := global.DB.Begin()
33 |
34 | var owner string
35 | // 检查资产是否存在
36 | switch resourceType {
37 | case "asset":
38 | resource := Asset{}
39 | err = db.Where("id = ?", resourceId).First(&resource).Error
40 | owner = resource.Owner
41 | case "command":
42 | resource := Command{}
43 | err = db.Where("id = ?", resourceId).First(&resource).Error
44 | owner = resource.Owner
45 | case "credential":
46 | resource := Credential{}
47 | err = db.Where("id = ?", resourceId).First(&resource).Error
48 | owner = resource.Owner
49 | }
50 |
51 | if err == gorm.ErrRecordNotFound {
52 | return echo.NewHTTPError(404, "资源「"+resourceId+"」不存在")
53 | }
54 |
55 | for i := range userIds {
56 | if owner == userIds[i] {
57 | return echo.NewHTTPError(400, "参数错误")
58 | }
59 | }
60 |
61 | db.Where("resource_id = ?", resourceId).Delete(&ResourceSharer{})
62 |
63 | for i := range userIds {
64 | userId := userIds[i]
65 | if len(userId) == 0 {
66 | continue
67 | }
68 | id := utils.Sign([]string{resourceId, resourceType, userId})
69 | resource := &ResourceSharer{
70 | ID: id,
71 | ResourceId: resourceId,
72 | ResourceType: resourceType,
73 | UserId: userId,
74 | }
75 | err = db.Create(resource).Error
76 | if err != nil {
77 | return err
78 | }
79 | }
80 | db.Commit()
81 | return nil
82 | }
83 |
84 | func DeleteByUserIdAndResourceTypeAndResourceIdIn(userGroupId, userId, resourceType string, resourceIds []string) error {
85 | db := global.DB
86 | if userGroupId != "" {
87 | db = db.Where("user_group_id = ?", userGroupId)
88 | }
89 |
90 | if userId != "" {
91 | db = db.Where("user_id = ?", userId)
92 | }
93 |
94 | if resourceType != "" {
95 | db = db.Where("resource_type = ?", resourceType)
96 | }
97 |
98 | if resourceIds != nil {
99 | db = db.Where("resource_id in ?", resourceIds)
100 | }
101 |
102 | return db.Delete(&ResourceSharer{}).Error
103 | }
104 |
105 | func DeleteResourceSharerByResourceId(resourceId string) error {
106 | return global.DB.Where("resource_id = ?", resourceId).Delete(&ResourceSharer{}).Error
107 | }
108 |
109 | func AddSharerResources(userGroupId, userId, resourceType string, resourceIds []string) error {
110 | return global.DB.Transaction(func(tx *gorm.DB) (err error) {
111 |
112 | for i := range resourceIds {
113 | resourceId := resourceIds[i]
114 |
115 | var owner string
116 | // 检查资产是否存在
117 | switch resourceType {
118 | case "asset":
119 | resource := Asset{}
120 | err = tx.Where("id = ?", resourceId).First(&resource).Error
121 | owner = resource.Owner
122 | case "command":
123 | resource := Command{}
124 | err = tx.Where("id = ?", resourceId).First(&resource).Error
125 | owner = resource.Owner
126 | case "credential":
127 | resource := Credential{}
128 | err = tx.Where("id = ?", resourceId).First(&resource).Error
129 | owner = resource.Owner
130 | }
131 |
132 | if owner == userId {
133 | return echo.NewHTTPError(400, "参数错误")
134 | }
135 |
136 | id := utils.Sign([]string{resourceId, resourceType, userId, userGroupId})
137 | resource := &ResourceSharer{
138 | ID: id,
139 | ResourceId: resourceId,
140 | ResourceType: resourceType,
141 | UserId: userId,
142 | UserGroupId: userGroupId,
143 | }
144 | err = tx.Create(resource).Error
145 | if err != nil {
146 | return err
147 | }
148 | }
149 | return nil
150 | })
151 | }
152 |
153 | func FindAssetIdsByUserId(userId string) (assetIds []string, err error) {
154 | groupIds, err := FindUserGroupIdsByUserId(userId)
155 | if err != nil {
156 | return nil, err
157 | }
158 |
159 | db := global.DB
160 | db = db.Table("resource_sharers").Select("resource_id").Where("user_id = ?", userId)
161 | if groupIds != nil && len(groupIds) > 0 {
162 | db = db.Or("user_group_id in ?", groupIds)
163 | }
164 | err = db.Find(&assetIds).Error
165 | if assetIds == nil {
166 | assetIds = make([]string, 0)
167 | }
168 | return
169 | }
170 |
--------------------------------------------------------------------------------
/web/src/components/dashboard/Dashboard.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Card, Col, PageHeader, Radio, Row, Statistic} from "antd";
3 | import {DesktopOutlined, IdcardOutlined, LinkOutlined, UserOutlined} from '@ant-design/icons';
4 | import {itemRender} from '../../utils/utils'
5 | import request from "../../common/request";
6 | import './Dashboard.css'
7 | import {Link} from "react-router-dom";
8 | import {Area} from '@ant-design/charts';
9 | import Logout from "../user/Logout";
10 | import {isAdmin} from "../../service/permission";
11 |
12 |
13 | const routes = [
14 | {
15 | path: '',
16 | breadcrumbName: '首页',
17 | },
18 | {
19 | path: 'dashboard',
20 | breadcrumbName: '仪表盘',
21 | }
22 | ];
23 |
24 | class Dashboard extends Component {
25 |
26 | state = {
27 | counter: {},
28 | d: 'w',
29 | session: [],
30 | }
31 |
32 | componentDidMount() {
33 | this.getCounter();
34 | this.getD();
35 | }
36 |
37 | componentWillUnmount() {
38 |
39 | }
40 |
41 | getCounter = async () => {
42 | let result = await request.get('/overview/counter');
43 | if (result['code'] === 1) {
44 | this.setState({
45 | counter: result['data']
46 | })
47 | }
48 | }
49 |
50 | getD = async () => {
51 | let result = await request.get('/overview/sessions?d=' + this.state.d);
52 | if (result['code'] === 1) {
53 | this.setState({
54 | session: result['data']
55 | })
56 | }
57 | }
58 |
59 | handleChangeD = (e) => {
60 | let d = e.target.value;
61 | this.setState({
62 | d: d
63 | }, () => this.getD())
64 | }
65 |
66 | render() {
67 |
68 | const config = {
69 | data: this.state.session,
70 | xField: 'day',
71 | yField: 'count',
72 | seriesField: 'protocol',
73 | };
74 |
75 | const buttonRadio =
76 | 按周
77 | 按月
78 |
79 |
80 | return (
81 | <>
82 |
92 | ]}
93 | >
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | }/>
104 |
105 |
106 |
107 |
108 |
109 |
110 | }/>
112 |
113 |
114 |
115 |
116 |
117 |
118 | }/>
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | }/>
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | >
142 | );
143 | }
144 | }
145 |
146 | export default Dashboard;
147 |
--------------------------------------------------------------------------------
/web/src/components/access/Monitor.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import Guacamole from 'guacamole-common-js';
3 | import {Modal, Result, Spin} from 'antd'
4 | import qs from "qs";
5 | import {wsServer} from "../../common/constants";
6 | import {getToken} from "../../utils/utils";
7 | import './Access.css'
8 |
9 | const STATE_IDLE = 0;
10 | const STATE_CONNECTING = 1;
11 | const STATE_WAITING = 2;
12 | const STATE_CONNECTED = 3;
13 | const STATE_DISCONNECTING = 4;
14 | const STATE_DISCONNECTED = 5;
15 |
16 | class Monitor extends Component {
17 |
18 | formRef = React.createRef()
19 |
20 | state = {
21 | client: {},
22 | containerOverflow: 'hidden',
23 | width: 0,
24 | height: 0,
25 | rate: 1,
26 | loading: false,
27 | tip: '',
28 | closed: false,
29 | };
30 |
31 | async componentDidMount() {
32 | const connectionId = this.props.connectionId;
33 | let rate = this.props.rate;
34 | let protocol = this.props.protocol;
35 | let width = this.props.width;
36 | let height = this.props.height;
37 |
38 | if (protocol === 'ssh' || protocol === 'telnet') {
39 | rate = rate * 0.5;
40 | width = width * 2;
41 | height = height * 2;
42 | }
43 | this.setState({
44 | width: width * rate,
45 | height: height * rate,
46 | rate: rate,
47 | })
48 | this.renderDisplay(connectionId);
49 | }
50 |
51 | componentWillUnmount() {
52 | if (this.state.client) {
53 | this.state.client.disconnect();
54 | }
55 | }
56 |
57 | onTunnelStateChange = (state) => {
58 | console.log('onTunnelStateChange', state);
59 | if (state === Guacamole.Tunnel.State.CLOSED) {
60 | this.setState({
61 | loading: false,
62 | closed: true,
63 | });
64 | }
65 | };
66 |
67 | onClientStateChange = (state) => {
68 | switch (state) {
69 | case STATE_IDLE:
70 | this.setState({
71 | loading: true,
72 | tip: '正在初始化中...'
73 | });
74 | break;
75 | case STATE_CONNECTING:
76 | this.setState({
77 | loading: true,
78 | tip: '正在努力连接中...'
79 | });
80 | break;
81 | case STATE_WAITING:
82 | this.setState({
83 | loading: true,
84 | tip: '正在等待服务器响应...'
85 | });
86 | break;
87 | case STATE_CONNECTED:
88 | this.setState({
89 | loading: false
90 | });
91 | if (this.state.client) {
92 | this.state.client.getDisplay().scale(this.state.rate);
93 | }
94 | break;
95 | case STATE_DISCONNECTING:
96 |
97 | break;
98 | case STATE_DISCONNECTED:
99 |
100 | break;
101 | default:
102 | break;
103 | }
104 | };
105 |
106 | showMessage(message) {
107 | Modal.error({
108 | title: '提示',
109 | content: message,
110 | });
111 | }
112 |
113 | async renderDisplay(connectionId, protocol) {
114 |
115 | let tunnel = new Guacamole.WebSocketTunnel(wsServer + '/tunnel');
116 |
117 | tunnel.onstatechange = this.onTunnelStateChange;
118 | let client = new Guacamole.Client(tunnel);
119 |
120 | // 处理客户端的状态变化事件
121 | client.onstatechange = this.onClientStateChange;
122 | const display = document.getElementById("display");
123 |
124 | // Add client to display div
125 | const element = client.getDisplay().getElement();
126 | display.appendChild(element);
127 |
128 | let token = getToken();
129 |
130 | let params = {
131 | 'connectionId': connectionId,
132 | 'X-Auth-Token': token
133 | };
134 |
135 | let paramStr = qs.stringify(params);
136 |
137 | // Connect
138 | client.connect(paramStr);
139 |
140 | // Disconnect on close
141 | window.onunload = function () {
142 | client.disconnect();
143 | };
144 |
145 | this.setState({
146 | client: client
147 | })
148 | }
149 |
150 | render() {
151 |
152 | return (
153 |
154 |
155 | {
156 | this.state.closed ?
157 |
:
160 |
168 | }
169 |
170 |
171 |
172 |
173 | );
174 | }
175 | }
176 |
177 | export default Monitor;
178 |
--------------------------------------------------------------------------------
/web/src/components/command/BatchCommand.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Card, Input, List, PageHeader, Spin} from "antd";
3 | import Console from "../access/Console";
4 | import {itemRender} from "../../utils/utils";
5 | import Logout from "../user/Logout";
6 | import './Command.css'
7 | import request from "../../common/request";
8 | import {message} from "antd/es";
9 |
10 | const {Search} = Input;
11 | const routes = [
12 | {
13 | path: '',
14 | breadcrumbName: '首页',
15 | },
16 | {
17 | path: '/dynamic-command',
18 | breadcrumbName: '动态指令',
19 | },
20 | {
21 | path: '/batch-command',
22 | breadcrumbName: '批量执行命令',
23 | }
24 | ];
25 |
26 | class BatchCommand extends Component {
27 |
28 | commandRef = React.createRef();
29 |
30 | state = {
31 | webSockets: [],
32 | assets: [],
33 | active: undefined,
34 | loading: true
35 | }
36 |
37 | componentDidMount() {
38 | let params = new URLSearchParams(this.props.location.search);
39 | let assets = JSON.parse(params.get('assets'));
40 | let commandId = params.get('commandId');
41 |
42 | this.init(commandId, assets)
43 | }
44 |
45 | init = async (commandId, assets) => {
46 |
47 | let result = await request.get(`/commands/${commandId}`);
48 | if (result['code'] !== 1) {
49 | message.error(result['message'], 10);
50 | this.setState({
51 | loading: false
52 | })
53 | return;
54 | }
55 |
56 | let command = result['data']['content'];
57 | this.setState({
58 | loading: false,
59 | command: command,
60 | assets: assets
61 | })
62 | }
63 |
64 | onPaneChange = activeKey => {
65 | this.setState({activeKey});
66 | };
67 |
68 | appendWebsocket = (webSocket) => {
69 | this.state.webSockets.push(webSocket);
70 | }
71 |
72 | render() {
73 | return (
74 | <>
75 |
84 | ]}
85 | subTitle="动态指令"
86 | >
87 |
88 |
89 |
90 |
91 | {
92 | for (let i = 0; i < this.state.webSockets.length; i++) {
93 | let ws = this.state.webSockets[i]['ws'];
94 | if (ws.readyState === WebSocket.OPEN) {
95 | ws.send(JSON.stringify({
96 | type: 'data',
97 | content: value + String.fromCharCode(13)
98 | }));
99 | }
100 | }
101 | this.commandRef.current.setValue('');
102 | }} enterButton='执行'/>
103 |
104 |
105 |
106 | (
110 |
111 | {
114 | if (this.state.active === item['id']) {
115 | this.setState({
116 | active: undefined
117 | })
118 | } else {
119 | this.setState({
120 | active: item['id']
121 | })
122 | }
123 | }}
124 | >
125 |
129 |
130 |
131 | )}
132 | />
133 |
134 |
135 |
136 | >
137 | );
138 | }
139 | }
140 |
141 | export default BatchCommand;
--------------------------------------------------------------------------------
/web/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve asset; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onStateChange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/web/src/components/Login.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Button, Card, Checkbox, Form, Input, Modal, Typography} from "antd";
3 | import './Login.css'
4 | import request from "../common/request";
5 | import {message} from "antd/es";
6 | import {withRouter} from "react-router-dom";
7 | import {LockOutlined, OneToOneOutlined, UserOutlined} from '@ant-design/icons';
8 |
9 | const {Title} = Typography;
10 |
11 | class LoginForm extends Component {
12 |
13 | formRef = React.createRef()
14 |
15 | state = {
16 | inLogin: false,
17 | height: window.innerHeight,
18 | width: window.innerWidth,
19 | loginAccount: undefined,
20 | totpModalVisible: false,
21 | confirmLoading: false
22 | };
23 |
24 | componentDidMount() {
25 | window.addEventListener('resize', () => {
26 | this.setState({
27 | height: window.innerHeight,
28 | width: window.innerWidth
29 | })
30 | });
31 | }
32 |
33 | handleSubmit = async params => {
34 | this.setState({
35 | inLogin: true
36 | });
37 |
38 | try {
39 | let result = await request.post('/login', params);
40 |
41 | if (result.code === 0) {
42 | // 进行双因子认证
43 | this.setState({
44 | loginAccount: params,
45 | totpModalVisible: true
46 | })
47 | return;
48 | }
49 | if (result.code !== 1) {
50 | throw new Error(result.message);
51 | }
52 |
53 | // 跳转登录
54 | sessionStorage.removeItem('current');
55 | sessionStorage.removeItem('openKeys');
56 | localStorage.setItem('X-Auth-Token', result['data']);
57 | // this.props.history.push();
58 | window.location.href = "/"
59 | } catch (e) {
60 | message.error(e.message);
61 | } finally {
62 | this.setState({
63 | inLogin: false
64 | });
65 | }
66 | };
67 |
68 | handleOk = async (values) => {
69 | this.setState({
70 | confirmLoading: true
71 | })
72 | let loginAccount = this.state.loginAccount;
73 | loginAccount['totp'] = values['totp'];
74 | try {
75 | let result = await request.post('/loginWithTotp', loginAccount);
76 |
77 | if (result.code !== 1) {
78 | throw new Error(result.message);
79 | }
80 |
81 | // 跳转登录
82 | sessionStorage.removeItem('current');
83 | sessionStorage.removeItem('openKeys');
84 | localStorage.setItem('X-Auth-Token', result['data']);
85 | // this.props.history.push();
86 | window.location.href = "/"
87 | } catch (e) {
88 | message.error(e.message);
89 | } finally {
90 | this.setState({
91 | confirmLoading: false
92 | });
93 | }
94 | }
95 |
96 | handleCancel = () => {
97 | this.setState({
98 | totpModalVisible: false
99 | })
100 | }
101 |
102 | render() {
103 | return (
104 |
153 |
154 | );
155 | }
156 | }
157 |
158 | export default withRouter(LoginForm);
159 |
--------------------------------------------------------------------------------
/pkg/api/asset.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "github.com/labstack/echo/v4"
7 | "next-terminal/pkg/model"
8 | "next-terminal/pkg/utils"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | func AssetCreateEndpoint(c echo.Context) error {
14 | m := echo.Map{}
15 | if err := c.Bind(&m); err != nil {
16 | return err
17 | }
18 |
19 | data, _ := json.Marshal(m)
20 | var item model.Asset
21 | if err := json.Unmarshal(data, &item); err != nil {
22 | return err
23 | }
24 |
25 | account, _ := GetCurrentAccount(c)
26 | item.Owner = account.ID
27 | item.ID = utils.UUID()
28 | item.Created = utils.NowJsonTime()
29 |
30 | if err := model.CreateNewAsset(&item); err != nil {
31 | return err
32 | }
33 |
34 | if err := model.UpdateAssetAttributes(item.ID, item.Protocol, m); err != nil {
35 | return err
36 | }
37 |
38 | // 创建后自动检测资产是否存活
39 | go func() {
40 | active := utils.Tcping(item.IP, item.Port)
41 | model.UpdateAssetActiveById(active, item.ID)
42 | }()
43 |
44 | return Success(c, item)
45 | }
46 |
47 | func AssetPagingEndpoint(c echo.Context) error {
48 | pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex"))
49 | pageSize, _ := strconv.Atoi(c.QueryParam("pageSize"))
50 | name := c.QueryParam("name")
51 | protocol := c.QueryParam("protocol")
52 | tags := c.QueryParam("tags")
53 | owner := c.QueryParam("owner")
54 | sharer := c.QueryParam("sharer")
55 | userGroupId := c.QueryParam("userGroupId")
56 |
57 | account, _ := GetCurrentAccount(c)
58 | items, total, err := model.FindPageAsset(pageIndex, pageSize, name, protocol, tags, account, owner, sharer, userGroupId)
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return Success(c, H{
64 | "total": total,
65 | "items": items,
66 | })
67 | }
68 |
69 | func AssetAllEndpoint(c echo.Context) error {
70 | protocol := c.QueryParam("protocol")
71 | account, _ := GetCurrentAccount(c)
72 | items, _ := model.FindAssetByConditions(protocol, account)
73 | return Success(c, items)
74 | }
75 |
76 | func AssetUpdateEndpoint(c echo.Context) error {
77 | id := c.Param("id")
78 | if err := PreCheckAssetPermission(c, id); err != nil {
79 | return err
80 | }
81 |
82 | m := echo.Map{}
83 | if err := c.Bind(&m); err != nil {
84 | return err
85 | }
86 |
87 | data, _ := json.Marshal(m)
88 | var item model.Asset
89 | if err := json.Unmarshal(data, &item); err != nil {
90 | return err
91 | }
92 |
93 | switch item.AccountType {
94 | case "credential":
95 | item.Username = "-"
96 | item.Password = "-"
97 | item.PrivateKey = "-"
98 | item.Passphrase = "-"
99 | case "private-key":
100 | item.Password = "-"
101 | item.CredentialId = "-"
102 | if len(item.Username) == 0 {
103 | item.Username = "-"
104 | }
105 | if len(item.Passphrase) == 0 {
106 | item.Passphrase = "-"
107 | }
108 | case "custom":
109 | item.PrivateKey = "-"
110 | item.Passphrase = "-"
111 | item.CredentialId = "-"
112 | }
113 |
114 | if len(item.Tags) == 0 {
115 | item.Tags = "-"
116 | }
117 |
118 | if item.Description == "" {
119 | item.Description = "-"
120 | }
121 |
122 | model.UpdateAssetById(&item, id)
123 | if err := model.UpdateAssetAttributes(id, item.Protocol, m); err != nil {
124 | return err
125 | }
126 |
127 | return Success(c, nil)
128 | }
129 |
130 | func AssetGetAttributeEndpoint(c echo.Context) error {
131 |
132 | assetId := c.Param("id")
133 | attributeMap, err := model.FindAssetAttrMapByAssetId(assetId)
134 | if err != nil {
135 | return err
136 | }
137 | return Success(c, attributeMap)
138 | }
139 |
140 | func AssetUpdateAttributeEndpoint(c echo.Context) error {
141 | m := echo.Map{}
142 | if err := c.Bind(&m); err != nil {
143 | return err
144 | }
145 |
146 | assetId := c.Param("id")
147 | protocol := c.QueryParam("protocol")
148 | err := model.UpdateAssetAttributes(assetId, protocol, m)
149 | if err != nil {
150 | return err
151 | }
152 | return Success(c, "")
153 | }
154 |
155 | func AssetDeleteEndpoint(c echo.Context) error {
156 | id := c.Param("id")
157 | split := strings.Split(id, ",")
158 | for i := range split {
159 | if err := PreCheckAssetPermission(c, split[i]); err != nil {
160 | return err
161 | }
162 | if err := model.DeleteAssetById(split[i]); err != nil {
163 | return err
164 | }
165 | // 删除资产与用户的关系
166 | if err := model.DeleteResourceSharerByResourceId(split[i]); err != nil {
167 | return err
168 | }
169 | }
170 |
171 | return Success(c, nil)
172 | }
173 |
174 | func AssetGetEndpoint(c echo.Context) (err error) {
175 | id := c.Param("id")
176 |
177 | var item model.Asset
178 | if item, err = model.FindAssetById(id); err != nil {
179 | return err
180 | }
181 | attributeMap, err := model.FindAssetAttrMapByAssetId(id)
182 | if err != nil {
183 | return err
184 | }
185 | itemMap := utils.StructToMap(item)
186 | for key := range attributeMap {
187 | itemMap[key] = attributeMap[key]
188 | }
189 |
190 | return Success(c, itemMap)
191 | }
192 |
193 | func AssetTcpingEndpoint(c echo.Context) (err error) {
194 | id := c.Param("id")
195 |
196 | var item model.Asset
197 | if item, err = model.FindAssetById(id); err != nil {
198 | return err
199 | }
200 |
201 | active := utils.Tcping(item.IP, item.Port)
202 |
203 | model.UpdateAssetActiveById(active, item.ID)
204 | return Success(c, active)
205 | }
206 |
207 | func AssetTagsEndpoint(c echo.Context) (err error) {
208 | var items []string
209 | if items, err = model.FindAssetTags(); err != nil {
210 | return err
211 | }
212 | return Success(c, items)
213 | }
214 |
215 | func AssetChangeOwnerEndpoint(c echo.Context) (err error) {
216 | id := c.Param("id")
217 |
218 | if err := PreCheckAssetPermission(c, id); err != nil {
219 | return err
220 | }
221 |
222 | owner := c.QueryParam("owner")
223 | model.UpdateAssetById(&model.Asset{Owner: owner}, id)
224 | return Success(c, "")
225 | }
226 |
227 | func PreCheckAssetPermission(c echo.Context, id string) error {
228 | item, err := model.FindAssetById(id)
229 | if err != nil {
230 | return err
231 | }
232 |
233 | if !HasPermission(c, item.Owner) {
234 | return errors.New("permission denied")
235 | }
236 | return nil
237 | }
238 |
--------------------------------------------------------------------------------
/web/src/components/access/Console.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import "xterm/css/xterm.css"
3 | import {Terminal} from "xterm";
4 | import qs from "qs";
5 | import {wsServer} from "../../common/constants";
6 | import "./Console.css"
7 | import {getToken, isEmpty} from "../../utils/utils";
8 | import {FitAddon} from 'xterm-addon-fit'
9 | import request from "../../common/request";
10 | import {message} from "antd";
11 |
12 | class Console extends Component {
13 |
14 | state = {
15 | containerOverflow: 'hidden',
16 | width: 0,
17 | height: 0,
18 | term: undefined,
19 | webSocket: undefined,
20 | fitAddon: undefined
21 | };
22 |
23 | componentDidMount = async () => {
24 |
25 | let command = this.props.command;
26 | let assetId = this.props.assetId;
27 | let width = this.props.width;
28 | let height = this.props.height;
29 | let sessionId = await this.createSession(assetId);
30 | if (isEmpty(sessionId)) {
31 | return;
32 | }
33 |
34 | let term = new Terminal({
35 | fontFamily: 'monaco, Consolas, "Lucida Console", monospace',
36 | fontSize: 14,
37 | theme: {
38 | background: '#1b1b1b'
39 | },
40 | rightClickSelectsWord: true,
41 | });
42 |
43 | term.open(this.refs.terminal);
44 | const fitAddon = new FitAddon();
45 | term.loadAddon(fitAddon);
46 | fitAddon.fit();
47 | term.focus();
48 |
49 | term.writeln('Trying to connect to the server ...');
50 |
51 | term.onData(data => {
52 | let webSocket = this.state.webSocket;
53 | if (webSocket !== undefined) {
54 | webSocket.send(JSON.stringify({type: 'data', content: data}));
55 | }
56 | });
57 |
58 | let token = getToken();
59 | let params = {
60 | 'cols': term.cols,
61 | 'rows': term.rows,
62 | 'sessionId': sessionId,
63 | 'X-Auth-Token': token
64 | };
65 |
66 | let paramStr = qs.stringify(params);
67 |
68 | let webSocket = new WebSocket(wsServer + '/ssh?' + paramStr);
69 |
70 | this.props.appendWebsocket({'id': assetId, 'ws': webSocket});
71 |
72 | webSocket.onopen = (e => {
73 | this.onWindowResize();
74 | });
75 |
76 | webSocket.onerror = (e) => {
77 | term.writeln("Failed to connect to server.");
78 | }
79 | webSocket.onclose = (e) => {
80 | term.writeln("Connection is closed.");
81 | }
82 |
83 | let executedCommand = false
84 | webSocket.onmessage = (e) => {
85 | let msg = JSON.parse(e.data);
86 | switch (msg['type']) {
87 | case 'connected':
88 | term.clear();
89 | this.updateSessionStatus(sessionId);
90 | break;
91 | case 'data':
92 | term.write(msg['content']);
93 | break;
94 | case 'closed':
95 | term.writeln(`\x1B[1;3;31m${msg['content']}\x1B[0m `)
96 | webSocket.close();
97 | break;
98 | default:
99 | break;
100 | }
101 |
102 | if (!executedCommand) {
103 | if (command !== '') {
104 | let webSocket = this.state.webSocket;
105 | if (webSocket !== undefined && webSocket.readyState === WebSocket.OPEN) {
106 | webSocket.send(JSON.stringify({
107 | type: 'data',
108 | content: command + String.fromCharCode(13)
109 | }));
110 | }
111 | }
112 | executedCommand = true;
113 | }
114 | }
115 |
116 | this.setState({
117 | term: term,
118 | fitAddon: fitAddon,
119 | webSocket: webSocket,
120 | width: width,
121 | height: height
122 | });
123 |
124 | window.addEventListener('resize', this.onWindowResize);
125 | }
126 |
127 | componentWillUnmount() {
128 | let webSocket = this.state.webSocket;
129 | if (webSocket) {
130 | webSocket.close()
131 | }
132 | }
133 |
134 | async createSession(assetsId) {
135 | let result = await request.post(`/sessions?assetId=${assetsId}&mode=naive`);
136 | if (result['code'] !== 1) {
137 | this.showMessage(result['message']);
138 | return null;
139 | }
140 | return result['data']['id'];
141 | }
142 |
143 | updateSessionStatus = async (sessionId) => {
144 | let result = await request.post(`/sessions/${sessionId}/connect`);
145 | if (result['code'] !== 1) {
146 | message.error(result['message']);
147 | }
148 | }
149 |
150 | onWindowResize = (e) => {
151 | let term = this.state.term;
152 | let fitAddon = this.state.fitAddon;
153 | let webSocket = this.state.webSocket;
154 | if (term && fitAddon && webSocket) {
155 |
156 | let height = term.cols;
157 | let width = term.rows;
158 |
159 | try {
160 | fitAddon.fit();
161 | } catch (e) {
162 | console.log(e);
163 | }
164 |
165 | term.focus();
166 | if (webSocket.readyState === WebSocket.OPEN) {
167 | webSocket.send(JSON.stringify({type: 'resize', content: JSON.stringify({height, width})}));
168 | }
169 | }
170 | };
171 |
172 | render() {
173 | return (
174 |
182 | );
183 | }
184 | }
185 |
186 | export default Console;
187 |
--------------------------------------------------------------------------------
/pkg/api/account.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/model"
6 | "next-terminal/pkg/totp"
7 | "next-terminal/pkg/utils"
8 | "strings"
9 | "time"
10 |
11 | "github.com/labstack/echo/v4"
12 | )
13 |
14 | const (
15 | RememberEffectiveTime = time.Hour * time.Duration(24*14)
16 | NotRememberEffectiveTime = time.Hour * time.Duration(2)
17 | )
18 |
19 | type LoginAccount struct {
20 | Username string `json:"username"`
21 | Password string `json:"password"`
22 | Remember bool `json:"remember"`
23 | TOTP string `json:"totp"`
24 | }
25 |
26 | type ConfirmTOTP struct {
27 | Secret string `json:"secret"`
28 | TOTP string `json:"totp"`
29 | }
30 |
31 | type ChangePassword struct {
32 | NewPassword string `json:"newPassword"`
33 | OldPassword string `json:"oldPassword"`
34 | }
35 |
36 | type Authorization struct {
37 | Token string
38 | Remember bool
39 | User model.User
40 | }
41 |
42 | func LoginEndpoint(c echo.Context) error {
43 | var loginAccount LoginAccount
44 | if err := c.Bind(&loginAccount); err != nil {
45 | return err
46 | }
47 |
48 | user, err := model.FindUserByUsername(loginAccount.Username)
49 | if err != nil {
50 | return Fail(c, -1, "您输入的账号或密码不正确")
51 | }
52 |
53 | if err := utils.Encoder.Match([]byte(user.Password), []byte(loginAccount.Password)); err != nil {
54 | return Fail(c, -1, "您输入的账号或密码不正确")
55 | }
56 |
57 | if user.TOTPSecret != "" && user.TOTPSecret != "-" {
58 | return Fail(c, 0, "")
59 | }
60 |
61 | token, err := Login(c, loginAccount, user)
62 | if err != nil {
63 | return err
64 | }
65 |
66 | return Success(c, token)
67 | }
68 |
69 | func Login(c echo.Context, loginAccount LoginAccount, user model.User) (token string, err error) {
70 | token = strings.Join([]string{utils.UUID(), utils.UUID(), utils.UUID(), utils.UUID()}, "")
71 |
72 | authorization := Authorization{
73 | Token: token,
74 | Remember: loginAccount.Remember,
75 | User: user,
76 | }
77 |
78 | if authorization.Remember {
79 | // 记住登录有效期两周
80 | global.Cache.Set(token, authorization, RememberEffectiveTime)
81 | } else {
82 | global.Cache.Set(token, authorization, NotRememberEffectiveTime)
83 | }
84 |
85 | // 保存登录日志
86 | loginLog := model.LoginLog{
87 | ID: token,
88 | UserId: user.ID,
89 | ClientIP: c.RealIP(),
90 | ClientUserAgent: c.Request().UserAgent(),
91 | LoginTime: utils.NowJsonTime(),
92 | Remember: authorization.Remember,
93 | }
94 |
95 | if model.CreateNewLoginLog(&loginLog) != nil {
96 | return "", err
97 | }
98 |
99 | // 修改登录状态
100 | model.UpdateUserById(&model.User{Online: true}, user.ID)
101 | return token, nil
102 | }
103 |
104 | func loginWithTotpEndpoint(c echo.Context) error {
105 | var loginAccount LoginAccount
106 | if err := c.Bind(&loginAccount); err != nil {
107 | return err
108 | }
109 |
110 | user, err := model.FindUserByUsername(loginAccount.Username)
111 | if err != nil {
112 | return Fail(c, -1, "您输入的账号或密码不正确")
113 | }
114 |
115 | if err := utils.Encoder.Match([]byte(user.Password), []byte(loginAccount.Password)); err != nil {
116 | return Fail(c, -1, "您输入的账号或密码不正确")
117 | }
118 |
119 | if !totp.Validate(loginAccount.TOTP, user.TOTPSecret) {
120 | return Fail(c, -2, "您的TOTP不匹配")
121 | }
122 |
123 | token, err := Login(c, loginAccount, user)
124 | if err != nil {
125 | return err
126 | }
127 |
128 | return Success(c, token)
129 | }
130 |
131 | func LogoutEndpoint(c echo.Context) error {
132 | token := GetToken(c)
133 | global.Cache.Delete(token)
134 | model.Logout(token)
135 | return Success(c, nil)
136 | }
137 |
138 | func ConfirmTOTPEndpoint(c echo.Context) error {
139 | account, _ := GetCurrentAccount(c)
140 |
141 | var confirmTOTP ConfirmTOTP
142 | if err := c.Bind(&confirmTOTP); err != nil {
143 | return err
144 | }
145 |
146 | if !totp.Validate(confirmTOTP.TOTP, confirmTOTP.Secret) {
147 | return Fail(c, -1, "TOTP 验证失败,请重试")
148 | }
149 |
150 | u := &model.User{
151 | TOTPSecret: confirmTOTP.Secret,
152 | }
153 |
154 | model.UpdateUserById(u, account.ID)
155 |
156 | return Success(c, nil)
157 | }
158 |
159 | func ReloadTOTPEndpoint(c echo.Context) error {
160 | account, _ := GetCurrentAccount(c)
161 |
162 | key, err := totp.NewTOTP(totp.GenerateOpts{
163 | Issuer: c.Request().Host,
164 | AccountName: account.Username,
165 | })
166 | if err != nil {
167 | return Fail(c, -1, err.Error())
168 | }
169 |
170 | qrcode, err := key.Image(200, 200)
171 | if err != nil {
172 | return Fail(c, -1, err.Error())
173 | }
174 |
175 | qrEncode, err := utils.ImageToBase64Encode(qrcode)
176 | if err != nil {
177 | return Fail(c, -1, err.Error())
178 | }
179 |
180 | return Success(c, map[string]string{
181 | "qr": qrEncode,
182 | "secret": key.Secret(),
183 | })
184 | }
185 |
186 | func ResetTOTPEndpoint(c echo.Context) error {
187 | account, _ := GetCurrentAccount(c)
188 | u := &model.User{
189 | TOTPSecret: "-",
190 | }
191 | model.UpdateUserById(u, account.ID)
192 | return Success(c, "")
193 | }
194 |
195 | func ChangePasswordEndpoint(c echo.Context) error {
196 | account, _ := GetCurrentAccount(c)
197 |
198 | var changePassword ChangePassword
199 | if err := c.Bind(&changePassword); err != nil {
200 | return err
201 | }
202 |
203 | if err := utils.Encoder.Match([]byte(account.Password), []byte(changePassword.OldPassword)); err != nil {
204 | return Fail(c, -1, "您输入的原密码不正确")
205 | }
206 |
207 | passwd, err := utils.Encoder.Encode([]byte(changePassword.NewPassword))
208 | if err != nil {
209 | return err
210 | }
211 | u := &model.User{
212 | Password: string(passwd),
213 | }
214 |
215 | model.UpdateUserById(u, account.ID)
216 |
217 | return LogoutEndpoint(c)
218 | }
219 |
220 | type AccountInfo struct {
221 | Id string `json:"id"`
222 | Username string `json:"username"`
223 | Nickname string `json:"nickname"`
224 | Type string `json:"type"`
225 | EnableTotp bool `json:"enableTotp"`
226 | }
227 |
228 | func InfoEndpoint(c echo.Context) error {
229 | account, _ := GetCurrentAccount(c)
230 |
231 | user, err := model.FindUserById(account.ID)
232 | if err != nil {
233 | return err
234 | }
235 |
236 | info := AccountInfo{
237 | Id: user.ID,
238 | Username: user.Username,
239 | Nickname: user.Nickname,
240 | Type: user.Type,
241 | EnableTotp: user.TOTPSecret != "" && user.TOTPSecret != "-",
242 | }
243 | return Success(c, info)
244 | }
245 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | nested "github.com/antonfisher/nested-logrus-formatter"
7 | "github.com/labstack/gommon/log"
8 | "github.com/patrickmn/go-cache"
9 | "github.com/sirupsen/logrus"
10 | "gorm.io/driver/mysql"
11 | "gorm.io/driver/sqlite"
12 | "gorm.io/gorm"
13 | "io"
14 | "next-terminal/pkg/api"
15 | "next-terminal/pkg/config"
16 | "next-terminal/pkg/global"
17 | "next-terminal/pkg/handle"
18 | "next-terminal/pkg/model"
19 | "next-terminal/pkg/utils"
20 | "os"
21 | "strconv"
22 | "time"
23 | )
24 |
25 | const Version = "v0.2.4"
26 |
27 | func main() {
28 | log.Fatal(Run())
29 | }
30 |
31 | func Run() error {
32 |
33 | fmt.Printf(`
34 | _______ __ ___________ .__ .__
35 | \ \ ____ ___ ____/ |_ \__ ___/__________ _____ |__| ____ _____ | |
36 | / | \_/ __ \\ \/ /\ __\ | |_/ __ \_ __ \/ \| |/ \\__ \ | |
37 | / | \ ___/ > < | | | |\ ___/| | \/ Y Y \ | | \/ __ \| |__
38 | \____|__ /\___ >__/\_ \ |__| |____| \___ >__| |__|_| /__|___| (____ /____/
39 | \/ \/ \/ \/ \/ \/ \/ ` + Version + "\n\n")
40 |
41 | var err error
42 | //logrus.SetReportCaller(true)
43 | logrus.SetLevel(logrus.DebugLevel)
44 | logrus.SetFormatter(&nested.Formatter{
45 | HideKeys: true,
46 | FieldsOrder: []string{"component", "category"},
47 | })
48 |
49 | writer1 := &bytes.Buffer{}
50 | writer2 := os.Stdout
51 | writer3, err := os.OpenFile("next-terminal.log", os.O_WRONLY|os.O_CREATE, 0755)
52 | if err != nil {
53 | log.Fatalf("create file log.txt failed: %v", err)
54 | }
55 |
56 | logrus.SetOutput(io.MultiWriter(writer1, writer2, writer3))
57 |
58 | global.Config, err = config.SetupConfig()
59 | if err != nil {
60 | return err
61 | }
62 |
63 | fmt.Printf("当前数据库模式为:%v\n", global.Config.DB)
64 | if global.Config.DB == "mysql" {
65 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
66 | global.Config.Mysql.Username,
67 | global.Config.Mysql.Password,
68 | global.Config.Mysql.Hostname,
69 | global.Config.Mysql.Port,
70 | global.Config.Mysql.Database,
71 | )
72 | global.DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
73 | //Logger: logger.Default.LogMode(logger.Info),
74 | })
75 | } else {
76 | global.DB, err = gorm.Open(sqlite.Open(global.Config.Sqlite.File), &gorm.Config{
77 | //Logger: logger.Default.LogMode(logger.Info),
78 | })
79 | }
80 |
81 | if err != nil {
82 | logrus.Errorf("连接数据库异常:%v", err.Error())
83 | return err
84 | }
85 |
86 | if err := global.DB.AutoMigrate(&model.User{}); err != nil {
87 | return err
88 | }
89 |
90 | users := model.FindAllUser()
91 | if len(users) == 0 {
92 |
93 | initPassword := "admin"
94 | var pass []byte
95 | if pass, err = utils.Encoder.Encode([]byte(initPassword)); err != nil {
96 | return err
97 | }
98 |
99 | user := model.User{
100 | ID: utils.UUID(),
101 | Username: "admin",
102 | Password: string(pass),
103 | Nickname: "超级管理员",
104 | Type: model.TypeAdmin,
105 | Created: utils.NowJsonTime(),
106 | }
107 | if err := model.CreateNewUser(&user); err != nil {
108 | return err
109 | }
110 | logrus.Infof("初始用户创建成功,账号:「%v」密码:「%v」", user.Username, initPassword)
111 | } else {
112 | for i := range users {
113 | // 修正默认用户类型为管理员
114 | if users[i].Type == "" {
115 | user := model.User{
116 | Type: model.TypeAdmin,
117 | }
118 | model.UpdateUserById(&user, users[i].ID)
119 | logrus.Infof("自动修正用户「%v」ID「%v」类型为管理员", users[i].Nickname, users[i].ID)
120 | }
121 | }
122 | }
123 |
124 | if err := global.DB.AutoMigrate(&model.Asset{}); err != nil {
125 | return err
126 | }
127 | if err := global.DB.AutoMigrate(&model.AssetAttribute{}); err != nil {
128 | return err
129 | }
130 | if err := global.DB.AutoMigrate(&model.Session{}); err != nil {
131 | return err
132 | }
133 | if err := global.DB.AutoMigrate(&model.Command{}); err != nil {
134 | return err
135 | }
136 | if err := global.DB.AutoMigrate(&model.Credential{}); err != nil {
137 | return err
138 | }
139 | if err := global.DB.AutoMigrate(&model.Property{}); err != nil {
140 | return err
141 | }
142 | if err := global.DB.AutoMigrate(&model.ResourceSharer{}); err != nil {
143 | return err
144 | }
145 | if err := global.DB.AutoMigrate(&model.UserGroup{}); err != nil {
146 | return err
147 | }
148 | if err := global.DB.AutoMigrate(&model.UserGroupMember{}); err != nil {
149 | return err
150 | }
151 | if err := global.DB.AutoMigrate(&model.LoginLog{}); err != nil {
152 | return err
153 | }
154 | if err := global.DB.AutoMigrate(&model.Num{}); err != nil {
155 | return err
156 | }
157 |
158 | if len(model.FindAllTemp()) == 0 {
159 | for i := 0; i <= 30; i++ {
160 | if err := model.CreateNewTemp(&model.Num{I: strconv.Itoa(i)}); err != nil {
161 | return err
162 | }
163 | }
164 | }
165 |
166 | // 配置缓存器
167 | global.Cache = cache.New(5*time.Minute, 10*time.Minute)
168 | global.Cache.OnEvicted(func(key string, value interface{}) {
169 | logrus.Debugf("用户Token「%v」过期", key)
170 | model.Logout(key)
171 | })
172 | global.Store = global.NewStore()
173 |
174 | loginLogs, err := model.FindAliveLoginLogs()
175 | if err != nil {
176 | return err
177 | }
178 |
179 | for i := range loginLogs {
180 | loginLog := loginLogs[i]
181 | token := loginLog.ID
182 | user, err := model.FindUserById(loginLog.UserId)
183 | if err != nil {
184 | logrus.Debugf("用户「%v」获取失败,忽略", loginLog.UserId)
185 | continue
186 | }
187 |
188 | authorization := api.Authorization{
189 | Token: token,
190 | Remember: loginLog.Remember,
191 | User: user,
192 | }
193 |
194 | if authorization.Remember {
195 | // 记住登录有效期两周
196 | global.Cache.Set(token, authorization, api.RememberEffectiveTime)
197 | } else {
198 | global.Cache.Set(token, authorization, api.NotRememberEffectiveTime)
199 | }
200 | logrus.Debugf("重新加载用户「%v」授权Token「%v」到缓存", user.Nickname, token)
201 | }
202 |
203 | e := api.SetupRoutes()
204 | if err := handle.InitProperties(); err != nil {
205 | return err
206 | }
207 | // 启动定时任务
208 | go handle.RunTicker()
209 | go handle.RunDataFix()
210 |
211 | if global.Config.Server.Cert != "" && global.Config.Server.Key != "" {
212 | return e.StartTLS(global.Config.Server.Addr, global.Config.Server.Cert, global.Config.Server.Key)
213 | } else {
214 | return e.Start(global.Config.Server.Addr)
215 | }
216 |
217 | }
218 |
--------------------------------------------------------------------------------
/pkg/api/routes.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "next-terminal/pkg/global"
6 | "next-terminal/pkg/log"
7 | "next-terminal/pkg/model"
8 |
9 | "github.com/labstack/echo/v4"
10 | "github.com/labstack/echo/v4/middleware"
11 | )
12 |
13 | const Token = "X-Auth-Token"
14 |
15 | func SetupRoutes() *echo.Echo {
16 |
17 | e := echo.New()
18 | e.HideBanner = true
19 | e.Logger = log.GetEchoLogger()
20 |
21 | e.File("/", "web/build/index.html")
22 | e.File("/logo.svg", "web/build/logo.svg")
23 | e.File("/favicon.ico", "web/build/favicon.ico")
24 | e.Static("/static", "web/build/static")
25 |
26 | e.Use(middleware.Recover())
27 | e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
28 | Skipper: middleware.DefaultSkipper,
29 | AllowOrigins: []string{"*"},
30 | AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
31 | }))
32 | e.Use(ErrorHandler)
33 | e.Use(Auth)
34 |
35 | e.POST("/login", LoginEndpoint)
36 | e.POST("/loginWithTotp", loginWithTotpEndpoint)
37 |
38 | e.GET("/tunnel", TunEndpoint)
39 | e.GET("/ssh", SSHEndpoint)
40 |
41 | e.POST("/logout", LogoutEndpoint)
42 | e.POST("/change-password", ChangePasswordEndpoint)
43 | e.GET("/reload-totp", ReloadTOTPEndpoint)
44 | e.POST("/reset-totp", ResetTOTPEndpoint)
45 | e.POST("/confirm-totp", ConfirmTOTPEndpoint)
46 | e.GET("/info", InfoEndpoint)
47 |
48 | users := e.Group("/users")
49 | {
50 | users.POST("", Admin(UserCreateEndpoint))
51 | users.GET("/paging", UserPagingEndpoint)
52 | users.PUT("/:id", Admin(UserUpdateEndpoint))
53 | users.DELETE("/:id", Admin(UserDeleteEndpoint))
54 | users.GET("/:id", Admin(UserGetEndpoint))
55 | users.POST("/:id/change-password", Admin(UserChangePasswordEndpoint))
56 | users.POST("/:id/reset-totp", Admin(UserResetTotpEndpoint))
57 | }
58 |
59 | userGroups := e.Group("/user-groups", Admin)
60 | {
61 | userGroups.POST("", UserGroupCreateEndpoint)
62 | userGroups.GET("/paging", UserGroupPagingEndpoint)
63 | userGroups.PUT("/:id", UserGroupUpdateEndpoint)
64 | userGroups.DELETE("/:id", UserGroupDeleteEndpoint)
65 | userGroups.GET("/:id", UserGroupGetEndpoint)
66 | //userGroups.POST("/:id/members", UserGroupAddMembersEndpoint)
67 | //userGroups.DELETE("/:id/members/:memberId", UserGroupDelMembersEndpoint)
68 | }
69 |
70 | assets := e.Group("/assets", Auth)
71 | {
72 | assets.GET("", AssetAllEndpoint)
73 | assets.POST("", AssetCreateEndpoint)
74 | assets.GET("/paging", AssetPagingEndpoint)
75 | assets.POST("/:id/tcping", AssetTcpingEndpoint)
76 | assets.PUT("/:id", AssetUpdateEndpoint)
77 | assets.DELETE("/:id", AssetDeleteEndpoint)
78 | assets.GET("/:id", AssetGetEndpoint)
79 | assets.GET("/:id/attributes", AssetGetAttributeEndpoint)
80 | assets.POST("/:id/change-owner", Admin(AssetChangeOwnerEndpoint))
81 | }
82 |
83 | e.GET("/tags", AssetTagsEndpoint)
84 |
85 | commands := e.Group("/commands")
86 | {
87 | commands.GET("/paging", CommandPagingEndpoint)
88 | commands.POST("", CommandCreateEndpoint)
89 | commands.PUT("/:id", CommandUpdateEndpoint)
90 | commands.DELETE("/:id", CommandDeleteEndpoint)
91 | commands.GET("/:id", CommandGetEndpoint)
92 | commands.POST("/:id/change-owner", Admin(CommandChangeOwnerEndpoint))
93 | }
94 |
95 | credentials := e.Group("/credentials")
96 | {
97 | credentials.GET("", CredentialAllEndpoint)
98 | credentials.GET("/paging", CredentialPagingEndpoint)
99 | credentials.POST("", CredentialCreateEndpoint)
100 | credentials.PUT("/:id", CredentialUpdateEndpoint)
101 | credentials.DELETE("/:id", CredentialDeleteEndpoint)
102 | credentials.GET("/:id", CredentialGetEndpoint)
103 | credentials.POST("/:id/change-owner", Admin(CredentialChangeOwnerEndpoint))
104 | }
105 |
106 | sessions := e.Group("/sessions")
107 | {
108 | sessions.POST("", SessionCreateEndpoint)
109 | sessions.GET("/paging", SessionPagingEndpoint)
110 | sessions.POST("/:id/connect", SessionConnectEndpoint)
111 | sessions.POST("/:id/disconnect", Admin(SessionDisconnectEndpoint))
112 | sessions.POST("/:id/resize", SessionResizeEndpoint)
113 | sessions.GET("/:id/ls", SessionLsEndpoint)
114 | sessions.GET("/:id/download", SessionDownloadEndpoint)
115 | sessions.POST("/:id/upload", SessionUploadEndpoint)
116 | sessions.POST("/:id/mkdir", SessionMkDirEndpoint)
117 | sessions.POST("/:id/rm", SessionRmEndpoint)
118 | sessions.POST("/:id/rename", SessionRenameEndpoint)
119 | sessions.DELETE("/:id", SessionDeleteEndpoint)
120 | sessions.GET("/:id/recording", SessionRecordingEndpoint)
121 | }
122 |
123 | resourceSharers := e.Group("/resource-sharers")
124 | {
125 | resourceSharers.GET("/sharers", RSGetSharersEndPoint)
126 | resourceSharers.POST("/overwrite-sharers", RSOverwriteSharersEndPoint)
127 | resourceSharers.POST("/remove-resources", Admin(ResourceRemoveByUserIdAssignEndPoint))
128 | resourceSharers.POST("/add-resources", Admin(ResourceAddByUserIdAssignEndPoint))
129 | }
130 |
131 | loginLogs := e.Group("login-logs", Admin)
132 | {
133 | loginLogs.GET("/paging", LoginLogPagingEndpoint)
134 | loginLogs.DELETE("/:id", LoginLogDeleteEndpoint)
135 | }
136 |
137 | e.GET("/properties", PropertyGetEndpoint)
138 | e.PUT("/properties", Admin(PropertyUpdateEndpoint))
139 |
140 | e.GET("/overview/counter", OverviewCounterEndPoint)
141 | e.GET("/overview/sessions", OverviewSessionPoint)
142 |
143 | return e
144 | }
145 |
146 | type H map[string]interface{}
147 |
148 | func Fail(c echo.Context, code int, message string) error {
149 | return c.JSON(200, H{
150 | "code": code,
151 | "message": message,
152 | })
153 | }
154 |
155 | func Success(c echo.Context, data interface{}) error {
156 | return c.JSON(200, H{
157 | "code": 1,
158 | "message": "success",
159 | "data": data,
160 | })
161 | }
162 |
163 | func NotFound(c echo.Context, message string) error {
164 | return c.JSON(200, H{
165 | "code": -1,
166 | "message": message,
167 | })
168 | }
169 |
170 | func GetToken(c echo.Context) string {
171 | token := c.Request().Header.Get(Token)
172 | if len(token) > 0 {
173 | return token
174 | }
175 | return c.QueryParam(Token)
176 | }
177 |
178 | func GetCurrentAccount(c echo.Context) (model.User, bool) {
179 | token := GetToken(c)
180 | get, b := global.Cache.Get(token)
181 | if b {
182 | return get.(Authorization).User, true
183 | }
184 | return model.User{}, false
185 | }
186 |
187 | func HasPermission(c echo.Context, owner string) bool {
188 | // 检测是否登录
189 | account, found := GetCurrentAccount(c)
190 | if !found {
191 | return false
192 | }
193 | // 检测是否为管理人员
194 | if model.TypeAdmin == account.Type {
195 | return true
196 | }
197 | // 检测是否为所有者
198 | if owner == account.ID {
199 | return true
200 | }
201 | return false
202 | }
203 |
--------------------------------------------------------------------------------
/pkg/api/ssh.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gorilla/websocket"
6 | "github.com/labstack/echo/v4"
7 | "github.com/sirupsen/logrus"
8 | "net/http"
9 | "next-terminal/pkg/global"
10 | "next-terminal/pkg/guacd"
11 | "next-terminal/pkg/model"
12 | "next-terminal/pkg/term"
13 | "next-terminal/pkg/utils"
14 | "path"
15 | "strconv"
16 | "time"
17 | )
18 |
19 | var UpGrader = websocket.Upgrader{
20 | CheckOrigin: func(r *http.Request) bool {
21 | return true
22 | },
23 | Subprotocols: []string{"guacamole"},
24 | }
25 |
26 | const (
27 | Connected = "connected"
28 | Data = "data"
29 | Resize = "resize"
30 | Closed = "closed"
31 | )
32 |
33 | type Message struct {
34 | Type string `json:"type"`
35 | Content string `json:"content"`
36 | }
37 |
38 | type WindowSize struct {
39 | Cols int `json:"cols"`
40 | Rows int `json:"rows"`
41 | }
42 |
43 | func SSHEndpoint(c echo.Context) (err error) {
44 | ws, err := UpGrader.Upgrade(c.Response().Writer, c.Request(), nil)
45 | if err != nil {
46 | logrus.Errorf("升级为WebSocket协议失败:%v", err.Error())
47 | return err
48 | }
49 |
50 | sessionId := c.QueryParam("sessionId")
51 | cols, _ := strconv.Atoi(c.QueryParam("cols"))
52 | rows, _ := strconv.Atoi(c.QueryParam("rows"))
53 |
54 | session, err := model.FindSessionById(sessionId)
55 | if err != nil {
56 | msg := Message{
57 | Type: Closed,
58 | Content: "get sshSession error." + err.Error(),
59 | }
60 | _ = WriteMessage(ws, msg)
61 | return err
62 | }
63 |
64 | user, _ := GetCurrentAccount(c)
65 | if model.TypeUser == user.Type {
66 | // 检测是否有访问权限
67 | assetIds, err := model.FindAssetIdsByUserId(user.ID)
68 | if err != nil {
69 | return err
70 | }
71 |
72 | if !utils.Contains(assetIds, session.AssetId) {
73 | msg := Message{
74 | Type: Closed,
75 | Content: "您没有权限访问此资产",
76 | }
77 | return WriteMessage(ws, msg)
78 | }
79 | }
80 |
81 | var (
82 | username = session.Username
83 | password = session.Password
84 | privateKey = session.PrivateKey
85 | passphrase = session.Passphrase
86 | ip = session.IP
87 | port = session.Port
88 | )
89 |
90 | recording := ""
91 | propertyMap := model.FindAllPropertiesMap()
92 | if propertyMap[guacd.EnableRecording] == "true" {
93 | recording = path.Join(propertyMap[guacd.RecordingPath], sessionId, "recording.cast")
94 | }
95 |
96 | tun := global.Tun{
97 | Protocol: session.Protocol,
98 | Mode: session.Mode,
99 | WebSocket: ws,
100 | }
101 |
102 | if session.ConnectionId != "" {
103 | // 监控会话
104 | observable, ok := global.Store.Get(sessionId)
105 | if ok {
106 | observers := append(observable.Observers, tun)
107 | observable.Observers = observers
108 | global.Store.Set(sessionId, observable)
109 | logrus.Debugf("加入会话%v,当前观察者数量为:%v", session.ConnectionId, len(observers))
110 | }
111 |
112 | return err
113 | }
114 |
115 | nextTerminal, err := term.NewNextTerminal(ip, port, username, password, privateKey, passphrase, rows, cols, recording)
116 |
117 | if err != nil {
118 | logrus.Errorf("创建SSH客户端失败:%v", err.Error())
119 | msg := Message{
120 | Type: Closed,
121 | Content: err.Error(),
122 | }
123 | err := WriteMessage(ws, msg)
124 | return err
125 | }
126 | tun.NextTerminal = nextTerminal
127 |
128 | var observers []global.Tun
129 | observable := global.Observable{
130 | Subject: &tun,
131 | Observers: observers,
132 | }
133 |
134 | global.Store.Set(sessionId, &observable)
135 |
136 | sess := model.Session{
137 | ConnectionId: sessionId,
138 | Width: cols,
139 | Height: rows,
140 | Status: model.Connecting,
141 | Recording: recording,
142 | }
143 | // 创建新会话
144 | logrus.Debugf("创建新会话 %v", sess.ConnectionId)
145 | if err := model.UpdateSessionById(&sess, sessionId); err != nil {
146 | return err
147 | }
148 |
149 | msg := Message{
150 | Type: Connected,
151 | Content: "",
152 | }
153 | _ = WriteMessage(ws, msg)
154 |
155 | quitChan := make(chan bool)
156 |
157 | go ReadMessage(nextTerminal, quitChan, ws)
158 |
159 | for {
160 | _, message, err := ws.ReadMessage()
161 | if err != nil {
162 | // web socket会话关闭后主动关闭ssh会话
163 | CloseSessionById(sessionId, Normal, "正常退出")
164 | quitChan <- true
165 | quitChan <- true
166 | break
167 | }
168 |
169 | var msg Message
170 | err = json.Unmarshal(message, &msg)
171 | if err != nil {
172 | logrus.Warnf("解析Json失败: %v, 原始字符串:%v", err, string(message))
173 | continue
174 | }
175 |
176 | switch msg.Type {
177 | case Resize:
178 | var winSize WindowSize
179 | err = json.Unmarshal([]byte(msg.Content), &winSize)
180 | if err != nil {
181 | logrus.Warnf("解析SSH会话窗口大小失败: %v", err)
182 | continue
183 | }
184 | if err := nextTerminal.WindowChange(winSize.Rows, winSize.Cols); err != nil {
185 | logrus.Warnf("更改SSH会话窗口大小失败: %v", err)
186 | continue
187 | }
188 | case Data:
189 | _, err = nextTerminal.Write([]byte(msg.Content))
190 | if err != nil {
191 | logrus.Debugf("SSH会话写入失败: %v", err)
192 | msg := Message{
193 | Type: Closed,
194 | Content: "the remote connection is closed.",
195 | }
196 | _ = WriteMessage(ws, msg)
197 | }
198 | }
199 |
200 | }
201 | return err
202 | }
203 |
204 | func ReadMessage(nextTerminal *term.NextTerminal, quitChan chan bool, ws *websocket.Conn) {
205 |
206 | var quit bool
207 | for {
208 | select {
209 | case quit = <-quitChan:
210 | if quit {
211 | return
212 | }
213 | default:
214 | p, n, err := nextTerminal.Read()
215 | if err != nil {
216 | msg := Message{
217 | Type: Closed,
218 | Content: err.Error(),
219 | }
220 | _ = WriteMessage(ws, msg)
221 | }
222 | if n > 0 {
223 | s := string(p)
224 | msg := Message{
225 | Type: Data,
226 | Content: s,
227 | }
228 | _ = WriteMessage(ws, msg)
229 | }
230 | time.Sleep(time.Duration(10) * time.Millisecond)
231 | }
232 | }
233 | }
234 |
235 | func WriteMessage(ws *websocket.Conn, msg Message) error {
236 | message, err := json.Marshal(msg)
237 | if err != nil {
238 | return err
239 | }
240 | WriteByteMessage(ws, message)
241 | return err
242 | }
243 |
244 | func WriteByteMessage(ws *websocket.Conn, p []byte) {
245 | err := ws.WriteMessage(websocket.TextMessage, p)
246 | if err != nil {
247 | logrus.Debugf("write: %v", err)
248 | }
249 | }
250 |
251 | func CreateNextTerminalBySession(session model.Session) (*term.NextTerminal, error) {
252 | var (
253 | username = session.Username
254 | password = session.Password
255 | privateKey = session.PrivateKey
256 | passphrase = session.Passphrase
257 | ip = session.IP
258 | port = session.Port
259 | )
260 | return term.NewNextTerminal(ip, port, username, password, privateKey, passphrase, 10, 10, "")
261 | }
262 |
--------------------------------------------------------------------------------
/web/src/utils/utils.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Link} from "react-router-dom";
3 |
4 | export const sleep = function (ms) {
5 | return new Promise(resolve => setTimeout(resolve, ms))
6 | }
7 |
8 | export const getToken = function () {
9 | return localStorage.getItem('X-Auth-Token');
10 | }
11 |
12 | export const getHeaders = function () {
13 | return {'X-Auth-Token': getToken()};
14 | }
15 |
16 | export const itemRender = function (route, params, routes, paths) {
17 | const last = routes.indexOf(route) === routes.length - 1;
18 | return last ? (
19 | {route.breadcrumbName}
20 | ) : (
21 | {route.breadcrumbName}
22 | );
23 | }
24 |
25 | export const formatDate = function (time, format) {
26 | let date = new Date(time);
27 | let o = {
28 | "M+": date.getMonth() + 1,
29 | "d+": date.getDate(),
30 | "h+": date.getHours(),
31 | "m+": date.getMinutes(),
32 | "s+": date.getSeconds(),
33 | "q+": Math.floor((date.getMonth() + 3) / 3), //quarter
34 | "S": date.getMilliseconds() //millisecond
35 | };
36 | if (/(y+)/.test(format)) {
37 | format = format.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length));
38 | }
39 | for (let k in o) {
40 | if (new RegExp("(" + k + ")").test(format)) {
41 | format = format.replace(RegExp.$1, RegExp.$1.length === 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length));
42 | }
43 | }
44 | return format;
45 | };
46 |
47 | export const isLeapYear = function (year) {
48 | return (year % 4 === 0 && year % 100 !== 0) || (year % 100 === 0 && year % 400 === 0);
49 | };
50 |
51 | export const groupBy = (list, fn) => {
52 | const groups = {};
53 |
54 | list.forEach(x => {
55 | let groupKey = fn(x).toString();
56 | groups[groupKey] = groups[groupKey] || [];
57 | groups[groupKey].push(x);
58 | });
59 |
60 | return groups;
61 | };
62 |
63 | export const cloneObj = (obj, ignoreFields) => {
64 | let str, newObj = obj.constructor === Array ? [] : {};
65 | if (typeof obj !== 'object') {
66 | return;
67 | } else if (window.JSON) {
68 | str = JSON.stringify(obj);
69 | newObj = JSON.parse(str);
70 | } else {
71 | for (const i in obj) {
72 | newObj[i] = typeof obj[i] === 'object' ? cloneObj(obj[i]) : obj[i];
73 | }
74 | }
75 | return newObj;
76 | };
77 |
78 | export function download(url) {
79 | let aElement = document.createElement('a');
80 | aElement.setAttribute('download', '');
81 | // aElement.setAttribute('target', '_blank');
82 | aElement.setAttribute('href', url);
83 | aElement.click();
84 | }
85 |
86 | export function differTime(start, end) {
87 | //总秒数
88 | let millisecond = Math.floor((end.getTime() - start.getTime()) / 1000);
89 |
90 | //总天数
91 | let allDay = Math.floor(millisecond / (24 * 60 * 60));
92 |
93 | //注意同getYear的区别
94 | let startYear = start.getFullYear();
95 | let currentYear = end.getFullYear();
96 |
97 | //闰年个数
98 | let leapYear = 0;
99 | for (let i = startYear; i < currentYear; i++) {
100 | if (isLeapYear(i)) {
101 | leapYear++;
102 | }
103 | }
104 |
105 | //年数
106 | const year = Math.floor((allDay - leapYear * 366) / 365 + leapYear);
107 |
108 | //天数
109 | let day;
110 | if (allDay > 366) {
111 | day = (allDay - leapYear * 366) % 365;
112 | } else {
113 | day = allDay;
114 | }
115 | //取余数(秒)
116 | const remainder = millisecond % (24 * 60 * 60);
117 | //小时数
118 | const hour = Math.floor(remainder / (60 * 60));
119 | //分钟数
120 | const minute = Math.floor(remainder % (60 * 60) / 60);
121 | //秒数
122 | const second = remainder - hour * 60 * 60 - minute * 60;
123 |
124 | let show = '';
125 | if (year > 0) {
126 | show += year + '年';
127 | }
128 |
129 | if (day > 0) {
130 | show += day + '天';
131 | }
132 |
133 | if (hour > 0) {
134 | show += hour + '小时';
135 | }
136 |
137 | if (minute > 0) {
138 | show += minute + '分钟';
139 | }
140 |
141 | if (second > 0) {
142 | show += second + '秒';
143 | }
144 | return show;
145 | }
146 |
147 | export const isEmpty = (text) => {
148 | return text === undefined || text == null || text.length === 0;
149 | }
150 |
151 | export const NT_PACKAGE = () => {
152 | const _package = require("../../package.json");
153 | const name = _package.name;
154 | const version = _package.version;
155 | return {
156 | name: name,
157 | version: version
158 | }
159 | }
160 |
161 | export function compare(p) {
162 | return function (m, n) {
163 | const a = m[p];
164 | const b = n[p];
165 | if (a > b) {
166 | return 1;
167 | }
168 | if (a < b) {
169 | return -1;
170 | }
171 | return 0;
172 | }
173 | }
174 |
175 | export function difference(a, b) {
176 | let aSet = new Set(a)
177 | let bSet = new Set(b)
178 | return Array.from(new Set(a.concat(b).filter(v => !aSet.has(v) || !bSet.has(v))))
179 | }
180 |
181 | export function requestFullScreen(element) {
182 | // 判断各种浏览器,找到正确的方法
183 | const requestMethod = element.requestFullScreen || //W3C
184 | element.webkitRequestFullScreen || //FireFox
185 | element.mozRequestFullScreen || //Chrome等
186 | element.msRequestFullScreen; //IE11
187 | if (requestMethod) {
188 | requestMethod.call(element);
189 | } else if (typeof window.ActiveXObject !== "undefined") { //for Internet Explorer
190 | const wScript = new window.ActiveXObject("WScript.Shell");
191 | if (wScript !== null) {
192 | wScript.SendKeys("{F11}");
193 | }
194 | }
195 | }
196 |
197 | //退出全屏 判断浏览器种类
198 | export function exitFull() {
199 | // 判断各种浏览器,找到正确的方法
200 | const exitMethod = document.exitFullscreen || //W3C
201 | document.mozCancelFullScreen || //FireFox
202 | document.webkitExitFullscreen || //Chrome等
203 | document.webkitExitFullscreen; //IE11
204 | if (exitMethod) {
205 | exitMethod.call(document);
206 | } else if (typeof window.ActiveXObject !== "undefined") { //for Internet Explorer
207 | const wScript = new window.ActiveXObject("WScript.Shell");
208 | if (wScript !== null) {
209 | wScript.SendKeys("{F11}");
210 | }
211 | }
212 | }
213 |
214 | export function renderSize(value) {
215 | if (null == value || value === '' || value === 0) {
216 | return "0 Bytes";
217 | }
218 | const unitArr = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
219 | let srcSize = parseFloat(value);
220 | let index = Math.floor(Math.log(srcSize) / Math.log(1024));
221 | let size = srcSize / Math.pow(1024, index);
222 | size = size.toFixed(2);
223 | return size + ' ' + unitArr[index];
224 | }
225 |
226 | export function getFileName(fullFileName){
227 | return fullFileName.substring(fullFileName.lastIndexOf('/') + 1, fullFileName.length);
228 | }
--------------------------------------------------------------------------------
/pkg/model/session.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/utils"
6 | "time"
7 | )
8 |
9 | const (
10 | NoConnect = "no_connect"
11 | Connecting = "connecting"
12 | Connected = "connected"
13 | Disconnected = "disconnected"
14 | )
15 |
16 | const (
17 | Guacd = "guacd"
18 | Naive = "naive"
19 | )
20 |
21 | type Session struct {
22 | ID string `gorm:"primary_key" json:"id"`
23 | Protocol string `json:"protocol"`
24 | IP string `json:"ip"`
25 | Port int `json:"port"`
26 | ConnectionId string `json:"connectionId"`
27 | AssetId string `gorm:"index" json:"assetId"`
28 | Username string `json:"username"`
29 | Password string `json:"password"`
30 | Creator string `gorm:"index" json:"creator"`
31 | ClientIP string `json:"clientIp"`
32 | Width int `json:"width"`
33 | Height int `json:"height"`
34 | Status string `gorm:"index" json:"status"`
35 | Recording string `json:"recording"`
36 | PrivateKey string `json:"privateKey"`
37 | Passphrase string `json:"passphrase"`
38 | Code int `json:"code"`
39 | Message string `json:"message"`
40 | ConnectedTime utils.JsonTime `json:"connectedTime"`
41 | DisconnectedTime utils.JsonTime `json:"disconnectedTime"`
42 | Mode string `json:"mode"`
43 | }
44 |
45 | func (r *Session) TableName() string {
46 | return "sessions"
47 | }
48 |
49 | type SessionVo struct {
50 | ID string `json:"id"`
51 | Protocol string `json:"protocol"`
52 | IP string `json:"ip"`
53 | Port int `json:"port"`
54 | Username string `json:"username"`
55 | ConnectionId string `json:"connectionId"`
56 | AssetId string `json:"assetId"`
57 | Creator string `json:"creator"`
58 | ClientIP string `json:"clientIp"`
59 | Width int `json:"width"`
60 | Height int `json:"height"`
61 | Status string `json:"status"`
62 | Recording string `json:"recording"`
63 | ConnectedTime utils.JsonTime `json:"connectedTime"`
64 | DisconnectedTime utils.JsonTime `json:"disconnectedTime"`
65 | AssetName string `json:"assetName"`
66 | CreatorName string `json:"creatorName"`
67 | Code int `json:"code"`
68 | Message string `json:"message"`
69 | Mode string `json:"mode"`
70 | }
71 |
72 | func FindPageSession(pageIndex, pageSize int, status, userId, clientIp, assetId, protocol string) (results []SessionVo, total int64, err error) {
73 |
74 | db := global.DB
75 | var params []interface{}
76 |
77 | params = append(params, status)
78 |
79 | itemSql := "SELECT s.id,s.mode, s.protocol,s.recording, s.connection_id, s.asset_id, s.creator, s.client_ip, s.width, s.height, s.ip, s.port, s.username, s.status, s.connected_time, s.disconnected_time,s.code, s.message, a.name AS asset_name, u.nickname AS creator_name FROM sessions s LEFT JOIN assets a ON s.asset_id = a.id LEFT JOIN users u ON s.creator = u.id WHERE s.STATUS = ? "
80 | countSql := "select count(*) from sessions as s where s.status = ? "
81 |
82 | if len(userId) > 0 {
83 | itemSql += " and s.creator = ?"
84 | countSql += " and s.creator = ?"
85 | params = append(params, userId)
86 | }
87 |
88 | if len(clientIp) > 0 {
89 | itemSql += " and s.client_ip like ?"
90 | countSql += " and s.client_ip like ?"
91 | params = append(params, "%"+clientIp+"%")
92 | }
93 |
94 | if len(assetId) > 0 {
95 | itemSql += " and s.asset_id = ?"
96 | countSql += " and s.asset_id = ?"
97 | params = append(params, assetId)
98 | }
99 |
100 | if len(protocol) > 0 {
101 | itemSql += " and s.protocol = ?"
102 | countSql += " and s.protocol = ?"
103 | params = append(params, protocol)
104 | }
105 |
106 | params = append(params, (pageIndex-1)*pageSize, pageSize)
107 | itemSql += " order by s.connected_time desc LIMIT ?, ?"
108 |
109 | db.Raw(countSql, params...).Scan(&total)
110 |
111 | err = db.Raw(itemSql, params...).Scan(&results).Error
112 |
113 | if results == nil {
114 | results = make([]SessionVo, 0)
115 | }
116 | return
117 | }
118 |
119 | func FindSessionByStatus(status string) (o []Session, err error) {
120 | err = global.DB.Where("status = ?", status).Find(&o).Error
121 | return
122 | }
123 |
124 | func FindSessionByStatusIn(statuses []string) (o []Session, err error) {
125 | err = global.DB.Where("status in ?", statuses).Find(&o).Error
126 | return
127 | }
128 |
129 | func CreateNewSession(o *Session) (err error) {
130 | err = global.DB.Create(o).Error
131 | return
132 | }
133 |
134 | func FindSessionById(id string) (o Session, err error) {
135 | err = global.DB.Where("id = ?", id).First(&o).Error
136 | return
137 | }
138 |
139 | func FindSessionByConnectionId(connectionId string) (o Session, err error) {
140 | err = global.DB.Where("connection_id = ?", connectionId).First(&o).Error
141 | return
142 | }
143 |
144 | func UpdateSessionById(o *Session, id string) error {
145 | o.ID = id
146 | return global.DB.Updates(o).Error
147 | }
148 |
149 | func UpdateSessionWindowSizeById(width, height int, id string) error {
150 | session := Session{}
151 | session.Width = width
152 | session.Height = height
153 |
154 | return UpdateSessionById(&session, id)
155 | }
156 |
157 | func DeleteSessionById(id string) {
158 | global.DB.Where("id = ?", id).Delete(&Session{})
159 | }
160 |
161 | func DeleteSessionByStatus(status string) {
162 | global.DB.Where("status = ?", status).Delete(&Session{})
163 | }
164 |
165 | func CountOnlineSession() (total int64, err error) {
166 | err = global.DB.Where("status = ?", Connected).Find(&Session{}).Count(&total).Error
167 | return
168 | }
169 |
170 | type D struct {
171 | Day string `json:"day"`
172 | Count int `json:"count"`
173 | Protocol string `json:"protocol"`
174 | }
175 |
176 | func CountSessionByDay(day int) (results []D, err error) {
177 |
178 | today := time.Now().Format("20060102")
179 | sql := "select t1.`day`, count(t2.id) as count\nfrom (\n SELECT @date := DATE_ADD(@date, INTERVAL - 1 DAY) day\n FROM (SELECT @date := DATE_ADD('" + today + "', INTERVAL + 1 DAY) FROM nums) as t0\n LIMIT ?\n )\n as t1\n left join\n (\n select DATE(s.connected_time) as day, s.id\n from sessions as s\n WHERE protocol = ? and DATE(connected_time) <= '" + today + "'\n AND DATE(connected_time) > DATE_SUB('" + today + "', INTERVAL ? DAY)\n ) as t2 on t1.day = t2.day\ngroup by t1.day"
180 |
181 | protocols := []string{"rdp", "ssh", "vnc", "telnet"}
182 |
183 | for i := range protocols {
184 | var result []D
185 | err = global.DB.Raw(sql, day, protocols[i], day).Scan(&result).Error
186 | if err != nil {
187 | return nil, err
188 | }
189 | for j := range result {
190 | result[j].Protocol = protocols[i]
191 | }
192 | results = append(results, result...)
193 | }
194 |
195 | return
196 | }
197 |
--------------------------------------------------------------------------------
/pkg/model/asset.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "next-terminal/pkg/global"
5 | "next-terminal/pkg/utils"
6 | "strings"
7 | )
8 |
9 | type Asset struct {
10 | ID string `gorm:"primary_key " json:"id"`
11 | Name string `json:"name"`
12 | IP string `json:"ip"`
13 | Protocol string `json:"protocol"`
14 | Port int `json:"port"`
15 | AccountType string `json:"accountType"`
16 | Username string `json:"username"`
17 | Password string `json:"password"`
18 | CredentialId string `gorm:"index" json:"credentialId"`
19 | PrivateKey string `json:"privateKey"`
20 | Passphrase string `json:"passphrase"`
21 | Description string `json:"description"`
22 | Active bool `json:"active"`
23 | Created utils.JsonTime `json:"created"`
24 | Tags string `json:"tags"`
25 | Owner string `gorm:"index" json:"owner"`
26 | }
27 |
28 | type AssetVo struct {
29 | ID string `json:"id"`
30 | Name string `json:"name"`
31 | IP string `json:"ip"`
32 | Protocol string `json:"protocol"`
33 | Port int `json:"port"`
34 | Active bool `json:"active"`
35 | Created utils.JsonTime `json:"created"`
36 | Tags string `json:"tags"`
37 | Owner string `json:"owner"`
38 | OwnerName string `json:"ownerName"`
39 | SharerCount int64 `json:"sharerCount"`
40 | }
41 |
42 | func (r *Asset) TableName() string {
43 | return "assets"
44 | }
45 |
46 | func FindAllAsset() (o []Asset, err error) {
47 | err = global.DB.Find(&o).Error
48 | return
49 | }
50 |
51 | func FindAssetByConditions(protocol string, account User) (o []Asset, err error) {
52 | db := global.DB.Table("assets").Select("assets.id,assets.name,assets.ip,assets.port,assets.protocol,assets.active,assets.owner,assets.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on assets.owner = users.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id")
53 |
54 | if TypeUser == account.Type {
55 | owner := account.ID
56 | db = db.Where("assets.owner = ? or resource_sharers.user_id = ?", owner, owner)
57 | }
58 |
59 | if len(protocol) > 0 {
60 | db = db.Where("assets.protocol = ?", protocol)
61 | }
62 | err = db.Find(&o).Error
63 | return
64 | }
65 |
66 | func FindPageAsset(pageIndex, pageSize int, name, protocol, tags string, account User, owner, sharer, userGroupId string) (o []AssetVo, total int64, err error) {
67 | db := global.DB.Table("assets").Select("assets.id,assets.name,assets.ip,assets.port,assets.protocol,assets.active,assets.owner,assets.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on assets.owner = users.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id")
68 | dbCounter := global.DB.Table("assets").Select("DISTINCT assets.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id")
69 |
70 | if TypeUser == account.Type {
71 | owner := account.ID
72 | db = db.Where("assets.owner = ? or resource_sharers.user_id = ?", owner, owner)
73 | dbCounter = dbCounter.Where("assets.owner = ? or resource_sharers.user_id = ?", owner, owner)
74 |
75 | // 查询用户所在用户组列表
76 | userGroupIds, err := FindUserGroupIdsByUserId(account.ID)
77 | if err != nil {
78 | return nil, 0, err
79 | }
80 |
81 | if userGroupIds != nil && len(userGroupIds) > 0 {
82 | db = db.Or("resource_sharers.user_group_id in ?", userGroupIds)
83 | dbCounter = dbCounter.Or("resource_sharers.user_group_id in ?", userGroupIds)
84 | }
85 | } else {
86 | if len(owner) > 0 {
87 | db = db.Where("assets.owner = ?", owner)
88 | dbCounter = dbCounter.Where("assets.owner = ?", owner)
89 | }
90 | if len(sharer) > 0 {
91 | db = db.Where("resource_sharers.user_id = ?", sharer)
92 | dbCounter = dbCounter.Where("resource_sharers.user_id = ?", sharer)
93 | }
94 |
95 | if len(userGroupId) > 0 {
96 | db = db.Where("resource_sharers.user_group_id = ?", userGroupId)
97 | dbCounter = dbCounter.Where("resource_sharers.user_group_id = ?", userGroupId)
98 | }
99 | }
100 |
101 | if len(name) > 0 {
102 | db = db.Where("assets.name like ?", "%"+name+"%")
103 | dbCounter = dbCounter.Where("assets.name like ?", "%"+name+"%")
104 | }
105 |
106 | if len(protocol) > 0 {
107 | db = db.Where("assets.protocol = ?", protocol)
108 | dbCounter = dbCounter.Where("assets.protocol = ?", protocol)
109 | }
110 |
111 | if len(tags) > 0 {
112 | tagArr := strings.Split(tags, ",")
113 | for i := range tagArr {
114 | if global.Config.DB == "sqlite" {
115 | db = db.Where("(',' || assets.tags || ',') LIKE ?", "%,"+tagArr[i]+",%")
116 | dbCounter = dbCounter.Where("(',' || assets.tags || ',') LIKE ?", "%,"+tagArr[i]+",%")
117 | } else {
118 | db = db.Where("find_in_set(?, assets.tags)", tagArr[i])
119 | dbCounter = dbCounter.Where("find_in_set(?, assets.tags)", tagArr[i])
120 | }
121 | }
122 | }
123 |
124 | err = dbCounter.Count(&total).Error
125 | if err != nil {
126 | return nil, 0, err
127 | }
128 | err = db.Order("assets.created desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error
129 |
130 | if o == nil {
131 | o = make([]AssetVo, 0)
132 | }
133 | return
134 | }
135 |
136 | func CreateNewAsset(o *Asset) (err error) {
137 | if err = global.DB.Create(o).Error; err != nil {
138 | return err
139 | }
140 | return nil
141 | }
142 |
143 | func FindAssetById(id string) (o Asset, err error) {
144 | err = global.DB.Where("id = ?", id).First(&o).Error
145 | return
146 | }
147 |
148 | func UpdateAssetById(o *Asset, id string) {
149 | o.ID = id
150 | global.DB.Updates(o)
151 | }
152 |
153 | func UpdateAssetActiveById(active bool, id string) {
154 | sql := "update assets set active = ? where id = ?"
155 | global.DB.Exec(sql, active, id)
156 | }
157 |
158 | func DeleteAssetById(id string) error {
159 | return global.DB.Where("id = ?", id).Delete(&Asset{}).Error
160 | }
161 |
162 | func CountAsset() (total int64, err error) {
163 | err = global.DB.Find(&Asset{}).Count(&total).Error
164 | return
165 | }
166 |
167 | func CountAssetByUserId(userId string) (total int64, err error) {
168 | db := global.DB.Joins("left join resource_sharers on assets.id = resource_sharers.resource_id")
169 |
170 | db = db.Where("assets.owner = ? or resource_sharers.user_id = ?", userId, userId)
171 |
172 | // 查询用户所在用户组列表
173 | userGroupIds, err := FindUserGroupIdsByUserId(userId)
174 | if err != nil {
175 | return 0, err
176 | }
177 |
178 | if userGroupIds != nil && len(userGroupIds) > 0 {
179 | db = db.Or("resource_sharers.user_group_id in ?", userGroupIds)
180 | }
181 | err = db.Find(&Asset{}).Count(&total).Error
182 | return
183 | }
184 |
185 | func FindAssetTags() (o []string, err error) {
186 | var assets []Asset
187 | err = global.DB.Not("tags = ?", "").Find(&assets).Error
188 | if err != nil {
189 | return nil, err
190 | }
191 |
192 | o = make([]string, 0)
193 |
194 | for i := range assets {
195 | if len(assets[i].Tags) == 0 {
196 | continue
197 | }
198 | split := strings.Split(assets[i].Tags, ",")
199 |
200 | o = append(o, split...)
201 | }
202 |
203 | return utils.Distinct(o), nil
204 | }
205 |
--------------------------------------------------------------------------------
/web/src/components/session/Playback.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import Guacamole from "guacamole-common-js";
3 | import {server} from "../../common/constants";
4 | import {Button, Col, Row, Slider, Typography} from "antd";
5 | import {PauseCircleOutlined, PlayCircleOutlined} from '@ant-design/icons';
6 | import {Tooltip} from "antd/lib/index";
7 |
8 | const {Text} = Typography;
9 |
10 | class Playback extends Component {
11 |
12 | state = {
13 | playPauseIcon: ,
14 | playPauseIconTitle: '播放',
15 | recording: undefined,
16 | percent: 0,
17 | max: 0,
18 | }
19 |
20 | componentDidMount() {
21 | let sessionId = this.props.sessionId;
22 | this.initPlayer(sessionId);
23 | }
24 |
25 | componentWillMount() {
26 |
27 | }
28 |
29 | initPlayer(sessionId) {
30 | var RECORDING_URL = `${server}/sessions/${sessionId}/recording`;
31 |
32 | var display = document.getElementById('display');
33 |
34 | var tunnel = new Guacamole.StaticHTTPTunnel(RECORDING_URL);
35 | var recording = new Guacamole.SessionRecording(tunnel);
36 |
37 | var recordingDisplay = recording.getDisplay();
38 |
39 | /**
40 | * Converts the given number to a string, adding leading zeroes as necessary
41 | * to reach a specific minimum length.
42 | *
43 | * @param {Numer} num
44 | * The number to convert to a string.
45 | *
46 | * @param {Number} minLength
47 | * The minimum length of the resulting string, in characters.
48 | *
49 | * @returns {String}
50 | * A string representation of the given number, with leading zeroes
51 | * added as necessary to reach the specified minimum length.
52 | */
53 | var zeroPad = function zeroPad(num, minLength) {
54 |
55 | // Convert provided number to string
56 | var str = num.toString();
57 |
58 | // Add leading zeroes until string is long enough
59 | while (str.length < minLength)
60 | str = '0' + str;
61 |
62 | return str;
63 |
64 | };
65 |
66 | /**
67 | * Converts the given millisecond timestamp into a human-readable string in
68 | * MM:SS format.
69 | *
70 | * @param {Number} millis
71 | * An arbitrary timestamp, in milliseconds.
72 | *
73 | * @returns {String}
74 | * A human-readable string representation of the given timestamp, in
75 | * MM:SS format.
76 | */
77 | var formatTime = function formatTime(millis) {
78 |
79 | // Calculate total number of whole seconds
80 | var totalSeconds = Math.floor(millis / 1000);
81 |
82 | // Split into seconds and minutes
83 | var seconds = totalSeconds % 60;
84 | var minutes = Math.floor(totalSeconds / 60);
85 |
86 | // Format seconds and minutes as MM:SS
87 | return zeroPad(minutes, 2) + ':' + zeroPad(seconds, 2);
88 |
89 | };
90 |
91 | // Add playback display to DOM
92 | display.appendChild(recordingDisplay.getElement());
93 |
94 | // Begin downloading the recording
95 | recording.connect();
96 |
97 | // If playing, the play/pause button should read "Pause"
98 | recording.onplay = () => {
99 | // 暂停
100 | this.setState({
101 | playPauseIcon: ,
102 | playPauseIconTitle: '暂停',
103 | })
104 | };
105 |
106 | // If paused, the play/pause button should read "Play"
107 | recording.onpause = () => {
108 | // 播放
109 | this.setState({
110 | playPauseIcon: ,
111 | playPauseIconTitle: '播放',
112 | })
113 | };
114 |
115 | // Toggle play/pause when display or button are clicked
116 | display.onclick = this.handlePlayPause;
117 |
118 | // Fit display within containing div
119 | recordingDisplay.onresize = function displayResized(width, height) {
120 |
121 | // Do not scale if display has no width
122 | if (!width)
123 | return;
124 |
125 | // Scale display to fit width of container
126 | recordingDisplay.scale(display.offsetWidth / width);
127 | };
128 |
129 | recording.onseek = (millis) => {
130 | this.setState({
131 | percent: millis,
132 | position: formatTime(millis)
133 | })
134 | };
135 |
136 | recording.onprogress = (millis) => {
137 | this.setState({
138 | max: millis,
139 | duration: formatTime(millis)
140 | })
141 | };
142 |
143 | this.setState({
144 | recording: recording
145 | }, () => {
146 | this.handlePlayPause();
147 | });
148 | }
149 |
150 | handlePlayPause = () => {
151 | let recording = this.state.recording;
152 | if (recording) {
153 | if (this.state.percent === this.state.max) {
154 | // 重播
155 | this.setState({
156 | percent: 0
157 | }, () => {
158 | recording.seek(0, () => {
159 | recording.play();
160 | });
161 | });
162 | }
163 |
164 | if (!recording.isPlaying()) {
165 | recording.play();
166 | } else {
167 | recording.pause();
168 | }
169 | }
170 | }
171 |
172 | handleProgressChange = (value) => {
173 | let recording = this.state.recording;
174 | if (recording) {
175 | // Request seek
176 | recording.seek(value, () => {
177 | console.log('complete');
178 | });
179 | }
180 |
181 | }
182 |
183 | render() {
184 | return (
185 |
186 |
187 |
188 |
194 |
195 |
197 |
198 |
199 |
201 |
202 |
203 |
205 |
206 |
207 | {this.state.position}/ {this.state.duration}
208 |
209 |
210 |
211 |
212 | );
213 | }
214 | }
215 |
216 | export default Playback;
217 |
--------------------------------------------------------------------------------