├── .dockerignore ├── ui ├── src │ ├── locales │ │ ├── modules │ │ │ ├── en.json │ │ │ ├── ja.json │ │ │ ├── zh.json │ │ │ ├── zh_Hant.json │ │ │ └── index.ts │ │ ├── index.ts │ │ └── date.ts │ ├── styles │ │ ├── fonts │ │ │ ├── OpenSans-Bold.ttf │ │ │ ├── OpenSans-Light.ttf │ │ │ ├── OpenSans-Italic.ttf │ │ │ ├── OpenSans-Regular.ttf │ │ │ └── OpenSans-BoldItalic.ttf │ │ ├── base.css │ │ └── keyboard.css │ ├── types │ │ ├── guacamole.type.ts │ │ └── postmessage.type.ts │ ├── main.ts │ ├── stores │ │ └── counter.ts │ ├── utils │ │ ├── clipboard.ts │ │ ├── common.ts │ │ ├── status.ts │ │ ├── lunaBus.ts │ │ ├── config.ts │ │ └── guacamole_helper.ts │ ├── router │ │ └── index.ts │ ├── api │ │ └── index.ts │ ├── components │ │ ├── CardContainer │ │ │ └── index.vue │ │ ├── SessionShare │ │ │ ├── index.vue │ │ │ └── widget │ │ │ │ └── UserItem.vue │ │ ├── KeyboardOption.vue │ │ ├── CombinationKey.vue │ │ ├── ClipBoardText.vue │ │ ├── OtherOption.vue │ │ └── Osk.vue │ ├── views │ │ ├── MonitorView.vue │ │ └── ShareView.vue │ ├── App.vue │ └── hooks │ │ └── useColor.ts ├── .gitattributes ├── env.d.ts ├── public │ └── favicon.ico ├── .prettierrc.json ├── tsconfig.json ├── .editorconfig ├── tsconfig.app.json ├── index.html ├── .gitignore ├── tsconfig.node.json ├── README.md ├── eslint.config.ts ├── vite.config.ts ├── package.json └── components.d.ts ├── pkg ├── config │ ├── config_test.yml │ ├── const.go │ ├── config_test.go │ └── config.go ├── session │ ├── interface.go │ ├── message.go │ ├── permisson.go │ ├── recorder.go │ ├── display.go │ ├── session.go │ └── parser.go ├── tunnel │ ├── room_cache.go │ ├── api_response.go │ ├── ws_error.go │ ├── stream_input.go │ ├── cache.go │ ├── cache_local.go │ ├── stream_output.go │ └── monitor.go ├── guacd │ ├── configuration.go │ ├── parameters_vnc.go │ ├── parameters.go │ ├── instrcution_name.go │ ├── instruction_test.go │ ├── information.go │ ├── qwertz.go │ ├── keyboard_map.go │ ├── parameters_rdp.go │ ├── instruction.go │ ├── status.go │ └── tunnel.go ├── middleware │ ├── session.go │ └── cookie.go ├── logger │ ├── gloable.go │ └── logger.go └── gateway │ └── domain.go ├── .golangci.yml ├── docker-compose.yaml.example ├── Dockerfile-ee ├── README_zh-CN.md ├── supervisord.conf ├── README.md ├── config_example.yml ├── .gitignore ├── entrypoint.sh ├── .github ├── release-config.yml └── workflows │ ├── jms-generic-action-handler.yml │ ├── golangci-lint.yml │ ├── build-ghcr-image.yml │ ├── build-base-image.yml │ ├── release-drafter.yml │ └── jms-build-test.yml.disabled ├── Dockerfile-base ├── .goreleaser.yaml ├── Dockerfile.guacd ├── Dockerfile ├── Makefile └── go.mod /.dockerignore: -------------------------------------------------------------------------------- 1 | */data 2 | */node_modules -------------------------------------------------------------------------------- /ui/src/locales/modules/en.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/src/locales/modules/ja.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/src/locales/modules/zh.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /ui/src/locales/modules/zh_Hant.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/src/styles/fonts/OpenSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/src/styles/fonts/OpenSans-Bold.ttf -------------------------------------------------------------------------------- /ui/src/styles/fonts/OpenSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/src/styles/fonts/OpenSans-Light.ttf -------------------------------------------------------------------------------- /ui/src/styles/fonts/OpenSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/src/styles/fonts/OpenSans-Italic.ttf -------------------------------------------------------------------------------- /ui/src/styles/fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/src/styles/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /ui/src/styles/fonts/OpenSans-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jumpserver/lion/HEAD/ui/src/styles/fonts/OpenSans-BoldItalic.ttf -------------------------------------------------------------------------------- /pkg/config/config_test.yml: -------------------------------------------------------------------------------- 1 | CORE_HOST: http://10.0.0.5:8080 2 | 3 | BOOTSTRAP_TOKEN: ICAgICAgICBUWCBl 4 | 5 | GUA_HOST: 127.0.0.1 6 | 7 | GUA_PORT: 4822 -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | 5 | 6 | linters: 7 | enable: 8 | - govet 9 | - staticcheck 10 | 11 | -------------------------------------------------------------------------------- /ui/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /pkg/session/interface.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | type ParseEngine interface { 4 | ParseStream(userInChan chan *Message) 5 | 6 | Close() 7 | 8 | CommandRecordChan() chan *ExecutedCommand 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/types/guacamole.type.ts: -------------------------------------------------------------------------------- 1 | export interface GuacamoleDisplay { 2 | getWidth: () => number; 3 | getHeight: () => number; 4 | scale: (scale: number) => void; 5 | getElement: () => HTMLElement; 6 | } 7 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | end_of_line = lf 9 | max_line_length = 100 10 | -------------------------------------------------------------------------------- /pkg/config/const.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | GinCtxUserKey = "JMS-CtxUserKey" 5 | ) 6 | 7 | const ( 8 | GinSessionName = "session-Lion" 9 | GinSessionKey = "SESSION" 10 | ) 11 | 12 | const ( 13 | ShareTypeRedis = "redis" 14 | ShareTypeLocal = "local" 15 | ) 16 | -------------------------------------------------------------------------------- /ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSetupByYml(t *testing.T) { 8 | var conf = getDefaultConfig() 9 | loadConfigFromFile("config_test.yml", &conf) 10 | t.Log(conf) 11 | } 12 | 13 | func TestSetupByEnv(t *testing.T) { 14 | var conf = getDefaultConfig() 15 | loadConfigFromEnv(&conf) 16 | t.Log(conf) 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/locales/modules/index.ts: -------------------------------------------------------------------------------- 1 | import en from './en.json'; 2 | import zh from './zh.json'; 3 | import ja from './ja.json'; 4 | import zh_Hant from './zh_Hant.json'; 5 | 6 | export const message = { 7 | zh: { 8 | ...zh, 9 | }, 10 | zh_hant: { 11 | ...zh_Hant, 12 | }, 13 | en: { 14 | ...en, 15 | }, 16 | ja: { 17 | ...ja, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import './styles/base.css'; 2 | 3 | import { createApp } from 'vue'; 4 | import { createPinia } from 'pinia'; 5 | import i18n from '@/locales'; 6 | 7 | import App from './App.vue'; 8 | import router from './router'; 9 | 10 | const app = createApp(App); 11 | 12 | app.use(createPinia()); 13 | app.use(router); 14 | app.use(i18n); 15 | app.mount('#app'); 16 | -------------------------------------------------------------------------------- /ui/src/stores/counter.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from 'vue'; 2 | import { defineStore } from 'pinia'; 3 | 4 | export const useCounterStore = defineStore('counter', () => { 5 | const count = ref(0); 6 | const doubleCount = computed(() => count.value * 2); 7 | function increment() { 8 | count.value++; 9 | } 10 | 11 | return { count, doubleCount, increment }; 12 | }); 13 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Lion Terminal 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | 2 | export async function readClipboardText(): Promise { 3 | try { 4 | if (navigator.clipboard && navigator.clipboard.readText) { 5 | return await navigator.clipboard.readText() 6 | } 7 | console.log("navigator.clipboard api not found") 8 | return '' 9 | } catch (err) { 10 | console.error('Failed to read clipboard:', err); 11 | return ''; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yaml.example: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | 3 | networks: 4 | guacd: 5 | driver: bridge 6 | 7 | services: 8 | guacd: 9 | image: jumpserver/guacd:1.4.0 10 | container_name: guacd 11 | ports: 12 | - "4822:4822" 13 | environment: 14 | GUACD_LOG_LEVEL: debug 15 | networks: 16 | - guacd 17 | restart: always 18 | volumes: 19 | - ./data/:/opt/lion/data/:rw # /opt/lion/-> 本地项目路径, 修改 ./data 目录权限为777 20 | -------------------------------------------------------------------------------- /Dockerfile-ee: -------------------------------------------------------------------------------- 1 | ARG VERSION=dev 2 | 3 | FROM jumpserver/lion:${VERSION}-ce 4 | ARG TARGETARCH 5 | 6 | ARG DEPENDENCIES=" \ 7 | curl \ 8 | iputils-ping \ 9 | telnet \ 10 | vim \ 11 | wget" 12 | 13 | RUN set -ex \ 14 | && apt-get update \ 15 | && apt-get install -y --no-install-recommends ${DEPENDENCIES} 16 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /pkg/session/message.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | type Message struct { 4 | Opcode string `json:"opcode"` 5 | Body []string `json:"data"` 6 | Meta MetaMessage `json:"meta"` // receive的信息必须携带Meta 7 | } 8 | 9 | type MetaMessage struct { 10 | UserId string `json:"user_id"` 11 | User string `json:"user"` 12 | Created string `json:"created"` 13 | 14 | TerminalId string `json:"terminal_id"` 15 | Primary bool `json:"primary"` 16 | Writable bool `json:"writable"` 17 | } 18 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*", 9 | "eslint.config.*" 10 | ], 11 | "compilerOptions": { 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README_zh-CN.md: -------------------------------------------------------------------------------- 1 | # Lion 2 | 3 | **简体中文** · [English](./README.md) 4 | 5 | ## 介绍 6 | 7 | 该项目使用 Golang 和 Vue 重构了 JumpServer 的 Guacamole 组件,负责 RDP 和 VNC 的连接。 主要基于 [Apache Guacamole](http://guacamole.apache.org/) 8 | 开发。 9 | 10 | ## 配置 11 | 12 | 启动的配置文件参考[config_example](config_example.yml) 13 | 14 | ## 构建镜像 15 | 16 | ```shell 17 | docker build -t jumpserver/lion . 18 | ``` 19 | 20 | ## docker启动 21 | 22 | ```shell 23 | docker run -d --name jms_lion -p 8081:8081 \ 24 | -v $(pwd)/data:/opt/lion/data \ 25 | -v $(pwd)/config.yml:/opt/lion/config.yml \ 26 | jumpserver/lion 27 | ``` -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:guacd] 6 | command=/opt/guacamole/sbin/guacd -b 0.0.0.0 -L %(ENV_GUACD_LOG_LEVEL)s -f 7 | redirect_stderr=true 8 | stdout_logfile=/opt/lion/data/logs/guacd.log 9 | stdout_logfile_maxbytes=50MB 10 | stdout_logfile_backups=10 11 | stdout_capture_maxbytes=1MB 12 | autorestart=true 13 | 14 | [program:lion] 15 | directory=/opt/lion/ 16 | command=/opt/lion/lion 17 | stdout_logfile=/dev/stdout 18 | stdout_logfile_maxbytes=0 19 | stderr_logfile=/dev/stderr 20 | stderr_logfile_maxbytes=0 21 | autorestart=true -------------------------------------------------------------------------------- /pkg/tunnel/room_cache.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | type MetaShareUserMessage struct { 4 | ShareId string `json:"share_id"` 5 | 6 | SessionId string `json:"session_id"` 7 | UserId string `json:"user_id"` 8 | User string `json:"user"` 9 | Created string `json:"created"` 10 | RemoteAddr string `json:"remote_addr"` 11 | Primary bool `json:"primary"` 12 | Writable bool `json:"writable"` 13 | } 14 | 15 | type SessionRoomMessage struct { 16 | Id string `json:"id"` 17 | SessionId string `json:"session_id"` 18 | Event *Event `json:"event"` 19 | } 20 | -------------------------------------------------------------------------------- /pkg/tunnel/api_response.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ErrNoAuthUser = errors.New("no auth user") 8 | 9 | type APIResponse struct { 10 | Success bool `json:"success"` 11 | Message string `json:"message"` 12 | Data interface{} `json:"data"` 13 | } 14 | 15 | func SuccessResponse(data interface{}) APIResponse { 16 | return APIResponse{ 17 | Success: true, 18 | Data: data, 19 | } 20 | } 21 | 22 | func ErrorResponse(err error) APIResponse { 23 | return APIResponse{ 24 | Success: false, 25 | Message: err.Error(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lion 2 | 3 | **English** · [简体中文](./README_zh-CN.md) 4 | 5 | ## Introduction 6 | 7 | This project uses Golang and Vue, handling RDP and VNC connections. It is mainly based on [Apache Guacamole](http://guacamole.apache.org/) 8 | 9 | ## Configuration 10 | 11 | Refer to the configuration file [config_example](config_example.yml) 12 | 13 | ## Build the image 14 | 15 | ```shell 16 | docker build -t jumpserver/lion . 17 | ``` 18 | 19 | ## Docker start 20 | 21 | ```shell 22 | docker run -d --name jms_lion -p 8081:8081 \ 23 | -v $(pwd)/data:/opt/lion/data \ 24 | -v $(pwd)/config.yml:/opt/lion/config.yml \ 25 | jumpserver/lion 26 | ``` -------------------------------------------------------------------------------- /ui/src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import { useCookies } from 'vue3-cookies'; 2 | const { cookies } = useCookies(); 3 | import { message } from './modules'; 4 | import { createI18n } from 'vue-i18n'; 5 | const storeLang = cookies.get('lang'); 6 | const cookieLang = cookies.get('django_language'); 7 | 8 | const browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en'; 9 | 10 | export const LanguageCode = cookieLang || storeLang || browserLang || 'en'; 11 | import date from './date'; 12 | 13 | const i18n = createI18n({ 14 | locale: LanguageCode, 15 | fallbackLocale: 'en', 16 | legacy: false, 17 | allowComposition: true, 18 | silentFallbackWarn: true, 19 | silentTranslationWarn: true, 20 | messages: message, 21 | dateTimeFormats: date, 22 | }); 23 | 24 | export default i18n; 25 | -------------------------------------------------------------------------------- /config_example.yml: -------------------------------------------------------------------------------- 1 | # 项目名称, 会用来向Jumpserver注册, 识别而已, 不能重复 2 | # NAME: {{ Hostname }} 3 | 4 | # Jumpserver项目的url, api请求注册会使用 5 | CORE_HOST: http://127.0.0.1:8080 6 | 7 | # Bootstrap Token, 预共享秘钥, 用来注册使用的service account和terminal 8 | # 请和jumpserver 配置文件中保持一致,注册完成后可以删除 9 | BOOTSTRAP_TOKEN: 10 | 11 | # 启动时绑定的ip, 默认 0.0.0.0 12 | # BIND_HOST: 0.0.0.0 13 | 14 | # 监听的HTTP/WS端口号,默认8081 15 | # HTTPD_PORT: 8081 16 | 17 | # 设置日志级别 [DEBUG, INFO, WARN, ERROR, FATAL, CRITICAL] 18 | # LOG_LEVEL: INFO 19 | 20 | # Guacamole Server ip, 默认127.0.0.1 21 | # GUA_HOST: 127.0.0.1 22 | 23 | # Guacamole Server 端口号,默认4822 24 | # GUA_PORT: 4822 25 | 26 | # 会话共享使用的类型 [local, redis], 默认local 27 | # SHARE_ROOM_TYPE: local 28 | 29 | # Redis配置 30 | # REDIS_HOST: 127.0.0.1 31 | # REDIS_PORT: 6379 32 | # REDIS_PASSWORD: 33 | # REDIS_DB_ROOM: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .idea 17 | data/ 18 | docker-compose.yaml 19 | docker-compose.yml 20 | !config_example.yml 21 | .DS_Store 22 | ui/node_modules/ 23 | ui/dist/ 24 | ui/guacamole/ 25 | ui/lion/ 26 | config.yml 27 | vendor/ 28 | 29 | # local env files 30 | .env.local 31 | .env.*.local 32 | 33 | # Log files 34 | ui/npm-debug.log* 35 | ui/yarn-debug.log* 36 | ui/yarn-error.log* 37 | ui/pnpm-debug.log* 38 | 39 | # Editor directories and files 40 | .vscode 41 | *.suoui 42 | *.ntvs* 43 | *.njsproj 44 | *.sln 45 | *.sw? 46 | build 47 | build/ 48 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | 4 | if [ -n "$CORE_HOST" ]; then 5 | until check ${CORE_HOST}/api/health/; do 6 | echo "wait for jms_core ${CORE_HOST} ready" 7 | sleep 2 8 | done 9 | fi 10 | 11 | if [ ! -d "/opt/lion/data/logs" ]; then 12 | mkdir -p /opt/lion/data/logs 13 | fi 14 | 15 | : ${LOG_LEVEL:='ERROR'} 16 | 17 | case $LOG_LEVEL in 18 | "DEBUG") 19 | level="debug" 20 | ;; 21 | "INFO") 22 | level='info' 23 | ;; 24 | "WARN") 25 | level='warning' 26 | ;; 27 | "ERROR" | "FATAL" | "CRITICAL") 28 | level='error' 29 | ;; 30 | *) 31 | level='error' 32 | ;; 33 | esac 34 | export GUACD_LOG_LEVEL=$level 35 | 36 | echo 37 | date 38 | echo "LION Version $VERSION, more see https://www.jumpserver.org" 39 | echo "Quit the server with CONTROL-C." 40 | echo 41 | 42 | exec "$@" -------------------------------------------------------------------------------- /pkg/guacd/configuration.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | func NewConfiguration() (conf Configuration) { 4 | conf.Parameters = make(map[string]string) 5 | return conf 6 | } 7 | 8 | type Configuration struct { 9 | ConnectionID string 10 | Protocol string 11 | Parameters map[string]string 12 | } 13 | 14 | func (conf *Configuration) SetParameter(name, value string) { 15 | conf.Parameters[name] = value 16 | } 17 | 18 | func (conf *Configuration) UnSetParameter(name string) { 19 | delete(conf.Parameters, name) 20 | } 21 | 22 | func (conf *Configuration) GetParameter(name string) string { 23 | return conf.Parameters[name] 24 | } 25 | 26 | func (conf *Configuration) Clone() Configuration { 27 | newConf := NewConfiguration() 28 | newConf.ConnectionID = conf.ConnectionID 29 | newConf.Protocol = conf.Protocol 30 | for k, v := range conf.Parameters { 31 | newConf.Parameters[k] = v 32 | } 33 | return newConf 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | import ConnectView from '../views/ConnectView.vue'; 3 | 4 | console.log('router init', import.meta.env); 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(import.meta.env.BASE_URL), 8 | routes: [ 9 | { 10 | path: '/connect/', 11 | name: 'connect', 12 | component: ConnectView, 13 | }, 14 | { 15 | path: '/monitor/', 16 | name: 'monitor', 17 | // route level code-splitting 18 | // this generates a separate chunk (About.[hash].js) for this route 19 | // which is lazy-loaded when the route is visited. 20 | component: () => import('../views/MonitorView.vue'), 21 | }, 22 | { 23 | path: '/share/:id/', 24 | name: 'share', 25 | component: () => import('../views/ShareView.vue'), 26 | }, 27 | ], 28 | }); 29 | 30 | export default router; 31 | -------------------------------------------------------------------------------- /ui/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { createAlova } from 'alova'; 2 | import fetchAdapter from 'alova/fetch'; 3 | import { BASE_URL } from '@/utils/common'; 4 | 5 | export const alovaInstance = createAlova({ 6 | baseURL: BASE_URL, 7 | requestAdapter: fetchAdapter(), 8 | }); 9 | 10 | const getSuggestionUsers = (query: string) => { 11 | const params = { 12 | search: query, 13 | }; 14 | return alovaInstance.Get('/api/v1/users/users/suggestions/', { params: params }); 15 | }; 16 | 17 | const createShareURL = (data: any) => { 18 | return alovaInstance.Post(`/lion/api/share/`, data); 19 | }; 20 | 21 | const getShareSession = (id: string, data: any) => { 22 | return alovaInstance.Post(`/lion/api/share/${id}/`, data); 23 | }; 24 | 25 | const removeShareUser = (data: any) => { 26 | return alovaInstance.Post(`/lion/api/share/remove/`, data); 27 | }; 28 | 29 | export { getSuggestionUsers, createShareURL, getShareSession, removeShareUser }; 30 | -------------------------------------------------------------------------------- /.github/release-config.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🌱 新功能 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - 'feat' 9 | - '新功能' 10 | - title: '🚀 性能优化 Optimization' 11 | labels: 12 | - 'perf' 13 | - 'opt' 14 | - 'refactor' 15 | - 'Optimization' 16 | - '优化' 17 | - title: '🐛 Bug修复 Bug Fixes' 18 | labels: 19 | - 'fix' 20 | - 'bugfix' 21 | - 'bug' 22 | - title: '🧰 其它 Maintenance' 23 | labels: 24 | - 'chore' 25 | - 'docs' 26 | exclude-labels: 27 | - 'no' 28 | - '无需处理' 29 | - 'wontfix' 30 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 31 | version-resolver: 32 | major: 33 | labels: 34 | - 'major' 35 | minor: 36 | labels: 37 | - 'minor' 38 | patch: 39 | labels: 40 | - 'patch' 41 | default: patch 42 | template: | 43 | ## 版本变化 What’s Changed 44 | 45 | $CHANGES 46 | -------------------------------------------------------------------------------- /pkg/guacd/parameters_vnc.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | // Network parameters 4 | const ( 5 | VNCHostname = "hostname" 6 | VNCPort = "port" 7 | VNCAutoretry = "autoretry" 8 | ) 9 | 10 | // Authentication 11 | const ( 12 | VNCUsername = "username" 13 | VNCPassword = "password" 14 | ) 15 | 16 | // Display Settings 17 | const ( 18 | VNCColorDepth = "color-depth" 19 | VNCSwapRedBlue = "swap-red-blue" 20 | VNCCursor = "cursor" 21 | VNCEncoding = "encodings" 22 | VNCReadOnly = "read-only" 23 | ) 24 | 25 | // VNC Repeater 26 | 27 | const ( 28 | VNCDestHost = "dest-host" 29 | VNCDestPort = "dest-port" 30 | ) 31 | 32 | // Reverse VNC connections 33 | 34 | const ( 35 | VNCReverseConnect = "reverse-connect" 36 | VNCListenTimeout = "listen-timeout" 37 | ) 38 | 39 | // Audio support (via PulseAudio) 40 | 41 | const ( 42 | VNCEnableAudio = "enableAudio" 43 | VNCAudioServername = "audio-servername" 44 | ) 45 | 46 | // Clipboard encoding 47 | 48 | const ( 49 | VNCClipboardEncoding = "clipboard-encoding" // ISO8859-1| UTF-8 | UTF-16| CP1252 50 | ) 51 | -------------------------------------------------------------------------------- /pkg/middleware/session.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | ginSessions "github.com/gin-contrib/sessions" 7 | "github.com/gin-gonic/gin" 8 | 9 | "lion/pkg/config" 10 | "lion/pkg/logger" 11 | 12 | "github.com/jumpserver-dev/sdk-go/service" 13 | ) 14 | 15 | func GinSessionAuth(store ginSessions.Store) gin.HandlerFunc { 16 | return ginSessions.Sessions(config.GinSessionName, store) 17 | } 18 | 19 | func SessionAuth(jmsService *service.JMService) gin.HandlerFunc { 20 | return func(ctx *gin.Context) { 21 | ginSession := ginSessions.Default(ctx) 22 | if result := ginSession.Get(config.GinSessionKey); result != nil { 23 | logger.Errorf("Token auth failed %+v", ginSession) 24 | if uid, ok := result.(string); ok { 25 | if user, err := jmsService.GetUserById(uid); err == nil { 26 | ctx.Set(config.GinCtxUserKey, user) 27 | logger.Debugf("Token auth user: %s", user) 28 | return 29 | } 30 | } 31 | } 32 | logger.Errorf("Token auth failed %+v", ginSession) 33 | ctx.Status(http.StatusForbidden) 34 | ctx.Abort() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # . 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. 12 | 13 | ## Customize configuration 14 | 15 | See [Vite Configuration Reference](https://vite.dev/config/). 16 | 17 | ## Project Setup 18 | 19 | ```sh 20 | npm install 21 | ``` 22 | 23 | ### Compile and Hot-Reload for Development 24 | 25 | ```sh 26 | npm run dev 27 | ``` 28 | 29 | ### Type-Check, Compile and Minify for Production 30 | 31 | ```sh 32 | npm run build 33 | ``` 34 | 35 | ### Lint with [ESLint](https://eslint.org/) 36 | 37 | ```sh 38 | npm run lint 39 | ``` 40 | -------------------------------------------------------------------------------- /ui/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { globalIgnores } from 'eslint/config' 2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' 3 | import pluginVue from 'eslint-plugin-vue' 4 | import pluginOxlint from 'eslint-plugin-oxlint' 5 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 6 | 7 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: 8 | // import { configureVueProject } from '@vue/eslint-config-typescript' 9 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] }) 10 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup 11 | 12 | export default defineConfigWithVueTs( 13 | { 14 | name: 'app/files-to-lint', 15 | files: ['**/*.{ts,mts,tsx,vue}'], 16 | }, 17 | 18 | globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), 19 | 20 | pluginVue.configs['flat/essential'], 21 | vueTsConfigs.recommended, 22 | ...pluginOxlint.configs['flat/recommended'], 23 | skipFormatting, 24 | { 25 | rules: { 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | }, 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /.github/workflows/jms-generic-action-handler.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | pull_request: 4 | types: [opened, synchronize, closed] 5 | release: 6 | types: [created] 7 | 8 | name: JumpServer repos generic handler 9 | 10 | jobs: 11 | handle_pull_request: 12 | if: github.event_name == 'pull_request' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: jumpserver/action-generic-handler@master 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} 18 | I18N_TOKEN: ${{ secrets.I18N_TOKEN }} 19 | 20 | handle_push: 21 | if: github.event_name == 'push' 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: jumpserver/action-generic-handler@master 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} 27 | I18N_TOKEN: ${{ secrets.I18N_TOKEN }} 28 | 29 | handle_release: 30 | if: github.event_name == 'release' 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: jumpserver/action-generic-handler@master 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} 36 | I18N_TOKEN: ${{ secrets.I18N_TOKEN }} 37 | -------------------------------------------------------------------------------- /ui/src/styles/base.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import './keyboard.css'; 3 | 4 | @font-face { 5 | font-family: 'Open Sans'; 6 | src: url('./fonts/OpenSans-Regular.ttf'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } 10 | 11 | @font-face { 12 | font-family: 'Open Sans'; 13 | src: url('./fonts/OpenSans-Bold.ttf'); 14 | font-weight: bold; 15 | font-style: normal; 16 | } 17 | 18 | @font-face { 19 | font-family: 'Open Sans'; 20 | src: url('./fonts/OpenSans-Light.ttf'); 21 | font-weight: 300; 22 | font-style: normal; 23 | } 24 | 25 | @font-face { 26 | font-family: 'Open Sans'; 27 | src: url('./fonts/OpenSans-Italic.ttf'); 28 | font-weight: 300; 29 | font-style: italic; 30 | } 31 | 32 | body { 33 | height: 100%; 34 | /* -moz-osx-font-smoothing: grayscale; */ 35 | -webkit-font-smoothing: auto; 36 | background-color: #000000; 37 | font-family: 'open sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 38 | font-size: 13px; 39 | line-height: 1.428; 40 | } 41 | 42 | ::-webkit-scrollbar-track { 43 | box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.3); 44 | background-color: #0a0a0a; 45 | } 46 | 47 | ::-webkit-scrollbar-thumb { 48 | background-color: #494141; 49 | border-radius: 6px; 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/CardContainer/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | 40 | 47 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 6 | import Components from 'unplugin-vue-components/vite' 7 | import vueJsx from '@vitejs/plugin-vue-jsx' 8 | import vueDevTools from 'vite-plugin-vue-devtools' 9 | import tailwindcss from '@tailwindcss/vite' 10 | // https://vite.dev/config/ 11 | export default defineConfig({ 12 | base: '/lion/', 13 | plugins: [ 14 | vue(), 15 | tailwindcss(), 16 | vueJsx(), 17 | Components({ dts: true, resolvers: [NaiveUiResolver()] }), 18 | ], 19 | resolve: { 20 | extensions: ['.js', '.ts', '.tsx', '.vue'], 21 | alias: { 22 | '@': fileURLToPath(new URL('./src', import.meta.url)) 23 | }, 24 | }, 25 | server: { 26 | port: 9529, 27 | proxy: { 28 | '^/lion/ws': { 29 | target: 'http://localhost:8081', 30 | ws: true, 31 | changeOrigin: true, 32 | }, 33 | '^/lion/api': { 34 | target: 'http://localhost:8081', 35 | ws: true, 36 | changeOrigin: true, 37 | }, 38 | '^/lion/token': { 39 | target: 'http://localhost:8081', 40 | changeOrigin: true, 41 | ws: true, 42 | }, 43 | } 44 | }, 45 | }) 46 | -------------------------------------------------------------------------------- /Dockerfile-base: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-trixie AS stage-go-build 2 | 3 | FROM node:20-trixie 4 | COPY --from=stage-go-build /usr/local/go/ /usr/local/go/ 5 | COPY --from=stage-go-build /go/ /go/ 6 | ENV GOPATH=/go 7 | ENV PATH=/go/bin:/usr/local/go/bin:$PATH 8 | ARG TARGETARCH 9 | ARG NPM_REGISTRY="https://registry.npmmirror.com" 10 | ENV NPM_REGISTY=$NPM_REGISTRY 11 | 12 | RUN set -ex \ 13 | && npm config set registry ${NPM_REGISTRY} \ 14 | && yarn config set registry ${NPM_REGISTRY} 15 | 16 | WORKDIR /opt 17 | 18 | ARG CHECK_VERSION=v1.0.5 19 | RUN set -ex \ 20 | && wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \ 21 | && tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz -C /usr/local/bin/ check \ 22 | && chown root:root /usr/local/bin/check \ 23 | && chmod 755 /usr/local/bin/check \ 24 | && rm -f check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz 25 | 26 | WORKDIR /opt/lion/ui 27 | 28 | RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,sharing=locked,id=lion \ 29 | --mount=type=bind,source=ui/package.json,target=package.json \ 30 | --mount=type=bind,source=ui/yarn.lock,target=yarn.lock \ 31 | yarn install 32 | 33 | ENV CGO_ENABLED=0 34 | ENV GO111MODULE=on 35 | 36 | WORKDIR /opt/lion 37 | 38 | COPY go.mod go.sum ./ 39 | 40 | RUN go mod download -x 41 | -------------------------------------------------------------------------------- /pkg/middleware/cookie.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "lion/pkg/config" 11 | "lion/pkg/logger" 12 | 13 | "github.com/jumpserver-dev/sdk-go/model" 14 | "github.com/jumpserver-dev/sdk-go/service" 15 | ) 16 | 17 | func JmsCookieAuth(jmsService *service.JMService) gin.HandlerFunc { 18 | return func(ctx *gin.Context) { 19 | var ( 20 | err error 21 | user *model.User 22 | ) 23 | reqCookies := ctx.Request.Cookies() 24 | var cookies = make(map[string]string) 25 | for _, cookie := range reqCookies { 26 | cookies[cookie.Name] = cookie.Value 27 | } 28 | user, err = jmsService.CheckUserCookie(cookies) 29 | if err != nil { 30 | logger.Errorf("Check user cookie failed: %+v %s", cookies, err.Error()) 31 | loginUrl := fmt.Sprintf("/core/auth/login/?next=%s", url.QueryEscape(ctx.Request.URL.RequestURI())) 32 | ctx.Redirect(http.StatusFound, loginUrl) 33 | ctx.Abort() 34 | return 35 | } 36 | ctx.Set(config.GinCtxUserKey, user) 37 | } 38 | } 39 | 40 | func HTTPMiddleDebugAuth() gin.HandlerFunc { 41 | return func(ctx *gin.Context) { 42 | switch ctx.ClientIP() { 43 | case "127.0.0.1", "localhost": 44 | return 45 | default: 46 | _ = ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid host %s", ctx.ClientIP())) 47 | return 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/session/permisson.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "lion/pkg/config" 5 | 6 | "github.com/jumpserver-dev/sdk-go/model" 7 | ) 8 | 9 | type ActionPermission struct { 10 | EnableConnect bool `json:"enable_connect"` 11 | 12 | EnableCopy bool `json:"enable_copy"` 13 | EnablePaste bool `json:"enable_paste"` 14 | 15 | EnableUpload bool `json:"enable_upload"` 16 | EnableDownload bool `json:"enable_download"` 17 | EnableShare bool `json:"enable_share"` 18 | } 19 | 20 | func NewActionPermission(perm *model.Permission, connectType string) *ActionPermission { 21 | action := ActionPermission{ 22 | EnableConnect: perm.EnableConnect(), 23 | EnableCopy: perm.EnableCopy(), 24 | EnablePaste: perm.EnablePaste(), 25 | EnableUpload: perm.EnableUpload(), 26 | EnableDownload: perm.EnableDownload(), 27 | EnableShare: perm.EnableShare(), 28 | } 29 | globConfig := config.GlobalConfig 30 | switch connectType { 31 | case TypeRemoteApp: 32 | if globConfig.EnableRemoteAppUpDownLoad { 33 | action.EnableDownload = true 34 | action.EnableUpload = true 35 | } 36 | if globConfig.EnableRemoteAPPCopyPaste { 37 | action.EnablePaste = true 38 | action.EnableCopy = true 39 | } 40 | case TypeRDP, TypeVNC: 41 | } 42 | if globConfig.DisableAllUpDownload { 43 | action.EnableDownload = false 44 | action.EnableUpload = false 45 | } 46 | if globConfig.DisableAllCopyPaste { 47 | action.EnablePaste = false 48 | action.EnableCopy = false 49 | } 50 | return &action 51 | } 52 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | version: 2 3 | 4 | project_name: lion 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | - go generate ./... 10 | 11 | builds: 12 | - id: lion 13 | main: main.go 14 | binary: lion 15 | goos: 16 | - linux 17 | - darwin 18 | - freebsd 19 | - netbsd 20 | goarch: 21 | - amd64 22 | - arm64 23 | - mips64le 24 | - ppc64le 25 | - s390x 26 | - riscv64 27 | - loong64 28 | env: 29 | - CGO_ENABLED=0 30 | ldflags: 31 | - -w -s 32 | - -X 'main.Buildstamp={{ .Date }}' 33 | - -X 'main.Githash={{ .ShortCommit }}' 34 | - -X 'main.Goversion={{ .Env.GOVERSION }}' 35 | - -X 'main.Version={{ .Tag }}' 36 | 37 | archives: 38 | - format: tar.gz 39 | wrap_in_directory: true 40 | files: 41 | - LICENSE 42 | - README.md 43 | - config_example.yml 44 | - entrypoint.sh 45 | - supervisord.conf 46 | - ui/dist/** 47 | 48 | format_overrides: 49 | - goos: windows 50 | format: zip 51 | name_template: "{{ .ProjectName }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}" 52 | 53 | checksum: 54 | name_template: "checksums.txt" 55 | 56 | release: 57 | draft: true 58 | mode: append 59 | extra_files: 60 | - glob: dist/*.tar.gz 61 | - glob: dist/*.txt 62 | name_template: "Release {{.Tag}}" 63 | 64 | changelog: 65 | sort: asc 66 | filters: 67 | exclude: 68 | - "^docs:" 69 | - "^test:" -------------------------------------------------------------------------------- /ui/src/views/MonitorView.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - dev 7 | 8 | permissions: 9 | contents: read 10 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 11 | # pull-requests: read 12 | 13 | jobs: 14 | golangci: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Create ui/dist directory 21 | run: | 22 | mkdir -p ui/dist 23 | touch ui/dist/.gitkeep 24 | 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | 29 | - uses: golangci/golangci-lint-action@v6 30 | with: 31 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 32 | version: latest 33 | 34 | # Optional: working directory, useful for monorepos 35 | # working-directory: somedir 36 | 37 | # Optional: golangci-lint command line arguments. 38 | # args: --issues-exit-code=0 39 | 40 | # Optional: show only new issues if it's a pull request. The default value is `false`. 41 | # only-new-issues: true 42 | 43 | # Optional: if set to true then the all caching functionality will be complete disabled, 44 | # takes precedence over all other caching options. 45 | # skip-cache: true 46 | 47 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 48 | # skip-pkg-cache: true 49 | 50 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 51 | # skip-build-cache: true -------------------------------------------------------------------------------- /ui/src/locales/date.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | en: { 3 | short: { 4 | year: 'numeric', 5 | month: 'short', 6 | day: 'numeric', 7 | }, 8 | medium: { 9 | year: 'numeric', 10 | month: '2-digit', 11 | day: '2-digit', 12 | hour: '2-digit', 13 | minute: '2-digit', 14 | second: '2-digit', 15 | hourCycle: 'h23', 16 | hour12: false, 17 | }, 18 | long: { 19 | year: 'numeric', 20 | month: 'short', 21 | day: 'numeric', 22 | hour: 'numeric', 23 | minute: 'numeric', 24 | }, 25 | }, 26 | cn: { 27 | short: { 28 | year: 'numeric', 29 | month: 'short', 30 | day: 'numeric', 31 | }, 32 | medium: { 33 | year: 'numeric', 34 | month: '2-digit', 35 | day: '2-digit', 36 | hour: '2-digit', 37 | minute: '2-digit', 38 | second: '2-digit', 39 | hourCycle: 'h23', 40 | hour12: false, 41 | }, 42 | long: { 43 | year: 'numeric', 44 | month: 'short', 45 | day: 'numeric', 46 | hour: 'numeric', 47 | minute: 'numeric', 48 | hour12: true, 49 | }, 50 | }, 51 | ja: { 52 | short: { 53 | year: 'numeric', 54 | month: 'short', 55 | day: 'numeric', 56 | }, 57 | medium: { 58 | year: 'numeric', 59 | month: '2-digit', 60 | day: '2-digit', 61 | hour: '2-digit', 62 | minute: '2-digit', 63 | second: '2-digit', 64 | hourCycle: 'h23', 65 | hour12: false, 66 | }, 67 | long: { 68 | year: 'numeric', 69 | month: 'short', 70 | day: 'numeric', 71 | hour: 'numeric', 72 | minute: 'numeric', 73 | hour12: true, 74 | }, 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /pkg/guacd/parameters.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | // Network parameters 4 | 5 | const ( 6 | Hostname = "hostname" 7 | Port = "port" 8 | 9 | Username = "username" 10 | Password = "password" 11 | ) 12 | 13 | // Session Recording 14 | const ( 15 | RecordingPath = "recording-path" 16 | CreateRecordingPath = "create-recording-path" 17 | RecordingName = "recording-name" 18 | RecordingExcludeOutput = "recording-exclude-output" 19 | RecordingExcludeMouse = "recording-exclude-mouse" 20 | RecordingIncludeKeys = "recording-include-keys" 21 | ) 22 | 23 | // SFTP 24 | 25 | const ( 26 | EnableSftp = "enable-sftp" 27 | SftpHostname = "sftp-hostname" 28 | SftpPort = "sftp-port" 29 | SftpHostKey = "sftp-host-key" 30 | SftpUsername = "sftp-username" 31 | SftpPassword = "sftp-password" 32 | SftpPrivateKey = "sftp-private-key" 33 | SftpPassphrase = "sftp-passphrase" 34 | SftpDirectory = "sftp-directory" 35 | SftpRootDirectory = "sftp-root-directory" 36 | SftpServerAliveInterval = "sftp-server-alive-interval" 37 | SftpDisableDownload = "sftp-disable-download" 38 | SftpDisableUpload = "sftp-disable-upload" 39 | ) 40 | 41 | // Disabling clipboard access 42 | 43 | const ( 44 | DisableCopy = "disable-copy" 45 | DisablePaste = "disable-paste" 46 | ) 47 | 48 | // Wake-on-LAN Configuration 49 | 50 | const ( 51 | WolSendPacket = "wol-send-packet" 52 | WolMacAddr = "wol-mac-addr" 53 | WolBroadcastAddr = "wol-broadcast-addr" 54 | WolWaitTime = "wol-wait-time" 55 | ) 56 | 57 | const ( 58 | READONLY = "read-only" 59 | ) 60 | 61 | const ( 62 | BoolFalse = "false" 63 | BoolTrue = "true" 64 | ) 65 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lion-terminal", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build", 12 | "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", 13 | "lint:eslint": "eslint . --fix", 14 | "lint": "run-s lint:*", 15 | "format": "prettier --write src/" 16 | }, 17 | "dependencies": { 18 | "@tailwindcss/vite": "^4.1.10", 19 | "@vueuse/core": "^14.0.0", 20 | "alova": "^3.3.3", 21 | "guacamole-common-js": "1.5.0", 22 | "lucide-vue-next": "^0.525.0", 23 | "naive-ui": "^2.42.0", 24 | "pinia": "^3.0.1", 25 | "vue": "^3.5.13", 26 | "vue-i18n": "^11.1.7", 27 | "vue-router": "^4.5.0", 28 | "vue3-cookies": "^1.0.6" 29 | }, 30 | "devDependencies": { 31 | "@tsconfig/node22": "^22.0.1", 32 | "@types/node": "^22.14.0", 33 | "@vitejs/plugin-vue": "^5.2.3", 34 | "@vitejs/plugin-vue-jsx": "^4.1.2", 35 | "@vue/eslint-config-prettier": "^10.2.0", 36 | "@vue/eslint-config-typescript": "^14.5.0", 37 | "@vue/tsconfig": "^0.7.0", 38 | "eslint": "^9.22.0", 39 | "eslint-plugin-oxlint": "^0.16.0", 40 | "eslint-plugin-vue": "~10.0.0", 41 | "jiti": "^2.4.2", 42 | "npm-run-all2": "^7.0.2", 43 | "oxlint": "^0.16.0", 44 | "prettier": "3.5.3", 45 | "sass": "^1.89.2", 46 | "tailwindcss": "^4.1.10", 47 | "typescript": "~5.8.0", 48 | "unplugin-auto-import": "^19.3.0", 49 | "unplugin-vue-components": "^28.8.0", 50 | "vite": "^6.2.4", 51 | "vite-plugin-vue-devtools": "^7.7.2", 52 | "vue-eslint-parser": "^10.0.0", 53 | "vue-tsc": "^2.2.8" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Dockerfile.guacd: -------------------------------------------------------------------------------- 1 | FROM jumpserver/lion-base:20250818_063833 AS stage-build 2 | ARG TARGETARCH 3 | 4 | ARG GOPROXY=https://goproxy.io 5 | ENV CGO_ENABLED=0 6 | ENV GO111MODULE=on 7 | 8 | COPY . . 9 | 10 | WORKDIR /opt/lion/ui 11 | 12 | RUN yarn build 13 | 14 | WORKDIR /opt/lion/ 15 | 16 | ARG VERSION 17 | ENV VERSION=$VERSION 18 | 19 | RUN export GOFlAGS="-X 'main.Buildstamp=`date -u '+%Y-%m-%d %I:%M:%S%p'`'" \ 20 | && export GOFlAGS="${GOFlAGS} -X 'main.Githash=`git rev-parse HEAD`'" \ 21 | && export GOFlAGS="${GOFlAGS} -X 'main.Goversion=`go version`'" \ 22 | && export GOFlAGS="${GOFlAGS} -X 'main.Version=${VERSION}'" \ 23 | && go build -trimpath -x -ldflags "$GOFlAGS" -o lion . 24 | 25 | RUN chmod +x entrypoint.sh 26 | 27 | FROM guacamole/guacd:1.6.0 28 | ARG TARGETARCH 29 | ENV LANG=en_US.UTF-8 30 | 31 | USER root 32 | 33 | ARG DEPENDENCIES=" \ 34 | ca-certificates \ 35 | supervisor \ 36 | curl \ 37 | iputils-ping \ 38 | vim \ 39 | wget" 40 | RUN set -ex \ 41 | && apk update \ 42 | && apk add ${DEPENDENCIES} 43 | 44 | WORKDIR /opt/lion 45 | 46 | COPY --from=stage-build /usr/local/bin/check /usr/local/bin/check 47 | COPY --from=stage-build /opt/lion/ui/dist ui/dist/ 48 | COPY --from=stage-build /opt/lion/lion . 49 | COPY --from=stage-build /opt/lion/config_example.yml . 50 | COPY --from=stage-build /opt/lion/entrypoint.sh . 51 | COPY --from=stage-build /opt/lion/supervisord.conf /etc/supervisor/supervisord.conf 52 | 53 | ARG VERSION 54 | ENV VERSION=$VERSION 55 | 56 | VOLUME /opt/lion/data 57 | 58 | ENTRYPOINT ["./entrypoint.sh"] 59 | 60 | EXPOSE 8081 61 | 62 | STOPSIGNAL SIGQUIT 63 | 64 | CMD [ "supervisord", "-c", "/etc/supervisor/supervisord.conf" ] 65 | -------------------------------------------------------------------------------- /.github/workflows/build-ghcr-image.yml: -------------------------------------------------------------------------------- 1 | name: build image and push to ghcr.io 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | BRANCH: 7 | description: 'branch' 8 | type: string 9 | default: 'dev' 10 | VERSION: 11 | description: 'version' 12 | type: string 13 | default: 'dev' 14 | PLATFORMS: 15 | description: 'platforms' 16 | type: string 17 | default: 'linux/amd64,linux/arm64' 18 | IMAGE_TAG: 19 | description: "image tag" 20 | type: string 21 | default: 'dev' 22 | required: true 23 | jobs: 24 | build-and-push: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | ref: ${{ inputs.BRANCH }} 32 | 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | - name: Login to GitHub Container Registry 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.repository_owner }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Extract repository name 45 | id: repo 46 | run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV 47 | 48 | - name: Extract image prefix 49 | run: | 50 | echo "IMAGE_PREFIX=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV 51 | 52 | - name: Build and push multi-arch image 53 | uses: docker/build-push-action@v6 54 | with: 55 | context: . 56 | platforms: ${{ inputs.PLATFORMS }} 57 | push: true 58 | build-args: VERSION=${{ inputs.VERSION }} 59 | file: Dockerfile 60 | tags: ${{ env.IMAGE_PREFIX }}/${{ env.REPO }}:${{ inputs.IMAGE_TAG }} 61 | 62 | -------------------------------------------------------------------------------- /pkg/session/recorder.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "time" 5 | 6 | "lion/pkg/logger" 7 | 8 | "github.com/jumpserver-dev/sdk-go/model" 9 | "github.com/jumpserver-dev/sdk-go/service" 10 | "github.com/jumpserver-dev/sdk-go/storage" 11 | ) 12 | 13 | type CommandRecorder struct { 14 | sessionID string 15 | storage storage.CommandStorage 16 | 17 | queue chan *model.Command 18 | closed chan struct{} 19 | 20 | jmsService *service.JMService 21 | } 22 | 23 | func (c *CommandRecorder) Record(command *model.Command) { 24 | c.queue <- command 25 | } 26 | 27 | func (c *CommandRecorder) End() { 28 | select { 29 | case <-c.closed: 30 | return 31 | default: 32 | } 33 | close(c.closed) 34 | } 35 | 36 | func (c *CommandRecorder) record() { 37 | cmdList := make([]*model.Command, 0, 10) 38 | maxRetry := 0 39 | logger.Infof("Session %s: Command recorder start", c.sessionID) 40 | defer logger.Infof("Session %s: Command recorder close", c.sessionID) 41 | tick := time.NewTicker(time.Second * 10) 42 | defer tick.Stop() 43 | for { 44 | select { 45 | case <-c.closed: 46 | if len(cmdList) == 0 { 47 | return 48 | } 49 | case p, ok := <-c.queue: 50 | if !ok { 51 | return 52 | } 53 | cmdList = append(cmdList, p) 54 | if len(cmdList) < 5 { 55 | continue 56 | } 57 | case <-tick.C: 58 | if len(cmdList) == 0 { 59 | continue 60 | } 61 | } 62 | err := c.storage.BulkSave(cmdList) 63 | if err != nil && c.storage.TypeName() != "server" { 64 | logger.Warnf("Session %s: Switch default command storage save.", c.sessionID) 65 | err = c.jmsService.PushSessionCommand(cmdList) 66 | } 67 | if err == nil { 68 | cmdList = cmdList[:0] 69 | maxRetry = 0 70 | continue 71 | } 72 | if err != nil { 73 | logger.Errorf("Session %s: command bulk save err: %s", c.sessionID, err) 74 | } 75 | 76 | if maxRetry > 5 { 77 | cmdList = cmdList[1:] 78 | } 79 | maxRetry++ 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/build-base-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Base Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'pr*' 7 | paths: 8 | - 'go.mod' 9 | - 'Dockerfile-base' 10 | - 'ui/package.json' 11 | - 'ui/yarn.lock' 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Login to DockerHub 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Extract date 34 | id: vars 35 | run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV 36 | 37 | - name: Extract repository name 38 | id: repo 39 | run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV 40 | 41 | - name: Build and push multi-arch image 42 | uses: docker/build-push-action@v6 43 | with: 44 | platforms: linux/amd64,linux/arm64 45 | push: true 46 | file: Dockerfile-base 47 | tags: jumpserver/${{ env.REPO }}-base:${{ env.IMAGE_TAG }} 48 | 49 | - name: Update Dockerfile 50 | run: | 51 | sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile 52 | 53 | - name: Commit changes 54 | run: | 55 | git config --global user.name 'github-actions[bot]' 56 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 57 | git add Dockerfile 58 | git commit -m "perf: Update Dockerfile with new base image tag" 59 | git push 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /pkg/guacd/instrcution_name.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | // Streaming instructions 4 | const ( 5 | InstructionStreamingAck = "ack" 6 | InstructionStreamingArgv = "argv" 7 | InstructionStreamingAudio = "audio" 8 | InstructionStreamingBlob = "blob" 9 | InstructionStreamingClipboard = "clipboard" 10 | InstructionStreamingEnd = "end" 11 | InstructionStreamingFile = "file" 12 | InstructionStreamingImg = "img" 13 | InstructionStreamingNest = "nest" 14 | InstructionStreamingPipe = "pipe" 15 | InstructionStreamingVideo = "video" 16 | ) 17 | 18 | // Object instructions 19 | const ( 20 | InstructionObjectBody = "body" 21 | InstructionObjectFilesystem = "filesystem" 22 | InstructionObjectGet = "get" 23 | InstructionObjectPut = "put" 24 | InstructionObjectUndefine = "undefine" 25 | ) 26 | 27 | // Client handshake instructions 28 | const ( 29 | InstructionClientHandshakeAudio = "audio" 30 | InstructionClientHandshakeConnect = "connect" 31 | InstructionClientHandshakeImage = "image" 32 | InstructionClientHandshakeSelect = "select" 33 | InstructionClientHandshakeSize = "size" 34 | InstructionClientHandshakeTimezone = "timezone" 35 | InstructionClientHandshakeVideo = "video" 36 | ) 37 | 38 | // Server handshake instructions 39 | const ( 40 | InstructionServerHandshakeArgs = "args" 41 | ) 42 | 43 | // Client control instructions 44 | const ( 45 | InstructionClientDisconnect = "disconnect" 46 | InstructionClientNop = "nop" 47 | InstructionClientSync = "sync" 48 | ) 49 | 50 | // Server control instructions 51 | const ( 52 | InstructionServerDisconnect = "disconnect" 53 | InstructionServerError = "error" 54 | InstructionServerLog = "log" 55 | InstructionServerMouse = "mouse" 56 | ) 57 | 58 | // Client events 59 | const ( 60 | InstructionKey = "key" 61 | InstructionMouse = "mouse" 62 | InstructionSize = "size" 63 | ) 64 | 65 | const ( 66 | InstructionRequired = "required" 67 | ) 68 | -------------------------------------------------------------------------------- /pkg/guacd/instruction_test.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValidateInstructionString(t *testing.T) { 8 | tests := []string{ 9 | "1.a,2.bc,3.def,10.helloworld;", 10 | "4.test,5.test2;", 11 | "0.;", 12 | "3.foo;", 13 | "4.args,13.VERSION_1_3_0,8.hostname,4.port,6.domain,8.username,8.password," + 14 | "5.width,6.height,3.dpi,15.initial-program,11.color-depth," + 15 | "13.disable-audio,15.enable-printing,12.printer-name,12.enable-drive,10.drive-name," + 16 | "10.drive-path,17.create-drive-path,16.disable-download,14.disable-upload," + 17 | "7.console,13.console-audio,13.server-layout,8.security,11.ignore-cert," + 18 | "12.disable-auth,10.remote-app,14.remote-app-dir,15.remote-app-args,15.static-channels," + 19 | "11.client-name,16.enable-wallpaper,14.enable-theming,21.enable-font-smoothing,23.enable-full-window-drag," + 20 | "26.enable-desktop-composition,22.enable-menu-animations,22.disable-bitmap-caching,25.disable-offscreen-caching,21.disable-glyph-caching,16.preconnection-id,18.preconnection-blob,8.timezone,11.enable-sftp,13.sftp-hostname,13.sftp-host-key,9.sftp-port,13.sftp-username,13.sftp-password,16.sftp-private-key,15.sftp-passphrase,14.sftp-directory,19.sftp-root-directory,26.sftp-server-alive-interval,21.sftp-disable-download,19.sftp-disable-upload,14.recording-path,14.recording-name,24.recording-exclude-output,23.recording-exclude-mouse,22.recording-include-keys,21.create-recording-path,13.resize-method,18.enable-audio-input,9.read-only,16.gateway-hostname,12.gateway-port,14.gateway-domain,16.gateway-username,16.gateway-password,17.load-balance-info," + 21 | "12.disable-copy,13.disable-paste,15.wol-send-packet,12.wol-mac-addr,18.wol-broadcast-addr,13.wol-wait-time;", 22 | "5.audio,1.1,31.audio/L16;", 23 | "5e.audio,1.1,31.audio/L16;", 24 | ";", 25 | } 26 | 27 | for i := range tests { 28 | ins, err := ParseInstructionString(tests[i]) 29 | if err != nil { 30 | t.Log(err) 31 | continue 32 | } 33 | t.Log(ins) 34 | 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /pkg/tunnel/ws_error.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "strconv" 5 | 6 | "lion/pkg/guacd" 7 | ) 8 | 9 | type JMSGuacamoleError struct { 10 | code int 11 | msg string 12 | } 13 | 14 | func (g JMSGuacamoleError) String() string { 15 | ins := g.Instruction() 16 | return ins.String() 17 | } 18 | 19 | func (g JMSGuacamoleError) Instruction() guacd.Instruction { 20 | return guacd.NewInstruction( 21 | guacd.InstructionServerError, 22 | g.msg, 23 | strconv.Itoa(g.code)) 24 | } 25 | 26 | func NewJMSGuacamoleError(code int, msg string) JMSGuacamoleError { 27 | return JMSGuacamoleError{ 28 | code: code, 29 | msg: msg, 30 | } 31 | } 32 | 33 | const ( 34 | InstructionJmsEvent = "jms_event" 35 | ) 36 | 37 | func NewJmsEventInstruction(event string, jsonData string) guacd.Instruction { 38 | return guacd.NewInstruction(InstructionJmsEvent, event, jsonData) 39 | } 40 | 41 | // todo: 构造一种通用的错误框架,方便前后端处理异常 42 | 43 | func NewJMSIdleTimeOutError(min int) JMSGuacamoleError { 44 | return NewJMSGuacamoleError(1003, strconv.Itoa(min)) 45 | } 46 | 47 | func NewJMSMaxSessionTimeError(hour int) JMSGuacamoleError { 48 | return NewJMSGuacamoleError(1010, strconv.Itoa(hour)) 49 | } 50 | 51 | var ( 52 | ErrNoSession = NewJMSGuacamoleError(1000, "Not Found Session") 53 | 54 | ErrAuthUser = NewJMSGuacamoleError(1001, "Not auth user") 55 | 56 | ErrBadParams = NewJMSGuacamoleError(1002, "Not session params") 57 | 58 | //ErrIdleTimeOut = NewJMSGuacamoleError(1003, "Terminated by idle timeout") 59 | 60 | ErrPermissionExpired = NewJMSGuacamoleError(1004, "Terminated by permission expired") 61 | 62 | //ErrTerminatedByAdmin = NewJMSGuacamoleError(1005, "Terminated by Admin") 63 | 64 | ErrAPIFailed = NewJMSGuacamoleError(1006, "API failed") 65 | 66 | ErrGatewayFailed = NewJMSGuacamoleError(1007, "Gateway not available") 67 | 68 | ErrGuacamoleServer = NewJMSGuacamoleError(1008, "Connect guacamole server failed") 69 | 70 | ErrPermission = NewJMSGuacamoleError(256, "No permission") 71 | 72 | ErrDisconnect = NewJMSGuacamoleError(1009, "Disconnect by client") 73 | ) 74 | -------------------------------------------------------------------------------- /pkg/logger/gloable.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "gopkg.in/natefinch/lumberjack.v2" 11 | 12 | "lion/pkg/config" 13 | ) 14 | 15 | var globalLogger = &Logger{newLogger: log.New(os.Stdout, "", log.Lmsgprefix)} 16 | 17 | const logTimeFormat = "2006-01-02 15:04:05" 18 | 19 | func SetupLogger(conf *config.Config) { 20 | fileName := filepath.Join(conf.LogDirPath, "lion.log") 21 | loggerWriter := &lumberjack.Logger{ 22 | Filename: fileName, 23 | MaxSize: 5, 24 | MaxAge: 7, 25 | LocalTime: true, 26 | Compress: true, 27 | } 28 | writer := io.MultiWriter(loggerWriter, os.Stdout) 29 | l := log.New(writer, "", log.Lmsgprefix) 30 | globalLogger = &Logger{newLogger: l, level: ParseLevel(conf.LogLevel)} 31 | } 32 | 33 | func Debug(v ...interface{}) { 34 | globalLogger.Output(LevelDebug, fmt.Sprint(v...)) 35 | } 36 | 37 | func Debugf(format string, v ...interface{}) { 38 | globalLogger.Output(LevelDebug, fmt.Sprintf(format, v...)) 39 | } 40 | 41 | func Info(v ...interface{}) { 42 | globalLogger.Output(LevelInfo, fmt.Sprint(v...)) 43 | } 44 | 45 | func Infof(format string, v ...interface{}) { 46 | globalLogger.Output(LevelInfo, fmt.Sprintf(format, v...)) 47 | } 48 | 49 | func Warn(v ...interface{}) { 50 | globalLogger.Output(LevelWarn, fmt.Sprint(v...)) 51 | } 52 | 53 | func Warnf(format string, v ...interface{}) { 54 | globalLogger.Output(LevelWarn, fmt.Sprintf(format, v...)) 55 | } 56 | 57 | func Error(v ...interface{}) { 58 | globalLogger.Output(LevelError, fmt.Sprint(v...)) 59 | } 60 | 61 | func Errorf(format string, v ...interface{}) { 62 | globalLogger.Output(LevelError, fmt.Sprintf(format, v...)) 63 | } 64 | 65 | func Fatal(v ...interface{}) { 66 | globalLogger.Output(LevelFatal, fmt.Sprint(v...)) 67 | } 68 | 69 | func Fatalf(format string, v ...interface{}) { 70 | globalLogger.Output(LevelFatal, fmt.Sprintf(format, v...)) 71 | } 72 | 73 | func Panic(v ...interface{}) { 74 | globalLogger.Output(LevelPanic, fmt.Sprint(v...)) 75 | } 76 | 77 | func Panicf(format string, v ...interface{}) { 78 | globalLogger.Output(LevelPanic, fmt.Sprintf(format, v...)) 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Create Release And Upload assets 8 | 9 | jobs: 10 | create-release: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go_version: [ 'stable' ] 16 | node_version: [ '20' ] 17 | outputs: 18 | upload_url: ${{ steps.create_release.outputs.upload_url }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/cache@v4 22 | with: 23 | path: | 24 | ~/.npm 25 | ~/.cache 26 | ~/go/pkg/mod 27 | key: ${{ runner.os }}-build-${{ github.sha }} 28 | restore-keys: ${{ runner.os }}-build- 29 | 30 | - name: Get version 31 | id: get_version 32 | run: | 33 | TAG=$(basename ${GITHUB_REF}) 34 | echo "TAG=$TAG" >> $GITHUB_OUTPUT 35 | 36 | - name: Create Release 37 | id: create_release 38 | uses: release-drafter/release-drafter@v6 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | with: 42 | config-name: release-config.yml 43 | version: ${{ steps.get_version.outputs.TAG }} 44 | tag: ${{ steps.get_version.outputs.TAG }} 45 | 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node_version }} 49 | 50 | - uses: actions/setup-go@v5 51 | with: 52 | go-version: ${{ matrix.go_version }} 53 | cache: false 54 | 55 | - name: Make Build 56 | id: make_build 57 | run: | 58 | make all -s && ls build 59 | env: 60 | VERSION: ${{ steps.get_version.outputs.TAG }} 61 | 62 | - name: Release Upload Assets 63 | uses: softprops/action-gh-release@v2 64 | if: startsWith(github.ref, 'refs/tags/') 65 | with: 66 | draft: true 67 | files: | 68 | build/*.gz 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jumpserver/lion-base:20251125_031411 AS stage-build 2 | ARG TARGETARCH 3 | 4 | ARG GOPROXY=https://goproxy.io 5 | ENV CGO_ENABLED=0 6 | ENV GO111MODULE=on 7 | 8 | COPY . . 9 | 10 | WORKDIR /opt/lion/ui 11 | 12 | RUN yarn build 13 | 14 | WORKDIR /opt/lion/ 15 | 16 | ARG VERSION 17 | ENV VERSION=$VERSION 18 | 19 | RUN export GOFlAGS="-X 'main.Buildstamp=`date -u '+%Y-%m-%d %I:%M:%S%p'`'" \ 20 | && export GOFlAGS="${GOFlAGS} -X 'main.Githash=`git rev-parse HEAD`'" \ 21 | && export GOFlAGS="${GOFlAGS} -X 'main.Goversion=`go version`'" \ 22 | && export GOFlAGS="${GOFlAGS} -X 'main.Version=${VERSION}'" \ 23 | && go build -trimpath -x -ldflags "$GOFlAGS" -o lion . 24 | 25 | RUN chmod +x entrypoint.sh 26 | 27 | FROM jumpserver/guacd:1.5.5-bullseye 28 | ARG TARGETARCH 29 | ENV LANG=en_US.UTF-8 30 | USER root 31 | ARG DEPENDENCIES=" \ 32 | ca-certificates \ 33 | supervisor" 34 | 35 | ARG PREFIX_DIR=/opt/guacamole 36 | ENV LD_LIBRARY_PATH=${PREFIX_DIR}/lib 37 | 38 | ARG APT_MIRROR=http://deb.debian.org 39 | 40 | RUN set -ex \ 41 | && sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \ 42 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 43 | && apt-get update \ 44 | && apt-get install -y --no-install-recommends ${DEPENDENCIES} \ 45 | && apt-get install -y --no-install-recommends $(cat "${PREFIX_DIR}"/DEPENDENCIES) \ 46 | && apt-get clean all \ 47 | && rm -rf /var/lib/apt/lists/* \ 48 | && mkdir -p /lib32 /libx32 49 | 50 | WORKDIR /opt/lion 51 | 52 | COPY --from=stage-build /usr/local/bin/check /usr/local/bin/check 53 | COPY --from=stage-build /opt/lion/ui/dist ui/dist/ 54 | COPY --from=stage-build /opt/lion/lion . 55 | COPY --from=stage-build /opt/lion/config_example.yml . 56 | COPY --from=stage-build /opt/lion/entrypoint.sh . 57 | COPY --from=stage-build /opt/lion/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 58 | 59 | ARG VERSION 60 | ENV VERSION=$VERSION 61 | 62 | VOLUME /opt/lion/data 63 | 64 | ENTRYPOINT ["./entrypoint.sh"] 65 | 66 | EXPOSE 8081 67 | 68 | STOPSIGNAL SIGQUIT 69 | 70 | CMD [ "supervisord", "-c", "/etc/supervisor/supervisord.conf" ] 71 | -------------------------------------------------------------------------------- /.github/workflows/jms-build-test.yml.disabled: -------------------------------------------------------------------------------- 1 | name: "Run Build Test" 2 | on: 3 | push: 4 | paths: 5 | - 'Dockerfile' 6 | - 'Dockerfile*' 7 | - 'Dockerfile-*' 8 | - 'go.mod' 9 | - 'go.sum' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | component: [lion] 17 | version: [v4] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: docker/setup-buildx-action@v3 21 | 22 | - name: Prepare Build 23 | run: | 24 | sed -i 's@registry.npmmirror.com@registry.yarnpkg.com@g' ui/yarn.lock 25 | sed -i 's@^FROM registry.fit2cloud.com/jumpserver@FROM ghcr.io/jumpserver@g' Dockerfile 26 | sed -i 's@^FROM registry.fit2cloud.com/jumpserver@FROM ghcr.io/jumpserver@g' Dockerfile-ee 27 | 28 | - name: Login to GitHub Container Registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build CE Image 36 | uses: docker/build-push-action@v5 37 | with: 38 | context: . 39 | push: true 40 | file: Dockerfile 41 | tags: ghcr.io/jumpserver/${{ matrix.component }}:${{ matrix.version }}-ce 42 | platforms: linux/amd64 43 | build-args: | 44 | VERSION=${{ matrix.version }} 45 | GOPROXY=direct 46 | APT_MIRROR=http://deb.debian.org 47 | NPM_REGISTRY=https://registry.yarnpkg.com 48 | outputs: type=image,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | 52 | - name: Build EE Image 53 | uses: docker/build-push-action@v5 54 | with: 55 | context: . 56 | push: false 57 | file: Dockerfile-ee 58 | tags: ghcr.io/jumpserver/${{ matrix.component }}:${{ matrix.version }} 59 | platforms: linux/amd64 60 | build-args: | 61 | VERSION=${{ matrix.version }} 62 | APT_MIRROR=http://deb.debian.org 63 | outputs: type=image,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /pkg/guacd/information.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | const ( 4 | defaultOptimalScreenWidth = 1024 5 | defaultOptimalScreenHeight = 768 6 | defaultOptimalResolution = 96 7 | 8 | defaultTimezone = "Asia/Shanghai" 9 | ) 10 | 11 | func NewClientInformation() ClientInformation { 12 | return ClientInformation{ 13 | OptimalScreenWidth: defaultOptimalScreenWidth, 14 | OptimalScreenHeight: defaultOptimalScreenHeight, 15 | OptimalResolution: defaultOptimalResolution, 16 | Timezone: defaultTimezone, 17 | AudioMimetypes: []string{"audio/L8", "audio/L16"}, 18 | ImageMimetypes: []string{"image/jpeg", "image/png", "image/webp"}, 19 | VideoMimetypes: []string{}, 20 | } 21 | } 22 | 23 | type ClientInformation struct { 24 | /** 25 | * The optimal screen width requested by the client, in pixels. 26 | */ 27 | OptimalScreenWidth int 28 | 29 | /** 30 | * The optimal screen height requested by the client, in pixels. 31 | */ 32 | OptimalScreenHeight int 33 | 34 | /** 35 | * The resolution of the optimal dimensions given, in DPI. 36 | */ 37 | OptimalResolution int 38 | 39 | /** 40 | * The list of audio mimetypes reported by the client to be supported. 41 | */ 42 | AudioMimetypes []string 43 | 44 | /** 45 | * The list of video mimetypes reported by the client to be supported. 46 | */ 47 | VideoMimetypes []string 48 | 49 | /** 50 | * The list of image mimetypes reported by the client to be supported. 51 | */ 52 | ImageMimetypes []string 53 | 54 | /** 55 | * The timezone reported by the client. 56 | */ 57 | Timezone string 58 | 59 | /** 60 | * qwerty keyboard layout 61 | */ 62 | KeyboardLayout string 63 | } 64 | 65 | func (info *ClientInformation) ExtraConfig() map[string]string { 66 | ret := make(map[string]string) 67 | if layout, ok := RDPServerLayouts[info.KeyboardLayout]; ok { 68 | ret[RDPServerLayout] = layout 69 | } 70 | return ret 71 | } 72 | 73 | func (info *ClientInformation) Clone() ClientInformation { 74 | return ClientInformation{ 75 | OptimalScreenWidth: info.OptimalScreenWidth, 76 | OptimalScreenHeight: info.OptimalScreenHeight, 77 | OptimalResolution: info.OptimalResolution, 78 | ImageMimetypes: []string{"image/jpeg", "image/png", "image/webp"}, 79 | Timezone: info.Timezone, 80 | KeyboardLayout: info.KeyboardLayout, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/components/SessionShare/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 75 | -------------------------------------------------------------------------------- /ui/src/components/KeyboardOption.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 54 | 55 | 75 | -------------------------------------------------------------------------------- /ui/src/components/CombinationKey.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 101 | -------------------------------------------------------------------------------- /pkg/guacd/qwertz.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | var RDPServerLayouts = map[string]string{ 4 | "de-de-qwertz": "de-de-qwertz", 5 | "de-ch-qwertz": "de-ch-qwertz", 6 | "en-gb-qwerty": "en-gb-qwerty", 7 | "en-us-qwerty": "en-us-qwerty", 8 | "es-es-qwerty": "es-es-qwerty", 9 | "es-latam-qwerty": "es-latam-qwerty", 10 | "failsafe": "failsafe", 11 | "fr-be-azerty": "fr-be-azerty", 12 | "fr-fr-azerty": "fr-fr-azerty", 13 | "fr-ca-qwerty": "fr-ca-qwerty", 14 | "fr-ch-qwertz": "fr-ch-qwertz", 15 | "hu-hu-qwertz": "hu-hu-qwertz", 16 | "it-it-qwerty": "it-it-qwerty", 17 | "ja-jp-qwerty": "ja-jp-qwerty", 18 | "no-no-qwerty": "no-no-qwerty", 19 | "pl-pl-qwerty": "pl-pl-qwerty", 20 | "pt-br-qwerty": "pt-br-qwerty", 21 | "pt-pt-qwerty": "pt-pt-qwerty", 22 | "ro-ro-qwerty": "ro-ro-qwerty", 23 | "sv-se-qwerty": "sv-se-qwerty", 24 | "da-dk-qwerty": "da-dk-qwerty", 25 | "tr-tr-qwerty": "tr-tr-qwerty", 26 | } 27 | 28 | /* 29 | https://github.com/apache/guacamole-client/blob/fe6677bf4ebaa8662418013ab1af8c7060224ef5/guacamole/src/main/frontend/src/translations/zh.json 30 | 31 | "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Swiss German (Qwertz)", 32 | "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)", 33 | "FIELD_OPTION_SERVER_LAYOUT_EMPTY" : "", 34 | "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK English (Qwerty)", 35 | "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)", 36 | "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Spanish (Qwerty)", 37 | "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)", 38 | "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE" : "Unicode", 39 | "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)", 40 | "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)", 41 | "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)", 42 | "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)", 43 | "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)", 44 | "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)", 45 | "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)", 46 | "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norwegian (Qwerty)", 47 | "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)", 48 | "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)", 49 | "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portuguese (Qwerty)", 50 | "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY" : "Romanian (Qwerty)", 51 | "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)", 52 | "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)", 53 | "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)", 54 | */ 55 | -------------------------------------------------------------------------------- /pkg/guacd/keyboard_map.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | var keyidentifierKeysym = map[int]string{ 4 | 0xFF3D: "AllCandidates", 5 | 0xFF30: "Alphanumeric", 6 | 0xFFE9: "Alt", 7 | 0xFE03: "Alt", 8 | 0xFD0E: "Attn", 9 | 0xFF54: "ArrowDown", 10 | 0xFF51: "ArrowLeft", 11 | 0xFF53: "ArrowRight", 12 | 0xFF52: "ArrowUp", 13 | 0xFF08: "Backspace", 14 | 0xFFE5: "CapsLock", 15 | 0xFF69: "Cancel", 16 | 0xFF0B: "Clear", 17 | 0xFF21: "Convert", 18 | 0xFD15: "Copy", 19 | 0xFD1C: "Crsel", 20 | 0xFF37: "CodeInput", 21 | 0xFF20: "Compose", 22 | 0xFFE3: "Control", 23 | 0xFFE4: "Control", 24 | 0xFF67: "ContextMenu", 25 | 0xFFFF: "Delete", 26 | 0xFF57: "End", 27 | 0xFF0D: "Enter\r", 28 | 0xFD06: "EraseEof", 29 | 0xFF1B: "Escape", 30 | 0xFF62: "Execute", 31 | 0xFD1D: "Exsel", 32 | 0xFFBE: "F1", 33 | 0xFFBF: "F2", 34 | 0xFFC0: "F3", 35 | 0xFFC1: "F4", 36 | 0xFFC2: "F5", 37 | 0xFFC3: "F6", 38 | 0xFFC4: "F7", 39 | 0xFFC5: "F8", 40 | 0xFFC6: "F9", 41 | 0xFFC7: "F10", 42 | 0xFFC8: "F11", 43 | 0xFFC9: "F12", 44 | 0xFFCA: "F13", 45 | 0xFFCB: "F14", 46 | 0xFFCC: "F15", 47 | 0xFFCD: "F16", 48 | 0xFFCE: "F17", 49 | 0xFFCF: "F18", 50 | 0xFFD0: "F19", 51 | 0xFFD1: "F20", 52 | 0xFFD2: "F21", 53 | 0xFFD3: "F22", 54 | 0xFFD4: "F23", 55 | 0xFFD5: "F24", 56 | 0xFF68: "Find", 57 | 0xFE0C: "GroupFirst", 58 | 0xFE0E: "GroupLast", 59 | 0xFE08: "GroupNext", 60 | 0xFE0A: "GroupPrevious", 61 | 0xFF31: "HangulMode", 62 | 0xFF29: "Hankaku", 63 | 0xFF34: "HanjaMode", 64 | 0xFF6A: "Help", 65 | 0xFF25: "Hiragana", 66 | 0xFF27: "HiraganaKatakana", 67 | 0xFF50: "Home", 68 | 0xFFED: "Hyper", 69 | 0xFFEE: "Hyper", 70 | 0xFF63: "Insert", 71 | 0xFF24: "JapaneseRomaji", 72 | 0xFF38: "JunjaMode", 73 | 0xFF2D: "KanaMode", 74 | 0xFF26: "Katakana", 75 | 0xFFE7: "Meta", 76 | 0xFFE8: "Meta", 77 | 0xFF7E: "ModeChange", 78 | 0xFF7F: "NumLock", 79 | 0xFF56: "PageDown", 80 | 0xFF55: "PageUp", 81 | 0xFF13: "Pause", 82 | 0xFD16: "Play", 83 | 0xFF3E: "PreviousCandidate", 84 | 0xFF61: "PrintScreen", 85 | 0xFF66: "Redo", 86 | 0xFF14: "Scroll", 87 | 0xFF60: "Select", 88 | 0xFFAC: "Separator", 89 | 0xFFE1: "Shift", 90 | 0xFFE2: "Shift", 91 | 0xFF3C: "SingleCandidate", 92 | 0xFFEC: "Super", 93 | 0xFF09: "Tab", 94 | 0xFF65: "Undo", 95 | 0xFFEB: "Win", 96 | 0xFF28: "Zenkaku", 97 | 0xFF2A: "ZenkakuHankaku", 98 | } 99 | 100 | const ( 101 | KeyPress = "1" 102 | KeyRelease = "0" 103 | ) 104 | 105 | const ( 106 | MouseLeft = "1" 107 | MouseMiddle = "2" 108 | MouseRight = "4" 109 | MouseUp = "8" 110 | MouseDown = "16" 111 | ) 112 | 113 | const ( 114 | KeyCodeUnknown = "Unknown" 115 | ) 116 | 117 | func KeysymToCharacter(keysym int) string { 118 | return keyidentifierKeysym[keysym] 119 | } 120 | -------------------------------------------------------------------------------- /pkg/tunnel/stream_input.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "encoding/base64" 5 | "io" 6 | "sync" 7 | 8 | "lion/pkg/guacd" 9 | "lion/pkg/logger" 10 | ) 11 | 12 | type InputStreamInterceptingFilter struct { 13 | tunnel *Connection 14 | streams map[string]*InputStreamResource 15 | sync.Mutex 16 | } 17 | 18 | func (filter *InputStreamInterceptingFilter) Filter(unfilteredInstruction *guacd.Instruction) *guacd.Instruction { 19 | 20 | if unfilteredInstruction.Opcode == guacd.InstructionStreamingAck { 21 | filter.handleAck(unfilteredInstruction) 22 | } 23 | return unfilteredInstruction 24 | } 25 | 26 | func (filter *InputStreamInterceptingFilter) handleAck(unfilteredInstruction *guacd.Instruction) { 27 | filter.Lock() 28 | defer filter.Unlock() 29 | // Verify all required arguments are present 30 | args := unfilteredInstruction.Args 31 | if len(args) < 3 { 32 | return 33 | } 34 | index := args[0] 35 | if stream, ok := filter.streams[index]; ok { 36 | status := args[2] 37 | if status != "0" { 38 | return 39 | } 40 | 41 | // Send next blob 42 | filter.readNextBlob(stream) 43 | 44 | //stream.reader.Read() 45 | } 46 | } 47 | 48 | func (filter *InputStreamInterceptingFilter) readNextBlob(stream *InputStreamResource) { 49 | buf := make([]byte, 6048) 50 | nr, err := stream.reader.Read(buf) 51 | if nr > 0 { 52 | filter.sendBlob(stream.streamIndex, buf[:nr]) 53 | } 54 | if err != nil { 55 | if err != io.EOF { 56 | stream.err = err 57 | } 58 | filter.closeInterceptedStream(stream.streamIndex) 59 | return 60 | } 61 | 62 | } 63 | 64 | func (filter *InputStreamInterceptingFilter) sendBlob(index string, p []byte) { 65 | err := filter.tunnel.WriteTunnelMessage(guacd.NewInstruction( 66 | guacd.InstructionStreamingBlob, index, base64.StdEncoding.EncodeToString(p))) 67 | if err != nil { 68 | logger.Error(err) 69 | } 70 | } 71 | 72 | func (filter *InputStreamInterceptingFilter) closeInterceptedStream(index string) { 73 | if outStream, ok := filter.streams[index]; ok { 74 | close(outStream.done) 75 | } 76 | delete(filter.streams, index) 77 | } 78 | 79 | func (filter *InputStreamInterceptingFilter) addInputStream(stream *InputStreamResource) { 80 | filter.Lock() 81 | defer filter.Unlock() 82 | filter.streams[stream.streamIndex] = stream 83 | filter.readNextBlob(stream) 84 | } 85 | 86 | // 上传文件的对象 87 | type InputStreamResource struct { 88 | streamIndex string 89 | //mediaType string // application/octet-stream 90 | reader io.ReadCloser 91 | done chan struct{} 92 | 93 | err error 94 | } 95 | 96 | func (r *InputStreamResource) Wait() { 97 | <-r.done 98 | } 99 | 100 | func (r *InputStreamResource) WaitErr() error { 101 | return r.err 102 | } 103 | -------------------------------------------------------------------------------- /ui/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeFilename(filename: string): string { 2 | return filename.replace(/[\\\/]+/g, '_'); 3 | } 4 | 5 | export const FileType = { 6 | NORMAL: 'NORMAL', 7 | DIRECTORY: 'DIRECTORY', 8 | }; 9 | 10 | export function isDirectory(guacFile: { type: string }): boolean { 11 | return guacFile.type === FileType.DIRECTORY; 12 | } 13 | 14 | let streamOrigin; 15 | // Work-around for IE missing window.location.origin 16 | if (!window.location.origin) { 17 | streamOrigin = 18 | window.location.protocol + 19 | '//' + 20 | window.location.hostname + 21 | (window.location.port ? ':' + window.location.port : ''); 22 | } else { 23 | streamOrigin = window.location.origin; 24 | } 25 | const scheme = document.location.protocol === 'https:' ? 'wss' : 'ws'; 26 | const port = document.location.port ? ':' + document.location.port : ''; 27 | const BASE_WS_URL = scheme + '://' + document.location.hostname + port; 28 | const BASE_URL = document.location.protocol + '//' + document.location.hostname + port; 29 | export { BASE_WS_URL, BASE_URL }; 30 | 31 | export const OriginSite = streamOrigin; 32 | 33 | export const BaseAPIURL = streamOrigin + '/lion/api'; 34 | 35 | const sessionBaseAPI = '/api'; 36 | const wsURL = '/lion/ws/connect/'; 37 | const monitorWsURL = '/lion/ws/monitor/'; 38 | 39 | export function getCurrentConnectParams() { 40 | const urlParams = getURLParams(); 41 | const data: any = {}; 42 | urlParams.forEach(function (value, key, parent) { 43 | data[key] = value; 44 | }); 45 | const result: any = {}; 46 | result['data'] = data; 47 | result['ws'] = wsURL; 48 | result['api'] = sessionBaseAPI; 49 | return result; 50 | } 51 | 52 | export function getMonitorConnectParams() { 53 | const urlParams = getURLParams(); 54 | const data: any = {}; 55 | urlParams.forEach(function (value, key, parent) { 56 | data[key] = value; 57 | }); 58 | const result: any = {}; 59 | result['data'] = data; 60 | result['ws'] = monitorWsURL; 61 | return result; 62 | } 63 | 64 | export function getURLParams() { 65 | return new URLSearchParams(window.location.search.slice(1)); 66 | } 67 | 68 | export function localStorageGet(key: string): string | object | null { 69 | let data = localStorage.getItem(key); 70 | if (!data) { 71 | return data; 72 | } 73 | try { 74 | data = JSON.parse(data); 75 | return data; 76 | } catch (e) { 77 | // 78 | } 79 | return data; 80 | } 81 | 82 | export function getCookie(name: string): string | undefined { 83 | const match = document.cookie.match(new RegExp(name + '=([^;]+)')); 84 | return match ? match[1] : undefined; 85 | } 86 | 87 | export function CopyTextToClipboard(text: string) { 88 | const transfer = document.createElement('textarea'); 89 | document.body.appendChild(transfer); 90 | transfer.value = text; 91 | transfer.focus(); 92 | transfer.select(); 93 | document.execCommand('copy'); 94 | document.body.removeChild(transfer); 95 | } 96 | -------------------------------------------------------------------------------- /pkg/guacd/parameters_rdp.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | // Network parameters 4 | const ( 5 | RDPHostname = "hostname" 6 | RDPPort = "port" // default 3389 7 | ) 8 | 9 | // Authentication and security 10 | 11 | const ( 12 | RDPUsername = "username" 13 | RDPPassword = "password" 14 | RDPDomain = "domain" 15 | RDPSecurity = "security" // any | nla | nla-ext | tls | vmconnect | rdp 16 | RDPIgnoreCert = "ignore-cert" 17 | RDPDisableAuth = "disable-auth" 18 | ) 19 | 20 | // Session settings 21 | 22 | const ( 23 | RDPClientName = "client-name" 24 | RDPConsole = "console" 25 | RDPInitialProgram = "initial-program" 26 | RDPServerLayout = "server-layout" 27 | RDPTimezone = "timezone" 28 | ) 29 | 30 | // Display settings 31 | 32 | const ( 33 | RDPColorDepth = "color-depth" 34 | RDPWidth = "width" 35 | RDPHeight = "height" 36 | RDPDpi = "dpi" 37 | RDPResizeMethod = "resize-method" // display-update| reconnect 38 | ) 39 | 40 | // Device redirection 41 | // https://tools.ietf.org/html/rfc4856 42 | const ( 43 | RDPDisableAudio = "disable-audio" 44 | RDPEnableAudioInput = "enable-audio-input" 45 | RDPPrinterName = "printer-name" 46 | RDPEnableDrive = "enable-drive" 47 | RDPDisableDownload = "disable-download" 48 | RDPDisableUpload = "disable-upload" 49 | RDPDriveName = "drive-name" 50 | RDPDrivePath = "drive-path" 51 | RDPCreateDrivePath = "create-drive-path" 52 | RDPConsoleAudio = "console-audio" 53 | RDPStaticChannels = "static-channels" 54 | ) 55 | 56 | // Preconnection PDU (Hyper-V / VMConnect) 57 | 58 | const ( 59 | RDPPreConnectionId = "preconnection-id" 60 | RDPPreConnectionBlob = "preconnection-blob" 61 | ) 62 | 63 | // Remote desktop gateway 64 | 65 | const ( 66 | RDPGatewayHostname = "gateway-hostname" 67 | RDPGatewayPort = "gateway-port" 68 | RDPGatewayUsername = "gateway-username" 69 | RDPGatewayPassword = "gateway-password" 70 | RDPGatewayDomain = "gateway-domain" 71 | ) 72 | 73 | // Load balancing and RDP connection brokers 74 | 75 | const ( 76 | RDPLoadBalanceInfo = "load-balance-info" 77 | ) 78 | 79 | // Performance flags 80 | 81 | const ( 82 | RDPEnableWallpaper = "enable-wallpaper" 83 | RDPEnableTheming = "enable-theming" 84 | RDPEnableFontSmoothing = "enable-font-smoothing" 85 | RDPEnableFullWindowDrag = "enable-full-window-drag" 86 | RDPEnableDesktopComposition = "enable-desktop-composition" 87 | RDPEnableMenuAnimations = "enable-menu-animations" 88 | RDPDisableBitmapCaching = "disable-bitmap-caching" 89 | RDPDisableOffscreenCaching = "disable-offscreen-caching" 90 | RDPDisableGlyphCaching = "disable-glyph-caching" 91 | ) 92 | 93 | // RemoteApp 94 | 95 | const ( 96 | RDPRemoteApp = "remote-app" 97 | RDPRemoteAppDir = "remote-app-dir" 98 | RDPRemoteAppArgs = "remote-app-args" 99 | ) 100 | -------------------------------------------------------------------------------- /pkg/guacd/instruction.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type Instruction struct { 11 | Opcode string 12 | Args []string 13 | ProtocolForm string 14 | } 15 | 16 | func NewInstruction(opcode string, args ...string) (ret Instruction) { 17 | ret.Opcode = opcode 18 | ret.Args = args 19 | return ret 20 | } 21 | 22 | // 构造 `OPCODE,ARG1,ARG2,ARG3,...;` 的格式 23 | func (opt *Instruction) String() string { 24 | if len(opt.ProtocolForm) > 0 { 25 | return opt.ProtocolForm 26 | } 27 | opt.ProtocolForm = fmt.Sprintf("%d.%s", len(opt.Opcode), opt.Opcode) 28 | for _, value := range opt.Args { 29 | opt.ProtocolForm += fmt.Sprintf(",%d.%s", len([]rune(value)), value) 30 | } 31 | opt.ProtocolForm += semicolonDelimiter 32 | return opt.ProtocolForm 33 | } 34 | 35 | const ( 36 | semicolonDelimiter = ";" 37 | ) 38 | 39 | const ( 40 | ByteDotDelimiter = '.' 41 | ByteCommaDelimiter = ',' 42 | ByteSemicolonDelimiter = ';' 43 | ) 44 | 45 | var ( 46 | ErrInstructionMissSemicolon = errors.New("instruction without semicolon") 47 | ErrInstructionMissDot = errors.New("instruction without dot") 48 | ErrInstructionBadDigit = errors.New("instruction with bad digit") 49 | ErrInstructionBadContent = errors.New("instruction with bad Content") 50 | ) 51 | 52 | // raw 是以 `;` 为结束符的原生字符串 53 | func ParseInstructionString(raw string) (ret Instruction, err error) { 54 | if !strings.HasSuffix(raw, semicolonDelimiter) { 55 | return Instruction{}, fmt.Errorf("%w: %s", ErrInstructionMissSemicolon, raw) 56 | } 57 | raw = trimSuffixSemicolonDelimiter(raw) 58 | rawRune := []rune(raw) 59 | args := make([]string, 0, 1024) 60 | var i = 0 61 | for len(rawRune) > 0 { 62 | switch rawRune[i] { 63 | case ByteCommaDelimiter, ByteSemicolonDelimiter: 64 | // 重置数据 65 | rawRune = rawRune[i+1:] 66 | i = 0 67 | continue 68 | case ByteDotDelimiter: 69 | // 解析 LENGTH.VALUE 格式的数据 70 | dotIndex := rawRune[:i] 71 | argContentLen, err := strconv.Atoi(string(dotIndex)) 72 | if err != nil { 73 | return Instruction{}, fmt.Errorf("%w: %s", 74 | ErrInstructionBadDigit, err.Error()) 75 | } 76 | if argContentLen > len(rawRune[i+1:]) { 77 | return Instruction{}, fmt.Errorf("%w: %s", 78 | ErrInstructionBadContent, raw) 79 | } 80 | argContent := string(rawRune[i+1 : argContentLen+i+1]) 81 | args = append(args, argContent) 82 | rawRune = rawRune[argContentLen+i+1:] 83 | i = 0 84 | continue 85 | default: 86 | i++ 87 | } 88 | if i >= len(rawRune) { 89 | return Instruction{}, fmt.Errorf("%w: %s", ErrInstructionBadContent, raw) 90 | } 91 | } 92 | if len(args) < 1 { 93 | return Instruction{}, fmt.Errorf("%w: no content", ErrInstructionBadContent) 94 | } 95 | return NewInstruction(args[0], args[1:]...), nil 96 | } 97 | 98 | func trimSuffixSemicolonDelimiter(content string) string { 99 | return strings.TrimSuffix(content, semicolonDelimiter) 100 | } 101 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=lion 2 | BUILDDIR=build 3 | 4 | VERSION ?= Unknown 5 | BuildTime := $(shell date -u '+%Y-%m-%d %I:%M:%S%p') 6 | COMMIT := $(shell git rev-parse HEAD) 7 | GOVERSION := $(shell go version) 8 | 9 | GOOS := $(shell go env GOOS) 10 | GOARCH := $(shell go env GOARCH) 11 | 12 | LDFLAGS=-w -s 13 | 14 | GOLDFLAGS=-X 'main.Version=$(VERSION)' 15 | GOLDFLAGS+=-X 'main.Buildstamp=$(BuildTime)' 16 | GOLDFLAGS+=-X 'main.Githash=$(COMMIT)' 17 | GOLDFLAGS+=-X 'main.Goversion=$(GOVERSION)' 18 | 19 | GOBUILD=CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS) ${LDFLAGS}" 20 | 21 | UIDIR=ui 22 | 23 | define make_artifact_full 24 | GOOS=$(1) GOARCH=$(2) $(GOBUILD) -o $(BUILDDIR)/$(NAME)-$(1)-$(2) 25 | mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/$(UIDIR)/dist/ 26 | cp $(BUILDDIR)/$(NAME)-$(1)-$(2) $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/$(NAME) 27 | cp README.md $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/README.md 28 | cp LICENSE $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/LICENSE 29 | cp config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/config_example.yml 30 | cp entrypoint.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/entrypoint.sh 31 | cp supervisord.conf $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/supervisord.conf 32 | cp -r $(UIDIR)/dist/* $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/$(UIDIR)/dist/ 33 | 34 | cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$(1)-$(2).tar.gz $(NAME)-$(VERSION)-$(1)-$(2) 35 | rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2) $(BUILDDIR)/$(NAME)-$(1)-$(2) 36 | endef 37 | 38 | build: 39 | GOARCH=$(GOARCH) GOOS=$(GOOS) $(GOBUILD) -o $(BUILDDIR)/$(NAME) . 40 | 41 | all: lion-ui 42 | $(call make_artifact_full,darwin,amd64) 43 | $(call make_artifact_full,darwin,arm64) 44 | $(call make_artifact_full,linux,amd64) 45 | $(call make_artifact_full,linux,arm64) 46 | $(call make_artifact_full,linux,mips64le) 47 | $(call make_artifact_full,linux,ppc64le) 48 | $(call make_artifact_full,linux,s390x) 49 | $(call make_artifact_full,linux,riscv64) 50 | $(call make_artifact_full,linux,loong64) 51 | 52 | local: lion-ui 53 | $(call make_artifact_full,$(shell go env GOOS),$(shell go env GOARCH)) 54 | 55 | darwin-amd64: lion-ui 56 | $(call make_artifact_full,darwin,amd64) 57 | 58 | darwin-arm64: lion-ui 59 | $(call make_artifact_full,darwin,arm64) 60 | 61 | linux-amd64: lion-ui 62 | $(call make_artifact_full,linux,amd64) 63 | 64 | linux-arm64: lion-ui 65 | $(call make_artifact_full,linux,arm64) 66 | 67 | linux-loong64: lion-ui 68 | $(call make_artifact_full,linux,loong64) 69 | 70 | linux-ppc64le: lion-ui 71 | $(call make_artifact_full,linux,ppc64le) 72 | 73 | linux-mips64le: lion-ui 74 | $(call make_artifact_full,linux,mips64le) 75 | 76 | linux-s390x: lion-ui 77 | $(call make_artifact_full,linux,s390x) 78 | 79 | linux-riscv64: lion-ui 80 | $(call make_artifact_full,linux,riscv64) 81 | 82 | .PHONY: docker 83 | docker: 84 | docker buildx build --build-arg VERSION=$(VERSION) -t jumpserver/lion:$(VERSION) . 85 | 86 | lion-ui: 87 | @echo "build ui" 88 | @cd $(UIDIR) && yarn install && yarn build 89 | 90 | clean: 91 | rm -rf $(BUILDDIR) 92 | -rm -rf $(UIDIR)/dist 93 | -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /ui/src/utils/status.ts: -------------------------------------------------------------------------------- 1 | export const ErrorStatusCodes: any = { 2 | 256: 'GuaErrUnSupport', 3 | 514: 'GuaErrUpStreamTimeout', 4 | 521: 'GuaErrSessionConflict', 5 | 769: 'GuaErrClientUnauthorized', 6 | 1000: 'JMSErrNoSession', 7 | 1001: 'JMSErrAuthUser', 8 | 1002: 'JMSErrBadParams', 9 | 1003: 'JMSErrIdleTimeOut', 10 | 1004: 'JMSErrPermissionExpired', 11 | 1005: 'JMSErrTerminatedByAdmin', 12 | 1006: 'JMSErrAPIFailed', 13 | 1007: 'JMSErrGatewayFailed', 14 | 1008: 'JMSErrGuacamoleServer', 15 | 1009: 'JMSErrDisconnected', 16 | 1010: 'JMSErrMaxSession', 17 | 1011: 'JMSErrRemoveShareUser', 18 | }; 19 | 20 | export function ConvertAPIError(errMsg: string | any): string { 21 | if (typeof errMsg !== 'string') { 22 | return errMsg; 23 | } 24 | const errArray = errMsg.split(':'); 25 | if (errArray.length >= 1) { 26 | return APIErrorType[errArray[0]] || errMsg; 27 | } 28 | return errMsg; 29 | } 30 | 31 | export const APIErrorType: any = { 32 | 'connect API core err': 'JMSErrAPIFailed', 33 | 'connect Panda API core err': 'JMSErrAPIFailed', 34 | 'unsupported type': 'JMSErrBadParams', 35 | 'unsupported protocol': 'JMSErrBadParams', 36 | 'permission deny': 'JMSErrPermission', 37 | }; 38 | 39 | export function ConvertGuacamoleError(errMsg: string | any): string { 40 | if (typeof errMsg !== 'string') { 41 | return errMsg; 42 | } 43 | return GuacamoleErrMsg[errMsg] || errMsg; 44 | } 45 | 46 | export const GuacamoleErrMsg: any = { 47 | 'Disconnected.': 'GuacamoleErrDisconnected', 48 | 'Credentials expired.': 'GuacamoleErrCredentialsExpired', 49 | 'Security negotiation failed (wrong security type?)': 'GuacamoleErrSecurityNegotiationFailed', 50 | 'Access denied by server (account locked/disabled?)': 'GuacamoleErrAccessDenied', 51 | 'Authentication failure (invalid credentials?)': 'GuacamoleErrAuthenticationFailure', 52 | 'SSL/TLS connection failed (untrusted/self-signed certificate?)': 53 | 'GuacamoleErrSSLTLSConnectionFailed', 54 | 'DNS lookup failed (incorrect hostname?)': 'GuacamoleErrDNSLookupFailed', 55 | 'Server refused connection (wrong security type?)': 56 | 'GuacamoleErrServerRefusedConnectionBySecurityType', 57 | 'Connection failed (server unreachable?)': 'GuacamoleErrConnectionFailed', 58 | 'Upstream error.': 'GuacamoleErrUpstreamError', 59 | 'Forcibly disconnected.': 'GuacamoleErrForciblyDisconnected', 60 | 'Logged off.': 'GuacamoleErrLoggedOff', 61 | 'Idle session time limit exceeded.': 'GuacamoleErrIdleSessionTimeLimitExceeded', 62 | 'Active session time limit exceeded.': 'GuacamoleErrActiveSessionTimeLimitExceeded', 63 | 'Disconnected by other connection.': 'GuacamoleErrDisconnectedByOtherConnection', 64 | 'Server refused connection.': 'GuacamoleErrServerRefusedConnection', 65 | 'Insufficient privileges.': 'GuacamoleErrInsufficientPrivileges', 66 | 'Manually disconnected.': 'GuacamoleErrManuallyDisconnected', 67 | 'Manually logged off.': 'GuacamoleErrManuallyLoggedOff', 68 | 69 | 'Unsupported credential type requested.': 'GuacamoleErrUnsupportedCredentialTypeRequested', 70 | 'Unable to connect to VNC server.': 'GuacamoleErrUnableToConnectToVNCServer', 71 | }; 72 | -------------------------------------------------------------------------------- /ui/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | // biome-ignore lint: disable 6 | export {} 7 | 8 | /* prettier-ignore */ 9 | declare module 'vue' { 10 | export interface GlobalComponents { 11 | CardContainer: typeof import('./src/components/CardContainer/index.vue')['default'] 12 | ClipBoardText: typeof import('./src/components/ClipBoardText.vue')['default'] 13 | CombinationKey: typeof import('./src/components/CombinationKey.vue')['default'] 14 | CreateLink: typeof import('./src/components/SessionShare/widget/CreateLink.vue')['default'] 15 | FileManager: typeof import('./src/components/FileManager.vue')['default'] 16 | KeyboardOption: typeof import('./src/components/KeyboardOption.vue')['default'] 17 | NButton: typeof import('naive-ui')['NButton'] 18 | NButtonGroup: typeof import('naive-ui')['NButtonGroup'] 19 | NCard: typeof import('naive-ui')['NCard'] 20 | NCollapse: typeof import('naive-ui')['NCollapse'] 21 | NCollapseItem: typeof import('naive-ui')['NCollapseItem'] 22 | NConfigProvider: typeof import('naive-ui')['NConfigProvider'] 23 | NDescriptions: typeof import('naive-ui')['NDescriptions'] 24 | NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] 25 | NDialogProvider: typeof import('naive-ui')['NDialogProvider'] 26 | NDivider: typeof import('naive-ui')['NDivider'] 27 | NDrawer: typeof import('naive-ui')['NDrawer'] 28 | NDrawerContent: typeof import('naive-ui')['NDrawerContent'] 29 | NDropdown: typeof import('naive-ui')['NDropdown'] 30 | NEmpty: typeof import('naive-ui')['NEmpty'] 31 | NFlex: typeof import('naive-ui')['NFlex'] 32 | NFormItem: typeof import('naive-ui')['NFormItem'] 33 | NGi: typeof import('naive-ui')['NGi'] 34 | NGradientText: typeof import('naive-ui')['NGradientText'] 35 | NGrid: typeof import('naive-ui')['NGrid'] 36 | NInput: typeof import('naive-ui')['NInput'] 37 | NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 38 | NModal: typeof import('naive-ui')['NModal'] 39 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 40 | NPopconfirm: typeof import('naive-ui')['NPopconfirm'] 41 | NSelect: typeof import('naive-ui')['NSelect'] 42 | NSpin: typeof import('naive-ui')['NSpin'] 43 | NSwitch: typeof import('naive-ui')['NSwitch'] 44 | NTabPane: typeof import('naive-ui')['NTabPane'] 45 | NTabs: typeof import('naive-ui')['NTabs'] 46 | NTag: typeof import('naive-ui')['NTag'] 47 | NText: typeof import('naive-ui')['NText'] 48 | NUploadFileList: typeof import('naive-ui')['NUploadFileList'] 49 | Osk: typeof import('./src/components/Osk.vue')['default'] 50 | OtherOption: typeof import('./src/components/OtherOption.vue')['default'] 51 | RouterLink: typeof import('vue-router')['RouterLink'] 52 | RouterView: typeof import('vue-router')['RouterView'] 53 | SessionShare: typeof import('./src/components/SessionShare/index.vue')['default'] 54 | UserItem: typeof import('./src/components/SessionShare/widget/UserItem.vue')['default'] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/utils/lunaBus.ts: -------------------------------------------------------------------------------- 1 | import type { Emitter } from 'mitt'; 2 | 3 | import mitt from 'mitt'; 4 | 5 | import type { LunaMessage, LunaMessageEvents } from '@/types/postmessage.type'; 6 | 7 | import { LUNA_MESSAGE_TYPE } from '@/types/postmessage.type'; 8 | 9 | // 获取所有事件类型 10 | export type LunaEventType = keyof LunaMessageEvents; 11 | 12 | // 创建事件-数据映射类型 13 | type EventPayloadMap = { 14 | [K in LunaEventType]: LunaMessageEvents[K]['data'] extends undefined 15 | ? void 16 | : LunaMessageEvents[K]['data']; 17 | }; 18 | 19 | const allEventTypes = Object.keys(LUNA_MESSAGE_TYPE) as LunaEventType[]; 20 | 21 | class LunaCommunicator { 22 | private mitt: Emitter; 23 | private lunaId: string = ''; 24 | private targetOrigin: string = '*'; 25 | private protocol: string = ''; 26 | 27 | constructor() { 28 | this.mitt = mitt(); 29 | this.setupMessageListener(); 30 | } 31 | 32 | private setupMessageListener() { 33 | window.addEventListener('message', (event: MessageEvent) => { 34 | const message: LunaMessage = event.data; 35 | switch (message.name) { 36 | case LUNA_MESSAGE_TYPE.PING: 37 | this.lunaId = message.id; 38 | this.targetOrigin = event.origin; 39 | this.protocol = message.protocol; 40 | this.sendLuna(LUNA_MESSAGE_TYPE.PONG, ''); 41 | console.log( 42 | `LunaCommunicator initialized with ID: ${this.lunaId}, Origin: ${this.targetOrigin}, Protocol: ${this.protocol}`, 43 | ); 44 | break; 45 | default: 46 | // 处理其他类型的消息 47 | if (allEventTypes.includes(message.name as LunaEventType)) { 48 | const eventType = message.name as keyof T; 49 | const data = message as T[keyof T]; 50 | this.mitt.emit(eventType, data); 51 | } else { 52 | console.warn(`Unhandled message type: ${message.name}`, message); 53 | } 54 | } 55 | }); 56 | } 57 | 58 | // 发送消息到目标窗口 59 | public sendLuna(name: K, data: T[K]) { 60 | if (!this.lunaId || !this.targetOrigin) { 61 | console.warn('Target window not set'); 62 | } 63 | 64 | window.parent.postMessage({ name, id: this.lunaId, data }, this.targetOrigin); 65 | } 66 | 67 | // 监听事件 68 | public onLuna(type: K, handler: (data: T[K]) => void) { 69 | this.mitt.on(type, handler); 70 | } 71 | 72 | // 移除监听器 73 | public offLuna(type: K, handler?: (data: T[K]) => void) { 74 | this.mitt.off(type, handler); 75 | } 76 | 77 | // 监听一次性事件 78 | public once(type: K, handler: (data: T[K]) => void) { 79 | const onceHandler = (data: T[K]) => { 80 | handler(data); 81 | this.offLuna(type, onceHandler); 82 | }; 83 | this.onLuna(type, onceHandler); 84 | } 85 | 86 | // 销毁实例 87 | public destroy() { 88 | this.mitt.all.clear(); 89 | } 90 | 91 | // 获取所有事件类型 92 | public getEventTypes(): Array { 93 | return Object.keys(this.mitt.all) as Array; 94 | } 95 | } 96 | 97 | export const lunaCommunicator = new LunaCommunicator(); 98 | -------------------------------------------------------------------------------- /pkg/tunnel/cache.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/jumpserver-dev/sdk-go/common" 7 | 8 | "lion/pkg/guacd" 9 | "lion/pkg/logger" 10 | ) 11 | 12 | type Tunneler interface { 13 | UUID() string 14 | WriteAndFlush(p []byte) (int, error) 15 | ReadInstruction() (guacd.Instruction, error) 16 | Close() error 17 | } 18 | 19 | type GuaTunnelCache interface { 20 | Add(*Connection) 21 | Delete(*Connection) 22 | Get(string) *Connection 23 | RangeActiveSessionIds() []string 24 | RangeActiveUserIds() map[string]struct{} 25 | GetBySessionId(sid string) *Connection 26 | GetMonitorTunnelerBySessionId(sid string) Tunneler 27 | RemoveMonitorTunneler(sid string, monitorTunnel Tunneler) 28 | 29 | GetSessionEventChan(sid string) *EventChan 30 | BroadcastSessionEvent(sid string, event *Event) 31 | RecycleSessionEventChannel(sid string, eventChan *EventChan) 32 | 33 | GetActiveConnections() []*Connection 34 | } 35 | 36 | type SessionEvent interface { 37 | GetSessionEventChannel(sid string) *EventChan 38 | RecycleSessionEventChannel(sid string, eventChan *EventChan) 39 | BroadcastSessionEvent(sid string, event *Event) 40 | } 41 | 42 | var ( 43 | _ GuaTunnelCache = (*GuaTunnelLocalCache)(nil) 44 | _ GuaTunnelCache = (*GuaTunnelRedisCache)(nil) 45 | ) 46 | 47 | type GuaTunnelCacheManager struct { 48 | GuaTunnelCache 49 | } 50 | 51 | type Room struct { 52 | sid string 53 | eventChanMaps map[string]*EventChan 54 | lock sync.Mutex 55 | } 56 | 57 | func (r *Room) GetEventChannel(sid string) *EventChan { 58 | r.lock.Lock() 59 | defer r.lock.Unlock() 60 | eventChan := NewEventChan(sid) 61 | r.eventChanMaps[eventChan.id] = eventChan 62 | return eventChan 63 | } 64 | 65 | func (r *Room) RecycleEventChannel(eventChan *EventChan) { 66 | r.lock.Lock() 67 | defer r.lock.Unlock() 68 | delete(r.eventChanMaps, eventChan.id) 69 | eventChan.Close() 70 | } 71 | 72 | func (r *Room) BroadcastEvent(event *Event) { 73 | r.lock.Lock() 74 | defer r.lock.Unlock() 75 | for _, eventChan := range r.eventChanMaps { 76 | eventChan.SendEvent(event) 77 | } 78 | } 79 | 80 | type EventChan struct { 81 | id string 82 | sid string 83 | eventCh chan *Event 84 | } 85 | 86 | func (e *EventChan) GetEventChannel() chan *Event { 87 | return e.eventCh 88 | } 89 | 90 | func (e *EventChan) SendEvent(event *Event) { 91 | select { 92 | case e.eventCh <- event: 93 | default: 94 | logger.Errorf("EventChan %s for session %s is full", e.id, e.sid) 95 | } 96 | } 97 | 98 | func (e *EventChan) Close() { 99 | close(e.eventCh) 100 | } 101 | 102 | func NewEventChan(sid string) *EventChan { 103 | return &EventChan{ 104 | id: common.UUID(), 105 | sid: sid, 106 | eventCh: make(chan *Event, 5), 107 | } 108 | } 109 | 110 | type Event struct { 111 | Type string 112 | Data []byte 113 | } 114 | 115 | const ( 116 | ShareJoin = "share_join" 117 | ShareExit = "share_exit" 118 | ShareUsers = "share_users" 119 | 120 | ShareRemoveUser = "share_remove_user" 121 | ShareSessionPause = "share_session_pause" 122 | ShareSessionResume = "share_session_resume" 123 | 124 | PermExpiredEvent = "perm_expired" 125 | PermValidEvent = "perm_valid" 126 | ) 127 | -------------------------------------------------------------------------------- /ui/src/components/SessionShare/widget/UserItem.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 126 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type Level int8 13 | 14 | const ( 15 | LevelDebug Level = iota 16 | LevelInfo 17 | LevelWarn 18 | LevelError 19 | LevelFatal 20 | LevelPanic 21 | ) 22 | 23 | func (l Level) String() string { 24 | switch l { 25 | case LevelDebug: 26 | return "DEBUG" 27 | case LevelInfo: 28 | return "INFO" 29 | case LevelWarn: 30 | return "WARN" 31 | case LevelError: 32 | return "ERROR" 33 | case LevelFatal: 34 | return "FATAL" 35 | case LevelPanic: 36 | return "PANIC" 37 | } 38 | return "" 39 | } 40 | 41 | func ParseLevel(l string) Level { 42 | switch l { 43 | case "DEBUG": 44 | return LevelDebug 45 | case "INFO": 46 | return LevelInfo 47 | case "WARN": 48 | return LevelWarn 49 | case "ERROR": 50 | return LevelError 51 | case "FATAL": 52 | return LevelFatal 53 | case "PANIC": 54 | return LevelPanic 55 | } 56 | return LevelInfo 57 | } 58 | 59 | type Logger struct { 60 | newLogger *log.Logger 61 | level Level 62 | } 63 | 64 | func (l *Logger) Output(level Level, message string) { 65 | if l.level > level { 66 | return 67 | } 68 | pc, fileName, _, ok := runtime.Caller(2) 69 | if !ok { 70 | fileName = "???" 71 | } 72 | name := runtime.FuncForPC(pc).Name() 73 | name = strings.Split(filepath.Base(name), ".")[0] 74 | message = fmt.Sprintf("%s %s %s [%s] %s", 75 | time.Now().Format(logTimeFormat), name, 76 | filepath.Base(fileName), level, message) 77 | switch level { 78 | case LevelDebug: 79 | l.newLogger.Println(message) 80 | case LevelInfo: 81 | l.newLogger.Println(message) 82 | case LevelWarn: 83 | l.newLogger.Println(message) 84 | case LevelError: 85 | l.newLogger.Println(message) 86 | case LevelFatal: 87 | l.newLogger.Fatalln(message) 88 | case LevelPanic: 89 | l.newLogger.Panicln(message) 90 | } 91 | } 92 | 93 | func (l *Logger) Debug(v ...interface{}) { 94 | l.Output(LevelDebug, fmt.Sprint(v...)) 95 | } 96 | 97 | func (l *Logger) Debugf(format string, v ...interface{}) { 98 | l.Output(LevelDebug, fmt.Sprintf(format, v...)) 99 | } 100 | 101 | func (l *Logger) Info(v ...interface{}) { 102 | l.Output(LevelInfo, fmt.Sprint(v...)) 103 | } 104 | 105 | func (l *Logger) Infof(format string, v ...interface{}) { 106 | l.Output(LevelInfo, fmt.Sprintf(format, v...)) 107 | } 108 | 109 | func (l *Logger) Warn(v ...interface{}) { 110 | l.Output(LevelWarn, fmt.Sprint(v...)) 111 | } 112 | 113 | func (l *Logger) Warnf(format string, v ...interface{}) { 114 | l.Output(LevelWarn, fmt.Sprintf(format, v...)) 115 | } 116 | 117 | func (l *Logger) Error(v ...interface{}) { 118 | l.Output(LevelError, fmt.Sprint(v...)) 119 | } 120 | 121 | func (l *Logger) Errorf(format string, v ...interface{}) { 122 | l.Output(LevelError, fmt.Sprintf(format, v...)) 123 | } 124 | 125 | func (l *Logger) Fatal(v ...interface{}) { 126 | l.Output(LevelFatal, fmt.Sprint(v...)) 127 | } 128 | 129 | func (l *Logger) Fatalf(format string, v ...interface{}) { 130 | l.Output(LevelFatal, fmt.Sprintf(format, v...)) 131 | } 132 | 133 | func (l *Logger) Panic(v ...interface{}) { 134 | l.Output(LevelPanic, fmt.Sprint(v...)) 135 | } 136 | 137 | func (l *Logger) Panicf(format string, v ...interface{}) { 138 | l.Output(LevelPanic, fmt.Sprintf(format, v...)) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/tunnel/cache_local.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "sync" 5 | 6 | "lion/pkg/guacd" 7 | "lion/pkg/logger" 8 | ) 9 | 10 | func NewLocalTunnelLocalCache() *GuaTunnelLocalCache { 11 | return &GuaTunnelLocalCache{ 12 | Tunnels: make(map[string]*Connection), 13 | Rooms: make(map[string]*Room), 14 | } 15 | } 16 | 17 | type GuaTunnelLocalCache struct { 18 | sync.Mutex 19 | Tunnels map[string]*Connection 20 | roomLock sync.Mutex 21 | Rooms map[string]*Room 22 | } 23 | 24 | func (g *GuaTunnelLocalCache) Add(t *Connection) { 25 | g.Lock() 26 | defer g.Unlock() 27 | g.Tunnels[t.guacdTunnel.UUID()] = t 28 | } 29 | 30 | func (g *GuaTunnelLocalCache) Delete(t *Connection) { 31 | g.Lock() 32 | defer g.Unlock() 33 | delete(g.Tunnels, t.guacdTunnel.UUID()) 34 | } 35 | 36 | func (g *GuaTunnelLocalCache) Get(tid string) *Connection { 37 | g.Lock() 38 | defer g.Unlock() 39 | return g.Tunnels[tid] 40 | } 41 | 42 | func (g *GuaTunnelLocalCache) RangeActiveSessionIds() []string { 43 | g.Lock() 44 | ret := make([]string, 0, len(g.Tunnels)) 45 | for i := range g.Tunnels { 46 | ret = append(ret, g.Tunnels[i].Sess.ID) 47 | } 48 | g.Unlock() 49 | return ret 50 | } 51 | 52 | func (g *GuaTunnelLocalCache) RangeActiveUserIds() map[string]struct{} { 53 | g.Lock() 54 | ret := make(map[string]struct{}) 55 | for i := range g.Tunnels { 56 | currentUser := g.Tunnels[i].Sess.User 57 | ret[currentUser.ID] = struct{}{} 58 | } 59 | g.Unlock() 60 | return ret 61 | } 62 | 63 | func (g *GuaTunnelLocalCache) GetBySessionId(sid string) *Connection { 64 | g.Lock() 65 | defer g.Unlock() 66 | for i := range g.Tunnels { 67 | if sid == g.Tunnels[i].Sess.ID { 68 | return g.Tunnels[i] 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | func (g *GuaTunnelLocalCache) GetMonitorTunnelerBySessionId(sid string) Tunneler { 75 | if conn := g.GetBySessionId(sid); conn != nil { 76 | if guacdTunnel, err := conn.CloneMonitorTunnel(); err == nil { 77 | return guacdTunnel 78 | } else { 79 | logger.Error(err) 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | func (g *GuaTunnelLocalCache) RemoveMonitorTunneler(sid string, monitorTunnel Tunneler) { 86 | if conn := g.GetBySessionId(sid); conn != nil { 87 | if tunnel, ok := monitorTunnel.(*guacd.Tunnel); ok { 88 | conn.unTraceMonitorTunnel(tunnel) 89 | } 90 | } 91 | } 92 | 93 | func (g *GuaTunnelLocalCache) GetSessionEventChan(sid string) *EventChan { 94 | g.roomLock.Lock() 95 | defer g.roomLock.Unlock() 96 | room := g.Rooms[sid] 97 | if room == nil { 98 | g.Rooms[sid] = &Room{ 99 | sid: sid, 100 | eventChanMaps: make(map[string]*EventChan), 101 | } 102 | } 103 | return g.Rooms[sid].GetEventChannel(sid) 104 | } 105 | 106 | func (g *GuaTunnelLocalCache) BroadcastSessionEvent(sid string, event *Event) { 107 | g.roomLock.Lock() 108 | defer g.roomLock.Unlock() 109 | if room, ok := g.Rooms[sid]; ok { 110 | room.BroadcastEvent(event) 111 | } 112 | } 113 | 114 | func (g *GuaTunnelLocalCache) RecycleSessionEventChannel(sid string, eventChan *EventChan) { 115 | g.roomLock.Lock() 116 | defer g.roomLock.Unlock() 117 | if room, ok := g.Rooms[sid]; ok { 118 | room.RecycleEventChannel(eventChan) 119 | if len(room.eventChanMaps) == 0 { 120 | delete(g.Rooms, sid) 121 | } 122 | } 123 | } 124 | 125 | func (g *GuaTunnelLocalCache) GetActiveConnections() []*Connection { 126 | g.Lock() 127 | defer g.Unlock() 128 | ret := make([]*Connection, 0, len(g.Tunnels)) 129 | for i := range g.Tunnels { 130 | ret = append(ret, g.Tunnels[i]) 131 | } 132 | return ret 133 | } 134 | -------------------------------------------------------------------------------- /pkg/session/display.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | 6 | "lion/pkg/guacd" 7 | ) 8 | 9 | type valueType string 10 | 11 | const ( 12 | Boolean valueType = "boolean" 13 | String valueType = "string" 14 | Integer valueType = "integer" 15 | ) 16 | 17 | type DisplayParameter struct { 18 | Key string 19 | DefaultValue string 20 | valueType valueType 21 | } 22 | 23 | var ( 24 | colorDepth = DisplayParameter{Key: guacd.RDPColorDepth, DefaultValue: "24", valueType: Integer} 25 | dpi = DisplayParameter{Key: guacd.RDPDpi, DefaultValue: "", valueType: Integer} 26 | disableAudio = DisplayParameter{Key: guacd.RDPDisableAudio, DefaultValue: "", valueType: Boolean} 27 | enableWallpaper = DisplayParameter{Key: guacd.RDPEnableWallpaper, DefaultValue: "", valueType: Boolean} 28 | enableTheming = DisplayParameter{Key: guacd.RDPEnableTheming, DefaultValue: "", valueType: Boolean} 29 | enableFontSmoothing = DisplayParameter{Key: guacd.RDPEnableFontSmoothing, DefaultValue: "", valueType: Boolean} 30 | enableFullWindowDrag = DisplayParameter{Key: guacd.RDPEnableFullWindowDrag, DefaultValue: "", valueType: Boolean} 31 | enableDesktopComposition = DisplayParameter{Key: guacd.RDPEnableDesktopComposition, DefaultValue: "", valueType: Boolean} 32 | enableMenuAnimations = DisplayParameter{Key: guacd.RDPEnableMenuAnimations, DefaultValue: "", valueType: Boolean} 33 | disableBitmapCaching = DisplayParameter{Key: guacd.RDPDisableBitmapCaching, DefaultValue: "", valueType: Boolean} 34 | disableOffscreenCaching = DisplayParameter{Key: guacd.RDPDisableOffscreenCaching, DefaultValue: "", valueType: Boolean} 35 | vncCursorRender = DisplayParameter{Key: guacd.VNCCursor, DefaultValue: "", valueType: String} 36 | enableConsoleAudio = DisplayParameter{Key: guacd.RDPConsoleAudio, DefaultValue: "", valueType: Boolean} 37 | enableAudioInput = DisplayParameter{Key: guacd.RDPEnableAudioInput, DefaultValue: "", valueType: Boolean} 38 | ) 39 | 40 | type Display struct { 41 | data map[string]DisplayParameter 42 | } 43 | 44 | func (d Display) GetDisplayParams() map[string]string { 45 | res := make(map[string]string) 46 | for envKey, displayParam := range d.data { 47 | res[displayParam.Key] = displayParam.DefaultValue 48 | if value := viper.GetString(envKey); value != "" { 49 | switch displayParam.valueType { 50 | case Boolean: 51 | booleanValue := viper.GetBool(envKey) 52 | res[displayParam.Key] = ConvertBoolToString(booleanValue) 53 | default: 54 | res[displayParam.Key] = value 55 | } 56 | } 57 | } 58 | return res 59 | } 60 | 61 | var RDPDisplay = Display{data: map[string]DisplayParameter{ 62 | "JUMPSERVER_COLOR_DEPTH": colorDepth, 63 | "JUMPSERVER_DPI": dpi, 64 | "JUMPSERVER_DISABLE_AUDIO": disableAudio, 65 | "JUMPSERVER_ENABLE_WALLPAPER": enableWallpaper, 66 | "JUMPSERVER_ENABLE_THEMING": enableTheming, 67 | "JUMPSERVER_ENABLE_FONT_SMOOTHING": enableFontSmoothing, 68 | "JUMPSERVER_ENABLE_FULL_WINDOW_DRAG": enableFullWindowDrag, 69 | "JUMPSERVER_ENABLE_DESKTOP_COMPOSITION": enableDesktopComposition, 70 | "JUMPSERVER_ENABLE_MENU_ANIMATIONS": enableMenuAnimations, 71 | "JUMPSERVER_DISABLE_BITMAP_CACHING": disableBitmapCaching, 72 | "JUMPSERVER_DISABLE_OFFSCREEN_CACHING": disableOffscreenCaching, 73 | "JUMPSERVER_ENABLE_CONSOLE_AUDIO": enableConsoleAudio, 74 | "JUMPSERVER_ENABLE_AUDIO_INPUT": enableAudioInput, 75 | }} 76 | 77 | var VNCDisplay = Display{data: map[string]DisplayParameter{ 78 | "JUMPSERVER_COLOR_DEPTH": colorDepth, 79 | "JUMPSERVER_VNC_CURSOR_RENDER": vncCursorRender, 80 | }} 81 | 82 | var RDPBuiltIn = map[string]string{ 83 | guacd.RDPDisableGlyphCaching: BoolTrue, 84 | } 85 | -------------------------------------------------------------------------------- /pkg/gateway/domain.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "strconv" 8 | "sync" 9 | "time" 10 | 11 | gossh "golang.org/x/crypto/ssh" 12 | 13 | "lion/pkg/logger" 14 | 15 | "github.com/jumpserver-dev/sdk-go/common" 16 | "github.com/jumpserver-dev/sdk-go/model" 17 | ) 18 | 19 | var ErrNoAvailable = errors.New("no available domain") 20 | 21 | const ( 22 | miniTimeout = 15 * time.Second 23 | ) 24 | 25 | type DomainGateway struct { 26 | DstAddr string // 10.0.0.1:3389 27 | 28 | sshClient *gossh.Client 29 | SelectedGateway *model.Gateway 30 | 31 | ln net.Listener 32 | 33 | once sync.Once 34 | } 35 | 36 | func (d *DomainGateway) run() { 37 | defer d.closeOnce() 38 | for { 39 | con, err := d.ln.Accept() 40 | if err != nil { 41 | break 42 | } 43 | logger.Infof("Accept new conn by gateway %s ", d.SelectedGateway.Name) 44 | go d.handlerConn(con) 45 | } 46 | logger.Infof("Stop proxy by gateway %s", d.SelectedGateway.Name) 47 | } 48 | 49 | func (d *DomainGateway) handlerConn(srcCon net.Conn) { 50 | defer srcCon.Close() 51 | dstCon, err := d.sshClient.Dial("tcp", d.DstAddr) 52 | if err != nil { 53 | logger.Errorf("Failed gateway dial %s: %s ", 54 | d.DstAddr, err.Error()) 55 | return 56 | } 57 | defer dstCon.Close() 58 | go func() { 59 | _, _ = io.Copy(dstCon, srcCon) 60 | _ = dstCon.Close() 61 | }() 62 | _, _ = io.Copy(srcCon, dstCon) 63 | logger.Infof("Gateway end proxy %s", d.DstAddr) 64 | } 65 | 66 | func (d *DomainGateway) Start() (err error) { 67 | if !d.getAvailableGateway() { 68 | return ErrNoAvailable 69 | } 70 | localIP := common.CurrentLocalIP() 71 | d.ln, err = net.Listen("tcp", net.JoinHostPort(localIP, "0")) 72 | if err != nil { 73 | _ = d.sshClient.Close() 74 | return err 75 | } 76 | go d.run() 77 | return nil 78 | } 79 | 80 | func (d *DomainGateway) GetListenAddr() *net.TCPAddr { 81 | return d.ln.Addr().(*net.TCPAddr) 82 | } 83 | 84 | func (d *DomainGateway) getAvailableGateway() bool { 85 | if d.SelectedGateway != nil { 86 | sshClient, err := d.createGatewaySSHClient(d.SelectedGateway) 87 | if err != nil { 88 | logger.Errorf("Dial select gateway %s err: %s ", d.SelectedGateway.Name, err) 89 | return false 90 | } 91 | logger.Debugf("Dial select gateway %s success", d.SelectedGateway.Name) 92 | d.sshClient = sshClient 93 | return true 94 | } 95 | return false 96 | } 97 | func (d *DomainGateway) createGatewaySSHClient(gateway *model.Gateway) (*gossh.Client, error) { 98 | auths := make([]gossh.AuthMethod, 0, 3) 99 | loginAccount := gateway.Account 100 | if loginAccount.IsSSHKey() { 101 | if signer, err1 := gossh.ParsePrivateKey([]byte(loginAccount.Secret)); err1 == nil { 102 | auths = append(auths, gossh.PublicKeys(signer)) 103 | } else { 104 | logger.Errorf("Domain gateway Parse private key error: %s", err1) 105 | } 106 | } else { 107 | auths = append(auths, gossh.Password(loginAccount.Secret)) 108 | auths = append(auths, gossh.KeyboardInteractive(func(user, instruction string, 109 | questions []string, echos []bool) (answers []string, err error) { 110 | return []string{loginAccount.Secret}, nil 111 | })) 112 | } 113 | sshConfig := gossh.ClientConfig{ 114 | User: loginAccount.Username, 115 | Auth: auths, 116 | HostKeyCallback: gossh.InsecureIgnoreHostKey(), 117 | Timeout: miniTimeout, 118 | } 119 | port := gateway.Protocols.GetProtocolPort("ssh") 120 | addr := net.JoinHostPort(gateway.Address, strconv.Itoa(port)) 121 | return gossh.Dial("tcp", addr, &sshConfig) 122 | } 123 | func (d *DomainGateway) Stop() { 124 | d.closeOnce() 125 | } 126 | 127 | func (d *DomainGateway) closeOnce() { 128 | d.once.Do(func() { 129 | _ = d.ln.Close() 130 | _ = d.sshClient.Close() 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /ui/src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { useCookies } from 'vue3-cookies'; 2 | 3 | const PORT = document.location.port ? `:${document.location.port}` : ''; 4 | const SCHEME = document.location.protocol === 'https:' ? 'wss' : 'ws'; 5 | 6 | export const BASE_WS_URL = SCHEME + '://' + document.location.hostname + PORT; 7 | export const BASE_URL = document.location.protocol + '//' + document.location.hostname + PORT; 8 | 9 | const { cookies } = useCookies(); 10 | 11 | const storeLang = cookies.get('lang'); 12 | const cookieLang = cookies.get('django_language'); 13 | 14 | const browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en'; 15 | 16 | export const LanguageCode = cookieLang || storeLang || browserLang || 'en'; 17 | export const ThemeCode = localStorage.getItem('themeType') || 'default'; 18 | 19 | export const MaxTimeout = 30 * 1000; 20 | 21 | export const MAX_TRANSFER_SIZE = 1024 * 1024 * 500; 22 | 23 | export const defaultTheme = { 24 | background: '#121414', 25 | foreground: '#ffffff', 26 | black: '#2e3436', 27 | red: '#cc0000', 28 | green: '#4e9a06', 29 | yellow: '#c4a000', 30 | blue: '#3465a4', 31 | magenta: '#75507b', 32 | cyan: '#06989a', 33 | white: '#d3d7cf', 34 | brightBlack: '#555753', 35 | brightRed: '#ef2929', 36 | brightGreen: '#8ae234', 37 | brightYellow: '#fce94f', 38 | brightBlue: '#729fcf', 39 | brightMagenta: '#ad7fa8', 40 | brightCyan: '#34e2e2', 41 | brightWhite: '#eeeeec', 42 | }; 43 | 44 | // 图片类型的 45 | export const FILE_SUFFIX_IMAGE = [ 46 | 'jpg', 47 | 'jpeg', 48 | 'png', 49 | 'gif', 50 | 'bmp', 51 | 'webp', 52 | 'ico', 53 | 'svg', 54 | 'heic', 55 | 'heif', 56 | ]; 57 | // 音频类型的 58 | export const FILE_SUFFIX_AUDIO = [ 59 | 'mp3', 60 | 'wav', 61 | 'ogg', 62 | 'm4a', 63 | 'aac', 64 | 'flac', 65 | 'm4b', 66 | 'm4p', 67 | 'm4b', 68 | 'm4p', 69 | 'm4b', 70 | 'm4p', 71 | ]; 72 | // 视频类型的 73 | export const FILE_SUFFIX_VIDEO = [ 74 | 'mp4', 75 | 'avi', 76 | 'mov', 77 | 'wmv', 78 | 'flv', 79 | 'mpeg', 80 | 'mpg', 81 | 'm4v', 82 | 'mkv', 83 | 'webm', 84 | 'vob', 85 | 'm2ts', 86 | 'mts', 87 | 'ts', 88 | 'm2t', 89 | 'm2ts', 90 | 'mts', 91 | 'ts', 92 | 'm2t', 93 | 'm2ts', 94 | ]; 95 | // 压缩包类型的 96 | export const FILE_SUFFIX_COMPRESSION = [ 97 | 'zip', 98 | 'rar', 99 | '7z', 100 | 'tar', 101 | 'gz', 102 | 'bz2', 103 | 'iso', 104 | 'dmg', 105 | 'pkg', 106 | 'deb', 107 | 'rpm', 108 | 'msi', 109 | 'exe', 110 | 'app', 111 | 'dmg', 112 | 'pkg', 113 | 'deb', 114 | 'rpm', 115 | 'msi', 116 | 'exe', 117 | 'app', 118 | ]; 119 | // 文档类型的 120 | export const FILE_SUFFIX_DOCUMENT = [ 121 | 'doc', 122 | 'docx', 123 | 'xls', 124 | 'xlsx', 125 | 'ppt', 126 | 'pptx', 127 | 'pdf', 128 | 'txt', 129 | 'md', 130 | 'csv', 131 | 'json', 132 | 'xml', 133 | 'yaml', 134 | 'yml', 135 | 'toml', 136 | 'ini', 137 | 'conf', 138 | 'cfg', 139 | 'config', 140 | 'log', 141 | 'yml', 142 | 'toml', 143 | 'ini', 144 | 'conf', 145 | 'cfg', 146 | 'config', 147 | 'log', 148 | 'lock', 149 | 'sock', 150 | ]; 151 | // 代码类型的 152 | export const FILE_SUFFIX_CODE = [ 153 | 'js', 154 | 'ts', 155 | 'py', 156 | 'java', 157 | 'c', 158 | 'cpp', 159 | 'h', 160 | 'hpp', 161 | 'css', 162 | 'html', 163 | 'php', 164 | 'ruby', 165 | 'go', 166 | 'rust', 167 | 'swift', 168 | 'kotlin', 169 | 'dart', 170 | 'scala', 171 | 'haskell', 172 | 'erlang', 173 | 'elixir', 174 | 'ocaml', 175 | 'erlang', 176 | 'elixir', 177 | 'ocaml', 178 | 'erlang', 179 | 'elixir', 180 | 'ocaml', 181 | 'erlang', 182 | 'elixir', 183 | 'ocaml', 184 | ]; 185 | // 安装包类型的 186 | export const FILE_SUFFIX_INSTALL = [ 187 | 'deb', 188 | 'rpm', 189 | 'msi', 190 | 'exe', 191 | 'app', 192 | 'dmg', 193 | 'pkg', 194 | 'deb', 195 | 'rpm', 196 | 'msi', 197 | 'exe', 198 | 'app', 199 | ]; 200 | // 数据库类型 201 | export const FILE_SUFFIX_DATABASE = [ 202 | 'mysql', 203 | 'oracle', 204 | 'postgresql', 205 | 'sqlserver', 206 | 'mongodb', 207 | 'redis', 208 | 'memcached', 209 | 'sqlite', 210 | 'mariadb', 211 | ]; 212 | -------------------------------------------------------------------------------- /ui/src/components/ClipBoardText.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 99 | 100 | 117 | 118 | 119 | 120 | 127 | 135 | 136 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /ui/src/components/OtherOption.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | 147 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module lion 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/gin-contrib/sessions v1.0.1 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/go-redis/redis/v8 v8.11.5 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/jumpserver-dev/sdk-go v0.0.0-20251119062736-f025e5399a93 11 | github.com/spf13/viper v1.21.0 12 | golang.org/x/crypto v0.45.0 13 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 14 | ) 15 | 16 | require ( 17 | github.com/Azure/azure-pipeline-go v0.2.3 // indirect 18 | github.com/Azure/azure-storage-blob-go v0.15.0 // indirect 19 | github.com/LeeEirc/httpsig v1.2.1 // indirect 20 | github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect 21 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 22 | github.com/aws/aws-sdk-go v1.44.306 // indirect 23 | github.com/bytedance/sonic v1.11.9 // indirect 24 | github.com/bytedance/sonic/loader v0.1.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/cloudwego/base64x v0.1.4 // indirect 27 | github.com/cloudwego/iasm v0.2.0 // indirect 28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 29 | github.com/dlclark/regexp2 v1.11.4 // indirect 30 | github.com/elastic/elastic-transport-go/v8 v8.6.0 // indirect 31 | github.com/elastic/go-elasticsearch/v6 v6.8.10 // indirect 32 | github.com/elastic/go-elasticsearch/v8 v8.14.0 // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/gabriel-vasile/mimetype v1.4.4 // indirect 35 | github.com/gin-contrib/sse v0.1.0 // indirect 36 | github.com/go-logr/logr v1.4.1 // indirect 37 | github.com/go-logr/stdr v1.2.2 // indirect 38 | github.com/go-ole/go-ole v1.2.6 // indirect 39 | github.com/go-playground/locales v0.14.1 // indirect 40 | github.com/go-playground/universal-translator v0.18.1 // indirect 41 | github.com/go-playground/validator/v10 v10.22.0 // indirect 42 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 43 | github.com/goccy/go-json v0.10.3 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/gorilla/context v1.1.2 // indirect 46 | github.com/gorilla/securecookie v1.1.2 // indirect 47 | github.com/gorilla/sessions v1.3.0 // indirect 48 | github.com/huaweicloud/huaweicloud-sdk-go-obs v3.25.4+incompatible // indirect 49 | github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect 50 | github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect 51 | github.com/jmespath/go-jmespath v0.4.0 // indirect 52 | github.com/json-iterator/go v1.1.12 // indirect 53 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 54 | github.com/leodido/go-urn v1.4.0 // indirect 55 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 56 | github.com/mattn/go-ieproxy v0.0.1 // indirect 57 | github.com/mattn/go-isatty v0.0.20 // indirect 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 59 | github.com/modern-go/reflect2 v1.0.2 // indirect 60 | github.com/oapi-codegen/runtime v1.1.1 // indirect 61 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 62 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 63 | github.com/sagikazarmark/locafero v0.11.0 // indirect 64 | github.com/shirou/gopsutil/v3 v3.24.5 // indirect 65 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 66 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 67 | github.com/spf13/afero v1.15.0 // indirect 68 | github.com/spf13/cast v1.10.0 // indirect 69 | github.com/spf13/pflag v1.0.10 // indirect 70 | github.com/subosito/gotenv v1.6.0 // indirect 71 | github.com/tklauser/go-sysconf v0.3.12 // indirect 72 | github.com/tklauser/numcpus v0.6.1 // indirect 73 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 74 | github.com/ugorji/go/codec v1.2.12 // indirect 75 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 76 | go.opentelemetry.io/otel v1.24.0 // indirect 77 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 78 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 79 | go.yaml.in/yaml/v3 v3.0.4 // indirect 80 | golang.org/x/arch v0.8.0 // indirect 81 | golang.org/x/net v0.47.0 // indirect 82 | golang.org/x/sys v0.38.0 // indirect 83 | golang.org/x/text v0.31.0 // indirect 84 | golang.org/x/time v0.12.0 // indirect 85 | google.golang.org/protobuf v1.34.2 // indirect 86 | gopkg.in/yaml.v3 v3.0.1 // indirect 87 | ) 88 | -------------------------------------------------------------------------------- /ui/src/utils/guacamole_helper.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import Guacamole from 'guacamole-common-js'; 3 | 4 | const supportImages: any[] = []; 5 | const pendingTests: any[] = []; 6 | const testImages: any = { 7 | /** 8 | * Test JPEG image, encoded as base64. 9 | */ 10 | 'image/jpeg': 11 | '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoH' + 12 | 'BwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQME' + 13 | 'BAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU' + 14 | 'FBQUFBQUFBQUFBQUFBT/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAA' + 15 | 'AAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAA' + 16 | 'AAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AVMH/2Q==', 17 | 18 | /** 19 | * Test PNG image, encoded as base64. 20 | */ 21 | 'image/png': 22 | 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvI' + 23 | 'AAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==', 24 | 25 | /** 26 | * Test WebP image, encoded as base64. 27 | */ 28 | 'image/webp': 'UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==', 29 | }; // 测试单个图片格式 30 | async function testImageFormat(mimeType: string, base64Data: any): Promise { 31 | return new Promise((resolve) => { 32 | const image = new Image(); 33 | 34 | image.onload = () => { 35 | // Image format is supported if successfully decoded with correct dimensions 36 | const isSupported = image.width === 1 && image.height === 1; 37 | resolve(isSupported); 38 | }; 39 | 40 | image.onerror = () => { 41 | console.debug(`Format ${mimeType} not supported`); 42 | resolve(false); 43 | }; 44 | 45 | // Set source to trigger loading 46 | image.src = `data:${mimeType};base64,${base64Data}`; 47 | }); 48 | } 49 | 50 | // Use Object.entries for better iteration over key-value pairs 51 | Object.entries(testImages).forEach(([mimeType, base64Data]) => { 52 | const imageTest = new Promise((resolve) => { 53 | const image = new Image(); 54 | 55 | // Set up handlers before setting src to avoid race conditions 56 | image.onload = () => { 57 | // Image format is supported if successfully decoded with correct dimensions 58 | if (image.width === 1 && image.height === 1) { 59 | supportImages.push(mimeType); 60 | } 61 | resolve(); 62 | }; 63 | 64 | // Handle errors separately for better debugging 65 | image.onerror = () => { 66 | console.debug(`Format ${mimeType} not supported`); 67 | resolve(); // Still resolve to continue testing other formats 68 | }; 69 | 70 | // Set source to trigger loading 71 | image.src = `data:${mimeType};base64,${base64Data}`; 72 | }); 73 | 74 | pendingTests.push(imageTest); 75 | }); 76 | 77 | export async function getSupportedImages(): Promise { 78 | // 清空之前的结果 79 | supportImages.length = 0; 80 | 81 | // 并行测试所有图片格式 82 | const testPromises = Object.entries(testImages).map(async ([mimeType, base64Data]) => { 83 | const isSupported = await testImageFormat(mimeType, base64Data); 84 | if (isSupported) { 85 | supportImages.push(mimeType); 86 | } 87 | return { mimeType, isSupported }; 88 | }); 89 | 90 | // 等待所有测试完成 91 | await Promise.all(testPromises); 92 | 93 | return [...supportImages]; // 返回副本 94 | } 95 | export async function getSupportedGuacVideos(): Promise { 96 | return Guacamole.VideoPlayer.getSupportedTypes(); 97 | } 98 | 99 | export async function getSupportedGuacAudios(): Promise { 100 | return Guacamole.AudioPlayer.getSupportedTypes(); 101 | } 102 | 103 | export async function getSupportedGuacMimeTypes(): Promise { 104 | const supportImages = await getSupportedImages(); 105 | const supportVideos = await getSupportedGuacVideos(); 106 | const supportAudios = await getSupportedGuacAudios(); 107 | let connectString = ''; 108 | supportImages.forEach((mimeType) => { 109 | connectString += '&GUAC_IMAGE=' + encodeURIComponent(mimeType); 110 | }); 111 | supportVideos.forEach((mimeType) => { 112 | connectString += '&GUAC_AUDIO=' + encodeURIComponent(mimeType); 113 | }); 114 | supportAudios.forEach((mimeType) => { 115 | connectString += '&GUAC_VIDEO=' + encodeURIComponent(mimeType); 116 | }); 117 | return connectString; 118 | } 119 | -------------------------------------------------------------------------------- /pkg/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "lion/pkg/guacd" 5 | 6 | "github.com/jumpserver-dev/sdk-go/common" 7 | "github.com/jumpserver-dev/sdk-go/model" 8 | ) 9 | 10 | type TunnelSession struct { 11 | ID string `json:"id"` 12 | Protocol string `json:"protocol"` 13 | Created common.UTCTime `json:"-"` 14 | Asset *model.Asset `json:"asset"` 15 | Account *model.Account `json:"-"` 16 | User *model.User `json:"user"` 17 | Platform *model.Platform `json:"platform"` 18 | RemoteApp *model.Applet `json:"remote_app"` 19 | Permission *model.Permission `json:"permission"` 20 | Gateway *model.Gateway `json:"-"` 21 | TerminalConfig *model.TerminalConfig `json:"-"` 22 | ExpireInfo model.ExpireInfo `json:"expire_info"` 23 | ActionPerm *ActionPermission `json:"action_permission"` 24 | DisplayAccount *model.Account `json:"system_user"` 25 | 26 | AppletOpts *model.AppletOption `json:"-"` 27 | AuthInfo *model.ConnectToken `json:"-"` 28 | 29 | VirtualAppOpts *model.VirtualAppContainer `json:"-"` 30 | 31 | ConnectedCallback func() error `json:"-"` 32 | ConnectedFailedCallback func(err error) error `json:"-"` 33 | DisConnectedCallback func() error `json:"-"` 34 | 35 | ReleaseAppletAccount func() error `json:"-"` 36 | 37 | ModelSession *model.Session `json:"-"` 38 | } 39 | 40 | const ( 41 | vnc = "vnc" 42 | rdp = "rdp" 43 | ) 44 | 45 | func (s TunnelSession) GuaConfiguration() guacd.Configuration { 46 | if s.AppletOpts != nil { 47 | return s.configurationRemoteAppRDP() 48 | } 49 | if s.VirtualAppOpts != nil { 50 | return s.configurationVirtualApp() 51 | } 52 | switch s.Protocol { 53 | case vnc: 54 | return s.configurationVNC() 55 | default: 56 | return s.configurationRDP() 57 | } 58 | } 59 | 60 | func (s TunnelSession) configurationVNC() guacd.Configuration { 61 | conf := VNCConfiguration{ 62 | SessionId: s.ID, 63 | Created: s.Created, 64 | User: s.User, 65 | Asset: s.Asset, 66 | Account: s.Account, 67 | Platform: s.Platform, 68 | TerminalConfig: s.TerminalConfig, 69 | ActionsPerm: s.ActionPerm, 70 | } 71 | return conf.GetGuacdConfiguration() 72 | } 73 | 74 | func (s TunnelSession) configurationRDP() guacd.Configuration { 75 | if s.AppletOpts != nil { 76 | return s.configurationRemoteAppRDP() 77 | } 78 | rdpConf := RDPConfiguration{ 79 | SessionId: s.ID, 80 | Created: s.Created, 81 | User: s.User, 82 | Asset: s.Asset, 83 | Account: s.Account, 84 | Platform: s.Platform, 85 | TerminalConfig: s.TerminalConfig, 86 | ActionsPerm: s.ActionPerm, 87 | } 88 | return rdpConf.GetGuacdConfiguration() 89 | } 90 | 91 | func (s TunnelSession) configurationRemoteAppRDP() guacd.Configuration { 92 | appletOpt := s.AppletOpts 93 | rdpConf := RDPConfiguration{ 94 | SessionId: s.ID, 95 | Created: s.Created, 96 | User: s.User, 97 | Asset: &appletOpt.Host, 98 | Account: &appletOpt.Account, 99 | Platform: s.Platform, 100 | TerminalConfig: s.TerminalConfig, 101 | ActionsPerm: s.ActionPerm, 102 | } 103 | conf := rdpConf.GetGuacdConfiguration() 104 | remoteAPP := appletOpt.RemoteAppOption 105 | // 设置 remote app 参数 106 | { 107 | conf.SetParameter(guacd.RDPRemoteApp, remoteAPP.Name) 108 | conf.SetParameter(guacd.RDPRemoteAppDir, "") 109 | conf.SetParameter(guacd.RDPRemoteAppArgs, remoteAPP.CmdLine) 110 | } 111 | return conf 112 | } 113 | 114 | func (s TunnelSession) configurationVirtualApp() guacd.Configuration { 115 | vncConf := VirtualAppConfiguration{ 116 | SessionId: s.ID, 117 | Created: s.Created, 118 | User: s.User, 119 | VirtualAppOpt: s.VirtualAppOpts, 120 | TerminalConfig: s.TerminalConfig, 121 | ActionsPerm: s.ActionPerm, 122 | } 123 | return vncConf.GetGuacdConfiguration() 124 | } 125 | 126 | const ( 127 | SecurityAny = "any" 128 | SecurityNla = "nla" 129 | SecurityNlaExt = "nla-ext" 130 | SecurityTls = "tls" 131 | SecurityVmConnect = "vmconnect" 132 | SecurityRdp = "rdp" 133 | ) 134 | 135 | func ValidateSecurityValue(security string) bool { 136 | switch security { 137 | case SecurityAny, 138 | SecurityNla, 139 | SecurityNlaExt, 140 | SecurityTls, 141 | SecurityVmConnect, 142 | SecurityRdp: 143 | return true 144 | } 145 | return false 146 | } 147 | -------------------------------------------------------------------------------- /ui/src/types/postmessage.type.ts: -------------------------------------------------------------------------------- 1 | export interface LunaMessageEvents { 2 | [LUNA_MESSAGE_TYPE.PING]: { 3 | data: LunaMessage; 4 | }; 5 | [LUNA_MESSAGE_TYPE.PONG]: { 6 | data: string; 7 | }; 8 | [LUNA_MESSAGE_TYPE.CMD]: { 9 | data: LunaMessage; 10 | }; 11 | [LUNA_MESSAGE_TYPE.FOCUS]: { 12 | data: LunaMessage; 13 | }; 14 | [LUNA_MESSAGE_TYPE.OPEN]: { 15 | data: LunaMessage; 16 | }; 17 | [LUNA_MESSAGE_TYPE.FILE]: { 18 | data: LunaMessage; 19 | }; 20 | [LUNA_MESSAGE_TYPE.CREATE_FILE_CONNECT_TOKEN]: { 21 | data: LunaMessage; 22 | }; 23 | [LUNA_MESSAGE_TYPE.SESSION_INFO]: { 24 | data: LunaMessage; 25 | }; 26 | [LUNA_MESSAGE_TYPE.SHARE_USER]: { 27 | data: LunaMessage; 28 | }; 29 | [LUNA_MESSAGE_TYPE.SHARE_USER_REMOVE]: { 30 | data: LunaMessage; 31 | }; 32 | [LUNA_MESSAGE_TYPE.SHARE_USER_ADD]: { 33 | data: LunaMessage; 34 | }; 35 | [LUNA_MESSAGE_TYPE.TERMINAL_THEME_CHANGE]: { 36 | data: LunaMessage; 37 | }; 38 | 39 | [LUNA_MESSAGE_TYPE.SHARE_CODE_REQUEST]: { 40 | data: ShareUserRequest; 41 | }; 42 | [LUNA_MESSAGE_TYPE.SHARE_CODE_RESPONSE]: { 43 | data: string; 44 | }; 45 | [LUNA_MESSAGE_TYPE.CLOSE]: { 46 | data: string; 47 | }; 48 | [LUNA_MESSAGE_TYPE.CONNECT]: { 49 | data: LunaMessage; 50 | }; 51 | [LUNA_MESSAGE_TYPE.TERMINAL_ERROR]: { 52 | data: LunaMessage; 53 | }; 54 | [LUNA_MESSAGE_TYPE.MESSAGE_NOTIFY]: { 55 | data: LunaMessage; 56 | }; 57 | [LUNA_MESSAGE_TYPE.KEYEVENT]: { 58 | data: string; 59 | }; 60 | [LUNA_MESSAGE_TYPE.TERMINAL_CONTENT]: { 61 | data: LunaMessage; 62 | }; 63 | [LUNA_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE]: { 64 | data: TerminalContentRepsonse; 65 | }; 66 | [LUNA_MESSAGE_TYPE.CLICK]: { 67 | data: string; 68 | }; 69 | [LUNA_MESSAGE_TYPE.FILE_MANAGE_EXPIRED]: { 70 | data: string; 71 | }; 72 | [LUNA_MESSAGE_TYPE.CHANGE_MAIN_THEME]: { 73 | data: LunaMessage; 74 | }; 75 | [LUNA_MESSAGE_TYPE.MOUSE_EVENT]: { 76 | data: string; 77 | }; 78 | [LUNA_MESSAGE_TYPE.KEYBOARDEVENT]: { 79 | data: string; 80 | }; 81 | [LUNA_MESSAGE_TYPE.INPUT_ACTIVE]: { 82 | data: string; 83 | }; 84 | } 85 | 86 | export interface LunaMessage { 87 | id: string; 88 | name: string; 89 | origin: string; 90 | protocol: string; 91 | data: string | object | null; 92 | theme?: string; 93 | user_meta?: string; 94 | } 95 | 96 | export interface ShareUserRequest { 97 | name: string; 98 | data: { 99 | sessionId: string; 100 | requestData: { 101 | expired_time: number; 102 | action_permission: string; 103 | action_perm: string; 104 | users: string[]; 105 | }; 106 | }; 107 | } 108 | 109 | export interface ShareUserResponse { 110 | shareId: string; 111 | code: string; 112 | terminalId: string; 113 | } 114 | 115 | export interface TerminalSessionInfo { 116 | session: TerminalSession; 117 | permission: TerminalPermission; 118 | backspaceAsCtrlH: boolean; 119 | ctrlCAsCtrlZ: boolean; 120 | themeName: string; 121 | } 122 | 123 | export interface TerminalSession { 124 | id: string; 125 | user: string; 126 | 127 | userId: string; 128 | } 129 | 130 | export interface TerminalPermission { 131 | actions: string[]; 132 | } 133 | 134 | export interface TerminalContentRepsonse { 135 | terminalId: string; 136 | content: string; 137 | sessionId: string; 138 | } 139 | 140 | export enum LUNA_MESSAGE_TYPE { 141 | PING = 'PING', 142 | PONG = 'PONG', 143 | CMD = 'CMD', 144 | FOCUS = 'FOCUS', 145 | OPEN = 'OPEN', 146 | FILE = 'FILE', 147 | CREATE_FILE_CONNECT_TOKEN = 'CREATE_FILE_CONNECT_TOKEN', 148 | 149 | SESSION_INFO = 'SESSION_INFO', 150 | 151 | SHARE_USER = 'SHARE_USER', 152 | SHARE_USER_REMOVE = 'SHARE_USER_REMOVE', 153 | SHARE_USER_ADD = 'SHARE_USER_ADD', 154 | SHARE_USER_LEAVE = 'SHARE_USER_LEAVE', 155 | 156 | TERMINAL_THEME_CHANGE = 'TERMINAL_THEME_CHANGE', 157 | 158 | SHARE_CODE_REQUEST = 'SHARE_CODE_REQUEST', 159 | SHARE_CODE_RESPONSE = 'SHARE_CODE_RESPONSE', 160 | 161 | CLOSE = 'CLOSE', 162 | CONNECT = 'CONNECT', 163 | TERMINAL_ERROR = 'TERMINAL_ERROR', 164 | MESSAGE_NOTIFY = 'MESSAGE_NOTIFY', 165 | KEYEVENT = 'KEYEVENT', 166 | 167 | TERMINAL_CONTENT = 'TERMINAL_CONTENT_REQUEST', 168 | TERMINAL_CONTENT_RESPONSE = 'TERMINAL_CONTENT_RESPONSE', 169 | CLICK = 'CLICK', 170 | CHANGE_MAIN_THEME = 'CHANGE_MAIN_THEME', 171 | FILE_MANAGE_EXPIRED = 'FILE_MANAGE_EXPIRED', 172 | 173 | MOUSE_EVENT = 'MOUSEEVENT', 174 | 175 | KEYBOARDEVENT = 'KEYBOARDEVENT', 176 | 177 | INPUT_ACTIVE = 'INPUT_ACTIVE', 178 | } 179 | -------------------------------------------------------------------------------- /pkg/guacd/status.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type GuacamoleStatus struct { 8 | HttpCode int 9 | WsCode int 10 | GuaCode int 11 | } 12 | 13 | func (g GuacamoleStatus) String() string { 14 | return fmt.Sprintf("%d - %d - %d", g.HttpCode, 15 | g.WsCode, g.GuaCode) 16 | } 17 | 18 | func (g GuacamoleStatus) Error() string { 19 | return g.String() 20 | } 21 | 22 | var ( 23 | /** 24 | * The operation succeeded. 25 | */ 26 | StatusSuccess GuacamoleStatus = GuacamoleStatus{200, 1000, 0x0000} 27 | 28 | /** 29 | * The requested operation is unsupported. 30 | */ 31 | StatusUnsupported GuacamoleStatus = GuacamoleStatus{501, 1011, 0x0100} 32 | 33 | /** 34 | * The operation could not be performed due to an internal failure. 35 | */ 36 | StatusServerError GuacamoleStatus = GuacamoleStatus{500, 1011, 0x0200} 37 | 38 | /** 39 | * The operation could not be performed as the server is busy. 40 | */ 41 | StatusServerBusy GuacamoleStatus = GuacamoleStatus{503, 1008, 0x0201} 42 | 43 | /** 44 | * The operation could not be performed because the upstream server is not 45 | * responding. 46 | */ 47 | StatusUpstreamTimeout GuacamoleStatus = GuacamoleStatus{504, 1011, 0x0202} 48 | 49 | /** 50 | * The operation was unsuccessful due to an error or otherwise unexpected 51 | * condition of the upstream server. 52 | */ 53 | StatusUpstreamError GuacamoleStatus = GuacamoleStatus{502, 1011, 0x0203} 54 | 55 | /** 56 | * The operation could not be performed as the requested resource does not 57 | * exist. 58 | */ 59 | StatusResourceNotFound GuacamoleStatus = GuacamoleStatus{404, 1002, 0x0204} 60 | 61 | /** 62 | * The operation could not be performed as the requested resource is already 63 | * in use. 64 | */ 65 | StatusResourceConflict GuacamoleStatus = GuacamoleStatus{409, 1008, 0x0205} 66 | 67 | /** 68 | * The operation could not be performed as the requested resource is now 69 | * closed. 70 | */ 71 | StatusResourceClosed GuacamoleStatus = GuacamoleStatus{404, 1002, 0x0206} 72 | 73 | /** 74 | * The operation could not be performed because the upstream server does 75 | * not appear to exist. 76 | */ 77 | StatusUpstreamNotFound GuacamoleStatus = GuacamoleStatus{502, 1011, 0x0207} 78 | 79 | /** 80 | * The operation could not be performed because the upstream server is not 81 | * available to service the request. 82 | */ 83 | StatusUpstreamUnavailable GuacamoleStatus = GuacamoleStatus{502, 1011, 0x0208} 84 | 85 | /** 86 | * The session within the upstream server has ended because it conflicted 87 | * with another session. 88 | */ 89 | StatusSessionConflict GuacamoleStatus = GuacamoleStatus{409, 1008, 0x0209} 90 | 91 | /** 92 | * The session within the upstream server has ended because it appeared to 93 | * be inactive. 94 | */ 95 | StatusSessionTimeout GuacamoleStatus = GuacamoleStatus{408, 1002, 0x020A} 96 | 97 | /** 98 | * The session within the upstream server has been forcibly terminated. 99 | */ 100 | StatusSessionClosed GuacamoleStatus = GuacamoleStatus{404, 1002, 0x020B} 101 | 102 | /** 103 | * The operation could not be performed because bad parameters were given. 104 | */ 105 | StatusClientBadRequest GuacamoleStatus = GuacamoleStatus{400, 1002, 0x0300} 106 | 107 | /** 108 | * Permission was denied to perform the operation, as the user is not yet 109 | * authorized (not yet logged in, for example). As HTTP 401 has implications 110 | * for HTTP-specific authorization schemes, this status continues to map to 111 | * HTTP 403 ("Forbidden"). To do otherwise would risk unintended effects. 112 | */ 113 | StatusClientUnauthorized GuacamoleStatus = GuacamoleStatus{403, 1008, 0x0301} 114 | 115 | /** 116 | * Permission was denied to perform the operation, and this operation will 117 | * not be granted even if the user is authorized. 118 | */ 119 | StatusClientForbidden GuacamoleStatus = GuacamoleStatus{403, 1008, 0x0303} 120 | 121 | /** 122 | * The client took too long to respond. 123 | */ 124 | StatusClientTimeout GuacamoleStatus = GuacamoleStatus{408, 1002, 0x0308} 125 | 126 | /** 127 | * The client sent too much data. 128 | */ 129 | StatusClientOverRun GuacamoleStatus = GuacamoleStatus{413, 1009, 0x030D} 130 | 131 | /** 132 | * The client sent data of an unsupported or unexpected type. 133 | */ 134 | StatusClientBadType GuacamoleStatus = GuacamoleStatus{415, 1003, 0x030F} 135 | 136 | /** 137 | * The operation failed because the current client is already using too 138 | * many resources. 139 | */ 140 | StatusClientTooMany GuacamoleStatus = GuacamoleStatus{429, 1008, 0x031D} 141 | ) 142 | -------------------------------------------------------------------------------- /ui/src/hooks/useColor.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | interface HSL { 4 | h: number; 5 | s: number; 6 | l: number; 7 | } 8 | 9 | const mainThemeColorMap = new Map( 10 | Object.entries({ 11 | default: '#483D3D', 12 | deepBlue: '#1A212C', 13 | darkGary: '#303237', 14 | }), 15 | ); 16 | 17 | const currentMainColoc = ref('#303237'); 18 | 19 | export const useColor = () => { 20 | const setCurrentMainColor = (color: string) => { 21 | const themeColor = mainThemeColorMap.get(color); 22 | 23 | if (themeColor) { 24 | currentMainColoc.value = themeColor; 25 | } else { 26 | currentMainColoc.value = '#483D3D'; 27 | } 28 | }; 29 | 30 | /** 31 | * 将十六进制颜色转换为HSL颜色 32 | * @param hex 十六进制颜色 33 | * @returns HSL颜色 34 | */ 35 | const hexToHSL = (hex: string): HSL => { 36 | let hexValue = hex.replace(/^#/, ''); 37 | 38 | if (hexValue.length === 3) { 39 | hexValue = hexValue 40 | .split('') 41 | .map((char) => char + char) 42 | .join(''); 43 | } 44 | 45 | // 解析RGB值 46 | const r = Number.parseInt(hexValue.substring(0, 2), 16) / 255; 47 | const g = Number.parseInt(hexValue.substring(2, 4), 16) / 255; 48 | const b = Number.parseInt(hexValue.substring(4, 6), 16) / 255; 49 | 50 | // 计算HSL值 51 | const max = Math.max(r, g, b); 52 | const min = Math.min(r, g, b); 53 | let h = 0; 54 | let s = 0; 55 | const l = (max + min) / 2; 56 | 57 | if (max !== min) { 58 | const d = max - min; 59 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 60 | 61 | switch (max) { 62 | case r: 63 | h = (g - b) / d + (g < b ? 6 : 0); 64 | break; 65 | case g: 66 | h = (b - r) / d + 2; 67 | break; 68 | case b: 69 | h = (r - g) / d + 4; 70 | break; 71 | } 72 | 73 | h /= 6; 74 | } 75 | 76 | // 转换为标准HSL格式 77 | return { 78 | h: Math.round(h * 360), 79 | s: Math.round(s * 100), 80 | l: Math.round(l * 100), 81 | }; 82 | }; 83 | 84 | /** 85 | * 将HSL颜色转换为十六进制颜色 86 | * @param h 色相 87 | * @param s 饱和度 88 | * @param l 亮度 89 | * @returns 十六进制颜色 90 | */ 91 | const hslToHex = (h: number, s: number, l: number) => { 92 | h /= 360; 93 | s /= 100; 94 | l /= 100; 95 | 96 | let r, g, b; 97 | 98 | if (s === 0) { 99 | // 如果饱和度为0,则为灰色 100 | r = g = b = l; 101 | } else { 102 | const hue2rgb = (p: number, q: number, t: number): number => { 103 | if (t < 0) t += 1; 104 | if (t > 1) t -= 1; 105 | if (t < 1 / 6) return p + (q - p) * 6 * t; 106 | if (t < 1 / 2) return q; 107 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 108 | return p; 109 | }; 110 | 111 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 112 | const p = 2 * l - q; 113 | 114 | r = hue2rgb(p, q, h + 1 / 3); 115 | g = hue2rgb(p, q, h); 116 | b = hue2rgb(p, q, h - 1 / 3); 117 | } 118 | 119 | // 转换为十六进制 120 | const toHex = (x: number): string => { 121 | const hex = Math.round(x * 255).toString(16); 122 | return hex.length === 1 ? `0${hex}` : hex; 123 | }; 124 | 125 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`; 126 | }; 127 | 128 | /** 129 | * 将颜色转换为rgba格式 130 | * @param alphaValue 透明度值 131 | * @param color 颜色 132 | * @returns rgba格式颜色 133 | */ 134 | const alpha = (alphaValue: number, color?: string) => { 135 | // 如果没有提供颜色,使用当前主题颜色 136 | const actualColor = color || currentMainColoc.value; 137 | // 确保透明度值在0-1之间 138 | const alpha = Math.max(0, Math.min(1, alphaValue)); 139 | 140 | // 移除#号并处理缩写形式 141 | let hex = actualColor.replace(/^#/, ''); 142 | 143 | if (hex.length === 3) { 144 | hex = hex 145 | .split('') 146 | .map((char) => char + char) 147 | .join(''); 148 | } 149 | 150 | // 解析RGB值 151 | const r = Number.parseInt(hex.substring(0, 2), 16); 152 | const g = Number.parseInt(hex.substring(2, 4), 16); 153 | const b = Number.parseInt(hex.substring(4, 6), 16); 154 | 155 | // 返回rgba格式 156 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 157 | }; 158 | 159 | /** 160 | * 将颜色变亮 161 | * @param amount 162 | * @param color 163 | * @param alphaValue 164 | * @returns 165 | */ 166 | const lighten = (amount: number, color?: string, alphaValue?: number) => { 167 | const actualColor = color || currentMainColoc.value; 168 | const hsl = hexToHSL(actualColor); 169 | const hexColor = hslToHex(hsl.h, hsl.s, Math.min(100, hsl.l + amount)); 170 | 171 | if (alphaValue !== undefined) { 172 | return alpha(alphaValue, hexColor); 173 | } 174 | 175 | return hexColor; 176 | }; 177 | 178 | /** 179 | * 将颜色变暗 180 | * @param amount 181 | * @param color 182 | * @param alphaValue 183 | * @returns 184 | */ 185 | const darken = (amount: number, color?: string, alphaValue?: number) => { 186 | const actualColor = color || currentMainColoc.value; 187 | const hsl = hexToHSL(actualColor); 188 | const hexColor = hslToHex(hsl.h, hsl.s, Math.max(0, hsl.l - amount)); 189 | 190 | // 如果提供了透明度参数,应用透明度 191 | if (alphaValue !== undefined) { 192 | return alpha(alphaValue, hexColor); 193 | } 194 | 195 | return hexColor; 196 | }; 197 | 198 | return { 199 | darken, 200 | lighten, 201 | alpha, 202 | setCurrentMainColor, 203 | currentMainColor: currentMainColoc, 204 | }; 205 | }; 206 | -------------------------------------------------------------------------------- /pkg/tunnel/stream_output.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "sync" 10 | 11 | "lion/pkg/guacd" 12 | "lion/pkg/logger" 13 | "lion/pkg/proxy" 14 | 15 | "github.com/jumpserver-dev/sdk-go/model" 16 | ) 17 | 18 | type OutputStreamInterceptingFilter struct { 19 | sync.Mutex 20 | tunnel *Connection 21 | streams map[string]OutStreamResource 22 | acknowledgeBlobs bool 23 | } 24 | 25 | func (filter *OutputStreamInterceptingFilter) Filter(unfilteredInstruction *guacd.Instruction) *guacd.Instruction { 26 | 27 | switch unfilteredInstruction.Opcode { 28 | case guacd.InstructionStreamingBlob: 29 | // Intercept "blob" instructions for in-progress streams 30 | //if (instruction.getOpcode().equals("blob")) 31 | //return handleBlob(instruction); 32 | return filter.handleBlob(unfilteredInstruction) 33 | case guacd.InstructionStreamingEnd: 34 | //// Intercept "end" instructions for in-progress streams 35 | //if (instruction.getOpcode().equals("end")) { 36 | // handleEnd(instruction); 37 | // return instruction; 38 | //} 39 | filter.handleEnd(unfilteredInstruction) 40 | return unfilteredInstruction 41 | case guacd.InstructionClientSync: 42 | // Monitor "sync" instructions to ensure the client does not starve 43 | // from lack of graphical updates 44 | //if (instruction.getOpcode().equals("sync")) { 45 | // handleSync(instruction); 46 | // return instruction; 47 | //} 48 | filter.handleSync(unfilteredInstruction) 49 | return unfilteredInstruction 50 | } 51 | 52 | // Pass instruction through untouched 53 | //return instruction 54 | return unfilteredInstruction 55 | } 56 | 57 | func (filter *OutputStreamInterceptingFilter) handleBlob(unfilteredInstruction *guacd.Instruction) *guacd.Instruction { 58 | // Verify all required arguments are present 59 | args := unfilteredInstruction.Args 60 | if len(args) < 2 { 61 | return unfilteredInstruction 62 | } 63 | index := args[0] 64 | if stream, ok := filter.streams[index]; ok { 65 | // Decode blob 66 | data := args[1] 67 | blob, err := base64.StdEncoding.DecodeString(data) 68 | if err != nil { 69 | logger.Errorf("Base64 decode blob err: %+v", err) 70 | return nil 71 | } 72 | 73 | _, err = stream.writer.Write(blob) 74 | if err != nil { 75 | stream.err = err 76 | logger.Errorf("OutputStream filter stream %s write err: %+v", stream.streamIndex, err) 77 | if err = filter.sendAck(index, "FAIL", guacd.StatusServerError); err != nil { 78 | logger.Errorf("OutputStream filter sendAck err: %+v", err) 79 | } 80 | return nil 81 | } 82 | if err1 := stream.recorder.RecordWrite(stream.ftpLog, blob); err1 != nil { 83 | logger.Errorf("OutputStream filter stream %s record write err: %+v", stream.streamIndex, err1) 84 | } 85 | if !filter.acknowledgeBlobs { 86 | filter.acknowledgeBlobs = true 87 | ins := guacd.NewInstruction(guacd.InstructionStreamingBlob, index, "") 88 | return &ins 89 | } 90 | 91 | err = filter.sendAck(index, "Ok", guacd.StatusSuccess) 92 | if err != nil { 93 | logger.Errorf("OutputStream filter sendAck err: %+v", err) 94 | } 95 | return nil 96 | } 97 | return unfilteredInstruction 98 | } 99 | 100 | func (filter *OutputStreamInterceptingFilter) handleSync(unfilteredInstruction *guacd.Instruction) { 101 | filter.acknowledgeBlobs = false 102 | } 103 | 104 | func (filter *OutputStreamInterceptingFilter) handleEnd(unfilteredInstruction *guacd.Instruction) { 105 | // Verify all required arguments are present 106 | //List args = instruction.getArgs(); 107 | //if (args.size() < 1) 108 | //return; 109 | 110 | // Terminate stream 111 | //closeInterceptedStream(args.get(0)); 112 | args := unfilteredInstruction.Args 113 | if len(args) < 1 { 114 | return 115 | } 116 | filter.closeInterceptedStream(args[0]) 117 | } 118 | 119 | func (filter *OutputStreamInterceptingFilter) sendAck(index, msg string, status guacd.GuacamoleStatus) error { 120 | 121 | // Error "ack" instructions implicitly close the stream 122 | //if (status != GuacamoleStatus.SUCCESS) 123 | // closeInterceptedStream(index); 124 | // 125 | //sendInstruction(new GuacamoleInstruction("ack", index, message, 126 | // Integer.toString(status.getGuacamoleStatusCode()))); 127 | if status.HttpCode != guacd.StatusSuccess.HttpCode { 128 | filter.closeInterceptedStream(index) 129 | } 130 | return filter.tunnel.WriteTunnelMessage(guacd.NewInstruction( 131 | guacd.InstructionStreamingAck, index, msg, 132 | strconv.Itoa(status.GuaCode))) 133 | 134 | } 135 | 136 | func (filter *OutputStreamInterceptingFilter) closeInterceptedStream(index string) { 137 | filter.Lock() 138 | defer filter.Unlock() 139 | if outStream, ok := filter.streams[index]; ok { 140 | close(outStream.done) 141 | } 142 | delete(filter.streams, index) 143 | } 144 | 145 | func (filter *OutputStreamInterceptingFilter) addOutStream(out OutStreamResource) { 146 | filter.Lock() 147 | defer filter.Unlock() 148 | err := filter.sendAck(out.streamIndex, "OK", guacd.StatusSuccess) 149 | if err != nil { 150 | logger.Errorf("OutputStream filter sendAck index %s err: %+v", out.streamIndex, err) 151 | out.err = err 152 | close(out.done) 153 | return 154 | } 155 | filter.streams[out.streamIndex] = out 156 | } 157 | 158 | // 下载文件的对象 159 | 160 | type OutStreamResource struct { 161 | streamIndex string 162 | mediaType string // application/octet-stream 163 | writer http.ResponseWriter 164 | done chan struct{} 165 | err error 166 | ctx context.Context 167 | 168 | ftpLog *model.FTPLog 169 | recorder *proxy.FTPFileRecorder 170 | } 171 | 172 | func (r *OutStreamResource) Wait() error { 173 | select { 174 | case <-r.done: 175 | case <-r.ctx.Done(): 176 | return fmt.Errorf("closed request %s", r.streamIndex) 177 | } 178 | return r.err 179 | } 180 | -------------------------------------------------------------------------------- /pkg/guacd/tunnel.go: -------------------------------------------------------------------------------- 1 | package guacd 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | defaultSocketTimeOut = time.Second * 15 16 | 17 | Version = "VERSION_1_3_0" 18 | ) 19 | 20 | func NewTunnel(address string, config Configuration, info ClientInformation) (tunnel *Tunnel, err error) { 21 | var conn net.Conn 22 | conn, err = net.DialTimeout("tcp", address, defaultSocketTimeOut) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer func() { 27 | // 如果err 则直接关闭 连接 28 | if err != nil { 29 | _ = conn.Close() 30 | log.Printf("关闭连接防止conn未关闭,%s\n", err.Error()) 31 | } 32 | }() 33 | tunnel = &Tunnel{} 34 | tunnel.conn = conn 35 | tunnel.rw = bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) 36 | tunnel.Config = config 37 | 38 | selectArg := config.ConnectionID 39 | if selectArg == "" { 40 | selectArg = config.Protocol 41 | } 42 | 43 | // Send requested protocol or connection ID 44 | if err = tunnel.WriteInstructionAndFlush(NewInstruction("select", selectArg)); err != nil { 45 | return nil, err 46 | } 47 | var connectArgs Instruction 48 | 49 | // Wait for server args 50 | connectArgs, err = tunnel.expect("args") 51 | if err != nil { 52 | return nil, err 53 | } 54 | // Build args list off provided names and config 55 | connectArgsValues := make([]string, len(connectArgs.Args)) 56 | for i := range connectArgs.Args { 57 | argName := connectArgs.Args[i] 58 | if strings.HasPrefix(argName, "VERSION") { 59 | connectArgsValues[i] = Version 60 | continue 61 | } 62 | connectArgsValues[i] = config.GetParameter(argName) 63 | } 64 | 65 | // send size 66 | width := info.OptimalScreenWidth 67 | height := info.OptimalScreenHeight 68 | dpi := info.OptimalResolution 69 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 70 | "size", 71 | strconv.Itoa(width), 72 | strconv.Itoa(height), 73 | strconv.Itoa(dpi))); err != nil { 74 | return nil, err 75 | } 76 | 77 | // Send supported audio formats 78 | supportedAudios := info.AudioMimetypes 79 | 80 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 81 | "audio", supportedAudios...)); err != nil { 82 | return nil, err 83 | } 84 | 85 | // Send supported video formats 86 | supportedVideos := info.VideoMimetypes 87 | 88 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 89 | "video", supportedVideos...)); err != nil { 90 | return nil, err 91 | } 92 | 93 | // Send supported image formats 94 | supportedImages := info.ImageMimetypes 95 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 96 | "image", supportedImages...)); err != nil { 97 | return nil, err 98 | } 99 | 100 | // Send client timezone, if supported and available 101 | clientTimezone := info.Timezone 102 | 103 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 104 | "timezone", clientTimezone)); err != nil { 105 | return nil, err 106 | } 107 | 108 | // Send args 109 | if err = tunnel.WriteInstructionAndFlush(NewInstruction( 110 | "connect", connectArgsValues...)); err != nil { 111 | return nil, err 112 | } 113 | 114 | // Wait for ready, store ID 115 | ready, err := tunnel.expect("ready") 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | if len(ready.Args) == 0 { 121 | err = errors.New("no connection id received") 122 | return nil, err 123 | } 124 | 125 | tunnel.uuid = ready.Args[0] 126 | tunnel.IsOpen = true 127 | tunnel.Config = config 128 | return tunnel, nil 129 | } 130 | 131 | type Tunnel struct { 132 | rw *bufio.ReadWriter 133 | conn net.Conn 134 | 135 | uuid string 136 | Config Configuration 137 | IsOpen bool 138 | } 139 | 140 | func (t *Tunnel) UUID() string { 141 | return t.uuid 142 | } 143 | 144 | func (t *Tunnel) WriteInstructionAndFlush(instruction Instruction) (err error) { 145 | _, err = t.WriteAndFlush([]byte(instruction.String())) 146 | return 147 | } 148 | 149 | func (t *Tunnel) WriteInstruction(instruction Instruction) (err error) { 150 | _, err = t.Write([]byte(instruction.String())) 151 | return 152 | } 153 | 154 | func (t *Tunnel) WriteAndFlush(p []byte) (int, error) { 155 | nw, err := t.rw.Write(p) 156 | if err != nil { 157 | return nw, err 158 | } 159 | err = t.rw.Flush() 160 | if err != nil { 161 | return nw, err 162 | } 163 | return nw, nil 164 | } 165 | 166 | func (t *Tunnel) Write(p []byte) (int, error) { 167 | return t.rw.Write(p) 168 | } 169 | 170 | func (t *Tunnel) Flush() error { 171 | return t.rw.Flush() 172 | } 173 | 174 | func (t *Tunnel) ReadInstruction() (instruction Instruction, err error) { 175 | var ret string 176 | for { 177 | if err = t.conn.SetReadDeadline(time.Now().Add(defaultSocketTimeOut)); err != nil { 178 | return Instruction{}, err 179 | } 180 | msg, err := t.rw.ReadString(ByteSemicolonDelimiter) 181 | if err != nil { 182 | return Instruction{}, err 183 | } 184 | ret += msg 185 | if retInstruction, err := ParseInstructionString(ret); err == nil { 186 | return retInstruction, nil 187 | } else { 188 | log.Printf("%s %v\n", ret, err.Error()) 189 | } 190 | } 191 | } 192 | 193 | func (t *Tunnel) Read() ([]byte, error) { 194 | var ( 195 | ins Instruction 196 | err error 197 | ) 198 | if ins, err = t.ReadInstruction(); err != nil { 199 | return nil, err 200 | } 201 | return []byte(ins.String()), nil 202 | } 203 | 204 | func (t *Tunnel) expect(opcode string) (instruction Instruction, err error) { 205 | instruction, err = t.ReadInstruction() 206 | if err != nil { 207 | return instruction, err 208 | } 209 | 210 | if opcode != instruction.Opcode { 211 | msg := fmt.Sprintf(`expected "%s" instruction but instead received "%s"`, opcode, instruction.Opcode) 212 | return instruction, errors.New(msg) 213 | } 214 | return instruction, nil 215 | } 216 | 217 | func (t *Tunnel) Close() error { 218 | t.IsOpen = false 219 | return t.conn.Close() 220 | } 221 | -------------------------------------------------------------------------------- /ui/src/styles/keyboard.css: -------------------------------------------------------------------------------- 1 | .guac-keyboard { 2 | display: inline-block; 3 | width: 100%; 4 | 5 | margin: 0; 6 | padding: 0; 7 | cursor: default; 8 | 9 | text-align: left; 10 | vertical-align: middle; 11 | } 12 | 13 | .guac-keyboard, 14 | .guac-keyboard * { 15 | overflow: hidden; 16 | white-space: nowrap; 17 | } 18 | 19 | .guac-keyboard .guac-keyboard-key-container { 20 | display: inline-block; 21 | margin: 0.05em; 22 | position: relative; 23 | } 24 | 25 | .guac-keyboard .guac-keyboard-key { 26 | position: absolute; 27 | left: 0; 28 | right: 0; 29 | top: 0; 30 | bottom: 0; 31 | 32 | background: #444; 33 | 34 | border: 0.125em solid #666; 35 | -moz-border-radius: 0.25em; 36 | -webkit-border-radius: 0.25em; 37 | -khtml-border-radius: 0.25em; 38 | border-radius: 0.25em; 39 | 40 | color: white; 41 | font-size: 40%; 42 | font-weight: lighter; 43 | text-align: center; 44 | white-space: pre; 45 | 46 | text-shadow: 47 | 1px 1px 0 rgba(0, 0, 0, 0.25), 48 | 1px -1px 0 rgba(0, 0, 0, 0.25), 49 | -1px 1px 0 rgba(0, 0, 0, 0.25), 50 | -1px -1px 0 rgba(0, 0, 0, 0.25); 51 | } 52 | 53 | .guac-keyboard .guac-keyboard-key:hover { 54 | cursor: pointer; 55 | } 56 | 57 | .guac-keyboard .guac-keyboard-key.highlight { 58 | background: #666; 59 | border-color: #666; 60 | } 61 | 62 | /* Align some keys to the left */ 63 | .guac-keyboard .guac-keyboard-key-caps, 64 | .guac-keyboard .guac-keyboard-key-enter, 65 | .guac-keyboard .guac-keyboard-key-tab, 66 | .guac-keyboard .guac-keyboard-key-lalt, 67 | .guac-keyboard .guac-keyboard-key-ralt, 68 | .guac-keyboard .guac-keyboard-key-alt-gr, 69 | .guac-keyboard .guac-keyboard-key-lctrl, 70 | .guac-keyboard .guac-keyboard-key-rctrl, 71 | .guac-keyboard .guac-keyboard-key-lshift, 72 | .guac-keyboard .guac-keyboard-key-rshift { 73 | text-align: left; 74 | padding-left: 0.75em; 75 | } 76 | 77 | /* Active shift */ 78 | .guac-keyboard.guac-keyboard-modifier-shift .guac-keyboard-key-rshift, 79 | .guac-keyboard.guac-keyboard-modifier-shift .guac-keyboard-key-lshift, 80 | 81 | /* Active ctrl */ 82 | .guac-keyboard.guac-keyboard-modifier-control .guac-keyboard-key-rctrl, 83 | .guac-keyboard.guac-keyboard-modifier-control .guac-keyboard-key-lctrl, 84 | 85 | /* Active alt */ 86 | .guac-keyboard.guac-keyboard-modifier-alt .guac-keyboard-key-ralt, 87 | .guac-keyboard.guac-keyboard-modifier-alt .guac-keyboard-key-lalt, 88 | 89 | /* Active alt-gr */ 90 | .guac-keyboard.guac-keyboard-modifier-alt-gr .guac-keyboard-key-alt-gr, 91 | 92 | /* Active caps */ 93 | .guac-keyboard.guac-keyboard-modifier-caps .guac-keyboard-key-caps, 94 | 95 | /* Active super */ 96 | .guac-keyboard.guac-keyboard-modifier-super .guac-keyboard-key-super, 97 | 98 | /* Active latin */ 99 | .guac-keyboard.guac-keyboard-modifier-lat .guac-keyboard-key-latin { 100 | background: #882; 101 | border-color: #dd4; 102 | } 103 | 104 | .guac-keyboard .guac-keyboard-key.guac-keyboard-pressed { 105 | background: #822; 106 | border-color: #d44; 107 | } 108 | 109 | .guac-keyboard .guac-keyboard-group { 110 | line-height: 0; 111 | } 112 | 113 | .guac-keyboard .guac-keyboard-group.guac-keyboard-alpha, 114 | .guac-keyboard .guac-keyboard-group.guac-keyboard-movement, 115 | .guac-keyboard .guac-keyboard-group.guac-keyboard-function, 116 | .guac-keyboard .guac-keyboard-group.guac-keyboard-virtual { 117 | display: inline-block; 118 | text-align: center; 119 | vertical-align: top; 120 | } 121 | 122 | .guac-keyboard .guac-keyboard-group.guac-keyboard-main, 123 | .guac-keyboard .guac-keyboard-group.guac-keyboard-top { 124 | /* IE10 */ 125 | display: -ms-flexbox; 126 | -ms-flex-align: stretch; 127 | -ms-flex-direction: row; 128 | 129 | /* Ancient Mozilla */ 130 | display: -moz-box; 131 | -moz-box-align: stretch; 132 | -moz-box-orient: horizontal; 133 | 134 | /* Ancient WebKit */ 135 | display: -webkit-box; 136 | -webkit-box-align: stretch; 137 | -webkit-box-orient: horizontal; 138 | 139 | /* Old WebKit */ 140 | display: -webkit-flex; 141 | -webkit-align-items: stretch; 142 | -webkit-flex-direction: row; 143 | 144 | /* W3C */ 145 | display: flex; 146 | align-items: stretch; 147 | flex-direction: row; 148 | } 149 | 150 | .guac-keyboard .guac-keyboard-group.guac-keyboard-movement, 151 | .guac-keyboard .guac-keyboard-group.guac-keyboard-virtual { 152 | -ms-flex: 1 1 auto; 153 | -moz-box-flex: 1; 154 | -webkit-box-flex: 1; 155 | -webkit-flex: 1 1 auto; 156 | flex: 1 1 auto; 157 | } 158 | 159 | .guac-keyboard .guac-keyboard-gap { 160 | display: inline-block; 161 | } 162 | 163 | /* Hide keycaps requiring modifiers which are NOT currently active. */ 164 | .guac-keyboard:not(.guac-keyboard-modifier-caps) 165 | .guac-keyboard-cap.guac-keyboard-requires-caps, 166 | 167 | .guac-keyboard:not(.guac-keyboard-modifier-shift) 168 | .guac-keyboard-cap.guac-keyboard-requires-shift, 169 | 170 | .guac-keyboard:not(.guac-keyboard-modifier-alt-gr) 171 | .guac-keyboard-cap.guac-keyboard-requires-alt-gr, 172 | 173 | .guac-keyboard:not(.guac-keyboard-modifier-lat) 174 | .guac-keyboard-cap.guac-keyboard-requires-lat, 175 | 176 | /* Hide keycaps NOT requiring modifiers which ARE currently active, where that 177 | modifier is used to determine which cap is displayed for the current key. */ 178 | .guac-keyboard.guac-keyboard-modifier-shift 179 | .guac-keyboard-key.guac-keyboard-uses-shift 180 | .guac-keyboard-cap:not(.guac-keyboard-requires-shift), 181 | 182 | .guac-keyboard.guac-keyboard-modifier-caps 183 | .guac-keyboard-key.guac-keyboard-uses-caps 184 | .guac-keyboard-cap:not(.guac-keyboard-requires-caps), 185 | 186 | .guac-keyboard.guac-keyboard-modifier-alt-gr 187 | .guac-keyboard-key.guac-keyboard-uses-alt-gr 188 | .guac-keyboard-cap:not(.guac-keyboard-requires-alt-gr), 189 | 190 | .guac-keyboard.guac-keyboard-modifier-lat 191 | .guac-keyboard-key.guac-keyboard-uses-lat 192 | .guac-keyboard-cap:not(.guac-keyboard-requires-lat) { 193 | display: none; 194 | } 195 | 196 | /* Fade out keys which do not use AltGr if AltGr is active */ 197 | .guac-keyboard.guac-keyboard-modifier-alt-gr 198 | .guac-keyboard-key:not(.guac-keyboard-uses-alt-gr):not(.guac-keyboard-key-alt-gr) { 199 | opacity: 0.5; 200 | } 201 | -------------------------------------------------------------------------------- /pkg/tunnel/monitor.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "strings" 7 | "sync" 8 | "sync/atomic" 9 | 10 | "github.com/gorilla/websocket" 11 | 12 | "lion/pkg/guacd" 13 | "lion/pkg/logger" 14 | 15 | "github.com/jumpserver-dev/sdk-go/model" 16 | ) 17 | 18 | type MonitorCon struct { 19 | Id string 20 | guacdTunnel Tunneler 21 | 22 | ws *websocket.Conn 23 | 24 | wsLock sync.Mutex 25 | guacdLock sync.Mutex 26 | 27 | Service *GuacamoleTunnelServer 28 | User *model.User 29 | Meta *MetaShareUserMessage 30 | 31 | lockedStatus atomic.Bool 32 | } 33 | 34 | func (m *MonitorCon) SendWsMessage(msg guacd.Instruction) error { 35 | return m.writeWsMessage([]byte(msg.String())) 36 | } 37 | 38 | func (m *MonitorCon) writeWsMessage(p []byte) error { 39 | m.wsLock.Lock() 40 | defer m.wsLock.Unlock() 41 | return m.ws.WriteMessage(websocket.TextMessage, p) 42 | } 43 | 44 | func (m *MonitorCon) WriteTunnelMessage(msg guacd.Instruction) (err error) { 45 | _, err = m.writeTunnelMessage([]byte(msg.String())) 46 | return err 47 | } 48 | 49 | func (m *MonitorCon) writeTunnelMessage(p []byte) (int, error) { 50 | m.guacdLock.Lock() 51 | defer m.guacdLock.Unlock() 52 | return m.guacdTunnel.WriteAndFlush(p) 53 | } 54 | func (m *MonitorCon) readTunnelInstruction() (*guacd.Instruction, error) { 55 | instruction, err := m.guacdTunnel.ReadInstruction() 56 | if err != nil { 57 | return nil, err 58 | } 59 | return &instruction, nil 60 | } 61 | 62 | func (m *MonitorCon) Run(ctx context.Context) (err error) { 63 | retChan := m.Service.Cache.GetSessionEventChan(m.Id) 64 | if m.Meta != nil { 65 | var jsonBuilder strings.Builder 66 | _ = json.NewEncoder(&jsonBuilder).Encode(m.Meta) 67 | metaJsonStr := jsonBuilder.String() 68 | currentUserInst := NewJmsEventInstruction("current_user", metaJsonStr) 69 | _ = m.SendWsMessage(currentUserInst) 70 | eventData := []byte(metaJsonStr) 71 | m.Service.Cache.BroadcastSessionEvent(m.Id, &Event{Type: ShareJoin, Data: eventData}) 72 | defer func() { 73 | m.Service.Cache.BroadcastSessionEvent(m.Id, &Event{Type: ShareExit, Data: eventData}) 74 | }() 75 | } 76 | 77 | exit := make(chan error, 2) 78 | go func(t *MonitorCon) { 79 | for { 80 | instruction, err1 := t.readTunnelInstruction() 81 | if err1 != nil { 82 | _ = t.writeWsMessage([]byte(ErrDisconnect.String())) 83 | logger.Infof("Monitor[%s] guacd tunnel read err: %+v", t.Id, err1) 84 | exit <- err1 85 | break 86 | } 87 | if err2 := t.writeWsMessage([]byte(instruction.String())); err2 != nil { 88 | logger.Error(err2) 89 | exit <- err2 90 | break 91 | } 92 | } 93 | _ = t.ws.Close() 94 | }(m) 95 | 96 | go func(t *MonitorCon) { 97 | for { 98 | _, message, err1 := t.ws.ReadMessage() 99 | if err1 != nil { 100 | logger.Infof("Monitor[%s] ws read err: %+v", t.Id, err1) 101 | 102 | exit <- err1 103 | break 104 | } 105 | if ret, err2 := guacd.ParseInstructionString(string(message)); err2 == nil { 106 | if ret.Opcode == INTERNALDATAOPCODE && len(ret.Args) >= 2 && ret.Args[0] == PINGOPCODE { 107 | if err3 := t.SendWsMessage(guacd.NewInstruction(INTERNALDATAOPCODE, PINGOPCODE)); err3 != nil { 108 | logger.Error(err3) 109 | } 110 | continue 111 | } 112 | if t.lockedStatus.Load() { 113 | switch ret.Opcode { 114 | case guacd.InstructionClientSync, 115 | guacd.InstructionClientNop, 116 | guacd.InstructionStreamingAck: 117 | default: 118 | logger.Infof("Session[%s] in locked status drop receive web client message opcode[%s]", 119 | t.Id, ret.Opcode) 120 | continue 121 | } 122 | _, err4 := t.writeTunnelMessage(message) 123 | if err4 != nil { 124 | logger.Errorf("Session[%s] guacamole server write err: %+v", t.Id, err2) 125 | exit <- err4 126 | break 127 | } 128 | logger.Debugf("Session[%s] send guacamole server message when locked status", t.Id) 129 | continue 130 | } 131 | } else { 132 | logger.Errorf("Monitor[%s] parse instruction err %s", t.Id, err2) 133 | } 134 | _, err3 := t.writeTunnelMessage(message) 135 | if err3 != nil { 136 | logger.Errorf("Monitor[%s] guacamole tunnel write err: %+v", t.Id, err3) 137 | exit <- err3 138 | break 139 | } 140 | } 141 | _ = t.guacdTunnel.Close() 142 | }(m) 143 | 144 | for { 145 | select { 146 | case err = <-exit: 147 | logger.Infof("Monitor[%s] exit: %+v", m.Id, err) 148 | return err 149 | case <-ctx.Done(): 150 | logger.Infof("Monitor[%s] done", m.Id) 151 | return nil 152 | case event := <-retChan.eventCh: 153 | if m.Meta == nil { 154 | logger.Debugf("Monitor[%s] do not need to handle event", m.Id) 155 | continue 156 | } 157 | go m.handleEvent(event) 158 | logger.Debugf("Monitor[%s] handle event: %s", m.Id, event.Type) 159 | } 160 | } 161 | } 162 | 163 | func (m *MonitorCon) handleEvent(eventMsg *Event) { 164 | logger.Debugf("Monitor[%s] handle event: %s", m.Id, eventMsg.Type) 165 | var inst guacd.Instruction 166 | switch eventMsg.Type { 167 | case ShareJoin: 168 | var meta MetaShareUserMessage 169 | _ = json.Unmarshal(eventMsg.Data, &meta) 170 | if m.Meta.ShareId == meta.ShareId { 171 | logger.Info("Ignore self join event") 172 | return 173 | } 174 | inst = NewJmsEventInstruction(ShareJoin, string(eventMsg.Data)) 175 | case ShareExit: 176 | inst = NewJmsEventInstruction(ShareExit, string(eventMsg.Data)) 177 | case ShareUsers: 178 | inst = NewJmsEventInstruction(ShareUsers, string(eventMsg.Data)) 179 | case ShareRemoveUser: 180 | var removeData struct { 181 | User string `json:"user"` 182 | Meta MetaShareUserMessage `json:"meta"` 183 | } 184 | _ = json.Unmarshal(eventMsg.Data, &removeData) 185 | if m.Meta.ShareId != removeData.Meta.ShareId { 186 | logger.Info("Ignore not self remove user event") 187 | return 188 | } 189 | errInst := NewJMSGuacamoleError(1011, removeData.User) 190 | inst = errInst.Instruction() 191 | case ShareSessionPause, ShareSessionResume: 192 | inst = NewJmsEventInstruction(eventMsg.Type, string(eventMsg.Data)) 193 | locked := eventMsg.Type == ShareSessionPause 194 | m.lockedStatus.Store(locked) 195 | default: 196 | return 197 | } 198 | _ = m.SendWsMessage(inst) 199 | } 200 | -------------------------------------------------------------------------------- /pkg/session/parser.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "lion/pkg/guacd" 14 | "lion/pkg/logger" 15 | 16 | "github.com/jumpserver-dev/sdk-go/model" 17 | "github.com/jumpserver-dev/sdk-go/service" 18 | ) 19 | 20 | var ( 21 | charEnter = []byte("\r") 22 | ) 23 | 24 | var _ ParseEngine = (*Parser)(nil) 25 | 26 | type Parser struct { 27 | id string 28 | jmsService *service.JMService 29 | cmdRecordChan chan *ExecutedCommand 30 | 31 | buf bytes.Buffer 32 | 33 | inputPreState bool 34 | inputState bool 35 | once *sync.Once 36 | lock *sync.RWMutex 37 | 38 | command string 39 | cmdCreateDate time.Time 40 | 41 | closed chan struct{} 42 | currentActiveUser CurrentActiveUser 43 | } 44 | 45 | func (p *Parser) initial() { 46 | p.once = new(sync.Once) 47 | p.lock = new(sync.RWMutex) 48 | p.closed = make(chan struct{}) 49 | p.cmdRecordChan = make(chan *ExecutedCommand, 1024) 50 | } 51 | 52 | // ParseStream 解析数据流 53 | func (p *Parser) ParseStream(userInChan chan *Message) { 54 | logger.Infof("Session %s: Parser start", p.id) 55 | go func() { 56 | defer func() { 57 | // 会话结束,结算命令结果 58 | p.sendCommandRecord() 59 | close(p.cmdRecordChan) 60 | logger.Infof("Session %s: Parser routine done", p.id) 61 | }() 62 | maxTimeout := time.Second * 20 63 | cmdRecordTicker := time.NewTicker(time.Second * 30) 64 | defer cmdRecordTicker.Stop() 65 | lastActiveTime := time.Now() 66 | for { 67 | select { 68 | case <-p.closed: 69 | return 70 | case now := <-cmdRecordTicker.C: 71 | if now.Sub(lastActiveTime) > maxTimeout { 72 | p.ParseUserInput(charEnter) //手动结算一次命令 73 | } 74 | continue 75 | case msg, ok := <-userInChan: 76 | if !ok { 77 | return 78 | } 79 | lastActiveTime = time.Now() 80 | p.UpdateActiveUser(msg) 81 | s := msg.Body 82 | var b []byte 83 | switch msg.Opcode { 84 | case guacd.InstructionMouse: 85 | var cmd string 86 | switch s[2] { 87 | case guacd.MouseLeft: 88 | cmd = "Left Button" 89 | case guacd.MouseRight: 90 | cmd = "Right Button" 91 | case guacd.MouseMiddle: 92 | cmd = "Middle Button" 93 | default: 94 | continue 95 | } 96 | p.ParseUserInput(charEnter) //手动结算一次命令 97 | cmd = fmt.Sprintf("Mouse Position[%s,%s] %s\r", s[0], s[1], cmd) 98 | b = append(b, []byte(cmd)...) 99 | case guacd.InstructionKey: 100 | switch s[1] { 101 | case guacd.KeyPress: 102 | keyCode, err := strconv.Atoi(s[0]) 103 | if err == nil { 104 | cb := []byte(guacd.KeysymToCharacter(keyCode)) 105 | if len(cb) == 0 { 106 | // guacamole-common.js unicode计算方法 107 | // if (codepoint >= 0x0100 && codepoint <= 0x10FFFF) 108 | // return 0x01000000 | codepoint; 109 | if keyCode > 0x01000000 { 110 | var to string 111 | unicode := strconv.FormatInt(int64(keyCode), 16) 112 | bs, _ := hex.DecodeString(unicode[3:]) 113 | for i, bl, br, r := 0, len(bs), bytes.NewReader(bs), uint16(0); i < bl; i += 2 { 114 | _ = binary.Read(br, binary.BigEndian, &r) 115 | to += string(rune(r)) 116 | } 117 | b = append(b, []byte(to)...) 118 | } else { 119 | // 未知的键值,转成 rune 字符 120 | b = append(b, []byte(string(rune(keyCode)))...) 121 | } 122 | } else { 123 | b = append(b, cb...) 124 | } 125 | } else { 126 | b = append(b, []byte(guacd.KeyCodeUnknown)...) 127 | } 128 | default: 129 | continue 130 | } 131 | } 132 | if len(b) == 0 { 133 | continue 134 | } 135 | _, _ = p.WriteData(b) 136 | p.ParseUserInput(b) 137 | } 138 | } 139 | }() 140 | } 141 | 142 | // ParseUserInput 解析用户的输入 143 | func (p *Parser) ParseUserInput(b []byte) { 144 | _ = p.parseInputState(b) 145 | } 146 | 147 | // parseInputState 切换用户输入状态, 并结算命令和结果 148 | func (p *Parser) parseInputState(b []byte) []byte { 149 | p.inputPreState = p.inputState 150 | if bytes.LastIndex(b, charEnter) >= 0 { 151 | // 连续输入enter key, 结算上一条可能存在的命令结果 152 | p.sendCommandRecord() 153 | p.inputState = false 154 | // 用户输入了Enter,开始结算命令 155 | p.parseCmdInput() 156 | } else { 157 | p.inputState = true 158 | // 用户又开始输入,并上次不处于输入状态,开始结算上次命令的结果 159 | if !p.inputPreState { 160 | p.sendCommandRecord() 161 | } 162 | } 163 | return b 164 | } 165 | 166 | // parseCmdInput 解析命令的输入 167 | func (p *Parser) parseCmdInput() { 168 | command := p.Parse() 169 | if len(command) <= 0 { 170 | p.command = "" 171 | } else { 172 | p.command = command 173 | } 174 | p.cmdCreateDate = time.Now() 175 | } 176 | 177 | func (p *Parser) WriteData(b []byte) (int, error) { 178 | p.lock.Lock() 179 | defer p.lock.Unlock() 180 | if p.buf.Len() >= 2048 { 181 | return 0, nil 182 | } 183 | if len(b) > 1 { 184 | p.buf.WriteByte(byte(' ')) 185 | } 186 | return p.buf.Write(b) 187 | } 188 | 189 | func (p *Parser) Parse() string { 190 | line := p.buf.String() 191 | line = strings.TrimPrefix(line, string(charEnter)) 192 | p.buf.Reset() 193 | return line 194 | } 195 | 196 | // Close 关闭parser 197 | func (p *Parser) Close() { 198 | select { 199 | case <-p.closed: 200 | return 201 | default: 202 | close(p.closed) 203 | } 204 | logger.Infof("Session %s: Parser close", p.id) 205 | } 206 | 207 | func (p *Parser) sendCommandRecord() { 208 | if p.command != "" { 209 | p.cmdRecordChan <- &ExecutedCommand{ 210 | Command: p.command, 211 | CreatedDate: p.cmdCreateDate, 212 | RiskLevel: model.NormalLevel, 213 | User: p.currentActiveUser, 214 | } 215 | p.command = "" 216 | } 217 | } 218 | 219 | func (p *Parser) CommandRecordChan() chan *ExecutedCommand { 220 | return p.cmdRecordChan 221 | } 222 | 223 | func (p *Parser) UpdateActiveUser(msg *Message) { 224 | p.currentActiveUser.UserId = msg.Meta.UserId 225 | p.currentActiveUser.User = msg.Meta.User 226 | } 227 | 228 | type ExecutedCommand struct { 229 | Command string 230 | Output string 231 | CreatedDate time.Time 232 | RiskLevel int 233 | User CurrentActiveUser 234 | } 235 | 236 | type CurrentActiveUser struct { 237 | UserId string 238 | User string 239 | } 240 | -------------------------------------------------------------------------------- /ui/src/components/Osk.vue: -------------------------------------------------------------------------------- 1 | 144 | 145 | 164 | 165 | 210 | -------------------------------------------------------------------------------- /ui/src/views/ShareView.vue: -------------------------------------------------------------------------------- 1 | 150 | 151 | 199 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/jumpserver-dev/sdk-go/common" 13 | 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | var GlobalConfig *Config 18 | 19 | type Config struct { 20 | Root string 21 | DrivePath string 22 | RecordPath string 23 | FTPFilePath string 24 | LogDirPath string 25 | AccessKeyFilePath string 26 | CertsFolderPath string 27 | SessionFolderPath string 28 | 29 | Name string `mapstructure:"NAME"` 30 | CoreHost string `mapstructure:"CORE_HOST"` 31 | BootstrapToken string `mapstructure:"BOOTSTRAP_TOKEN"` 32 | BindHost string `mapstructure:"BIND_HOST"` 33 | HTTPPort string `mapstructure:"HTTPD_PORT"` 34 | LogLevel string `mapstructure:"LOG_LEVEL"` 35 | 36 | GuacdAddrs string `mapstructure:"GUACD_ADDRS"` 37 | 38 | GuaHost string `mapstructure:"GUA_HOST"` 39 | GuaPort string `mapstructure:"GUA_PORT"` 40 | DisableAllCopyPaste bool `mapstructure:"JUMPSERVER_DISABLE_ALL_COPY_PASTE"` 41 | DisableAllUpDownload bool `mapstructure:"JUMPSERVER_DISABLE_ALL_UPLOAD_DOWNLOAD"` 42 | EnableRemoteAppUpDownLoad bool `mapstructure:"JUMPSERVER_REMOTE_APP_UPLOAD_DOWNLOAD_ENABLE"` 43 | EnableRemoteAPPCopyPaste bool `mapstructure:"JUMPSERVER_REMOTE_APP_COPY_PASTE_ENABLE"` 44 | CleanDriveScheduleTime int `mapstructure:"JUMPSERVER_CLEAN_DRIVE_SCHEDULE_TIME"` 45 | 46 | ShareRoomType string `mapstructure:"SHARE_ROOM_TYPE"` 47 | RedisHost string `mapstructure:"REDIS_HOST"` 48 | RedisPort int `mapstructure:"REDIS_PORT"` 49 | RedisPassword string `mapstructure:"REDIS_PASSWORD"` 50 | RedisDBIndex int `mapstructure:"REDIS_DB_ROOM"` 51 | 52 | RedisSentinelPassword string `mapstructure:"REDIS_SENTINEL_PASSWORD"` 53 | RedisSentinelHosts string `mapstructure:"REDIS_SENTINEL_HOSTS"` 54 | RedisUseSSL bool `mapstructure:"REDIS_USE_SSL"` 55 | 56 | EnableVideoWorker bool `mapstructure:"ENABLE_VIDEO_WORKER"` 57 | VideoWorkerHost string `mapstructure:"VIDEO_WORKER_HOST"` 58 | IgnoreVerifyCerts bool `mapstructure:"IGNORE_VERIFY_CERTS"` 59 | PandaHost string `mapstructure:"PANDA_HOST"` 60 | EnablePanda bool `mapstructure:"ENABLE_PANDA"` 61 | 62 | ReplayMaxSize int `mapstructure:"REPLAY_MAX_SIZE"` 63 | SecretEncryptKey string `mapstructure:"SECRET_ENCRYPT_KEY"` 64 | 65 | VncClipboardEncoding string `mapstructure:"VNC_CLIPBOARD_ENCODING"` 66 | } 67 | 68 | func (c *Config) UpdateRedisPassword(val string) { 69 | c.RedisPassword = val 70 | } 71 | 72 | func (c *Config) SelectGuacdAddr() string { 73 | if len(c.GuacdAddrs) == 0 { 74 | return net.JoinHostPort(c.GuaHost, c.GuaPort) 75 | } 76 | addresses := strings.Split(c.GuacdAddrs, ",") 77 | return addresses[rand.Intn(len(addresses))] 78 | } 79 | 80 | func Setup(configPath string) { 81 | var conf = getDefaultConfig() 82 | loadConfigFromEnv(&conf) 83 | loadConfigFromFile(configPath, &conf) 84 | GlobalConfig = &conf 85 | log.Printf("%+v\n", GlobalConfig) 86 | 87 | } 88 | 89 | func getDefaultConfig() Config { 90 | defaultName := getDefaultName() 91 | rootPath := getPwdDirPath() 92 | dataFolderPath := filepath.Join(rootPath, "data") 93 | driveFolderPath := filepath.Join(dataFolderPath, "drive") 94 | recordFolderPath := filepath.Join(dataFolderPath, "replays") 95 | sessionsPath := filepath.Join(dataFolderPath, "sessions") 96 | ftpFileFolderPath := filepath.Join(dataFolderPath, "ftp_files") 97 | LogDirPath := filepath.Join(dataFolderPath, "logs") 98 | keyFolderPath := filepath.Join(dataFolderPath, "keys") 99 | CertsFolderPath := filepath.Join(dataFolderPath, "certs") 100 | accessKeyFilePath := filepath.Join(keyFolderPath, ".access_key") 101 | 102 | folders := []string{dataFolderPath, driveFolderPath, recordFolderPath, 103 | keyFolderPath, LogDirPath, CertsFolderPath, sessionsPath} 104 | for i := range folders { 105 | if err := EnsureDirExist(folders[i]); err != nil { 106 | log.Fatalf("Create folder failed: %s", err.Error()) 107 | } 108 | } 109 | return Config{ 110 | Name: defaultName, 111 | Root: rootPath, 112 | RecordPath: recordFolderPath, 113 | FTPFilePath: ftpFileFolderPath, 114 | LogDirPath: LogDirPath, 115 | DrivePath: driveFolderPath, 116 | CertsFolderPath: CertsFolderPath, 117 | AccessKeyFilePath: accessKeyFilePath, 118 | SessionFolderPath: sessionsPath, 119 | CoreHost: "http://localhost:8080", 120 | BootstrapToken: "", 121 | BindHost: "0.0.0.0", 122 | HTTPPort: "8081", 123 | LogLevel: "INFO", 124 | GuaHost: "127.0.0.1", 125 | GuaPort: "4822", 126 | DisableAllCopyPaste: false, 127 | DisableAllUpDownload: false, 128 | EnableRemoteAppUpDownLoad: false, 129 | EnableRemoteAPPCopyPaste: false, 130 | CleanDriveScheduleTime: 1, 131 | PandaHost: "http://panda:9001", 132 | ReplayMaxSize: defaultMaxSize, 133 | VideoWorkerHost: "http://video:9000", 134 | } 135 | 136 | } 137 | 138 | // 300MB 139 | const defaultMaxSize = 1024 * 1024 * 300 140 | 141 | func EnsureDirExist(path string) error { 142 | if !haveDir(path) { 143 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 144 | return err 145 | } 146 | } 147 | return nil 148 | } 149 | 150 | func have(path string) bool { 151 | _, err := os.Stat(path) 152 | return err == nil 153 | } 154 | 155 | func haveDir(file string) bool { 156 | fi, err := os.Stat(file) 157 | return err == nil && fi.IsDir() 158 | } 159 | 160 | func getPwdDirPath() string { 161 | if rootPath, err := os.Getwd(); err == nil { 162 | return rootPath 163 | } 164 | return "" 165 | } 166 | 167 | func loadConfigFromEnv(conf *Config) { 168 | viper.AutomaticEnv() // 全局配置,用于其他 pkg 包可以用 viper 获取环境变量的值 169 | envViper := viper.New() 170 | for _, item := range os.Environ() { 171 | envItem := strings.SplitN(item, "=", 2) 172 | if len(envItem) == 2 { 173 | envViper.Set(envItem[0], viper.Get(envItem[0])) 174 | } 175 | } 176 | if err := envViper.Unmarshal(conf); err == nil { 177 | log.Println("Load config from env") 178 | } 179 | 180 | } 181 | 182 | func loadConfigFromFile(path string, conf *Config) { 183 | var err error 184 | if have(path) { 185 | fileViper := viper.New() 186 | fileViper.SetConfigFile(path) 187 | if err = fileViper.ReadInConfig(); err == nil { 188 | if err = fileViper.Unmarshal(conf); err == nil { 189 | log.Printf("Load config from %s success\n", path) 190 | return 191 | } 192 | } 193 | } 194 | if err != nil { 195 | log.Fatalf("Load config from %s failed: %s\n", path, err) 196 | } 197 | } 198 | 199 | const ( 200 | prefixName = "[Lion]-" 201 | 202 | hostEnvKey = "SERVER_HOSTNAME" 203 | 204 | defaultNameMaxLen = 128 205 | ) 206 | 207 | /* 208 | SERVER_HOSTNAME: 环境变量名,可用于自定义默认注册名称的前缀 209 | default name rule: 210 | [Lion]-{SERVER_HOSTNAME}-{HOSTNAME}-{UUID} 211 | or 212 | [Lion]-{HOSTNAME}-{UUID} 213 | */ 214 | 215 | func getDefaultName() string { 216 | hostname, _ := os.Hostname() 217 | hostname = fmt.Sprintf("%s-%s", hostname, common.RandomStr(7)) 218 | if serverHostname, ok := os.LookupEnv(hostEnvKey); ok { 219 | hostname = fmt.Sprintf("%s-%s", serverHostname, hostname) 220 | } 221 | hostRune := []rune(prefixName + hostname) 222 | if len(hostRune) <= defaultNameMaxLen { 223 | return string(hostRune) 224 | } 225 | name := make([]rune, defaultNameMaxLen) 226 | index := defaultNameMaxLen / 2 227 | copy(name[:16], hostRune[:index]) 228 | start := len(hostRune) - index 229 | copy(name[index:], hostRune[start:]) 230 | return string(name) 231 | } 232 | --------------------------------------------------------------------------------