├── 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 | ![资源占用截图](../screenshot/docker_stats.png) 4 | 5 | 资产管理 6 | 7 | ![资产](../screenshot/assets.png) 8 | 9 | rdp 10 | 11 | ![rdp](../screenshot/rdp.png) 12 | 13 | vnc 14 | 15 | ![vnc](../screenshot/vnc.png) 16 | 17 | ssh 18 | 19 | ![ssh](../screenshot/ssh.png) 20 | 21 | 批量执行命令 22 | 23 | ![批量执行命令](../screenshot/command.png) -------------------------------------------------------------------------------- /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 | ![Docker image](https://github.com/dushixiang/next-terminal/workflows/Docker%20image/badge.svg?branch=master) 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 | ![捐赠](./screenshot/donate.png) 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 | 23 | 24 | 25 | 26 | 34 | 退出登录 35 | 36 | 37 | 38 | 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 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |