refactor: 终端重构、系统参数配置调整

This commit is contained in:
meilin.huang
2023-08-31 21:49:20 +08:00
parent 537b179e78
commit d51cd4b289
27 changed files with 1055 additions and 356 deletions

View File

@@ -32,7 +32,7 @@ FROM alpine:3.16
RUN apk add --no-cache ca-certificates bash expat
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /mayfly

View File

@@ -11,7 +11,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"asciinema-player": "^3.5.0",
"axios": "^1.4.0",
"axios": "^1.5.0",
"countup.js": "^2.7.0",
"cropperjs": "^1.5.11",
"echarts": "^5.4.0",
@@ -32,7 +32,10 @@
"vue-clipboard3": "^1.0.1",
"vue-router": "^4.2.4",
"xterm": "^5.2.1",
"xterm-addon-fit": "^0.7.0"
"xterm-addon-fit": "^0.7.0",
"xterm-addon-search": "^0.12.0",
"xterm-addon-web-links": "^0.8.0",
"xterm-addon-webgl": "^0.15.0"
},
"devDependencies": {
"@types/lodash": "^4.14.178",

View File

@@ -5,12 +5,12 @@ export default {
otpVerify: (param: any) => request.post('/auth/accounts/otp-verify', param),
getPublicKey: () => request.get('/common/public-key'),
getConfigValue: (params: any) => request.get('/sys/configs/value', params),
oauth2LoginConfig: () => request.get('/sys/configs/oauth2-login'),
oauth2LoginConfig: () => request.get('/auth/oauth2-config'),
changePwd: (param: any) => request.post('/sys/accounts/change-pwd', param),
captcha: () => request.get('/sys/captcha'),
logout: () => request.post('/auth/accounts/logout'),
getPermissions: () => request.get('/sys/accounts/permissions'),
oauth2Callback: (params: any) => request.get('/auth/oauth2/callback', params),
getLdapEnabled: () => request.get("/auth/ldap/enabled"),
getLdapEnabled: () => request.get('/auth/ldap/enabled'),
ldapLogin: (param: any) => request.post('/auth/ldap/login', param),
};

View File

@@ -0,0 +1,286 @@
<template>
<div id="terminal-body" :style="{ height, background: themeConfig.terminalBackground }">
<div ref="terminalRef" class="terminal" />
<TerminalSearch ref="terminalSearchRef" :search-addon="state.addon.search" @close="focus" />
</div>
</template>
<script lang="ts" setup>
import 'xterm/css/xterm.css';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import TerminalSearch from './TerminalSearch.vue';
import { debounce } from 'lodash';
import { TerminalStatus } from './common';
const props = defineProps({
/**
* 初始化执行命令
*/
cmd: { type: String },
/**
* 连接url
*/
socketUrl: {
type: String,
},
/**
* 高度
*/
height: {
type: [String, Number],
default: '100%',
},
});
const emit = defineEmits(['statusChange']);
const terminalRef: any = ref(null);
const terminalSearchRef: any = ref(null);
const { themeConfig } = storeToRefs(useThemeConfig());
// 终端实例
let term: Terminal;
let socket: WebSocket;
let pingInterval: any;
const state = reactive({
// 插件
addon: {
fit: null as any,
search: null as any,
weblinks: null as any,
},
status: TerminalStatus.NoConnected,
});
onMounted(() => {
nextTick(() => {
init();
});
});
watch(
() => state.status,
() => {
emit('statusChange', state.status);
}
);
onBeforeUnmount(() => {
close();
});
function init() {
if (term) {
console.log('重新连接...');
close();
}
term = new Terminal({
fontSize: themeConfig.value.terminalFontSize || 15,
fontWeight: themeConfig.value.terminalFontWeight || 'normal',
fontFamily: 'JetBrainsMono, monaco, Consolas, Lucida Console, monospace',
cursorBlink: true,
disableStdin: false,
allowProposedApi: true,
theme: {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as any,
});
term.open(terminalRef.value);
// 注册自适应组件
const fitAddon = new FitAddon();
state.addon.fit = fitAddon;
term.loadAddon(fitAddon);
fitTerminal();
// 注册搜索组件
const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 初始化websocket
initSocket();
}
/**
* 连接成功
*/
const onConnected = () => {
// 注册心跳
pingInterval = setInterval(sendPing, 15000);
// 注册 terminal 事件
term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event));
// 注册自定义快捷键
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
// 注册搜索键 ctrl + f
if (event.key === 'f' && (event.ctrlKey || event.metaKey) && event.type === 'keydown') {
event.preventDefault();
terminalSearchRef.value.open();
}
return true;
});
// resize
sendResize(term.cols, term.rows);
// 注册窗口大小监听器
window.addEventListener('resize', debounce(fitTerminal, 400));
focus();
// 如果有初始要执行的命令,则发送执行命令
if (props.cmd) {
sendCmd(props.cmd + ' \r');
}
state.status = TerminalStatus.Connected;
};
// 自适应终端
const fitTerminal = () => {
const dimensions = state.addon.fit && state.addon.fit.proposeDimensions();
if (!dimensions) {
return;
}
if (dimensions?.cols && dimensions?.rows) {
term.resize(dimensions.cols, dimensions.rows);
}
};
const focus = () => {
setTimeout(() => term.focus(), 400);
};
const clear = () => {
term.clear();
term.clearSelection();
term.focus();
};
function initSocket() {
if (props.socketUrl) {
socket = new WebSocket(props.socketUrl);
}
// 监听socket连接
socket.onopen = () => {
onConnected();
};
// 监听socket错误信息
socket.onerror = (e: Event) => {
term.writeln('\r\n\x1b[31m提示: 连接错误...');
state.status = TerminalStatus.Error;
console.log('连接错误', e);
};
socket.onclose = (e: CloseEvent) => {
console.log('terminal socket close...', e.reason);
// 关闭窗口大小监听器
window.removeEventListener('resize', debounce(fitTerminal, 100));
// 清除 ping
pingInterval && clearInterval(pingInterval);
state.status = TerminalStatus.Disconnected;
};
// 监听socket消息
socket.onmessage = getMessage;
}
function getMessage(msg: any) {
// msg.data是真正后端返回的数据
term.write(msg.data);
}
enum MsgType {
Resize = 1,
Data = 2,
Ping = 3,
}
const send = (msg: any) => {
socket.send(JSON.stringify(msg));
};
const sendResize = (cols: number, rows: number) => {
send({
type: MsgType.Resize,
Cols: cols,
Rows: rows,
});
};
const sendPing = () => {
send({
type: MsgType.Ping,
msg: 'ping',
});
};
function sendCmd(key: any) {
send({
type: MsgType.Data,
msg: key,
});
}
function closeSocket() {
// 关闭 websocket
socket && socket.readyState === 1 && socket.close();
// 清除 ping
pingInterval && clearInterval(pingInterval);
}
function close() {
console.log('in terminal body close');
closeSocket();
if (term) {
state.addon.search.dispose();
state.addon.fit.dispose();
state.addon.weblinks.dispose();
term.dispose();
}
}
const getStatus = (): TerminalStatus => {
return state.status;
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus });
</script>
<style lang="scss">
#terminal-body {
background: #212529;
width: 100%;
.terminal {
width: 100%;
height: 100%;
.xterm .xterm-viewport {
overflow-y: hidden;
}
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div>
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
<el-dialog
title="终端"
v-model="openTerminal.visible"
top="32px"
class="terminal-dialog"
width="75%"
:close-on-click-modal="false"
:modal="true"
:show-close="false"
:fullscreen="openTerminal.fullscreen"
>
<template #header>
<div class="terminal-title-wrapper">
<!-- 左侧 -->
<div class="title-left-fixed">
<!-- title信息 -->
<div>
<slot name="headerTitle" :terminalInfo="openTerminal">
{{ openTerminal.headerTitle }}
</slot>
</div>
</div>
<!-- 右侧 -->
<div class="title-right-fixed">
<el-popconfirm @confirm="reConnect(openTerminal.terminalId)" title="确认重新连接?">
<template #reference>
<div class="mr15 pointer">
<el-tag v-if="openTerminal.status == TerminalStatus.Connected" type="success" effect="light" round> 已连接 </el-tag>
<el-tag v-else type="danger" effect="light" round> 未连接 </el-tag>
</div>
</template>
</el-popconfirm>
<el-popover placement="bottom" :width="200" trigger="hover">
<template #reference>
<SvgIcon name="QuestionFilled" :size="20" class="pointer-icon mr10" />
</template>
<div>ctrl | command + f (搜索)</div>
<div class="mt5">点击连接状态可重连</div>
</el-popover>
<SvgIcon
name="ArrowDown"
v-if="props.visibleMinimize"
@click="minimize(openTerminal.terminalId)"
:size="20"
class="pointer-icon mr10"
title="最小化"
/>
<!-- <SvgIcon name="FullScreen" @click="handlerFullScreen(openTerminal)" :size="20" class="pointer-icon mr10" title="全屏|退出全屏" /> -->
<SvgIcon name="Close" class="pointer-icon" @click="close(openTerminal.terminalId)" title="关闭" :size="20" />
</div>
</div>
</template>
<div class="terminal-wrapper" style="height: calc(100vh - 215px)">
<TerminalBody
@status-change="terminalStatusChange(openTerminal.terminalId, $event)"
:ref="(el) => setTerminalRef(el, openTerminal.terminalId)"
:cmd="openTerminal.cmd"
:socket-url="openTerminal.socketUrl"
/>
</div>
</el-dialog>
</div>
<!-- 终端最小化 -->
<div class="terminal-minimize-container">
<el-card
v-for="minimizeTerminal of minimizeTerminals"
:key="minimizeTerminal.terminalId"
:class="`terminal-minimize-item pointer ${minimizeTerminal.styleClass}`"
size="small"
@click="maximize(minimizeTerminal.terminalId)"
>
<el-tooltip effect="customized" :content="minimizeTerminal.desc" placement="top">
<span>
{{ minimizeTerminal.title }}
</span>
</el-tooltip>
<!-- 关闭按钮 -->
<SvgIcon name="CloseBold" @click.stop="closeMinimizeTerminal(minimizeTerminal.terminalId)" class="ml10 pointer-icon fr" :size="20" />
</el-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive } from 'vue';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { TerminalStatus } from './common';
const props = defineProps({
visibleMinimize: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close', 'minimize']);
const openTerminalRefs: any = {};
/**
terminal对象信息:
visible: false,
machineId: null as any,
terminalId: null as any,
machine: {} as any,
fullscreen: false,
*/
const state = reactive({
terminals: {} as any, // key -> terminalId value -> terminal
minimizeTerminals: {} as any, // key -> terminalId value -> 简易terminal
});
const { terminals, minimizeTerminals } = toRefs(state);
const setTerminalRef = (el: any, terminalId: any) => {
if (terminalId) {
openTerminalRefs[terminalId] = el;
}
};
function open(terminalInfo: any, cmd: string = '') {
let terminalId = terminalInfo.terminalId;
if (!terminalId) {
terminalId = Date.now();
}
state.terminals[terminalId] = {
...terminalInfo,
terminalId,
visible: true,
cmd,
status: TerminalStatus.NoConnected,
};
}
const terminalStatusChange = (terminalId: string, status: TerminalStatus) => {
const terminal = state.terminals[terminalId];
if (terminal) {
terminal.status = status;
}
const minTerminal = state.minimizeTerminals[terminalId];
if (!minTerminal) {
return;
}
minTerminal.styleClass = getTerminalStatysStyleClass(terminalId, status);
};
const getTerminalStatysStyleClass = (terminalId: any, status: any = null) => {
if (status == null) {
status = openTerminalRefs[terminalId].getStatus();
}
if (status == TerminalStatus.Connected) {
return 'terminal-status-success';
}
if (status == TerminalStatus.NoConnected) {
return 'terminal-status-no-connect';
}
return 'terminal-status-error';
};
const reConnect = (terminalId: any) => {
openTerminalRefs[terminalId].init();
};
function close(terminalId: any) {
console.log('in terminal dialog close');
delete state.terminals[terminalId];
// 关闭终端并删除终端ref
const terminalRef = openTerminalRefs[terminalId];
terminalRef && terminalRef.close();
delete openTerminalRefs[terminalId];
emit('close', terminalId);
}
function minimize(terminalId: number) {
console.log('in terminal dialog minimize: ', terminalId);
const terminal = state.terminals[terminalId];
if (!terminal) {
console.warn('不存在该终端信息: ', terminalId);
return;
}
terminal.visible = false;
const minTerminalInfo = {
terminalId: terminal.terminalId,
title: terminal.minTitle, // 截取terminalId最后两位区分多个terminal
desc: terminal.minDesc,
styleClass: getTerminalStatysStyleClass(terminalId),
};
state.minimizeTerminals[terminalId] = minTerminalInfo;
emit('minimize', minTerminalInfo);
}
function maximize(terminalId: any) {
console.log('in terminal dialog maximize: ', terminalId);
const minTerminal = state.minimizeTerminals[terminalId];
if (!minTerminal) {
console.log('no min terminal...');
return;
}
delete state.minimizeTerminals[terminalId];
// 显示终端信息
state.terminals[terminalId].visible = true;
const terminalRef = openTerminalRefs[terminalId];
// fit
setTimeout(() => {
terminalRef.fitTerminal();
terminalRef.focus();
}, 250);
}
const closeMinimizeTerminal = (terminalId: any) => {
delete state.minimizeTerminals[terminalId];
close(terminalId);
};
defineExpose({
open,
close,
minimize,
maximize,
});
</script>
<style lang="scss">
.terminal-dialog-container {
.el-dialog__header {
padding: 10px;
}
// .terminal-dialog {
// height: calc(100vh - 200px) !important;
// }
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
padding: 0px !important;
}
.terminal-title-wrapper {
display: flex;
justify-content: space-between;
font-size: 16px;
.title-right-fixed {
display: flex;
align-items: center;
font-size: 20px;
text-align: end;
}
}
}
.terminal-minimize-container {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 10;
display: flex;
flex-wrap: wrap-reverse;
justify-content: flex-end;
.terminal-minimize-item {
min-width: 120px;
// box-shadow: 0 3px 4px #dee2e6;
border-radius: 4px;
margin: 1px 1px;
}
.terminal-status-error {
box-shadow: 0 3px 4px var(--color-danger);
border-color: var(--color-danger);
}
.terminal-status-no-connect {
box-shadow: 0 3px 4px var(--color-warning);
border-color: var(--color-warning);
}
.terminal-status-success {
box-shadow: 0 3px 4px var(--color-success);
border-color: var(--color-success);
}
.el-card__body {
padding: 15px !important;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div id="search-card" v-show="search.visible" @keydown.esc="closeSearch">
<el-card title="搜索" size="small">
<!-- 搜索框 -->
<el-input
class="search-input"
ref="searchInputRef"
placeholder="请输入查找内容,回车搜索"
v-model="search.value"
@keyup.enter.native="searchKeywords(true)"
clearable
>
</el-input>
<!-- 选项 -->
<div class="search-options">
<el-row>
<el-col :span="12">
<el-checkbox class="usn" v-model="search.regex"> 正则匹配 </el-checkbox>
</el-col>
<el-col :span="12">
<el-checkbox class="usn" v-model="search.words"> 单词全匹配 </el-checkbox>
</el-col>
<el-col :span="12">
<el-checkbox class="usn" v-model="search.matchCase"> 区分大小写 </el-checkbox>
</el-col>
<el-col :span="12">
<el-checkbox class="usn" v-model="search.incremental"> 增量查找 </el-checkbox>
</el-col>
</el-row>
</div>
<!-- 按钮 -->
<div class="search-buttons">
<el-button class="terminal-search-button search-button-prev" type="primary" size="small" @click="searchKeywords(false)"> 上一个 </el-button>
<el-button class="terminal-search-button search-button-next" type="primary" size="small" @click="searchKeywords(true)"> 下一个 </el-button>
<el-button class="terminal-search-button search-button-next" type="primary" size="small" @click="closeSearch"> 关闭 </el-button>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, nextTick, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import { SearchAddon, ISearchOptions } from 'xterm-addon-search';
const props = defineProps({
searchAddon: {
type: [SearchAddon],
require: true,
},
});
const state = reactive({
search: {
visible: false,
value: '',
regex: false,
words: false,
matchCase: false,
incremental: false,
},
});
const { search } = toRefs(state);
const emit = defineEmits(['close']);
const searchInputRef: any = ref(null);
function open() {
const visible = state.search.visible;
state.search.visible = !visible;
console.log(state.search.visible);
if (!visible) {
nextTick(() => {
searchInputRef.value.focus();
});
}
}
function closeSearch() {
state.search.visible = false;
state.search.value = '';
props.searchAddon?.clearDecorations();
// 取消查询关键字高亮
props.searchAddon?.clearActiveDecoration();
emit('close');
}
function searchKeywords(direction: any) {
if (!state.search.value) {
return;
}
const option = {
regex: state.search.regex,
wholeWord: state.search.words,
caseSensitive: state.search.matchCase,
incremental: state.search.incremental,
};
let res;
if (direction) {
res = props.searchAddon?.findNext(state.search.value, getSearchOptions(option));
} else {
res = props.searchAddon?.findPrevious(state.search.value, getSearchOptions(option));
}
if (!res) {
ElMessage.info('未查询到匹配项');
}
}
const getSearchOptions = (searchOptions?: ISearchOptions): ISearchOptions => {
return {
...searchOptions,
decorations: {
matchOverviewRuler: '#888888',
activeMatchColorOverviewRuler: '#ffff00',
matchBackground: '#888888',
activeMatchBackground: '#ffff00',
},
};
};
defineExpose({ open });
</script>
<style lang="scss" scoped>
#search-card {
position: absolute;
top: 60px;
right: 20px;
z-index: 1200;
width: 270px;
.search-input {
width: 240px;
}
.search-options {
margin: 12px 0;
}
.search-buttons {
margin-top: 5px;
display: flex;
justify-content: flex-end;
}
.terminal-search-button {
margin-left: 10px;
}
}
</style>

View File

@@ -0,0 +1,6 @@
export enum TerminalStatus {
Error = -1,
NoConnected = 0,
Connected = 1,
Disconnected = 2,
}

View File

@@ -344,4 +344,16 @@ body,
.f12 {
font-size: 12px
}
.pointer {
cursor: pointer;
}
.pointer-icon {
cursor: pointer;
transition: color 0.3s;
}
.pointer-icon:hover {
color: var(--color-primary); /* 鼠标移动到图标时的颜色 */
}

View File

@@ -54,7 +54,10 @@
<template #action="{ data }">
<span v-auth="'machine:terminal'">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data)" link>终端</el-button>
<el-tooltip effect="customized" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
</span>
@@ -126,6 +129,16 @@
</el-descriptions>
</el-dialog>
<terminal-dialog ref="terminalDialogRef" :visibleMinimize="true">
<template #headerTitle="{ terminalInfo }">
{{ `${(terminalInfo.terminalId + '').slice(-2)}` }}
<el-divider direction="vertical" />
{{ `${terminalInfo.meta.username}@${terminalInfo.meta.ip}:${terminalInfo.meta.port}` }}
<el-divider direction="vertical" />
{{ terminalInfo.meta.name }}
</template>
</terminal-dialog>
<machine-edit
:title="machineEditDialog.title"
v-model:visible="machineEditDialog.visible"
@@ -146,10 +159,10 @@
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent } from 'vue';
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { machineApi } from './api';
import { machineApi, getMachineTerminalSocketUrl } from './api';
import { dateFormat } from '@/common/utils/date';
import TagInfo from '../component/TagInfo.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -157,6 +170,7 @@ import { TableColumn, TableQuery } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
// 组件
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue'));
const MachineEdit = defineAsyncComponent(() => import('./MachineEdit.vue'));
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileManage = defineAsyncComponent(() => import('./FileManage.vue'));
@@ -166,6 +180,7 @@ const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
const router = useRouter();
const pageTableRef: any = ref(null);
const terminalDialogRef: any = ref(null);
const perms = {
addMachine: 'machine:add',
@@ -180,12 +195,13 @@ const queryConfig = [TableQuery.slot('tagPath', '标签', 'tagPathSelect'), Tabl
const columns = ref([
TableColumn.new('tagPath', '标签路径').isSlot().setAddWidth(20),
TableColumn.new('name', '名称'),
TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(35),
TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(45),
TableColumn.new('username', '用户名'),
TableColumn.new('status', '状态').isSlot().setMinWidth(85),
TableColumn.new('remark', '备注'),
TableColumn.new('action', '操作').isSlot().setMinWidth(238).fixedRight().alignCenter(),
]);
// 该用户拥有的的操作列按钮权限使用v-if进行判断v-auth对el-dropdown-item无效
const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
@@ -275,15 +291,28 @@ const handleCommand = (commond: any) => {
}
};
const showTerminal = (row: any) => {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: row.id,
name: row.name,
},
const showTerminal = (row: any, event: PointerEvent) => {
// 按住ctrl点击则新建标签页打开, metaKey对应mac command键
if (event.ctrlKey || event.metaKey) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: row.id,
name: row.name,
},
});
window.open(href, '_blank');
return;
}
const terminalId = Date.now();
terminalDialogRef.value.open({
terminalId,
socketUrl: getMachineTerminalSocketUrl(row.id),
minTitle: `${row.name} [${(terminalId + '').slice(-2)}]`, // 截取terminalId最后两位区分多个terminal
minDesc: `${row.username}@${row.ip}:${row.port} (${row.name})`,
meta: row,
});
window.open(href, '_blank');
};
const closeCli = async (row: any) => {

View File

@@ -8,6 +8,8 @@
:show-close="true"
:before-close="handleClose"
width="55%"
draggable
append-to-body
>
<page-table
ref="pageTableRef"
@@ -89,8 +91,10 @@
:close-on-click-modal="false"
:modal="false"
@close="closeTermnial"
draggable
append-to-body
>
<ssh-terminal ref="terminal" :cmd="terminalDialog.cmd" :machineId="terminalDialog.machineId" height="560px" />
<TerminalBody ref="terminal" :cmd="terminalDialog.cmd" :socket-url="getMachineTerminalSocketUrl(terminalDialog.machineId)" height="560px" />
</el-dialog>
<script-edit
@@ -107,8 +111,8 @@
<script lang="ts" setup>
import { ref, toRefs, reactive, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import SshTerminal from './SshTerminal.vue';
import { machineApi } from './api';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { ScriptResultEnum, ScriptTypeEnum } from './enums';
import ScriptEdit from './ScriptEdit.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -313,4 +317,4 @@ const handleClose = () => {
state.scriptParamsDialog.paramsFormItem = [];
};
</script>
<style lang="sass"></style>
<style lang="scss"></style>

View File

@@ -1,179 +0,0 @@
<template>
<div :style="{ height: props.height }" id="xterm" class="xterm" />
</template>
<script lang="ts" setup>
import 'xterm/css/xterm.css';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { getSession } from '@/common/utils/storage';
import config from '@/common/config';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { nextTick, reactive, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
machineId: { type: Number },
cmd: { type: String },
height: { type: [String, Number] },
});
const { themeConfig } = storeToRefs(useThemeConfig());
const state = reactive({
cmd: '',
term: null as any,
socket: null as any,
});
const resize = 1;
const data = 2;
const ping = 3;
onMounted(() => {
state.cmd = props.cmd as any;
nextTick(() => {
initXterm();
initSocket();
});
});
onBeforeUnmount(() => {
closeAll();
});
function initXterm() {
const term: any = new Terminal({
fontSize: themeConfig.value.terminalFontSize || 15,
fontWeight: themeConfig.value.terminalFontWeight || 'normal',
fontFamily: 'JetBrainsMono, monaco, Consolas, Lucida Console, monospace',
cursorBlink: true,
disableStdin: false,
theme: {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as any,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('xterm'));
fitAddon.fit();
term.focus();
state.term = term;
// 监听窗口resize
window.addEventListener('resize', () => {
try {
// 窗口大小改变时触发xterm的resize方法使自适应
fitAddon.fit();
if (state.term) {
state.term.focus();
send({
type: resize,
Cols: parseInt(state.term.cols),
Rows: parseInt(state.term.rows),
});
}
} catch (e) {
console.log(e);
}
});
// / **
// *添加事件监听器,用于按下键时的事件。事件值包含
// *将在data事件以及DOM事件中发送的字符串
// *触发了它。
// * @返回一个IDisposable停止监听。
// * /
// / ** 更新xterm 4.x新增
// *为数据事件触发时添加事件侦听器。发生这种情况
// *用户输入或粘贴到终端时的示例。事件值
// *是`string`结果的结果,在典型的设置中,应该通过
// *到支持pty。
// * @返回一个IDisposable停止监听。
// * /
// 支持输入与粘贴方法
term.onData((key: any) => {
sendCmd(key);
});
}
let pingInterval: any;
function initSocket() {
state.socket = new WebSocket(
`${config.baseWsUrl}/machines/${props.machineId}/terminal?token=${getSession('token')}&cols=${state.term.cols}&rows=${state.term.rows}`
);
// 监听socket连接
state.socket.onopen = () => {
// 如果有初始要执行的命令,则发送执行命令
if (state.cmd) {
sendCmd(state.cmd + ' \r');
}
// 开启心跳
pingInterval = setInterval(() => {
send({ type: ping, msg: 'ping' });
}, 8000);
};
// 监听socket错误信息
state.socket.onerror = (e: any) => {
console.log('连接错误', e);
};
state.socket.onclose = () => {
if (state.term) {
state.term.writeln('\r\n\x1b[31m提示: 连接已关闭...');
}
if (pingInterval) {
clearInterval(pingInterval);
}
};
// 发送socket消息
state.socket.onsend = send;
// 监听socket消息
state.socket.onmessage = getMessage;
}
function getMessage(msg: any) {
// msg.data是真正后端返回的数据
state.term.write(msg.data);
}
function send(msg: any) {
state.socket.send(JSON.stringify(msg));
}
function sendCmd(key: any) {
send({
type: data,
msg: key,
});
}
function close() {
if (state.socket) {
state.socket.close();
console.log('socket关闭');
}
}
function closeAll() {
close();
if (state.term) {
state.term.dispose();
state.term = null;
}
}
</script>
<style lang="scss">
#xterm {
.xterm-viewport {
overflow-y: hidden;
}
}
</style>

View File

@@ -1,25 +1,18 @@
<template>
<div>
<ssh-terminal ref="terminal" :machineId="machineId" :height="height + 'px'" />
<div class="terminal-wrapper">
<TerminalBody :socket-url="getMachineTerminalSocketUrl(route.query.id)" />
</div>
</template>
<script lang="ts" setup>
import SshTerminal from './SshTerminal.vue';
import { reactive, toRefs, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { getMachineTerminalSocketUrl } from './api';
const route = useRoute();
const state = reactive({
machineId: 0,
height: 0,
});
const { machineId, height } = toRefs(state);
onMounted(() => {
state.height = window.innerHeight + 5;
state.machineId = Number.parseInt(route.query.id as string);
});
</script>
<style lang="scss"></style>
<style lang="scss">
.terminal-wrapper {
height: calc(100vh);
}
</style>

View File

@@ -1,4 +1,6 @@
import Api from '@/common/Api';
import config from '@/common/config';
import { getSession } from '@/common/utils/storage';
export const machineApi = {
// 获取权限列表
@@ -56,3 +58,7 @@ export const cronJobApi = {
delete: Api.newDelete('/machine-cronjobs/{id}'),
execList: Api.newGet('/machine-cronjobs/execs'),
};
export function getMachineTerminalSocketUrl(machineId: any) {
return `${config.baseWsUrl}/machines/${machineId}/terminal?token=${getSession('token')}`;
}

View File

@@ -601,10 +601,10 @@ asynckit@^0.4.0:
resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^1.4.0:
version "1.4.0"
resolved "https://registry.npmmirror.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
axios@^1.5.0:
version "1.5.0"
resolved "https://registry.npmmirror.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267"
integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
@@ -1949,6 +1949,21 @@ xterm-addon-fit@^0.7.0:
resolved "https://registry.npmmirror.com/xterm-addon-fit/-/xterm-addon-fit-0.7.0.tgz#b8ade6d96e63b47443862088f6670b49fb752c6a"
integrity sha512-tQgHGoHqRTgeROPnvmtEJywLKoC/V9eNs4bLLz7iyJr1aW/QFzRwfd3MGiJ6odJd9xEfxcW36/xRU47JkD5NKQ==
xterm-addon-search@^0.12.0:
version "0.12.0"
resolved "https://registry.npmmirror.com/xterm-addon-search/-/xterm-addon-search-0.12.0.tgz#2ef8f56aecf699a3989223a1260f1e079d7c74e2"
integrity sha512-hXAuO7Ts2+Jf9K8mZrUx8IFd7c/Flgks/jyqA1L4reymyfmXtcsd+WDLel8R9Tgy2CLyKABVBP09/Ua/FmXcvg==
xterm-addon-web-links@^0.8.0:
version "0.8.0"
resolved "https://registry.npmmirror.com/xterm-addon-web-links/-/xterm-addon-web-links-0.8.0.tgz#2cb1d57129271022569208578b0bf4774e7e6ea9"
integrity sha512-J4tKngmIu20ytX9SEJjAP3UGksah7iALqBtfTwT9ZnmFHVplCumYQsUJfKuS+JwMhjsjH61YXfndenLNvjRrEw==
xterm-addon-webgl@^0.15.0:
version "0.15.0"
resolved "https://registry.npmmirror.com/xterm-addon-webgl/-/xterm-addon-webgl-0.15.0.tgz#c10f93ca619524f5a470eaac44258bab0ae8e3c7"
integrity sha512-ZLcqogMFHr4g/YRhcCh3xE8tTklnyut/M+O/XhVsFBRB/YCvYhPdLQ5/AQk54V0wjWAQpa8CF3W8DVR9OqyMCg==
xterm@^5.2.1:
version "5.2.1"
resolved "https://registry.npmmirror.com/xterm/-/xterm-5.2.1.tgz#b3fea7bdb55b9be1d4b31f4cd1091f26ac42afb8"

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"mayfly-go/internal/auth/api/form"
"mayfly-go/internal/auth/config"
"mayfly-go/internal/common/utils"
msgapp "mayfly-go/internal/msg/application"
sysapp "mayfly-go/internal/sys/application"
@@ -22,7 +23,6 @@ import (
type AccountLogin struct {
AccountApp sysapp.Account
MsgApp msgapp.Msg
ConfigApp sysapp.Config
}
/** 用户账号密码登录 **/
@@ -31,7 +31,7 @@ type AccountLogin struct {
func (a *AccountLogin) Login(rc *req.Ctx) {
loginForm := ginx.BindJsonAndValid(rc.GinCtx, new(form.LoginForm))
accountLoginSecurity := a.ConfigApp.GetConfig(sysentity.ConfigKeyAccountLoginSecurity).ToAccountLoginSecurity()
accountLoginSecurity := config.GetAccountLoginSecurity()
// 判断是否有开启登录验证码校验
if accountLoginSecurity.UseCaptcha {
// 校验验证码

View File

@@ -2,6 +2,7 @@ package api
import (
"fmt"
"mayfly-go/internal/auth/config"
msgapp "mayfly-go/internal/msg/application"
msgentity "mayfly-go/internal/msg/domain/entity"
sysapp "mayfly-go/internal/sys/application"
@@ -24,7 +25,7 @@ const (
)
// 最后的登录校验共用。校验通过返回登录成功响应结果map
func LastLoginCheck(account *sysentity.Account, accountLoginSecurity *sysentity.AccountLoginSecurity, loginIp string) map[string]any {
func LastLoginCheck(account *sysentity.Account, accountLoginSecurity *config.AccountLoginSecurity, loginIp string) map[string]any {
biz.IsTrue(account.IsEnable(), "该账号不可用")
username := account.Username

View File

@@ -3,10 +3,8 @@ package api
import (
"crypto/tls"
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/pkg/errors"
"gorm.io/gorm"
"mayfly-go/internal/auth/api/form"
"mayfly-go/internal/auth/config"
msgapp "mayfly-go/internal/msg/application"
sysapp "mayfly-go/internal/sys/application"
sysentity "mayfly-go/internal/sys/domain/entity"
@@ -19,17 +17,20 @@ import (
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type LdapLogin struct {
AccountApp sysapp.Account
MsgApp msgapp.Msg
ConfigApp sysapp.Config
}
// @router /auth/ldap/enabled [get]
func (a *LdapLogin) GetLdapEnabled(rc *req.Ctx) {
ldapLoginConfig := a.ConfigApp.GetConfig(sysentity.ConfigKeyLdapLogin).ToLdapLogin()
ldapLoginConfig := config.GetLdapLogin()
rc.ResData = ldapLoginConfig.Enable
}
@@ -37,7 +38,7 @@ func (a *LdapLogin) GetLdapEnabled(rc *req.Ctx) {
func (a *LdapLogin) Login(rc *req.Ctx) {
loginForm := ginx.BindJsonAndValid(rc.GinCtx, new(form.LoginForm))
accountLoginSecurity := a.ConfigApp.GetConfig(sysentity.ConfigKeyAccountLoginSecurity).ToAccountLoginSecurity()
accountLoginSecurity := config.GetAccountLoginSecurity()
// 判断是否有开启登录验证码校验
if accountLoginSecurity.UseCaptcha {
// 校验验证码
@@ -115,7 +116,7 @@ type UserInfo struct {
// Authenticate 通过 LDAP 验证用户名密码
func Authenticate(username, password string) (*UserInfo, error) {
ldapConf := sysapp.GetConfigApp().GetConfig(sysentity.ConfigKeyLdapLogin).ToLdapLogin()
ldapConf := config.GetLdapLogin()
if !ldapConf.Enable {
return nil, errors.Errorf("未启用 LDAP 登录")
}
@@ -163,7 +164,7 @@ func Authenticate(username, password string) (*UserInfo, error) {
}
// Connect 创建 LDAP 连接
func Connect(ldapConf *sysentity.ConfigLdapLogin) (*ldap.Conn, error) {
func Connect(ldapConf *config.LdapLogin) (*ldap.Conn, error) {
conn, err := dial(ldapConf)
if err != nil {
return nil, err
@@ -178,7 +179,7 @@ func Connect(ldapConf *sysentity.ConfigLdapLogin) (*ldap.Conn, error) {
return conn, nil
}
func dial(ldapConf *sysentity.ConfigLdapLogin) (*ldap.Conn, error) {
func dial(ldapConf *config.LdapLogin) (*ldap.Conn, error) {
addr := fmt.Sprintf("%s:%s", ldapConf.Host, ldapConf.Port)
tlsConfig := &tls.Config{
ServerName: ldapConf.Host,

View File

@@ -5,6 +5,7 @@ import (
"io"
"mayfly-go/internal/auth/api/vo"
"mayfly-go/internal/auth/application"
"mayfly-go/internal/auth/config"
"mayfly-go/internal/auth/domain/entity"
msgapp "mayfly-go/internal/msg/application"
sysapp "mayfly-go/internal/sys/application"
@@ -25,7 +26,6 @@ import (
type Oauth2Login struct {
Oauth2App application.Oauth2
ConfigApp sysapp.Config
AccountApp sysapp.Account
MsgApp msgapp.Msg
}
@@ -129,7 +129,7 @@ func (a *Oauth2Login) OAuth2Callback(rc *req.Ctx) {
}
// 指定登录操作
func (a *Oauth2Login) doLoginAction(rc *req.Ctx, userId string, oauth *sysentity.ConfigOauth2Login) {
func (a *Oauth2Login) doLoginAction(rc *req.Ctx, userId string, oauth *config.Oauth2Login) {
// 查询用户是否存在
oauthAccount := &entity.Oauth2Account{Identity: userId}
err := a.Oauth2App.GetOAuthAccount(oauthAccount, "account_id", "identity")
@@ -175,14 +175,14 @@ func (a *Oauth2Login) doLoginAction(rc *req.Ctx, userId string, oauth *sysentity
clientIp := getIpAndRegion(rc)
rc.ReqParam = fmt.Sprintf("oauth2 login username: %s | ip: %s", account.Username, clientIp)
res := LastLoginCheck(account, a.ConfigApp.GetConfig(sysentity.ConfigKeyAccountLoginSecurity).ToAccountLoginSecurity(), clientIp)
res := LastLoginCheck(account, config.GetAccountLoginSecurity(), clientIp)
res["action"] = "oauthLogin"
res["isFirstOauth2Login"] = isFirst
rc.ResData = res
}
func (a *Oauth2Login) getOAuthClient() (*oauth2.Config, *sysentity.ConfigOauth2Login) {
oath2LoginConfig := a.ConfigApp.GetConfig(sysentity.ConfigKeyOauth2Login).ToOauth2Login()
func (a *Oauth2Login) getOAuthClient() (*oauth2.Config, *config.Oauth2Login) {
oath2LoginConfig := config.GetOauth2Login()
biz.IsTrue(oath2LoginConfig.Enable, "请先配置oauth2或启用oauth2登录")
biz.IsTrue(oath2LoginConfig.ClientId != "", "oauth2 clientId不能为空")
@@ -201,7 +201,7 @@ func (a *Oauth2Login) getOAuthClient() (*oauth2.Config, *sysentity.ConfigOauth2L
func (a *Oauth2Login) Oauth2Status(ctx *req.Ctx) {
res := &vo.Oauth2Status{}
oauth2LoginConfig := a.ConfigApp.GetConfig(sysentity.ConfigKeyOauth2Login).ToOauth2Login()
oauth2LoginConfig := config.GetOauth2Login()
res.Enable = oauth2LoginConfig.Enable
if res.Enable {
err := a.Oauth2App.GetOAuthAccount(&entity.Oauth2Account{
@@ -216,3 +216,12 @@ func (a *Oauth2Login) Oauth2Status(ctx *req.Ctx) {
func (a *Oauth2Login) Oauth2Unbind(rc *req.Ctx) {
a.Oauth2App.Unbind(rc.LoginAccount.Id)
}
// 获取oauth2登录配置信息因为有些字段是敏感字段故单独使用接口获取
func (c *Oauth2Login) Oauth2Config(rc *req.Ctx) {
oauth2LoginConfig := config.GetOauth2Login()
rc.ResData = map[string]any{
"enable": oauth2LoginConfig.Enable,
"name": oauth2LoginConfig.Name,
}
}

View File

@@ -0,0 +1,105 @@
package config
import (
sysapp "mayfly-go/internal/sys/application"
"mayfly-go/pkg/utils/stringx"
)
const (
ConfigKeyAccountLoginSecurity string = "AccountLoginSecurity" // 账号登录安全配置
ConfigKeyOauth2Login string = "Oauth2Login" // oauth2认证登录配置
ConfigKeyLdapLogin string = "LdapLogin" // ldap登录配置
)
type AccountLoginSecurity struct {
UseCaptcha bool // 是否使用登录验证码
UseOtp bool // 是否双因素校验
OtpIssuer string // otp发行人
LoginFailCount int // 允许失败次数
LoginFailMin int // 登录失败指定次数后禁止的分钟数
}
// 获取账号登录安全相关配置
func GetAccountLoginSecurity() *AccountLoginSecurity {
c := sysapp.GetConfigApp().GetConfig(ConfigKeyAccountLoginSecurity)
jm := c.GetJsonMap()
als := new(AccountLoginSecurity)
als.UseCaptcha = c.ConvBool(jm["useCaptcha"], true)
als.UseOtp = c.ConvBool(jm["useOtp"], false)
als.LoginFailCount = c.ConvInt(jm["loginFailCount"], 5)
als.LoginFailMin = c.ConvInt(jm["loginFailMin"], 10)
otpIssuer := jm["otpIssuer"]
if otpIssuer == "" {
otpIssuer = "mayfly-go"
}
als.OtpIssuer = otpIssuer
return als
}
type Oauth2Login struct {
Enable bool // 是否启用
Name string
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AuthorizationURL string `json:"authorizationURL"`
AccessTokenURL string `json:"accessTokenURL"`
RedirectURL string `json:"redirectURL"`
Scopes string `json:"scopes"`
ResourceURL string `json:"resourceURL"`
UserIdentifier string `json:"userIdentifier"`
AutoRegister bool `json:"autoRegister"` // 是否自动注册
}
// 获取Oauth2登录相关配置
func GetOauth2Login() *Oauth2Login {
c := sysapp.GetConfigApp().GetConfig(ConfigKeyOauth2Login)
jm := c.GetJsonMap()
ol := new(Oauth2Login)
ol.Enable = c.ConvBool(jm["enable"], false)
ol.Name = jm["name"]
ol.ClientId = jm["clientId"]
ol.ClientSecret = jm["clientSecret"]
ol.AuthorizationURL = jm["authorizationURL"]
ol.AccessTokenURL = jm["accessTokenURL"]
ol.RedirectURL = jm["redirectURL"]
ol.Scopes = stringx.Trim(jm["scopes"])
ol.ResourceURL = jm["resourceURL"]
ol.UserIdentifier = jm["userIdentifier"]
ol.AutoRegister = c.ConvBool(jm["autoRegister"], true)
return ol
}
type LdapLogin struct {
Enable bool // 是否启用
Host string
Port string `json:"port"`
SkipTLSVerify bool `json:"skipTLSVerify"` // 客户端是否跳过 TLS 证书验证
SecurityProtocol string `json:"securityProtocol"` // 安全协议为Null不使用安全协议如: StartTLS, LDAPS
BindDN string `json:"bindDn"` // LDAP 服务的管理员账号,如: "cn=admin,dc=example,dc=com"
BindPwd string `json:"bindPwd"` // LDAP 服务的管理员密码
BaseDN string `json:"baseDN"` // 用户所在的 base DN, 如: "ou=users,dc=example,dc=com"
UserFilter string `json:"userFilter"` // 过滤用户的方式, 如: "(uid=%s)"
UidMap string `json:"UidMap"` // 用户id和 LDAP 字段名之间的映射关系
UdnMap string `json:"UdnMap"` // 用户姓名(dispalyName)和 LDAP 字段名之间的映射关系
EmailMap string `json:"emailMap"` // 用户email和 LDAP 字段名之间的映射关系
}
// 获取LdapLogin相关配置
func GetLdapLogin() *LdapLogin {
c := sysapp.GetConfigApp().GetConfig(ConfigKeyLdapLogin)
jm := c.GetJsonMap()
ll := new(LdapLogin)
ll.Enable = c.ConvBool(jm["enable"], false)
ll.Host = jm["host"]
ll.Port = jm["port"]
ll.SkipTLSVerify = c.ConvBool(jm["skipTLSVerify"], true)
ll.SecurityProtocol = jm["securityProtocol"]
ll.BindDN = stringx.Trim(jm["bindDN"])
ll.BindPwd = stringx.Trim(jm["bindPwd"])
ll.BaseDN = stringx.Trim(jm["baseDN"])
ll.UserFilter = stringx.Trim(jm["userFilter"])
ll.UidMap = stringx.Trim(jm["uidMap"])
ll.UdnMap = stringx.Trim(jm["udnMap"])
ll.EmailMap = stringx.Trim(jm["emailMap"])
return ll
}

View File

@@ -12,20 +12,17 @@ import (
func Init(router *gin.RouterGroup) {
accountLogin := &api.AccountLogin{
ConfigApp: sysapp.GetConfigApp(),
AccountApp: sysapp.GetAccountApp(),
MsgApp: msgapp.GetMsgApp(),
}
ldapLogin := &api.LdapLogin{
ConfigApp: sysapp.GetConfigApp(),
AccountApp: sysapp.GetAccountApp(),
MsgApp: msgapp.GetMsgApp(),
}
oauth2Login := &api.Oauth2Login{
Oauth2App: application.GetAuthApp(),
ConfigApp: sysapp.GetConfigApp(),
AccountApp: sysapp.GetAccountApp(),
MsgApp: msgapp.GetMsgApp(),
}
@@ -45,6 +42,8 @@ func Init(router *gin.RouterGroup) {
/*--------oauth2登录相关----------*/
req.NewGet("/oauth2-config", oauth2Login.Oauth2Config).DontNeedToken(),
// oauth2登录
req.NewGet("/oauth2/login", oauth2Login.OAuth2Login).DontNeedToken(),

View File

@@ -3,10 +3,9 @@ package application
import (
"encoding/json"
"fmt"
"mayfly-go/internal/db/config"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
sysapp "mayfly-go/internal/sys/application"
sysentity "mayfly-go/internal/sys/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/model"
"strconv"
@@ -90,7 +89,7 @@ func (d *dbSqlExecAppImpl) Exec(execSqlReq *DbSqlExecReq) (*DbSqlExecRes, error)
isSelect := strings.HasPrefix(lowerSql, "select")
if isSelect {
// 如果配置为0则不校验分页参数
maxCount := sysapp.GetConfigApp().GetConfig(sysentity.ConfigKeyDbQueryMaxCount).IntValue(200)
maxCount := config.GetDbQueryMaxCount()
if maxCount != 0 {
biz.IsTrue(strings.Contains(lowerSql, "limit"), "请完善分页信息后执行")
}
@@ -140,7 +139,7 @@ func (d *dbSqlExecAppImpl) saveSqlExecLog(isQuery bool, dbSqlExecRecord *entity.
d.dbSqlExecRepo.Insert(dbSqlExecRecord)
return
}
if sysapp.GetConfigApp().GetConfig(sysentity.ConfigKeyDbSaveQuerySQL).BoolValue(false) {
if config.GetDbSaveQuerySql() {
dbSqlExecRecord.Table = "-"
dbSqlExecRecord.OldValue = "-"
dbSqlExecRecord.Type = entity.DbSqlExecTypeQuery
@@ -161,7 +160,7 @@ func doSelect(selectStmt *sqlparser.Select, execSqlReq *DbSqlExecReq) (*DbSqlExe
if selectExprsStr == "*" || strings.Contains(selectExprsStr, ".*") ||
len(strings.Split(selectExprsStr, ",")) > 1 {
// 如果配置为0则不校验分页参数
maxCount := sysapp.GetConfigApp().GetConfig(sysentity.ConfigKeyDbQueryMaxCount).IntValue(200)
maxCount := config.GetDbQueryMaxCount()
if maxCount != 0 {
limit := selectStmt.Limit
biz.NotNil(limit, "请完善分页信息后执行")

View File

@@ -0,0 +1,18 @@
package config
import sysapp "mayfly-go/internal/sys/application"
const (
ConfigKeyDbSaveQuerySQL string = "DbSaveQuerySQL" // 数据库是否记录查询相关sql
ConfigKeyDbQueryMaxCount string = "DbQueryMaxCount" // 数据库查询的最大数量
)
// 获取数据库最大查询数量配置
func GetDbQueryMaxCount() int {
return sysapp.GetConfigApp().GetConfig(ConfigKeyDbQueryMaxCount).IntValue(200)
}
// 获取数据库是否记录查询相关sql配置
func GetDbSaveQuerySql() bool {
return sysapp.GetConfigApp().GetConfig(ConfigKeyDbSaveQuerySQL).BoolValue(false)
}

View File

@@ -166,7 +166,7 @@ func (ts *TerminalSession) receiveWsMsg() {
case Data:
_, err := ts.terminal.Write([]byte(msgObj.Msg))
if err != nil {
global.Log.Debug("机器ssh终端写入消息失败: %s", err)
global.Log.Debugf("机器ssh终端写入消息失败: %s", err)
}
case Ping:
_, err := ts.terminal.SshSession.SendRequest("ping", true, nil)

View File

@@ -41,12 +41,3 @@ func (c *Config) SaveConfig(rc *req.Ctx) {
config.SetBaseInfo(rc.LoginAccount)
c.ConfigApp.Save(config)
}
// 获取oauth2登录配置信息因为有些字段是敏感字段故单独使用接口获取
func (c *Config) Oauth2Config(rc *req.Ctx) {
oauth2LoginConfig := c.ConfigApp.GetConfig(entity.ConfigKeyOauth2Login).ToOauth2Login()
rc.ResData = map[string]any{
"enable": oauth2LoginConfig.Enable,
"name": oauth2LoginConfig.Name,
}
}

View File

@@ -3,17 +3,11 @@ package entity
import (
"encoding/json"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/stringx"
"strconv"
)
const (
ConfigKeyAccountLoginSecurity string = "AccountLoginSecurity" // 账号登录安全配置
ConfigKeyOauth2Login string = "Oauth2Login" // oauth2认证登录配置
ConfigKeyLdapLogin string = "LdapLogin" // ldap登录配置
ConfigKeyDbQueryMaxCount string = "DbQueryMaxCount" // 数据库查询的最大数量
ConfigKeyDbSaveQuerySQL string = "DbSaveQuerySQL" // 数据库是否记录查询相关sql
ConfigUseWartermark string = "UseWartermark" // 是否使用水印
ConfigUseWartermark string = "UseWartermark" // 是否使用水印
)
type Config struct {
@@ -58,96 +52,6 @@ func (c *Config) IntValue(defaultValue int) int {
return c.ConvInt(c.Value, defaultValue)
}
type AccountLoginSecurity struct {
UseCaptcha bool // 是否使用登录验证码
UseOtp bool // 是否双因素校验
OtpIssuer string // otp发行人
LoginFailCount int // 允许失败次数
LoginFailMin int // 登录失败指定次数后禁止的分钟数
}
// 转换为AccountLoginSecurity结构体
func (c *Config) ToAccountLoginSecurity() *AccountLoginSecurity {
jm := c.GetJsonMap()
als := new(AccountLoginSecurity)
als.UseCaptcha = c.ConvBool(jm["useCaptcha"], true)
als.UseOtp = c.ConvBool(jm["useOtp"], false)
als.LoginFailCount = c.ConvInt(jm["loginFailCount"], 5)
als.LoginFailMin = c.ConvInt(jm["loginFailMin"], 10)
otpIssuer := jm["otpIssuer"]
if otpIssuer == "" {
otpIssuer = "mayfly-go"
}
als.OtpIssuer = otpIssuer
return als
}
type ConfigOauth2Login struct {
Enable bool // 是否启用
Name string
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AuthorizationURL string `json:"authorizationURL"`
AccessTokenURL string `json:"accessTokenURL"`
RedirectURL string `json:"redirectURL"`
Scopes string `json:"scopes"`
ResourceURL string `json:"resourceURL"`
UserIdentifier string `json:"userIdentifier"`
AutoRegister bool `json:"autoRegister"` // 是否自动注册
}
// 转换为Oauth2Login结构体
func (c *Config) ToOauth2Login() *ConfigOauth2Login {
jm := c.GetJsonMap()
ol := new(ConfigOauth2Login)
ol.Enable = c.ConvBool(jm["enable"], false)
ol.Name = jm["name"]
ol.ClientId = jm["clientId"]
ol.ClientSecret = jm["clientSecret"]
ol.AuthorizationURL = jm["authorizationURL"]
ol.AccessTokenURL = jm["accessTokenURL"]
ol.RedirectURL = jm["redirectURL"]
ol.Scopes = stringx.Trim(jm["scopes"])
ol.ResourceURL = jm["resourceURL"]
ol.UserIdentifier = jm["userIdentifier"]
ol.AutoRegister = c.ConvBool(jm["autoRegister"], true)
return ol
}
type ConfigLdapLogin struct {
Enable bool // 是否启用
Host string
Port string `json:"port"`
SkipTLSVerify bool `json:"skipTLSVerify"` // 客户端是否跳过 TLS 证书验证
SecurityProtocol string `json:"securityProtocol"` // 安全协议为Null不使用安全协议如: StartTLS, LDAPS
BindDN string `json:"bindDn"` // LDAP 服务的管理员账号,如: "cn=admin,dc=example,dc=com"
BindPwd string `json:"bindPwd"` // LDAP 服务的管理员密码
BaseDN string `json:"baseDN"` // 用户所在的 base DN, 如: "ou=users,dc=example,dc=com"
UserFilter string `json:"userFilter"` // 过滤用户的方式, 如: "(uid=%s)"
UidMap string `json:"UidMap"` // 用户id和 LDAP 字段名之间的映射关系
UdnMap string `json:"UdnMap"` // 用户姓名(dispalyName)和 LDAP 字段名之间的映射关系
EmailMap string `json:"emailMap"` // 用户email和 LDAP 字段名之间的映射关系
}
// 转换为LdapLogin结构体
func (c *Config) ToLdapLogin() *ConfigLdapLogin {
jm := c.GetJsonMap()
ll := new(ConfigLdapLogin)
ll.Enable = c.ConvBool(jm["enable"], false)
ll.Host = jm["host"]
ll.Port = jm["port"]
ll.SkipTLSVerify = c.ConvBool(jm["skipTLSVerify"], true)
ll.SecurityProtocol = jm["securityProtocol"]
ll.BindDN = stringx.Trim(jm["bindDN"])
ll.BindPwd = stringx.Trim(jm["bindPwd"])
ll.BaseDN = stringx.Trim(jm["baseDN"])
ll.UserFilter = stringx.Trim(jm["userFilter"])
ll.UidMap = stringx.Trim(jm["uidMap"])
ll.UdnMap = stringx.Trim(jm["udnMap"])
ll.EmailMap = stringx.Trim(jm["emailMap"])
return ll
}
// 转换配置中的值为bool类型默认"1"或"true"为true其他为false
func (c *Config) ConvBool(value string, defaultValue bool) bool {
if value == "" {

View File

@@ -20,8 +20,6 @@ func InitSysConfigRouter(router *gin.RouterGroup) {
// 获取指定配置key对应的值
req.NewGet("/value", r.GetConfigValueByKey).DontNeedToken(),
req.NewGet("/oauth2-login", r.Oauth2Config).DontNeedToken(),
req.NewPost("", r.SaveConfig).Log(req.NewLogSave("保存系统配置信息")).
RequiredPermissionCode("config:save"),
}

View File

@@ -3,10 +3,12 @@ package config
import (
"flag"
"fmt"
"log/slog"
"mayfly-go/pkg/utils/assert"
"mayfly-go/pkg/utils/ymlx"
"os"
"path/filepath"
"strconv"
)
// 配置文件映射对象
@@ -21,7 +23,9 @@ func Init() {
// 读取配置文件信息
yc := &Config{}
if err := ymlx.LoadYml(startConfigParam.ConfigFilePath, yc); err != nil {
panic(fmt.Sprintf("读取配置文件[%s]失败: %s", startConfigParam.ConfigFilePath, err.Error()))
slog.Warn(fmt.Sprintf("读取配置文件[%s]失败: %s, 使用系统默认配置或环境变量配置", startConfigParam.ConfigFilePath, err.Error()))
// 设置默认信息,主要方便后续的系统环境变量替换
yc.SetDefaultConfig()
}
// 校验配置文件内容信息
yc.Valid()
@@ -56,6 +60,15 @@ func (c *Config) Valid() {
// 替换系统环境变量,如果环境变量中存在该值,则优秀使用环境变量设定的值
func (c *Config) ReplaceOsEnv() {
serverPort := os.Getenv("MAYFLY_SERVER_PORT")
if serverPort != "" {
if num, err := strconv.Atoi(serverPort); err != nil {
panic("环境变量-[MAYFLY_SERVER_PORT]-服务端口号需为数字")
} else {
c.Server.Port = num
}
}
dbHost := os.Getenv("MAYFLY_DB_HOST")
if dbHost != "" {
c.Mysql.Host = dbHost
@@ -86,3 +99,29 @@ func (c *Config) ReplaceOsEnv() {
c.Jwt.Key = jwtKey
}
}
func (c *Config) SetDefaultConfig() {
c.Server = &Server{
Model: "release",
Port: 8888,
MachineRecPath: "./rec",
}
c.Jwt = &Jwt{
ExpireTime: 1440,
}
c.Aes = &Aes{
Key: "1111111111111111",
}
c.Mysql = &Mysql{
Host: "localhost:3306",
Config: "charset=utf8&loc=Local&parseTime=true",
MaxIdleConns: 5,
}
c.Log = &Log{
Level: "info",
}
}