mirror of
https://gitee.com/dromara/mayfly-go
synced 2025-11-02 15:30:25 +08:00
!112 feat: 机器管理支持ssh+rdp连接win服务器
* feat: rdp 文件管理 * feat: 机器管理支持ssh+rdp连接win服务器
This commit is contained in:
430
mayfly_go_web/src/components/terminal-rdp/MachineRdp.vue
Normal file
430
mayfly_go_web/src/components/terminal-rdp/MachineRdp.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div ref="viewportRef" class="viewport" :style="{ width: state.size.width + 'px', height: state.size.height + 'px' }">
|
||||
<div ref="displayRef" class="display" tabindex="0" />
|
||||
<div class="btn-box">
|
||||
<SvgIcon name="DocumentCopy" @click="openPaste" :size="20" class="pointer-icon mr10" title="剪贴板" />
|
||||
<SvgIcon name="FolderOpened" @click="openFilesystem" :size="20" class="pointer-icon mr10" title="文件管理" />
|
||||
<SvgIcon name="FullScreen" @click="state.fullscreen ? closeFullScreen() : openFullScreen()" :size="20" class="pointer-icon mr10" title="全屏" />
|
||||
|
||||
<el-popconfirm @confirm="connect(0, 0)" title="确认重新连接?">
|
||||
<template #reference>
|
||||
<SvgIcon name="Refresh" :size="20" class="pointer-icon mr10" title="重新连接" />
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
<clipboard-dialog ref="clipboardRef" v-model:visible="state.clipboardDialog.visible" @close="closePaste" @submit="onsubmitClipboard" />
|
||||
|
||||
<el-dialog destroy-on-close :title="state.filesystemDialog.title" v-model="state.filesystemDialog.visible" :close-on-click-modal="false" width="70%">
|
||||
<machine-file
|
||||
:machine-id="state.filesystemDialog.machineId"
|
||||
:protocol="state.filesystemDialog.protocol"
|
||||
:file-id="state.filesystemDialog.fileId"
|
||||
:path="state.filesystemDialog.path"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Guacamole from './guac/guacamole-common';
|
||||
import { getMachineRdpSocketUrl } from '@/views/ops/machine/api';
|
||||
import clipboard from './guac/clipboard';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { TerminalStatus } from '@/components/terminal/common';
|
||||
import ClipboardDialog from '@/components/terminal-rdp/guac/ClipboardDialog.vue';
|
||||
import { TerminalExpose } from '@/components/terminal-rdp/index';
|
||||
import SvgIcon from '@/components/svgIcon/index.vue';
|
||||
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
|
||||
import { exitFullscreen, launchIntoFullscreen, unWatchFullscreenChange, watchFullscreenChange } from '@/components/terminal-rdp/guac/screen';
|
||||
|
||||
const viewportRef = ref({} as any);
|
||||
const displayRef = ref({} as any);
|
||||
const clipboardRef = ref({} as any);
|
||||
|
||||
const props = defineProps({
|
||||
machineId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
clipboardList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['statusChange']);
|
||||
|
||||
const state = reactive({
|
||||
client: null as any,
|
||||
display: null as any,
|
||||
displayElm: {} as any,
|
||||
clipboard: {} as any,
|
||||
keyboard: {} as any,
|
||||
mouse: null as any,
|
||||
touchpad: null as any,
|
||||
errorMessage: '',
|
||||
arguments: {},
|
||||
status: TerminalStatus.NoConnected,
|
||||
size: {
|
||||
height: 710,
|
||||
width: 1024,
|
||||
force: false,
|
||||
},
|
||||
enableClipboard: true,
|
||||
clipboardDialog: {
|
||||
visible: false,
|
||||
},
|
||||
filesystemDialog: {
|
||||
visible: false,
|
||||
machineId: 0,
|
||||
protocol: 1,
|
||||
title: '',
|
||||
fileId: 0,
|
||||
path: '',
|
||||
},
|
||||
fullscreen: false,
|
||||
beforeFullSize: {
|
||||
height: 710,
|
||||
width: 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const installKeyboard = () => {
|
||||
state.keyboard = new Guacamole.Keyboard(state.displayElm);
|
||||
uninstallKeyboard();
|
||||
state.keyboard.onkeydown = (keysym: any) => {
|
||||
state.client.sendKeyEvent(1, keysym);
|
||||
};
|
||||
state.keyboard.onkeyup = (keysym: any) => {
|
||||
state.client.sendKeyEvent(0, keysym);
|
||||
};
|
||||
};
|
||||
const uninstallKeyboard = () => {
|
||||
state.keyboard!.onkeydown = state.keyboard!.onkeyup = () => {};
|
||||
};
|
||||
|
||||
const installMouse = () => {
|
||||
state.mouse = new Guacamole.Mouse(state.displayElm);
|
||||
// Hide software cursor when mouse leaves display
|
||||
state.mouse.onmouseout = () => {
|
||||
if (!state.display) return;
|
||||
state.display.showCursor(false);
|
||||
};
|
||||
state.mouse.onmousedown = state.mouse.onmouseup = state.mouse.onmousemove = handleMouseState;
|
||||
};
|
||||
|
||||
const installTouchpad = () => {
|
||||
state.touchpad = new Guacamole.Mouse.Touchpad(state.displayElm);
|
||||
|
||||
state.touchpad.onmousedown =
|
||||
state.touchpad.onmouseup =
|
||||
state.touchpad.onmousemove =
|
||||
(st: any) => {
|
||||
// 记录按下时,光标所在位置
|
||||
console.log(st);
|
||||
handleMouseState(st, true);
|
||||
};
|
||||
|
||||
// 记录单指按压时候手在屏幕的位置
|
||||
state.displayElm.ontouchend = (event: TouchEvent) => {
|
||||
console.log('end', event);
|
||||
state.displayElm.ontouchend = () => {};
|
||||
};
|
||||
};
|
||||
|
||||
const setClipboard = (data: string) => {
|
||||
clipboardRef.value.setValue(data);
|
||||
};
|
||||
|
||||
const installClipboard = () => {
|
||||
state.enableClipboard = clipboard.install(state.client) as any;
|
||||
clipboard.installWatcher(props.clipboardList, setClipboard);
|
||||
state.client.onclipboard = clipboard.onClipboard;
|
||||
};
|
||||
|
||||
const installDisplay = () => {
|
||||
let { width, height, force } = state.size;
|
||||
state.display = state.client.getDisplay();
|
||||
const displayElm = displayRef.value;
|
||||
displayElm.appendChild(state.display.getElement());
|
||||
displayElm.addEventListener('contextmenu', (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.returnValue = false;
|
||||
});
|
||||
state.client.connect('width=' + width + '&height=' + height + '&force=' + force);
|
||||
window.onunload = () => state.client.disconnect();
|
||||
|
||||
// allows focusing on the display div so that keyboard doesn't always go to session
|
||||
displayElm.onclick = () => {
|
||||
displayElm.focus();
|
||||
};
|
||||
displayElm.onfocus = () => {
|
||||
displayElm.className = 'focus';
|
||||
};
|
||||
displayElm.onblur = () => {
|
||||
displayElm.className = '';
|
||||
};
|
||||
|
||||
state.displayElm = displayElm;
|
||||
};
|
||||
|
||||
const installClient = () => {
|
||||
let tunnel = new Guacamole.WebSocketTunnel(getMachineRdpSocketUrl(props.machineId)) as any;
|
||||
if (state.client) {
|
||||
state.display?.scale(0);
|
||||
uninstallKeyboard();
|
||||
state.client.disconnect();
|
||||
}
|
||||
|
||||
state.client = new Guacamole.Client(tunnel);
|
||||
|
||||
tunnel.onerror = (status: any) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Tunnel failed ${JSON.stringify(status)}`);
|
||||
// state.connectionState = states.TUNNEL_ERROR;
|
||||
};
|
||||
|
||||
tunnel.onstatechange = (st: any) => {
|
||||
console.log('statechange', st);
|
||||
state.status = st;
|
||||
switch (st) {
|
||||
case 0: // 'CONNECTING'
|
||||
break;
|
||||
case 1: // 'OPEN'
|
||||
emit('statusChange', TerminalStatus.Connected);
|
||||
break;
|
||||
case 2: // 'CLOSED'
|
||||
emit('statusChange', TerminalStatus.Disconnected);
|
||||
break;
|
||||
case 3: // 'UNSTABLE'
|
||||
emit('statusChange', TerminalStatus.Error);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
state.client.onstatechange = (clientState: any) => {
|
||||
console.log('clientState', clientState);
|
||||
return;
|
||||
switch (clientState) {
|
||||
case 0:
|
||||
// states.IDLE;
|
||||
break;
|
||||
case 1:
|
||||
break;
|
||||
case 2:
|
||||
console.log('连接中...');
|
||||
break;
|
||||
case 3:
|
||||
console.log('连接成功...');
|
||||
// states.CONNECTED;
|
||||
window.addEventListener('resize', resize);
|
||||
viewportRef.value.addEventListener('mouseenter', resize);
|
||||
clipboard.setRemoteClipboard(state.client);
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case 4:
|
||||
case 5:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
state.client.onerror = (error: any) => {
|
||||
state.client.disconnect();
|
||||
console.error(`Client error ${JSON.stringify(error)}`);
|
||||
state.errorMessage = error.message;
|
||||
// state.connectionState = states.CLIENT_ERROR;
|
||||
};
|
||||
|
||||
state.client.onsync = () => {};
|
||||
|
||||
state.client.onargv = (stream: any, mimetype: any, name: any) => {
|
||||
if (mimetype !== 'text/plain') return;
|
||||
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
|
||||
// Assemble received data into a single string
|
||||
let value = '';
|
||||
reader.ontext = (text: any) => {
|
||||
value += text;
|
||||
};
|
||||
|
||||
// Test mutability once stream is finished, storing the current value for the argument only if it is mutable
|
||||
reader.onend = () => {
|
||||
const stream = state.client.createArgumentValueStream('text/plain', name);
|
||||
stream.onack = (status: any) => {
|
||||
if (status.isError()) {
|
||||
// ignore reject
|
||||
return;
|
||||
}
|
||||
state.arguments[name] = value;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const elm = viewportRef.value;
|
||||
if (!elm || !elm.offsetWidth) {
|
||||
// resize is being called on the hidden window
|
||||
return;
|
||||
}
|
||||
|
||||
let box = elm.parentElement;
|
||||
|
||||
let pixelDensity = window.devicePixelRatio || 1;
|
||||
const width = box.clientWidth * pixelDensity;
|
||||
const height = box.clientHeight * pixelDensity;
|
||||
state.size.width = width;
|
||||
state.size.height = height;
|
||||
if (state.display.getWidth() !== width || state.display.getHeight() !== height) {
|
||||
state.client.sendSize(width, height);
|
||||
}
|
||||
// setting timeout so display has time to get the correct size
|
||||
setTimeout(() => {
|
||||
const scale = Math.min(box.clientWidth / Math.max(state.display.getWidth(), 1), box.clientHeight / Math.max(state.display.getHeight(), 1));
|
||||
state.display.scale(scale);
|
||||
console.log(state.size);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleMouseState = (mouseState: any, showCursor = false) => {
|
||||
state.client.getDisplay().showCursor(showCursor);
|
||||
|
||||
const scaledMouseState = Object.assign({}, mouseState, {
|
||||
x: mouseState.x / state.display.getScale(),
|
||||
y: mouseState.y / state.display.getScale(),
|
||||
});
|
||||
state.client.sendMouseState(scaledMouseState);
|
||||
};
|
||||
|
||||
const connect = (width: number, height: number, force = false) => {
|
||||
if (!width && !height) {
|
||||
if (state.size && state.size.width && state.size.height) {
|
||||
width = state.size.width;
|
||||
height = state.size.height;
|
||||
} else {
|
||||
// 获取当前viewportRef宽高
|
||||
width = viewportRef.value.clientWidth;
|
||||
height = viewportRef.value.clientHeight;
|
||||
}
|
||||
}
|
||||
state.size = { width, height, force };
|
||||
|
||||
installClient();
|
||||
installDisplay();
|
||||
installKeyboard();
|
||||
installMouse();
|
||||
installTouchpad();
|
||||
installClipboard();
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
uninstallKeyboard();
|
||||
state.client?.disconnect();
|
||||
};
|
||||
|
||||
const blur = () => {
|
||||
uninstallKeyboard();
|
||||
};
|
||||
|
||||
const focus = () => {};
|
||||
|
||||
const openPaste = async () => {
|
||||
state.clipboardDialog.visible = true;
|
||||
};
|
||||
|
||||
const closePaste = async () => {
|
||||
installKeyboard();
|
||||
};
|
||||
|
||||
const onsubmitClipboard = (val: string) => {
|
||||
state.clipboardDialog.visible = false;
|
||||
installKeyboard();
|
||||
clipboard.sendRemoteClipboard(state.client, val);
|
||||
};
|
||||
|
||||
const openFilesystem = async () => {
|
||||
state.filesystemDialog.protocol = 2;
|
||||
state.filesystemDialog.machineId = props.machineId;
|
||||
state.filesystemDialog.fileId = props.machineId;
|
||||
state.filesystemDialog.path = '/';
|
||||
state.filesystemDialog.title = `远程桌面文件管理`;
|
||||
state.filesystemDialog.visible = true;
|
||||
};
|
||||
|
||||
const openFullScreen = function () {
|
||||
launchIntoFullscreen(viewportRef.value);
|
||||
state.fullscreen = true;
|
||||
|
||||
// 记录原始尺寸
|
||||
state.beforeFullSize = {
|
||||
width: state.size.width,
|
||||
height: state.size.height,
|
||||
};
|
||||
|
||||
// 使用新的宽高重新连接
|
||||
setTimeout(() => {
|
||||
connect(viewportRef.value.clientWidth, viewportRef.value.clientHeight, false);
|
||||
}, 500);
|
||||
|
||||
watchFullscreenChange(watchFullscreen);
|
||||
};
|
||||
|
||||
function watchFullscreen(event: Event, isFull: boolean) {
|
||||
if (!isFull) {
|
||||
closeFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
const closeFullScreen = function () {
|
||||
exitFullscreen();
|
||||
|
||||
state.fullscreen = false;
|
||||
|
||||
// 使用新的宽高重新连接
|
||||
setTimeout(() => {
|
||||
connect(state.beforeFullSize.width, state.beforeFullSize.height, false);
|
||||
}, 500);
|
||||
|
||||
// 取消注册esc事件,退出全屏
|
||||
unWatchFullscreenChange(watchFullscreen);
|
||||
};
|
||||
|
||||
const exposes = {
|
||||
connect,
|
||||
disconnect,
|
||||
init: connect,
|
||||
close: disconnect,
|
||||
fitTerminal: resize,
|
||||
focus,
|
||||
blur,
|
||||
setRemoteClipboard: onsubmitClipboard,
|
||||
} as TerminalExpose;
|
||||
|
||||
defineExpose(exposes);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 1024px;
|
||||
min-height: 710px;
|
||||
z-index: 1;
|
||||
}
|
||||
.display {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.btn-box {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
padding: 5px 0 5px 10px;
|
||||
background: #dddddd4a;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
130
mayfly_go_web/src/components/terminal-rdp/MachineRdpDialog.vue
Normal file
130
mayfly_go_web/src/components/terminal-rdp/MachineRdpDialog.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="rdpDialog" ref="dialogRef">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:before-close="handleClose"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
width="1024"
|
||||
@open="connect()"
|
||||
>
|
||||
<template #header>
|
||||
<div class="terminal-title-wrapper">
|
||||
<!-- 左侧 -->
|
||||
<div class="title-left-fixed">
|
||||
<!-- title信息 -->
|
||||
<div>
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧 -->
|
||||
<div class="title-right-fixed">
|
||||
<el-popconfirm @confirm="connect(true)" title="确认重新连接?">
|
||||
<template #reference>
|
||||
<div class="mr10 pointer">
|
||||
<el-tag v-if="state.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-popconfirm @confirm="handleClose" title="确认关闭?">
|
||||
<template #reference>
|
||||
<SvgIcon name="Close" class="pointer-icon" title="关闭" :size="20" />
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<machine-rdp ref="rdpRef" :machine-id="machineId" @status-change="handleStatusChange" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, toRefs, watch } from 'vue';
|
||||
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
|
||||
import { TerminalStatus } from '@/components/terminal/common';
|
||||
import SvgIcon from '@/components/svgIcon/index.vue';
|
||||
|
||||
const rdpRef = ref({} as any);
|
||||
const dialogRef = ref({} as any);
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean },
|
||||
machineId: { type: Number },
|
||||
title: { type: String },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'cancel', 'update:machineId']);
|
||||
|
||||
const state = reactive({
|
||||
dialogVisible: false,
|
||||
title: '',
|
||||
status: TerminalStatus.NoConnected,
|
||||
});
|
||||
|
||||
const { dialogVisible } = toRefs(state);
|
||||
|
||||
watch(props, async (newValue: any) => {
|
||||
const visible = newValue.visible;
|
||||
state.dialogVisible = visible;
|
||||
if (visible) {
|
||||
state.title = newValue.title;
|
||||
}
|
||||
});
|
||||
|
||||
const connect = (force = false) => {
|
||||
rdpRef.value?.disconnect();
|
||||
|
||||
let width = 1024;
|
||||
let height = 710;
|
||||
rdpRef.value?.connect(width, height, force);
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: TerminalStatus) => {
|
||||
state.status = status;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭取消按钮触发的事件
|
||||
*/
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
emit('update:machineId', null);
|
||||
emit('cancel');
|
||||
rdpRef.value?.disconnect();
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.rdpDialog {
|
||||
.el-dialog {
|
||||
padding: 0;
|
||||
.el-dialog__header {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
|
||||
max-height: 100% !important;
|
||||
padding: 0 !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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="clipboard-dialog">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="请输入需要粘贴的文本"
|
||||
:before-close="onclose"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
width="600"
|
||||
>
|
||||
<el-input v-model="state.modelValue" type="textarea" :rows="20" />
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="onsubmit">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, toRefs, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean },
|
||||
});
|
||||
|
||||
const emits = defineEmits(['submit', 'close', 'update:visible']);
|
||||
|
||||
const state = reactive({
|
||||
dialogVisible: false,
|
||||
modelValue: '',
|
||||
});
|
||||
|
||||
const { dialogVisible } = toRefs(state);
|
||||
|
||||
watch(props, async (newValue: any) => {
|
||||
state.dialogVisible = newValue.visible;
|
||||
});
|
||||
|
||||
const onclose = () => {
|
||||
emits('update:visible', false);
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const onsubmit = () => {
|
||||
state.dialogVisible = false;
|
||||
if (state.modelValue) {
|
||||
ElMessage.success('发送剪贴板数据成功');
|
||||
emits('submit', state.modelValue);
|
||||
} else {
|
||||
ElMessage.warning('请输入需要粘贴的文本');
|
||||
}
|
||||
};
|
||||
|
||||
const setValue = (val: string) => {
|
||||
state.modelValue = val;
|
||||
};
|
||||
|
||||
defineExpose({ setValue });
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.clipboard-dialog {
|
||||
}
|
||||
</style>
|
||||
147
mayfly_go_web/src/components/terminal-rdp/guac/clipboard.js
Normal file
147
mayfly_go_web/src/components/terminal-rdp/guac/clipboard.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import Guacamole from './guacamole-common';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const clipboard = {};
|
||||
|
||||
clipboard.install = (client) => {
|
||||
if (!navigator.clipboard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clipboard.getLocalClipboard().then((data) => (clipboard.cache = data));
|
||||
|
||||
window.addEventListener('load', clipboard.update(client), true);
|
||||
window.addEventListener('copy', clipboard.update(client));
|
||||
window.addEventListener('cut', clipboard.update(client));
|
||||
window.addEventListener(
|
||||
'focus',
|
||||
(e) => {
|
||||
if (e.target === window) {
|
||||
clipboard.update(client)();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
clipboard.update = (client) => {
|
||||
return () => {
|
||||
clipboard.getLocalClipboard().then((data) => {
|
||||
clipboard.cache = data;
|
||||
clipboard.setRemoteClipboard(client);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
clipboard.sendRemoteClipboard = (client, text) => {
|
||||
clipboard.cache = {
|
||||
type: 'text/plain',
|
||||
data: text,
|
||||
};
|
||||
|
||||
clipboard.setRemoteClipboard(client);
|
||||
};
|
||||
|
||||
clipboard.setRemoteClipboard = (client) => {
|
||||
if (!clipboard.cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
let writer;
|
||||
|
||||
const stream = client.createClipboardStream(clipboard.cache.type);
|
||||
|
||||
if (typeof clipboard.cache.data === 'string') {
|
||||
writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(clipboard.cache.data);
|
||||
writer.sendEnd();
|
||||
|
||||
clipboard.appendClipboardList('up', clipboard.cache.data);
|
||||
} else {
|
||||
writer = new Guacamole.BlobWriter(stream);
|
||||
writer.oncomplete = function clipboardSent() {
|
||||
writer.sendEnd();
|
||||
};
|
||||
writer.sendBlob(clipboard.cache.data);
|
||||
}
|
||||
};
|
||||
|
||||
clipboard.getLocalClipboard = async () => {
|
||||
// 获取本地剪贴板数据
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return {
|
||||
type: 'text/plain',
|
||||
data: text,
|
||||
};
|
||||
} else {
|
||||
ElMessage.warning('只有https才可以访问剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
clipboard.setLocalClipboard = async (data) => {
|
||||
if (data.type === 'text/plain') {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(data.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取到远程服务器剪贴板变动
|
||||
clipboard.onClipboard = (stream, mimetype) => {
|
||||
let reader;
|
||||
|
||||
if (/^text\//.exec(mimetype)) {
|
||||
reader = new Guacamole.StringReader(stream);
|
||||
|
||||
// Assemble received data into a single string
|
||||
let data = '';
|
||||
reader.ontext = (text) => {
|
||||
data += text;
|
||||
};
|
||||
|
||||
// Set clipboard contents once stream is finished
|
||||
reader.onend = () => {
|
||||
clipboard.setLocalClipboard({
|
||||
type: mimetype,
|
||||
data: data,
|
||||
});
|
||||
|
||||
clipboard.setClipboardFn && typeof clipboard.setClipboardFn === 'function' && clipboard.setClipboardFn(data);
|
||||
|
||||
clipboard.appendClipboardList('down', data);
|
||||
};
|
||||
} else {
|
||||
reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
clipboard.setLocalClipboard({
|
||||
type: mimetype,
|
||||
data: reader.getBlob(),
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/***
|
||||
* 注册剪贴板监听器,如果有本地或远程剪贴板变动,则会更新剪贴板列表
|
||||
*/
|
||||
clipboard.installWatcher = (clipboardList, setClipboardFn) => {
|
||||
clipboard.clipboardList = clipboardList;
|
||||
clipboard.setClipboardFn = setClipboardFn;
|
||||
};
|
||||
|
||||
clipboard.appendClipboardList = (src, data) => {
|
||||
clipboard.clipboardList = clipboard.clipboardList || [];
|
||||
// 循环判断是否重复
|
||||
for (let i = 0; i < clipboard.clipboardList.length; i++) {
|
||||
if (clipboard.clipboardList[i].data === data) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
clipboard.clipboardList.push({ type: 'text/plain', data, src });
|
||||
};
|
||||
|
||||
export default clipboard;
|
||||
14441
mayfly_go_web/src/components/terminal-rdp/guac/guacamole-common.js
Normal file
14441
mayfly_go_web/src/components/terminal-rdp/guac/guacamole-common.js
Normal file
File diff suppressed because it is too large
Load Diff
38
mayfly_go_web/src/components/terminal-rdp/guac/screen.js
Normal file
38
mayfly_go_web/src/components/terminal-rdp/guac/screen.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export function launchIntoFullscreen(element) {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.mozRequestFullScreen) {
|
||||
element.mozRequestFullScreen();
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
} else if (element.msRequestFullscreen) {
|
||||
element.msRequestFullscreen();
|
||||
}
|
||||
}
|
||||
export function exitFullscreen() {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function watchFullscreenChange(callback) {
|
||||
function onFullscreenChange(e) {
|
||||
let isFull = (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) != null;
|
||||
callback(e, isFull);
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('mozfullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('msfullscreenchange', onFullscreenChange);
|
||||
}
|
||||
|
||||
export function unWatchFullscreenChange(callback) {
|
||||
document.removeEventListener('fullscreenchange', callback);
|
||||
document.removeEventListener('mozfullscreenchange', callback);
|
||||
document.removeEventListener('webkitfullscreenchange', callback);
|
||||
document.removeEventListener('msfullscreenchange', callback);
|
||||
}
|
||||
55
mayfly_go_web/src/components/terminal-rdp/guac/states.js
Normal file
55
mayfly_go_web/src/components/terminal-rdp/guac/states.js
Normal file
@@ -0,0 +1,55 @@
|
||||
export default {
|
||||
/**
|
||||
* The Guacamole connection has not yet been attempted.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
IDLE : "IDLE",
|
||||
|
||||
/**
|
||||
* The Guacamole connection is being established.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
CONNECTING : "CONNECTING",
|
||||
|
||||
/**
|
||||
* The Guacamole connection has been successfully established, and the
|
||||
* client is now waiting for receipt of initial graphical data.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
WAITING : "WAITING",
|
||||
|
||||
/**
|
||||
* The Guacamole connection has been successfully established, and
|
||||
* initial graphical data has been received.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
CONNECTED : "CONNECTED",
|
||||
|
||||
/**
|
||||
* The Guacamole connection has terminated successfully. No errors are
|
||||
* indicated.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
DISCONNECTED : "DISCONNECTED",
|
||||
|
||||
/**
|
||||
* The Guacamole connection has terminated due to an error reported by
|
||||
* the client. The associated error code is stored in statusCode.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
CLIENT_ERROR : "CLIENT_ERROR",
|
||||
|
||||
/**
|
||||
* The Guacamole connection has terminated due to an error reported by
|
||||
* the tunnel. The associated error code is stored in statusCode.
|
||||
*
|
||||
* @type String
|
||||
*/
|
||||
TUNNEL_ERROR : "TUNNEL_ERROR"
|
||||
}
|
||||
11
mayfly_go_web/src/components/terminal-rdp/index.ts
Normal file
11
mayfly_go_web/src/components/terminal-rdp/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface TerminalExpose {
|
||||
/** 连接 */
|
||||
init(width: number, height: number, force: boolean): void;
|
||||
|
||||
/** 短开连接 */
|
||||
close(): void;
|
||||
|
||||
blur(): void;
|
||||
|
||||
focus(): void;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
|
||||
<el-dialog
|
||||
title="终端"
|
||||
title="SSH终端"
|
||||
v-model="openTerminal.visible"
|
||||
top="32px"
|
||||
class="terminal-dialog"
|
||||
@@ -92,7 +92,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, reactive } from 'vue';
|
||||
import { reactive, toRefs } from 'vue';
|
||||
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
||||
import SvgIcon from '@/components/svgIcon/index.vue';
|
||||
import { TerminalStatus } from './common';
|
||||
|
||||
@@ -92,7 +92,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
// 终端不需要连接系统websocket消息
|
||||
if (to.path != '/machine/terminal') {
|
||||
if (to.path != '/machine/terminal' && to.path != '/machine/terminal-rdp') {
|
||||
syssocket.init();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ export const staticRoutes: Array<RouteRecordRaw> = [
|
||||
titleRename: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/machine/terminal-rdp',
|
||||
name: 'machineTerminalRdp',
|
||||
component: () => import('@/views/ops/machine/RdpTerminalPage.vue'),
|
||||
meta: {
|
||||
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
|
||||
title: '终端 | {name}',
|
||||
// 是否根据query对标题名进行参数替换,即最终显示为‘终端_机器名’
|
||||
titleRename: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 错误页面路由
|
||||
|
||||
2
mayfly_go_web/src/types/shim.d.ts
vendored
2
mayfly_go_web/src/types/shim.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
// 申明外部 npm 插件模块
|
||||
declare module 'sql-formatter';
|
||||
declare module 'jsoneditor';
|
||||
declare module 'asciinema-player';
|
||||
declare module 'vue-grid-layout';
|
||||
declare module 'splitpanes';
|
||||
declare module 'uuid';
|
||||
|
||||
@@ -46,7 +46,7 @@ onMounted(async () => {
|
||||
|
||||
const getSshTunnelMachines = async () => {
|
||||
if (state.sshTunnelMachineList.length == 0) {
|
||||
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
|
||||
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100, ssh: 1 });
|
||||
state.sshTunnelMachineList = res.list;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="protocol" label="机器类型" required>
|
||||
<el-radio-group v-model="form.protocol" @change="handleChangeProtocol">
|
||||
<el-radio :value="1">SSH</el-radio>
|
||||
<el-radio :value="2">RDP</el-radio>
|
||||
<!-- <el-radio :value="3">VNC</el-radio> -->
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item prop="name" label="名称" required>
|
||||
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="ip" label="ip" required>
|
||||
<el-form-item prop="ip" label="ip" req uired>
|
||||
<el-col :span="18">
|
||||
<el-input :disabled="form.id" v-model.trim="form.ip" placeholder="主机ip" auto-complete="off"> </el-input>
|
||||
</el-col>
|
||||
@@ -79,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, reactive, watch, ref } from 'vue';
|
||||
import { reactive, ref, toRefs, watch } from 'vue';
|
||||
import { machineApi } from './api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import TagTreeSelect from '../component/TagTreeSelect.vue';
|
||||
@@ -116,6 +123,13 @@ const rules = {
|
||||
trigger: ['change', 'blur'],
|
||||
},
|
||||
],
|
||||
protocol: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择机器类型',
|
||||
trigger: ['change', 'blur'],
|
||||
},
|
||||
],
|
||||
ip: [
|
||||
{
|
||||
required: true,
|
||||
@@ -143,27 +157,30 @@ const machineForm: any = ref(null);
|
||||
const authCertSelectRef: any = ref(null);
|
||||
const tagSelectRef: any = ref(null);
|
||||
|
||||
const defaultForm = {
|
||||
id: null,
|
||||
code: '',
|
||||
tagPath: '',
|
||||
ip: null,
|
||||
port: 22,
|
||||
protocol: 1, // 1.ssh 2.rdp
|
||||
name: null,
|
||||
authCertId: null as any,
|
||||
username: '',
|
||||
password: '',
|
||||
tagId: [],
|
||||
remark: '',
|
||||
sshTunnelMachineId: null as any,
|
||||
enableRecorder: -1,
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
dialogVisible: false,
|
||||
tabActiveName: 'basic',
|
||||
sshTunnelMachineList: [] as any,
|
||||
authCerts: [] as any,
|
||||
authType: 1,
|
||||
form: {
|
||||
id: null,
|
||||
code: '',
|
||||
tagPath: '',
|
||||
ip: null,
|
||||
port: 22,
|
||||
name: null,
|
||||
authCertId: null as any,
|
||||
username: '',
|
||||
password: '',
|
||||
tagId: [],
|
||||
remark: '',
|
||||
sshTunnelMachineId: null as any,
|
||||
enableRecorder: -1,
|
||||
},
|
||||
form: defaultForm,
|
||||
submitForm: {},
|
||||
pwd: '',
|
||||
});
|
||||
@@ -176,6 +193,7 @@ const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.save
|
||||
watch(props, async (newValue: any) => {
|
||||
state.dialogVisible = newValue.visible;
|
||||
if (!state.dialogVisible) {
|
||||
state.form = defaultForm;
|
||||
return;
|
||||
}
|
||||
state.tabActiveName = 'basic';
|
||||
@@ -190,7 +208,6 @@ watch(props, async (newValue: any) => {
|
||||
state.authType = 1;
|
||||
}
|
||||
} else {
|
||||
state.form = { port: 22, tagId: [] } as any;
|
||||
state.authType = 1;
|
||||
}
|
||||
});
|
||||
@@ -245,6 +262,16 @@ const getReqForm = () => {
|
||||
return reqForm;
|
||||
};
|
||||
|
||||
const handleChangeProtocol = (val: any) => {
|
||||
if (val == 1) {
|
||||
state.form.port = 22;
|
||||
} else if (val == 2) {
|
||||
state.form.port = 3389;
|
||||
} else if (val == 3) {
|
||||
state.form.port = 5901;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('update:visible', false);
|
||||
emit('cancel');
|
||||
|
||||
@@ -86,10 +86,13 @@
|
||||
|
||||
<template #action="{ data }">
|
||||
<span v-auth="'machine:terminal'">
|
||||
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top">
|
||||
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button>
|
||||
<el-tooltip v-if="data.protocol == 1" :show-after="500" content="按住ctrl则为新标签打开" placement="top">
|
||||
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>SSH</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-button v-if="data.protocol == 2" type="primary" @click="showRDP(data)" link>RDP</el-button>
|
||||
<el-button v-if="data.protocol == 3" type="primary" @click="showRDP(data)" link>VNC</el-button>
|
||||
|
||||
<el-divider direction="vertical" border-style="dashed" />
|
||||
</span>
|
||||
|
||||
@@ -112,6 +115,8 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :command="{ type: 'rdp-blank', data }" v-if="data.protocol == 2"> RDP(新窗口) </el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.updateMachine]"> 编辑 </el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :command="{ type: 'process', data }" :disabled="data.status == -1"> 进程 </el-dropdown-item>
|
||||
@@ -179,14 +184,33 @@
|
||||
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title"></machine-stats>
|
||||
|
||||
<machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title"></machine-rec>
|
||||
|
||||
<MachineRdpDialog :title="machineRdpDialog.title" v-model:visible="machineRdpDialog.visible" v-model:machineId="machineRdpDialog.machineId">
|
||||
<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>
|
||||
</MachineRdpDialog>
|
||||
|
||||
<el-dialog destroy-on-close :title="filesystemDialog.title" v-model="filesystemDialog.visible" :close-on-click-modal="false" width="70%">
|
||||
<machine-file
|
||||
:machine-id="filesystemDialog.machineId"
|
||||
:protocol="filesystemDialog.protocol"
|
||||
:file-id="filesystemDialog.fileId"
|
||||
:path="filesystemDialog.path"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { machineApi, getMachineTerminalSocketUrl } from './api';
|
||||
import { getMachineTerminalSocketUrl, machineApi } from './api';
|
||||
import { dateFormat } from '@/common/utils/date';
|
||||
import ResourceTags from '../component/ResourceTags.vue';
|
||||
import PageTable from '@/components/pagetable/PageTable.vue';
|
||||
@@ -196,9 +220,11 @@ import { formatByteSize } from '@/common/utils/format';
|
||||
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
||||
import { SearchItem } from '@/components/SearchForm';
|
||||
import { getTagPathSearchItem } from '../component/tag';
|
||||
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
|
||||
|
||||
// 组件
|
||||
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue'));
|
||||
const MachineRdpDialog = defineAsyncComponent(() => import('@/components/terminal-rdp/MachineRdpDialog.vue'));
|
||||
const MachineEdit = defineAsyncComponent(() => import('./MachineEdit.vue'));
|
||||
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
|
||||
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
||||
@@ -270,6 +296,14 @@ const state = reactive({
|
||||
machineId: 0,
|
||||
title: '',
|
||||
},
|
||||
filesystemDialog: {
|
||||
visible: false,
|
||||
machineId: 0,
|
||||
protocol: 1,
|
||||
title: '',
|
||||
fileId: 0,
|
||||
path: '',
|
||||
},
|
||||
machineStatsDialog: {
|
||||
visible: false,
|
||||
stats: null,
|
||||
@@ -286,9 +320,26 @@ const state = reactive({
|
||||
machineId: 0,
|
||||
title: '',
|
||||
},
|
||||
machineRdpDialog: {
|
||||
visible: false,
|
||||
machineId: 0,
|
||||
title: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { params, infoDialog, selectionData, serviceDialog, processDialog, fileDialog, machineStatsDialog, machineEditDialog, machineRecDialog } = toRefs(state);
|
||||
const {
|
||||
params,
|
||||
infoDialog,
|
||||
selectionData,
|
||||
serviceDialog,
|
||||
processDialog,
|
||||
fileDialog,
|
||||
machineStatsDialog,
|
||||
machineEditDialog,
|
||||
machineRecDialog,
|
||||
machineRdpDialog,
|
||||
filesystemDialog,
|
||||
} = toRefs(state);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.lazy) {
|
||||
@@ -323,6 +374,14 @@ const handleCommand = (commond: any) => {
|
||||
showRec(data);
|
||||
return;
|
||||
}
|
||||
case 'rdp': {
|
||||
showRDP(data);
|
||||
return;
|
||||
}
|
||||
case 'rdp-blank': {
|
||||
showRDP(data, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,10 +478,21 @@ const submitSuccess = () => {
|
||||
search();
|
||||
};
|
||||
|
||||
const showFileManage = (selectionData: any) => {
|
||||
state.fileDialog.visible = true;
|
||||
state.fileDialog.machineId = selectionData.id;
|
||||
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
|
||||
const showFileManage = (data: any) => {
|
||||
if (data.protocol === 1) {
|
||||
// ssh
|
||||
state.fileDialog.visible = true;
|
||||
state.fileDialog.machineId = data.id;
|
||||
state.fileDialog.title = `${data.name} => ${data.ip}`;
|
||||
} else if (data.protocol === 2) {
|
||||
// rdp
|
||||
state.filesystemDialog.protocol = 2;
|
||||
state.filesystemDialog.machineId = data.id;
|
||||
state.filesystemDialog.fileId = data.id;
|
||||
state.filesystemDialog.path = '/';
|
||||
state.filesystemDialog.title = `${data.name} => 远程桌面文件`;
|
||||
state.filesystemDialog.visible = true;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatsFontClass = (availavle: number, total: number) => {
|
||||
@@ -453,6 +523,23 @@ const showRec = (row: any) => {
|
||||
state.machineRecDialog.visible = true;
|
||||
};
|
||||
|
||||
const showRDP = (row: any, blank = false) => {
|
||||
if (blank) {
|
||||
const { href } = router.resolve({
|
||||
path: `/machine/terminal-rdp`,
|
||||
query: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
},
|
||||
});
|
||||
window.open(href, '_blank');
|
||||
return;
|
||||
}
|
||||
state.machineRdpDialog.title = `${row.name}[${row.ip}]-远程桌面`;
|
||||
state.machineRdpDialog.machineId = row.id;
|
||||
state.machineRdpDialog.visible = true;
|
||||
};
|
||||
|
||||
defineExpose({ search });
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,8 +10,13 @@
|
||||
:tag-path-node-type="NodeTypeTagPath"
|
||||
>
|
||||
<template #prefix="{ data }">
|
||||
<SvgIcon v-if="data.icon && data.params.status == 1" :name="data.icon.name" :color="data.icon.color" />
|
||||
<SvgIcon v-if="data.icon && data.params.status == -1" :name="data.icon.name" color="var(--el-color-danger)" />
|
||||
<SvgIcon v-if="data.icon && data.params.status == 1 && data.params.protocol == 1" :name="data.icon.name" :color="data.icon.color" />
|
||||
<SvgIcon
|
||||
v-if="data.icon && data.params.status == -1 && data.params.protocol == 1"
|
||||
:name="data.icon.name"
|
||||
color="var(--el-color-danger)"
|
||||
/>
|
||||
<SvgIcon v-if="data.icon && data.params.protocol != 1" :name="data.icon.name" :color="data.icon.color" />
|
||||
</template>
|
||||
|
||||
<template #suffix="{ data }">
|
||||
@@ -35,7 +40,7 @@
|
||||
>
|
||||
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
|
||||
<template #label>
|
||||
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?">
|
||||
<el-popconfirm @confirm="handleReconnect(dt, true)" title="确认重新连接?">
|
||||
<template #reference>
|
||||
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
|
||||
><Connection />
|
||||
@@ -59,13 +64,20 @@
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<div class="terminal-wrapper" style="height: calc(100vh - 155px)">
|
||||
<div :ref="(el: any) => setTerminalWrapperRef(el, dt.key)" class="terminal-wrapper" style="height: calc(100vh - 155px)">
|
||||
<TerminalBody
|
||||
v-if="dt.params.protocol == 1"
|
||||
:mount-init="false"
|
||||
@status-change="terminalStatusChange(dt.key, $event)"
|
||||
:ref="(el) => setTerminalRef(el, dt.key)"
|
||||
:ref="(el: any) => setTerminalRef(el, dt.key)"
|
||||
:socket-url="dt.socketUrl"
|
||||
/>
|
||||
<machine-rdp
|
||||
v-if="dt.params.protocol != 1"
|
||||
:machine-id="dt.params.id"
|
||||
:ref="(el: any) => setTerminalRef(el, dt.key)"
|
||||
@status-change="terminalStatusChange(dt.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -102,7 +114,27 @@
|
||||
|
||||
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" />
|
||||
|
||||
<file-conf-list :title="fileDialog.title" v-model:visible="fileDialog.visible" v-model:machineId="fileDialog.machineId" />
|
||||
<file-conf-list
|
||||
:title="fileDialog.title"
|
||||
v-model:visible="fileDialog.visible"
|
||||
v-model:machineId="fileDialog.machineId"
|
||||
:protocol="fileDialog.protocol"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
destroy-on-close
|
||||
:title="state.filesystemDialog.title"
|
||||
v-model="state.filesystemDialog.visible"
|
||||
:close-on-click-modal="false"
|
||||
width="70%"
|
||||
>
|
||||
<machine-file
|
||||
:machine-id="state.filesystemDialog.machineId"
|
||||
:protocol="state.filesystemDialog.protocol"
|
||||
:file-id="state.filesystemDialog.fileId"
|
||||
:path="state.filesystemDialog.path"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
|
||||
|
||||
@@ -114,24 +146,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, reactive, defineAsyncComponent, nextTick } from 'vue';
|
||||
import { defineAsyncComponent, nextTick, reactive, ref, toRefs, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { machineApi, getMachineTerminalSocketUrl } from './api';
|
||||
import { getMachineTerminalSocketUrl, machineApi } from './api';
|
||||
import { dateFormat } from '@/common/utils/date';
|
||||
import { hasPerms } from '@/components/auth/auth';
|
||||
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
||||
import { NodeType, TagTreeNode } from '../component/tag';
|
||||
import TagTree from '../component/TagTree.vue';
|
||||
import { Splitpanes, Pane } from 'splitpanes';
|
||||
import { Pane, Splitpanes } from 'splitpanes';
|
||||
import { ContextmenuItem } from '@/components/contextmenu/index';
|
||||
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
||||
import { TerminalStatus } from '@/components/terminal/common';
|
||||
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
|
||||
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
|
||||
// 组件
|
||||
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
|
||||
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
||||
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
|
||||
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
|
||||
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
|
||||
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
||||
import { TerminalStatus } from '@/components/terminal/common';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -174,8 +208,17 @@ const state = reactive({
|
||||
fileDialog: {
|
||||
visible: false,
|
||||
machineId: 0,
|
||||
protocol: 1,
|
||||
title: '',
|
||||
},
|
||||
filesystemDialog: {
|
||||
visible: false,
|
||||
machineId: 0,
|
||||
protocol: 1,
|
||||
title: '',
|
||||
fileId: 0,
|
||||
path: '',
|
||||
},
|
||||
machineStatsDialog: {
|
||||
visible: false,
|
||||
stats: null,
|
||||
@@ -206,7 +249,7 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
|
||||
return res.list.map((x: any) =>
|
||||
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
|
||||
.withParams(x)
|
||||
.withDisabled(x.status == -1)
|
||||
.withDisabled(x.status == -1 && x.protocol == 1)
|
||||
.withIcon({
|
||||
name: 'Monitor',
|
||||
color: '#409eff',
|
||||
@@ -247,15 +290,27 @@ const NodeTypeMachine = (machine: any) => {
|
||||
const openTerminal = (machine: any, ex?: boolean) => {
|
||||
// 新窗口打开
|
||||
if (ex) {
|
||||
const { href } = router.resolve({
|
||||
path: `/machine/terminal`,
|
||||
query: {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
},
|
||||
});
|
||||
window.open(href, '_blank');
|
||||
return;
|
||||
if (machine.protocol == 1) {
|
||||
const { href } = router.resolve({
|
||||
path: `/machine/terminal`,
|
||||
query: {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
},
|
||||
});
|
||||
window.open(href, '_blank');
|
||||
return;
|
||||
} else if (machine.protocol == 2) {
|
||||
const { href } = router.resolve({
|
||||
path: `/machine/terminal-rdp`,
|
||||
query: {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
},
|
||||
});
|
||||
window.open(href, '_blank');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let { name, id, username } = machine;
|
||||
@@ -268,16 +323,18 @@ const openTerminal = (machine: any, ex?: boolean) => {
|
||||
// 只保留name的10个字,超出部分只保留前后4个字符,中间用省略号代替
|
||||
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
|
||||
|
||||
state.tabs.set(key, {
|
||||
let tab = {
|
||||
key,
|
||||
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
|
||||
params: machine,
|
||||
socketUrl: getMachineTerminalSocketUrl(id),
|
||||
});
|
||||
};
|
||||
|
||||
state.tabs.set(key, tab);
|
||||
state.activeTermName = key;
|
||||
|
||||
nextTick(() => {
|
||||
handleReconnect(key);
|
||||
handleReconnect(tab);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -302,9 +359,21 @@ const search = async () => {
|
||||
};
|
||||
|
||||
const showFileManage = (selectionData: any) => {
|
||||
state.fileDialog.visible = true;
|
||||
state.fileDialog.machineId = selectionData.id;
|
||||
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
|
||||
if (selectionData.protocol == 1) {
|
||||
state.fileDialog.visible = true;
|
||||
state.fileDialog.protocol = selectionData.protocol;
|
||||
state.fileDialog.machineId = selectionData.id;
|
||||
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
|
||||
}
|
||||
|
||||
if (selectionData.protocol == 2) {
|
||||
state.filesystemDialog.protocol = 2;
|
||||
state.filesystemDialog.machineId = selectionData.id;
|
||||
state.filesystemDialog.fileId = selectionData.id;
|
||||
state.filesystemDialog.path = '/';
|
||||
state.filesystemDialog.title = `远程桌面文件管理`;
|
||||
state.filesystemDialog.visible = true;
|
||||
}
|
||||
};
|
||||
|
||||
const showInfo = (info: any) => {
|
||||
@@ -337,7 +406,6 @@ const onRemoveTab = (targetName: string) => {
|
||||
} else {
|
||||
activeTermName = '';
|
||||
}
|
||||
|
||||
let info = state.tabs.get(targetName);
|
||||
if (info) {
|
||||
terminalRefs[info.key]?.close();
|
||||
@@ -349,6 +417,15 @@ const onRemoveTab = (targetName: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => state.activeTermName,
|
||||
(newValue, oldValue) => {
|
||||
console.log('oldValue', oldValue);
|
||||
oldValue && terminalRefs[oldValue]?.blur && terminalRefs[oldValue]?.blur();
|
||||
terminalRefs[newValue]?.focus && terminalRefs[newValue]?.focus();
|
||||
}
|
||||
);
|
||||
|
||||
const terminalStatusChange = (key: string, status: TerminalStatus) => {
|
||||
state.tabs.get(key).status = status;
|
||||
};
|
||||
@@ -360,6 +437,13 @@ const setTerminalRef = (el: any, key: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const terminalWrapperRefs: any = {};
|
||||
const setTerminalWrapperRef = (el: any, key: any) => {
|
||||
if (key) {
|
||||
terminalWrapperRefs[key] = el;
|
||||
}
|
||||
};
|
||||
|
||||
const onResizeTagTree = () => {
|
||||
fitTerminal();
|
||||
};
|
||||
@@ -372,14 +456,16 @@ const fitTerminal = () => {
|
||||
setTimeout(() => {
|
||||
let info = state.tabs.get(state.activeTermName);
|
||||
if (info) {
|
||||
terminalRefs[info.key]?.fitTerminal();
|
||||
terminalRefs[info.key]?.focus();
|
||||
terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
|
||||
terminalRefs[info.key]?.focus && terminalRefs[info.key]?.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleReconnect = (key: string) => {
|
||||
terminalRefs[key].init();
|
||||
const handleReconnect = (tab: any, force = false) => {
|
||||
let width = terminalWrapperRefs[tab.key].offsetWidth;
|
||||
let height = terminalWrapperRefs[tab.key].offsetHeight;
|
||||
terminalRefs[tab.key]?.init(width, height, force);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
28
mayfly_go_web/src/views/ops/machine/RdpTerminalPage.vue
Normal file
28
mayfly_go_web/src/views/ops/machine/RdpTerminalPage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="terminal-wrapper" ref="terminalWrapperRef">
|
||||
<machine-rdp ref="rdpRef" :machine-id="route.query.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { TerminalExpose } from '@/components/terminal-rdp';
|
||||
const route = useRoute();
|
||||
|
||||
const rdpRef = ref({} as TerminalExpose);
|
||||
const terminalWrapperRef = ref({} as any);
|
||||
|
||||
onMounted(() => {
|
||||
let width = terminalWrapperRef.value.clientWidth;
|
||||
let height = terminalWrapperRef.value.clientHeight;
|
||||
console.log(width, height);
|
||||
rdpRef.value?.init(width, height, false);
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.terminal-wrapper {
|
||||
height: calc(100vh);
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,6 @@ export const machineApi = {
|
||||
addConf: Api.newPost('/machines/{machineId}/files'),
|
||||
// 删除配置的文件or目录
|
||||
delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
|
||||
terminal: Api.newGet('/api/machines/{id}/terminal'),
|
||||
// 机器终端操作记录列表
|
||||
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
|
||||
// 机器终端操作记录详情
|
||||
@@ -69,3 +68,7 @@ export const cronJobApi = {
|
||||
export function getMachineTerminalSocketUrl(machineId: any) {
|
||||
return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`;
|
||||
}
|
||||
|
||||
export function getMachineRdpSocketUrl(machineId: any) {
|
||||
return `${config.baseWsUrl}/machines/${machineId}/rdp`;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</el-row>
|
||||
|
||||
<el-dialog destroy-on-close :title="fileDialog.title" v-model="fileDialog.visible" :close-on-click-modal="false" width="70%">
|
||||
<machine-file :title="fileDialog.title" :machine-id="machineId" :file-id="fileDialog.fileId" :path="fileDialog.path" />
|
||||
<machine-file :title="fileDialog.title" :machine-id="machineId" :file-id="fileDialog.fileId" :path="fileDialog.path" :protocol="protocol" />
|
||||
</el-dialog>
|
||||
|
||||
<machine-file-content
|
||||
@@ -59,7 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, reactive, watch } from 'vue';
|
||||
import { reactive, toRefs, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { machineApi } from '../api';
|
||||
import { FileTypeEnum } from '../enums';
|
||||
@@ -68,6 +68,7 @@ import MachineFileContent from './MachineFileContent.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean },
|
||||
protocol: { type: Number, default: 1 },
|
||||
machineId: { type: Number },
|
||||
title: { type: String },
|
||||
});
|
||||
@@ -96,6 +97,7 @@ const state = reactive({
|
||||
fileTable: [] as any,
|
||||
fileDialog: {
|
||||
visible: false,
|
||||
protocol: 1,
|
||||
title: '',
|
||||
fileId: 0,
|
||||
path: '',
|
||||
|
||||
@@ -265,26 +265,32 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<machine-file-content v-model:visible="fileContent.contentVisible" :machine-id="machineId" :file-id="fileId" :path="fileContent.path" />
|
||||
<machine-file-content
|
||||
v-model:visible="fileContent.contentVisible"
|
||||
:machine-id="machineId"
|
||||
:file-id="fileId"
|
||||
:path="fileContent.path"
|
||||
:protocol="protocol"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, reactive, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox, ElInput } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref, toRefs } from 'vue';
|
||||
import { ElInput, ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { machineApi } from '../api';
|
||||
|
||||
import { joinClientParams } from '@/common/request';
|
||||
import config from '@/common/config';
|
||||
import { isTrue } from '@/common/assert';
|
||||
import { isTrue, notBlank } from '@/common/assert';
|
||||
import MachineFileContent from './MachineFileContent.vue';
|
||||
import { notBlank } from '@/common/assert';
|
||||
import { getToken } from '@/common/utils/storage';
|
||||
import { formatByteSize, convertToBytes } from '@/common/utils/format';
|
||||
import { convertToBytes, formatByteSize } from '@/common/utils/format';
|
||||
import { getMachineConfig } from '@/common/sysconfig';
|
||||
|
||||
const props = defineProps({
|
||||
machineId: { type: Number },
|
||||
protocol: { type: Number, default: 1 },
|
||||
fileId: { type: Number, default: 0 },
|
||||
path: { type: String, default: '' },
|
||||
isFolder: { type: Boolean, default: true },
|
||||
@@ -415,6 +421,7 @@ const pasteFile = async () => {
|
||||
fileId: props.fileId,
|
||||
path: cmFile.paths,
|
||||
toPath: state.nowPath,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
ElMessage.success('粘贴成功');
|
||||
state.copyOrMvFile.paths = [];
|
||||
@@ -454,15 +461,14 @@ const fileRename = async (row: any) => {
|
||||
notBlank(row.name, '新名称不能为空');
|
||||
try {
|
||||
await machineApi.renameFile.request({
|
||||
machineId: props.machineId,
|
||||
fileId: props.fileId,
|
||||
machineId: parseInt(props.machineId + ''),
|
||||
fileId: parseInt(props.fileId + ''),
|
||||
oldname: state.nowPath + pathSep + state.renameFile.oldname,
|
||||
newname: state.nowPath + pathSep + row.name,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
ElMessage.success('重命名成功');
|
||||
// 修改路径上的文件名
|
||||
row.path = state.nowPath + pathSep + row.name;
|
||||
state.renameFile.oldname = '';
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
row.name = state.renameFile.oldname;
|
||||
}
|
||||
@@ -502,6 +508,7 @@ const lsFile = async (path: string) => {
|
||||
const res = await machineApi.lsFile.request({
|
||||
fileId: props.fileId,
|
||||
machineId: props.machineId,
|
||||
protocol: props.protocol,
|
||||
path,
|
||||
});
|
||||
for (const file of res) {
|
||||
@@ -530,6 +537,7 @@ const getDirSize = async (data: any) => {
|
||||
machineId: props.machineId,
|
||||
fileId: props.fileId,
|
||||
path: data.path,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
data.dirSize = res;
|
||||
} finally {
|
||||
@@ -547,6 +555,7 @@ const showFileStat = async (data: any) => {
|
||||
machineId: props.machineId,
|
||||
fileId: props.fileId,
|
||||
path: data.path,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
data.stat = res;
|
||||
} finally {
|
||||
@@ -566,6 +575,7 @@ const createFile = async () => {
|
||||
await machineApi.createFile.request({
|
||||
machineId: props.machineId,
|
||||
id: props.fileId,
|
||||
protocol: props.protocol,
|
||||
path,
|
||||
type,
|
||||
});
|
||||
@@ -599,6 +609,7 @@ const deleteFile = async (files: any) => {
|
||||
fileId: props.fileId,
|
||||
path: files.map((x: any) => x.path),
|
||||
machineId: props.machineId,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
ElMessage.success('删除成功');
|
||||
refresh();
|
||||
@@ -611,7 +622,10 @@ const deleteFile = async (files: any) => {
|
||||
|
||||
const downloadFile = (data: any) => {
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', `${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&${joinClientParams()}`);
|
||||
a.setAttribute(
|
||||
'href',
|
||||
`${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&machineId=${props.machineId}&protocol=${props.protocol}&${joinClientParams()}`
|
||||
);
|
||||
a.click();
|
||||
};
|
||||
|
||||
@@ -624,6 +638,9 @@ function uploadFolder(e: any) {
|
||||
// 把文件夹数据放到formData里面,下面的files和paths字段根据接口来定
|
||||
var form = new FormData();
|
||||
form.append('basePath', state.nowPath);
|
||||
form.append('machineId', props.machineId as any);
|
||||
form.append('protocol', props.protocol as any);
|
||||
form.append('fileId', props.fileId as any);
|
||||
|
||||
let totalFileSize = 0;
|
||||
for (let file of e.target.files) {
|
||||
@@ -677,6 +694,7 @@ const uploadFile = (content: any) => {
|
||||
params.append('file', content.file);
|
||||
params.append('path', path);
|
||||
params.append('machineId', props.machineId as any);
|
||||
params.append('protocol', props.protocol as any);
|
||||
params.append('fileId', props.fileId as any);
|
||||
params.append('token', token);
|
||||
machineApi.uploadFile
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, reactive, watch } from 'vue';
|
||||
import { reactive, toRefs, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { machineApi } from '../api';
|
||||
|
||||
@@ -32,6 +32,7 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
protocol: { type: Number, default: 1 },
|
||||
title: { type: String, default: '' },
|
||||
machineId: { type: Number },
|
||||
fileId: { type: Number, default: 0 },
|
||||
@@ -63,6 +64,7 @@ const getFileContent = async () => {
|
||||
fileId: props.fileId,
|
||||
path,
|
||||
machineId: props.machineId,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
state.fileType = getFileType(path);
|
||||
state.content = res;
|
||||
@@ -79,6 +81,7 @@ const updateContent = async () => {
|
||||
id: props.fileId,
|
||||
path: props.path,
|
||||
machineId: props.machineId,
|
||||
protocol: props.protocol,
|
||||
});
|
||||
ElMessage.success('修改成功');
|
||||
handleClose();
|
||||
|
||||
@@ -9,6 +9,11 @@ server:
|
||||
enable: false
|
||||
key-file: ./default.key
|
||||
cert-file: ./default.pem
|
||||
machine:
|
||||
# 如果需要添加rdp服务器,需要安装guacd服务,docker跑一个就行 docker run --name guacd -d -p 4822:4822 guacamole/guacd
|
||||
# 如果连接频繁中断,重启一下guacd
|
||||
guacd-host: 127.0.0.1
|
||||
guacd-port: 4822
|
||||
jwt:
|
||||
# jwt key,不设置默认使用随机字符串
|
||||
key:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package form
|
||||
|
||||
type MachineForm struct {
|
||||
Id uint64 `json:"id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Ip string `json:"ip" binding:"required"` // IP地址
|
||||
Port int `json:"port" binding:"required"` // 端口号
|
||||
Id uint64 `json:"id"`
|
||||
Protocol int `json:"protocol" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Ip string `json:"ip" binding:"required"` // IP地址
|
||||
Port int `json:"port" binding:"required"` // 端口号
|
||||
|
||||
// 资产授权凭证信息列表
|
||||
AuthCertId int `json:"authCertId"`
|
||||
@@ -40,9 +41,14 @@ type MachineScriptForm struct {
|
||||
Script string `json:"script" binding:"required"`
|
||||
}
|
||||
|
||||
type MachineCreateFileForm struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
type ServerFileOptionForm struct {
|
||||
MachineId uint64 `form:"machineId"`
|
||||
Protocol int `form:"protocol"`
|
||||
Path string `form:"path"`
|
||||
Type string `form:"type"`
|
||||
Content string `form:"content"`
|
||||
Id uint64 `form:"id"`
|
||||
FileId uint64 `form:"fileId"`
|
||||
}
|
||||
|
||||
type MachineFileUpdateForm struct {
|
||||
@@ -52,12 +58,19 @@ type MachineFileUpdateForm struct {
|
||||
}
|
||||
|
||||
type MachineFileOpForm struct {
|
||||
Path []string `json:"path" binding:"required"`
|
||||
ToPath string `json:"toPath"`
|
||||
Path []string `json:"path" binding:"required"`
|
||||
ToPath string `json:"toPath"`
|
||||
MachineId uint64 `json:"machineId" binding:"required"`
|
||||
Protocol int `json:"protocol" binding:"required"`
|
||||
FileId uint64 `json:"fileId" binding:"required"`
|
||||
}
|
||||
|
||||
type MachineFileRename struct {
|
||||
Oldname string `json:"oldname" binding:"required"`
|
||||
MachineId uint64 `json:"machineId" binding:"required"`
|
||||
Protocol int `json:"protocol" binding:"required"`
|
||||
FileId uint64 `json:"fileId" binding:"required"`
|
||||
|
||||
Oldname string `json:"oldname" binding:"required"`
|
||||
Newname string `json:"newname" binding:"required"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,28 +3,32 @@ package api
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/may-fly/cast"
|
||||
"mayfly-go/internal/common/consts"
|
||||
"mayfly-go/internal/machine/api/form"
|
||||
"mayfly-go/internal/machine/api/vo"
|
||||
"mayfly-go/internal/machine/application"
|
||||
"mayfly-go/internal/machine/config"
|
||||
"mayfly-go/internal/machine/domain/entity"
|
||||
"mayfly-go/internal/machine/guac"
|
||||
tagapp "mayfly-go/internal/tag/application"
|
||||
tagentity "mayfly-go/internal/tag/domain/entity"
|
||||
"mayfly-go/pkg/biz"
|
||||
"mayfly-go/pkg/errorx"
|
||||
"mayfly-go/pkg/logx"
|
||||
"mayfly-go/pkg/model"
|
||||
"mayfly-go/pkg/req"
|
||||
"mayfly-go/pkg/utils/anyx"
|
||||
"mayfly-go/pkg/utils/collx"
|
||||
"mayfly-go/pkg/ws"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Machine struct {
|
||||
@@ -204,6 +208,105 @@ func (m *Machine) MachineTermOpRecord(rc *req.Ctx) {
|
||||
rc.ResData = base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
const (
|
||||
SocketTimeout = 15 * time.Second
|
||||
MaxGuacMessage = 8192
|
||||
websocketReadBufferSize = MaxGuacMessage
|
||||
websocketWriteBufferSize = MaxGuacMessage * 2
|
||||
)
|
||||
|
||||
var (
|
||||
sessions = guac.NewMemorySessionStore()
|
||||
)
|
||||
|
||||
func (m *Machine) WsGuacamole(g *gin.Context) {
|
||||
upgrader := websocket.Upgrader{
|
||||
ReadBufferSize: websocketReadBufferSize,
|
||||
WriteBufferSize: websocketWriteBufferSize,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
wsConn, err := upgrader.Upgrade(g.Writer, g.Request, nil)
|
||||
biz.ErrIsNil(err)
|
||||
|
||||
rc := req.NewCtxWithGin(g).WithRequiredPermission(req.NewPermission("machine:terminal"))
|
||||
machineId := GetMachineId(rc)
|
||||
|
||||
mi, err := m.MachineApp.ToMachineInfoById(machineId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = mi.IfUseSshTunnelChangeIpPort()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
params := make(map[string]string)
|
||||
params["hostname"] = mi.Ip
|
||||
params["port"] = strconv.Itoa(mi.Port)
|
||||
params["username"] = mi.Username
|
||||
params["password"] = mi.Password
|
||||
params["ignore-cert"] = "true"
|
||||
|
||||
if mi.Protocol == 2 {
|
||||
params["scheme"] = "rdp"
|
||||
} else if mi.Protocol == 3 {
|
||||
params["scheme"] = "vnc"
|
||||
}
|
||||
|
||||
if mi.EnableRecorder == 1 {
|
||||
// 操作记录 查看文档:https://guacamole.apache.org/doc/gug/configuring-guacamole.html#graphical-recording
|
||||
params["recording-path"] = fmt.Sprintf("/rdp-rec/%d", machineId)
|
||||
params["create-recording-path"] = "true"
|
||||
params["recording-include-keys"] = "true"
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = wsConn.Close(); err != nil {
|
||||
logx.Warnf("Error closing websocket: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
query := g.Request.URL.Query()
|
||||
if query.Get("force") != "" {
|
||||
// 判断是否强制连接,是的话,查询是否有正在连接的会话,有的话强制关闭
|
||||
if cast.ToBool(query.Get("force")) {
|
||||
tn := sessions.Get(machineId)
|
||||
if tn != nil {
|
||||
_ = tn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tunnel, err := guac.DoConnect(query, params, machineId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err = tunnel.Close(); err != nil {
|
||||
logx.Warnf("Error closing tunnel: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sessions.Add(machineId, wsConn, g.Request, tunnel)
|
||||
|
||||
defer sessions.Delete(machineId, wsConn, g.Request, tunnel)
|
||||
|
||||
writer := tunnel.AcquireWriter()
|
||||
reader := tunnel.AcquireReader()
|
||||
|
||||
defer tunnel.ReleaseWriter()
|
||||
defer tunnel.ReleaseReader()
|
||||
|
||||
go guac.WsToGuacd(wsConn, tunnel, writer)
|
||||
guac.GuacdToWs(wsConn, tunnel, reader)
|
||||
|
||||
//OnConnect
|
||||
//OnDisconnect
|
||||
}
|
||||
|
||||
func GetMachineId(rc *req.Ctx) uint64 {
|
||||
machineId, _ := strconv.Atoi(rc.PathParam("machineId"))
|
||||
biz.IsTrue(machineId != 0, "machineId错误")
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"mayfly-go/pkg/utils/collx"
|
||||
"mayfly-go/pkg/utils/timex"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -60,20 +62,19 @@ func (m *MachineFile) DeleteFile(rc *req.Ctx) {
|
||||
/*** sftp相关操作 */
|
||||
|
||||
func (m *MachineFile) CreateFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
|
||||
form := req.BindJsonAndValid(rc, new(form.MachineCreateFileForm))
|
||||
path := form.Path
|
||||
opForm := req.BindJsonAndValid(rc, new(form.ServerFileOptionForm))
|
||||
path := opForm.Path
|
||||
|
||||
attrs := collx.Kvs("path", path)
|
||||
var mi *mcm.MachineInfo
|
||||
var err error
|
||||
if form.Type == dir {
|
||||
if opForm.Type == dir {
|
||||
attrs["type"] = "目录"
|
||||
mi, err = m.MachineFileApp.MkDir(fid, form.Path)
|
||||
mi, err = m.MachineFileApp.MkDir(opForm.FileId, opForm.Path, opForm)
|
||||
} else {
|
||||
attrs["type"] = "文件"
|
||||
mi, err = m.MachineFileApp.CreateFile(fid, form.Path)
|
||||
mi, err = m.MachineFileApp.CreateFile(opForm.FileId, opForm.Path, opForm)
|
||||
}
|
||||
attrs["machine"] = mi
|
||||
rc.ReqParam = attrs
|
||||
@@ -81,10 +82,21 @@ func (m *MachineFile) CreateFile(rc *req.Ctx) {
|
||||
}
|
||||
|
||||
func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
readPath := rc.Query("path")
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
readPath := opForm.Path
|
||||
// 特殊处理rdp文件
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path := m.MachineFileApp.GetRdpFilePath(opForm.MachineId, opForm.Path)
|
||||
fi, err := os.Stat(path)
|
||||
biz.ErrIsNilAppendErr(err, "读取文件内容失败: %s")
|
||||
biz.IsTrue(fi.Size() < max_read_size, "文件超过1m,请使用下载查看")
|
||||
datas, err := os.ReadFile(path)
|
||||
biz.ErrIsNilAppendErr(err, "读取文件内容失败: %s")
|
||||
rc.ResData = string(datas)
|
||||
return
|
||||
}
|
||||
|
||||
sftpFile, mi, err := m.MachineFileApp.ReadFile(fid, readPath)
|
||||
sftpFile, mi, err := m.MachineFileApp.ReadFile(opForm.FileId, readPath)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
|
||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||
defer sftpFile.Close()
|
||||
@@ -100,36 +112,56 @@ func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
|
||||
}
|
||||
|
||||
func (m *MachineFile) DownloadFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
readPath := rc.Query("path")
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
|
||||
sftpFile, mi, err := m.MachineFileApp.ReadFile(fid, readPath)
|
||||
readPath := opForm.Path
|
||||
|
||||
// 截取文件名,如/usr/local/test.java -》 test.java
|
||||
path := strings.Split(readPath, "/")
|
||||
fileName := path[len(path)-1]
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path := m.MachineFileApp.GetRdpFilePath(opForm.MachineId, opForm.Path)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
rc.Download(file, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
sftpFile, mi, err := m.MachineFileApp.ReadFile(opForm.FileId, readPath)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
|
||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||
defer sftpFile.Close()
|
||||
|
||||
// 截取文件名,如/usr/local/test.java -》 test.java
|
||||
path := strings.Split(readPath, "/")
|
||||
rc.Download(sftpFile, path[len(path)-1])
|
||||
rc.Download(sftpFile, fileName)
|
||||
}
|
||||
|
||||
func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
readPath := rc.Query("path")
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
readPath := opForm.Path
|
||||
rc.ReqParam = fmt.Sprintf("path: %s", readPath)
|
||||
|
||||
if !strings.HasSuffix(readPath, "/") {
|
||||
readPath = readPath + "/"
|
||||
}
|
||||
fis, err := m.MachineFileApp.ReadDir(fid, readPath)
|
||||
fis, err := m.MachineFileApp.ReadDir(opForm.FileId, opForm)
|
||||
biz.ErrIsNilAppendErr(err, "读取目录失败: %s")
|
||||
|
||||
fisVO := make([]vo.MachineFileInfo, 0)
|
||||
for _, fi := range fis {
|
||||
name := fi.Name()
|
||||
if !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
path := name
|
||||
if readPath != "/" && readPath != "" {
|
||||
path = readPath + name
|
||||
}
|
||||
|
||||
fisVO = append(fisVO, vo.MachineFileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
Path: readPath + fi.Name(),
|
||||
Path: path,
|
||||
Type: getFileType(fi.Mode()),
|
||||
Mode: fi.Mode().String(),
|
||||
ModTime: timex.DefaultFormat(fi.ModTime()),
|
||||
@@ -141,30 +173,25 @@ func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
|
||||
}
|
||||
|
||||
func (m *MachineFile) GetDirSize(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
readPath := rc.Query("path")
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
|
||||
size, err := m.MachineFileApp.GetDirSize(fid, readPath)
|
||||
size, err := m.MachineFileApp.GetDirSize(opForm.FileId, opForm)
|
||||
biz.ErrIsNil(err)
|
||||
rc.ResData = size
|
||||
}
|
||||
|
||||
func (m *MachineFile) GetFileStat(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
readPath := rc.Query("path")
|
||||
|
||||
res, err := m.MachineFileApp.FileStat(fid, readPath)
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
res, err := m.MachineFileApp.FileStat(opForm)
|
||||
biz.ErrIsNil(err, res)
|
||||
rc.ResData = res
|
||||
}
|
||||
|
||||
func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||
path := opForm.Path
|
||||
|
||||
form := req.BindJsonAndValid(rc, new(form.MachineFileUpdateForm))
|
||||
path := form.Path
|
||||
|
||||
mi, err := m.MachineFileApp.WriteFileContent(fid, path, []byte(form.Content))
|
||||
mi, err := m.MachineFileApp.WriteFileContent(opForm.FileId, path, []byte(opForm.Content), opForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", path)
|
||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||
}
|
||||
@@ -172,6 +199,8 @@ func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
|
||||
func (m *MachineFile) UploadFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
path := rc.PostForm("path")
|
||||
protocol, err := strconv.Atoi(rc.PostForm("protocol"))
|
||||
machineId, err := strconv.Atoi(rc.PostForm("machineId"))
|
||||
|
||||
fileheader, err := rc.FormFile("file")
|
||||
biz.ErrIsNilAppendErr(err, "读取文件失败: %s")
|
||||
@@ -190,7 +219,14 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
|
||||
}
|
||||
}()
|
||||
|
||||
mi, err := m.MachineFileApp.UploadFile(fid, path, fileheader.Filename, file)
|
||||
opForm := &form.ServerFileOptionForm{
|
||||
FileId: fid,
|
||||
MachineId: uint64(machineId),
|
||||
Protocol: protocol,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
mi, err := m.MachineFileApp.UploadFile(fid, path, fileheader.Filename, file, opForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", path, fileheader.Filename))
|
||||
biz.ErrIsNilAppendErr(err, "创建文件失败: %s")
|
||||
// 保存消息并发送文件上传成功通知
|
||||
@@ -221,6 +257,18 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
||||
|
||||
paths := mf.Value["paths"]
|
||||
|
||||
// protocol
|
||||
protocol, err := strconv.Atoi(mf.Value["protocol"][0])
|
||||
if protocol == entity.MachineProtocolRdp {
|
||||
machineId, _ := strconv.Atoi(mf.Value["machineId"][0])
|
||||
opForm := &form.ServerFileOptionForm{
|
||||
MachineId: uint64(machineId),
|
||||
Protocol: protocol,
|
||||
}
|
||||
m.MachineFileApp.UploadFiles(basePath, fileheaders, paths, opForm)
|
||||
return
|
||||
}
|
||||
|
||||
folderName := filepath.Dir(paths[0])
|
||||
mcli, err := m.MachineFileApp.GetMachineCli(fid, basePath+"/"+folderName)
|
||||
biz.ErrIsNil(err)
|
||||
@@ -296,38 +344,31 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
||||
}
|
||||
|
||||
func (m *MachineFile) RemoveFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
rmForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
|
||||
mi, err := m.MachineFileApp.RemoveFile(fid, rmForm.Path...)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", rmForm.Path)
|
||||
mi, err := m.MachineFileApp.RemoveFile(opForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "path", opForm)
|
||||
biz.ErrIsNilAppendErr(err, "删除文件失败: %s")
|
||||
}
|
||||
|
||||
func (m *MachineFile) CopyFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
|
||||
mi, err := m.MachineFileApp.Copy(fid, cpForm.ToPath, cpForm.Path...)
|
||||
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
mi, err := m.MachineFileApp.Copy(opForm)
|
||||
biz.ErrIsNilAppendErr(err, "文件拷贝失败: %s")
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "cp", cpForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "cp", opForm)
|
||||
}
|
||||
|
||||
func (m *MachineFile) MvFile(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
|
||||
mi, err := m.MachineFileApp.Mv(fid, cpForm.ToPath, cpForm.Path...)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "mv", cpForm)
|
||||
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||
mi, err := m.MachineFileApp.Mv(opForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "mv", opForm)
|
||||
biz.ErrIsNilAppendErr(err, "文件移动失败: %s")
|
||||
}
|
||||
|
||||
func (m *MachineFile) Rename(rc *req.Ctx) {
|
||||
fid := GetMachineFileId(rc)
|
||||
rename := req.BindJsonAndValid(rc, new(form.MachineFileRename))
|
||||
|
||||
mi, err := m.MachineFileApp.Rename(fid, rename.Oldname, rename.Newname)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "rename", rename)
|
||||
renameForm := req.BindJsonAndValid(rc, new(form.MachineFileRename))
|
||||
mi, err := m.MachineFileApp.Rename(renameForm)
|
||||
rc.ReqParam = collx.Kvs("machine", mi, "rename", renameForm)
|
||||
biz.ErrIsNilAppendErr(err, "文件重命名失败: %s")
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type MachineVO struct {
|
||||
Id uint64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Protocol int `json:"protocol"`
|
||||
Ip string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
|
||||
@@ -50,6 +50,8 @@ type Machine interface {
|
||||
|
||||
// 获取机器运行时状态信息
|
||||
GetMachineStats(machineId uint64) (*mcm.Stats, error)
|
||||
|
||||
ToMachineInfoById(machineId uint64) (*mcm.MachineInfo, error)
|
||||
}
|
||||
|
||||
type machineAppImpl struct {
|
||||
@@ -162,7 +164,7 @@ func (m *machineAppImpl) Delete(ctx context.Context, id uint64) error {
|
||||
}
|
||||
|
||||
func (m *machineAppImpl) NewCli(machineId uint64) (*mcm.Cli, error) {
|
||||
if mi, err := m.toMachineInfoById(machineId); err != nil {
|
||||
if mi, err := m.ToMachineInfoById(machineId); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return mi.Conn()
|
||||
@@ -171,13 +173,13 @@ func (m *machineAppImpl) NewCli(machineId uint64) (*mcm.Cli, error) {
|
||||
|
||||
func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) {
|
||||
return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
|
||||
return m.toMachineInfoById(mid)
|
||||
return m.ToMachineInfoById(mid)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *machineAppImpl) GetSshTunnelMachine(machineId int) (*mcm.SshTunnelMachine, error) {
|
||||
return mcm.GetSshTunnelMachine(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
|
||||
return m.toMachineInfoById(mid)
|
||||
return m.ToMachineInfoById(mid)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,7 +187,7 @@ func (m *machineAppImpl) TimerUpdateStats() {
|
||||
logx.Debug("开始定时收集并缓存服务器状态信息...")
|
||||
scheduler.AddFun("@every 2m", func() {
|
||||
machineIds := new([]entity.Machine)
|
||||
m.GetRepo().ListByCond(&entity.Machine{Status: entity.MachineStatusEnable}, machineIds, "id")
|
||||
m.GetRepo().ListByCond(&entity.Machine{Status: entity.MachineStatusEnable, Protocol: entity.MachineProtocolSsh}, machineIds, "id")
|
||||
for _, ma := range *machineIds {
|
||||
go func(mid uint64) {
|
||||
defer func() {
|
||||
@@ -215,12 +217,12 @@ func (m *machineAppImpl) GetMachineStats(machineId uint64) (*mcm.Stats, error) {
|
||||
}
|
||||
|
||||
// 生成机器信息,根据授权凭证id填充用户密码等
|
||||
func (m *machineAppImpl) toMachineInfoById(machineId uint64) (*mcm.MachineInfo, error) {
|
||||
func (m *machineAppImpl) ToMachineInfoById(machineId uint64) (*mcm.MachineInfo, error) {
|
||||
me, err := m.GetById(new(entity.Machine), machineId)
|
||||
if err != nil {
|
||||
return nil, errorx.NewBiz("机器信息不存在")
|
||||
}
|
||||
if me.Status != entity.MachineStatusEnable {
|
||||
if me.Status != entity.MachineStatusEnable && me.Protocol == 1 {
|
||||
return nil, errorx.NewBiz("该机器已被停用")
|
||||
}
|
||||
|
||||
@@ -240,6 +242,7 @@ func (m *machineAppImpl) toMachineInfo(me *entity.Machine) (*mcm.MachineInfo, er
|
||||
mi.Username = me.Username
|
||||
mi.TagPath = m.tagApp.ListTagPathByResource(consts.TagResourceTypeMachine, me.Code)
|
||||
mi.EnableRecorder = me.EnableRecorder
|
||||
mi.Protocol = me.Protocol
|
||||
|
||||
if me.UseAuthCert() {
|
||||
ac, err := m.authCertApp.GetById(new(entity.AuthCert), uint64(me.AuthCertId))
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"mayfly-go/internal/machine/api/form"
|
||||
"mayfly-go/internal/machine/config"
|
||||
"mayfly-go/internal/machine/domain/entity"
|
||||
"mayfly-go/internal/machine/domain/repository"
|
||||
"mayfly-go/internal/machine/mcm"
|
||||
@@ -13,7 +16,10 @@ import (
|
||||
"mayfly-go/pkg/errorx"
|
||||
"mayfly-go/pkg/logx"
|
||||
"mayfly-go/pkg/model"
|
||||
"mayfly-go/pkg/utils/bytex"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
@@ -36,40 +42,44 @@ type MachineFile interface {
|
||||
// 检查文件路径,并返回机器id
|
||||
GetMachineCli(fileId uint64, path ...string) (*mcm.Cli, error)
|
||||
|
||||
GetRdpFilePath(MachineId uint64, path string) string
|
||||
|
||||
/** sftp 相关操作 **/
|
||||
|
||||
// 创建目录
|
||||
MkDir(fid uint64, path string) (*mcm.MachineInfo, error)
|
||||
MkDir(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
|
||||
|
||||
// 创建文件
|
||||
CreateFile(fid uint64, path string) (*mcm.MachineInfo, error)
|
||||
CreateFile(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
|
||||
|
||||
// 读取目录
|
||||
ReadDir(fid uint64, path string) ([]fs.FileInfo, error)
|
||||
ReadDir(fid uint64, opForm *form.ServerFileOptionForm) ([]fs.FileInfo, error)
|
||||
|
||||
// 获取指定目录内容大小
|
||||
GetDirSize(fid uint64, path string) (string, error)
|
||||
GetDirSize(fid uint64, opForm *form.ServerFileOptionForm) (string, error)
|
||||
|
||||
// 获取文件stat
|
||||
FileStat(fid uint64, path string) (string, error)
|
||||
FileStat(opForm *form.ServerFileOptionForm) (string, error)
|
||||
|
||||
// 读取文件内容
|
||||
ReadFile(fileId uint64, path string) (*sftp.File, *mcm.MachineInfo, error)
|
||||
|
||||
// 写文件
|
||||
WriteFileContent(fileId uint64, path string, content []byte) (*mcm.MachineInfo, error)
|
||||
WriteFileContent(fileId uint64, path string, content []byte, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
|
||||
|
||||
// 文件上传
|
||||
UploadFile(fileId uint64, path, filename string, reader io.Reader) (*mcm.MachineInfo, error)
|
||||
UploadFile(fileId uint64, path, filename string, reader io.Reader, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
|
||||
|
||||
UploadFiles(basePath string, fileHeaders []*multipart.FileHeader, paths []string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
|
||||
|
||||
// 移除文件
|
||||
RemoveFile(fileId uint64, path ...string) (*mcm.MachineInfo, error)
|
||||
RemoveFile(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
|
||||
|
||||
Copy(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error)
|
||||
Copy(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
|
||||
|
||||
Mv(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error)
|
||||
Mv(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
|
||||
|
||||
Rename(fileId uint64, oldname string, newname string) (*mcm.MachineInfo, error)
|
||||
Rename(renameForm *form.MachineFileRename) (*mcm.MachineInfo, error)
|
||||
}
|
||||
|
||||
type machineFileAppImpl struct {
|
||||
@@ -107,19 +117,49 @@ func (m *machineFileAppImpl) Save(ctx context.Context, mf *entity.MachineFile) e
|
||||
return m.Insert(ctx, mf)
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) ReadDir(fid uint64, path string) ([]fs.FileInfo, error) {
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path = path + "/"
|
||||
func (m *machineFileAppImpl) ReadDir(fid uint64, opForm *form.ServerFileOptionForm) ([]fs.FileInfo, error) {
|
||||
if !strings.HasSuffix(opForm.Path, "/") {
|
||||
opForm.Path = opForm.Path + "/"
|
||||
}
|
||||
|
||||
_, sftpCli, err := m.GetMachineSftpCli(fid, path)
|
||||
// 如果是rdp,则直接读取本地文件
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
opForm.Path = m.GetRdpFilePath(opForm.MachineId, opForm.Path)
|
||||
return ioutil.ReadDir(opForm.Path)
|
||||
}
|
||||
|
||||
_, sftpCli, err := m.GetMachineSftpCli(fid, opForm.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sftpCli.ReadDir(path)
|
||||
return sftpCli.ReadDir(opForm.Path)
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error) {
|
||||
func (m *machineFileAppImpl) GetDirSize(fid uint64, opForm *form.ServerFileOptionForm) (string, error) {
|
||||
path := opForm.Path
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
dirPath := m.GetRdpFilePath(opForm.MachineId, path)
|
||||
|
||||
// 递归计算目录下文件大小
|
||||
var totalSize int64
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// 忽略目录本身
|
||||
if path != dirPath {
|
||||
totalSize += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return bytex.FormatSize(totalSize), nil
|
||||
}
|
||||
|
||||
mcli, err := m.GetMachineCli(fid, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -144,19 +184,31 @@ func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error)
|
||||
return strings.Split(res, "\t")[0], nil
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) FileStat(fid uint64, path string) (string, error) {
|
||||
mcli, err := m.GetMachineCli(fid, path)
|
||||
func (m *machineFileAppImpl) FileStat(opForm *form.ServerFileOptionForm) (string, error) {
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path := m.GetRdpFilePath(opForm.MachineId, opForm.Path)
|
||||
stat, err := os.Stat(path)
|
||||
return fmt.Sprintf("%v", stat), err
|
||||
}
|
||||
|
||||
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mcli.Run(fmt.Sprintf("stat -L %s", path))
|
||||
return mcli.Run(fmt.Sprintf("stat -L %s", opForm.Path))
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) MkDir(fid uint64, path string) (*mcm.MachineInfo, error) {
|
||||
func (m *machineFileAppImpl) MkDir(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path = path + "/"
|
||||
}
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path = m.GetRdpFilePath(opForm.MachineId, path)
|
||||
os.MkdirAll(path, os.ModePerm)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -166,12 +218,19 @@ func (m *machineFileAppImpl) MkDir(fid uint64, path string) (*mcm.MachineInfo, e
|
||||
return mi, err
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) CreateFile(fid uint64, path string) (*mcm.MachineInfo, error) {
|
||||
func (m *machineFileAppImpl) CreateFile(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path = m.GetRdpFilePath(opForm.MachineId, path)
|
||||
file, err := os.Create(path)
|
||||
defer file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := sftpCli.Create(path)
|
||||
if err != nil {
|
||||
return nil, errorx.NewBiz("创建文件失败: %s", err.Error())
|
||||
@@ -192,7 +251,19 @@ func (m *machineFileAppImpl) ReadFile(fileId uint64, path string) (*sftp.File, *
|
||||
}
|
||||
|
||||
// 写文件内容
|
||||
func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, content []byte) (*mcm.MachineInfo, error) {
|
||||
func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, content []byte, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path = m.GetRdpFilePath(opForm.MachineId, path)
|
||||
file, err := os.Create(path)
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Write(content)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -208,11 +279,22 @@ func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, conten
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, reader io.Reader) (*mcm.MachineInfo, error) {
|
||||
func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, reader io.Reader, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path = path + "/"
|
||||
}
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
path = m.GetRdpFilePath(opForm.MachineId, path)
|
||||
file, err := os.Create(path + filename)
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
io.Copy(file, reader)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -227,16 +309,63 @@ func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, re
|
||||
return mi, err
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) UploadFiles(basePath string, fileHeaders []*multipart.FileHeader, paths []string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
baseFolder := m.GetRdpFilePath(opForm.MachineId, basePath)
|
||||
|
||||
for i, fileHeader := range fileHeaders {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 创建文件夹
|
||||
rdpBaseDir := basePath
|
||||
if !strings.HasSuffix(rdpBaseDir, "/") {
|
||||
rdpBaseDir = rdpBaseDir + "/"
|
||||
}
|
||||
rdpDir := filepath.Dir(rdpBaseDir + paths[i])
|
||||
m.MkDir(0, rdpDir, opForm)
|
||||
|
||||
// 创建文件
|
||||
if !strings.HasSuffix(baseFolder, "/") {
|
||||
baseFolder = baseFolder + "/"
|
||||
}
|
||||
fileAbsPath := baseFolder + paths[i]
|
||||
createFile, err := os.Create(fileAbsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer createFile.Close()
|
||||
|
||||
// 复制文件内容
|
||||
io.Copy(createFile, file)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.MachineInfo, error) {
|
||||
mcli, err := m.GetMachineCli(fileId, path...)
|
||||
func (m *machineFileAppImpl) RemoveFile(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
for _, pt := range opForm.Path {
|
||||
pt = m.GetRdpFilePath(opForm.MachineId, pt)
|
||||
os.RemoveAll(pt)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
minfo := mcli.Info
|
||||
|
||||
// 优先使用命令删除(速度快),sftp需要递归遍历删除子文件等
|
||||
res, err := mcli.Run(fmt.Sprintf("rm -rf %s", strings.Join(path, " ")))
|
||||
res, err := mcli.Run(fmt.Sprintf("rm -rf %s", strings.Join(opForm.Path, " ")))
|
||||
if err == nil {
|
||||
return minfo, nil
|
||||
}
|
||||
@@ -247,7 +376,7 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
|
||||
return minfo, err
|
||||
}
|
||||
|
||||
for _, p := range path {
|
||||
for _, p := range opForm.Path {
|
||||
err = sftpCli.RemoveAll(p)
|
||||
if err != nil {
|
||||
break
|
||||
@@ -256,36 +385,82 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
|
||||
return minfo, err
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) Copy(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
|
||||
mcli, err := m.GetMachineCli(fileId, paths...)
|
||||
func (m *machineFileAppImpl) Copy(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
for _, pt := range opForm.Path {
|
||||
srcPath := m.GetRdpFilePath(opForm.MachineId, pt)
|
||||
targetPath := m.GetRdpFilePath(opForm.MachineId, opForm.ToPath+pt)
|
||||
|
||||
// 打开源文件
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
fmt.Println("Error opening source file:", err)
|
||||
return nil, err
|
||||
}
|
||||
// 创建目标文件
|
||||
destFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
fmt.Println("Error creating destination file:", err)
|
||||
return nil, err
|
||||
}
|
||||
io.Copy(destFile, srcFile)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mi := mcli.Info
|
||||
res, err := mcli.Run(fmt.Sprintf("cp -r %s %s", strings.Join(paths, " "), toPath))
|
||||
res, err := mcli.Run(fmt.Sprintf("cp -r %s %s", strings.Join(opForm.Path, " "), opForm.ToPath))
|
||||
if err != nil {
|
||||
return mi, errors.New(res)
|
||||
}
|
||||
return mi, err
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) Mv(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
|
||||
mcli, err := m.GetMachineCli(fileId, paths...)
|
||||
func (m *machineFileAppImpl) Mv(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||
if opForm.Protocol == entity.MachineProtocolRdp {
|
||||
for _, pt := range opForm.Path {
|
||||
// 获取文件名
|
||||
filename := filepath.Base(pt)
|
||||
topath := opForm.ToPath
|
||||
if !strings.HasSuffix(topath, "/") {
|
||||
topath += "/"
|
||||
}
|
||||
|
||||
srcPath := m.GetRdpFilePath(opForm.MachineId, pt)
|
||||
targetPath := m.GetRdpFilePath(opForm.MachineId, topath+filename)
|
||||
os.Rename(srcPath, targetPath)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mi := mcli.Info
|
||||
res, err := mcli.Run(fmt.Sprintf("mv %s %s", strings.Join(paths, " "), toPath))
|
||||
res, err := mcli.Run(fmt.Sprintf("mv %s %s", strings.Join(opForm.Path, " "), opForm.ToPath))
|
||||
if err != nil {
|
||||
return mi, errorx.NewBiz(res)
|
||||
}
|
||||
return mi, err
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) Rename(fileId uint64, oldname string, newname string) (*mcm.MachineInfo, error) {
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(fileId, newname)
|
||||
func (m *machineFileAppImpl) Rename(renameForm *form.MachineFileRename) (*mcm.MachineInfo, error) {
|
||||
oldname := renameForm.Oldname
|
||||
newname := renameForm.Newname
|
||||
if renameForm.Protocol == entity.MachineProtocolRdp {
|
||||
oldname = m.GetRdpFilePath(renameForm.MachineId, renameForm.Oldname)
|
||||
newname = m.GetRdpFilePath(renameForm.MachineId, renameForm.Newname)
|
||||
return nil, os.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
mi, sftpCli, err := m.GetMachineSftpCli(renameForm.FileId, newname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -322,3 +497,7 @@ func (m *machineFileAppImpl) GetMachineSftpCli(fid uint64, inputPath ...string)
|
||||
|
||||
return mcli.Info, sftpCli, nil
|
||||
}
|
||||
|
||||
func (m *machineFileAppImpl) GetRdpFilePath(MachineId uint64, path string) string {
|
||||
return fmt.Sprintf("%s/%d%s", config.GetMachine().GuacdFilePath, MachineId, path)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ type Machine struct {
|
||||
TerminalRecPath string // 终端操作记录存储位置
|
||||
UploadMaxFileSize int64 // 允许上传的最大文件size
|
||||
TermOpSaveDays int // 终端记录保存天数
|
||||
GuacdHost string // guacd服务地址 默认 127.0.0.1
|
||||
GuacdPort int // guacd服务端口 默认 4822
|
||||
GuacdFilePath string // guacd服务文件存储位置,用于挂载RDP文件夹
|
||||
GuacdRecPath string // guacd服务记录存储位置,用于记录rdp操作记录
|
||||
}
|
||||
|
||||
// 获取机器相关配置
|
||||
@@ -43,5 +47,11 @@ func GetMachine() *Machine {
|
||||
}
|
||||
mc.UploadMaxFileSize = uploadMaxFileSize
|
||||
mc.TermOpSaveDays = cast.ToIntD(jm["termOpSaveDays"], 30)
|
||||
// guacd
|
||||
mc.GuacdHost = cast.ToStringD(jm["guacdHost"], "127.0.0.1")
|
||||
mc.GuacdPort = cast.ToIntD(jm["guacdPort"], 4822)
|
||||
mc.GuacdFilePath = cast.ToStringD(jm["guacdFilePath"], "")
|
||||
mc.GuacdRecPath = cast.ToStringD(jm["guacdRecPath"], "")
|
||||
|
||||
return mc
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Machine struct {
|
||||
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Protocol int `json:"protocol"` // 连接协议 1.ssh 2.rdp
|
||||
Ip string `json:"ip"` // IP地址
|
||||
Port int `json:"port"` // 端口号
|
||||
Username string `json:"username"` // 用户名
|
||||
@@ -25,6 +26,9 @@ type Machine struct {
|
||||
const (
|
||||
MachineStatusEnable int8 = 1 // 启用状态
|
||||
MachineStatusDisable int8 = -1 // 禁用状态
|
||||
|
||||
MachineProtocolSsh = 1
|
||||
MachineProtocolRdp = 2
|
||||
)
|
||||
|
||||
func (m *Machine) PwdEncrypt() error {
|
||||
|
||||
@@ -8,6 +8,7 @@ type MachineQuery struct {
|
||||
Status int8 `json:"status" form:"status"`
|
||||
Ip string `json:"ip" form:"ip"` // IP地址
|
||||
TagPath string `json:"tagPath" form:"tagPath"`
|
||||
Ssh int8 `json:"ssh" form:"ssh"`
|
||||
|
||||
Codes []string
|
||||
}
|
||||
|
||||
36
server/internal/machine/guac/config.go
Normal file
36
server/internal/machine/guac/config.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package guac
|
||||
|
||||
// Config is the data sent to guacd to configure the session during the handshake.
|
||||
type Config struct {
|
||||
// ConnectionID is used to reconnect to an existing session, otherwise leave blank for a new session.
|
||||
ConnectionID string
|
||||
// Protocol is the protocol of the connection from guacd to the remote (rdp, ssh, etc).
|
||||
Protocol string
|
||||
// Parameters are used to configure protocol specific options like sla for rdp or terminal color schemes.
|
||||
Parameters map[string]string
|
||||
// OptimalScreenWidth is the desired width of the screen
|
||||
OptimalScreenWidth int
|
||||
// OptimalScreenHeight is the desired height of the screen
|
||||
OptimalScreenHeight int
|
||||
// OptimalResolution is the desired resolution of the screen
|
||||
OptimalResolution int
|
||||
// AudioMimetypes is an array of the supported audio types
|
||||
AudioMimetypes []string
|
||||
// VideoMimetypes is an array of the supported video types
|
||||
VideoMimetypes []string
|
||||
// ImageMimetypes is an array of the supported image types
|
||||
ImageMimetypes []string
|
||||
}
|
||||
|
||||
// NewGuacamoleConfiguration returns a Config with sane defaults
|
||||
func NewGuacamoleConfiguration() *Config {
|
||||
return &Config{
|
||||
Parameters: map[string]string{},
|
||||
OptimalScreenWidth: 1024,
|
||||
OptimalScreenHeight: 768,
|
||||
OptimalResolution: 96,
|
||||
AudioMimetypes: make([]string, 0, 1),
|
||||
VideoMimetypes: make([]string, 0, 1),
|
||||
ImageMimetypes: make([]string, 0, 1),
|
||||
}
|
||||
}
|
||||
29
server/internal/machine/guac/counted_lock.go
Normal file
29
server/internal/machine/guac/counted_lock.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// CountedLock counts how many goroutines are waiting on the lock
|
||||
type CountedLock struct {
|
||||
core sync.Mutex
|
||||
numLocks int32
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (r *CountedLock) Lock() {
|
||||
atomic.AddInt32(&r.numLocks, 1)
|
||||
r.core.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (r *CountedLock) Unlock() {
|
||||
atomic.AddInt32(&r.numLocks, -1)
|
||||
r.core.Unlock()
|
||||
}
|
||||
|
||||
// HasQueued returns true if a goroutine is waiting on the lock
|
||||
func (r *CountedLock) HasQueued() bool {
|
||||
return atomic.LoadInt32(&r.numLocks) > 1
|
||||
}
|
||||
99
server/internal/machine/guac/errors.go
Normal file
99
server/internal/machine/guac/errors.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrGuac struct {
|
||||
error
|
||||
Status Status
|
||||
Kind ErrKind
|
||||
}
|
||||
|
||||
type ErrKind int
|
||||
|
||||
const (
|
||||
ErrClientBadType ErrKind = iota
|
||||
ErrClient
|
||||
ErrClientOverrun
|
||||
ErrClientTimeout
|
||||
ErrClientTooMany
|
||||
ErrConnectionClosed
|
||||
ErrOther
|
||||
ErrResourceClosed
|
||||
ErrResourceConflict
|
||||
ErrResourceNotFound
|
||||
ErrSecurity
|
||||
ErrServerBusy
|
||||
ErrServer
|
||||
ErrSessionClosed
|
||||
ErrSessionConflict
|
||||
ErrSessionTimeout
|
||||
ErrUnauthorized
|
||||
ErrUnsupported
|
||||
ErrUpstream
|
||||
ErrUpstreamNotFound
|
||||
ErrUpstreamTimeout
|
||||
ErrUpstreamUnavailable
|
||||
)
|
||||
|
||||
// Status convert ErrKind to Status
|
||||
func (e ErrKind) Status() (state Status) {
|
||||
switch e {
|
||||
case ErrClientBadType:
|
||||
return ClientBadType
|
||||
case ErrClient:
|
||||
return ClientBadRequest
|
||||
case ErrClientOverrun:
|
||||
return ClientOverrun
|
||||
case ErrClientTimeout:
|
||||
return ClientTimeout
|
||||
case ErrClientTooMany:
|
||||
return ClientTooMany
|
||||
case ErrConnectionClosed:
|
||||
return ServerError
|
||||
case ErrOther:
|
||||
return ServerError
|
||||
case ErrResourceClosed:
|
||||
return ResourceClosed
|
||||
case ErrResourceConflict:
|
||||
return ResourceConflict
|
||||
case ErrResourceNotFound:
|
||||
return ResourceNotFound
|
||||
case ErrSecurity:
|
||||
return ClientForbidden
|
||||
case ErrServerBusy:
|
||||
return ServerBusy
|
||||
case ErrServer:
|
||||
return ServerError
|
||||
case ErrSessionClosed:
|
||||
return SessionClosed
|
||||
case ErrSessionConflict:
|
||||
return SessionConflict
|
||||
case ErrSessionTimeout:
|
||||
return SessionTimeout
|
||||
case ErrUnauthorized:
|
||||
return ClientUnauthorized
|
||||
case ErrUnsupported:
|
||||
return Unsupported
|
||||
case ErrUpstream:
|
||||
return UpstreamError
|
||||
case ErrUpstreamNotFound:
|
||||
return UpstreamNotFound
|
||||
case ErrUpstreamTimeout:
|
||||
return UpstreamTimeout
|
||||
case ErrUpstreamUnavailable:
|
||||
return UpstreamUnavailable
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NewError creates a new error struct instance with Kind and included message
|
||||
func (e ErrKind) NewError(args ...string) error {
|
||||
return &ErrGuac{
|
||||
error: fmt.Errorf("%v", strings.Join(args, ", ")),
|
||||
Status: e.Status(),
|
||||
Kind: e,
|
||||
}
|
||||
}
|
||||
146
server/internal/machine/guac/guac.go
Normal file
146
server/internal/machine/guac/guac.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gorilla/websocket"
|
||||
"io"
|
||||
"mayfly-go/internal/machine/config"
|
||||
"mayfly-go/pkg/logx"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// creates the tunnel to the remote machine (via guacd)
|
||||
func DoConnect(query url.Values, parameters map[string]string, machineId uint64) (Tunnel, error) {
|
||||
conf := NewGuacamoleConfiguration()
|
||||
|
||||
parameters["enable-wallpaper"] = "true" // 允许显示墙纸
|
||||
//parameters["resize-method"] = "reconnect"
|
||||
parameters["enable-font-smoothing"] = "true"
|
||||
parameters["enable-desktop-composition"] = "true"
|
||||
parameters["enable-menu-animations"] = "false"
|
||||
parameters["disable-bitmap-caching"] = "true"
|
||||
parameters["disable-offscreen-caching"] = "true"
|
||||
parameters["force-lossless"] = "true" // 无损压缩
|
||||
parameters["color-depth"] = "32" //32 真彩(32位);24 真彩(24位);16 低色(16位);8 256色
|
||||
|
||||
// drive
|
||||
parameters["enable-drive"] = "true"
|
||||
parameters["drive-name"] = "Filesystem"
|
||||
parameters["create-drive-path"] = "true"
|
||||
parameters["drive-path"] = fmt.Sprintf("/rdp-file/%d", machineId)
|
||||
|
||||
conf.Protocol = parameters["scheme"]
|
||||
conf.Parameters = parameters
|
||||
conf.OptimalScreenWidth = 800
|
||||
conf.OptimalScreenHeight = 600
|
||||
|
||||
var err error
|
||||
|
||||
if query.Get("width") != "" {
|
||||
conf.OptimalScreenWidth, err = strconv.Atoi(query.Get("width"))
|
||||
if err != nil || conf.OptimalScreenWidth == 0 {
|
||||
logx.Error("Invalid width")
|
||||
conf.OptimalScreenWidth = 800
|
||||
}
|
||||
}
|
||||
|
||||
if query.Get("height") != "" {
|
||||
conf.OptimalScreenHeight, err = strconv.Atoi(query.Get("height"))
|
||||
if err != nil || conf.OptimalScreenHeight == 0 {
|
||||
logx.Error("Invalid height")
|
||||
conf.OptimalScreenHeight = 600
|
||||
}
|
||||
}
|
||||
|
||||
//conf.ConnectionID = uuid.New().String()
|
||||
|
||||
conf.AudioMimetypes = []string{"audio/L8", "audio/L16"}
|
||||
conf.ImageMimetypes = []string{"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
logx.Debug("Connecting to guacd")
|
||||
guacdAddr := fmt.Sprintf("%v:%v", config.GetMachine().GuacdHost, config.GetMachine().GuacdPort)
|
||||
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
|
||||
if err != nil {
|
||||
logx.Error("error resolving guacd address", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.DialTCP("tcp", nil, addr)
|
||||
if err != nil {
|
||||
logx.Error("error while connecting to guacd", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stream := NewStream(conn, SocketTimeout)
|
||||
|
||||
logx.Debug("Connected to guacd")
|
||||
//conf.ConnectionID = uuid.New().String()
|
||||
|
||||
logx.Debugf("Starting handshake with %#v", conf)
|
||||
err = stream.Handshake(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logx.Debug("Socket configured")
|
||||
return NewSimpleTunnel(stream), nil
|
||||
}
|
||||
|
||||
func WsToGuacd(ws *websocket.Conn, tunnel Tunnel, guacd io.Writer) {
|
||||
for {
|
||||
_, data, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
logx.Warnf("Error reading message from ws: %v", err)
|
||||
_ = tunnel.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(data, internalOpcodeIns) {
|
||||
// messages starting with the InternalDataOpcode are never sent to guacd
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err = guacd.Write(data); err != nil {
|
||||
logx.Warnf("Failed writing to guacd: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GuacdToWs(ws *websocket.Conn, tunnel Tunnel, guacd InstructionReader) {
|
||||
buf := bytes.NewBuffer(make([]byte, 0, MaxGuacMessage*2))
|
||||
|
||||
for {
|
||||
ins, err := guacd.ReadSome()
|
||||
if err != nil {
|
||||
logx.Warnf("Error reading message from guacd: %v", err)
|
||||
_ = tunnel.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(ins, internalOpcodeIns) {
|
||||
// messages starting with the InternalDataOpcode are never sent to the websocket
|
||||
continue
|
||||
}
|
||||
logx.Debugf("guacd msg: %s", string(ins))
|
||||
if _, err = buf.Write(ins); err != nil {
|
||||
logx.Warnf("Failed to buffer guacd to ws: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// if the buffer has more data in it or we've reached the max buffer size, send the data and reset
|
||||
if !guacd.Available() || buf.Len() >= MaxGuacMessage {
|
||||
if err = ws.WriteMessage(1, buf.Bytes()); err != nil {
|
||||
if errors.Is(err, websocket.ErrCloseSent) {
|
||||
return
|
||||
}
|
||||
logx.Warnf("Failed sending message to ws: %v", err)
|
||||
return
|
||||
}
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
113
server/internal/machine/guac/instruction.go
Normal file
113
server/internal/machine/guac/instruction.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Instruction represents a Guacamole instruction
|
||||
type Instruction struct {
|
||||
Opcode string
|
||||
Args []string
|
||||
cache string
|
||||
}
|
||||
|
||||
// NewInstruction creates an instruction
|
||||
func NewInstruction(opcode string, args ...string) *Instruction {
|
||||
return &Instruction{
|
||||
Opcode: opcode,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the on-wire representation of the instruction
|
||||
func (i *Instruction) String() string {
|
||||
if len(i.cache) > 0 {
|
||||
return i.cache
|
||||
}
|
||||
|
||||
i.cache = fmt.Sprintf("%d.%s", len(i.Opcode), i.Opcode)
|
||||
for _, value := range i.Args {
|
||||
i.cache += fmt.Sprintf(",%d.%s", len(value), value)
|
||||
}
|
||||
i.cache += ";"
|
||||
|
||||
return i.cache
|
||||
}
|
||||
|
||||
func (i *Instruction) Byte() []byte {
|
||||
return []byte(i.String())
|
||||
}
|
||||
|
||||
func Parse(buf []byte) (*Instruction, error) {
|
||||
data := []rune(string(buf))
|
||||
|
||||
elementStart := 0
|
||||
|
||||
// Build list of elements
|
||||
elements := make([]string, 0, 1)
|
||||
for elementStart < len(data) {
|
||||
// Find end of length
|
||||
lengthEnd := -1
|
||||
for i := elementStart; i < len(data); i++ {
|
||||
if data[i] == '.' {
|
||||
lengthEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// read() is required to return a complete instruction. If it does
|
||||
// not, this is a severe internal error.
|
||||
if lengthEnd == -1 {
|
||||
return nil, errors.New("guac.Parse: incomplete instruction")
|
||||
}
|
||||
|
||||
// Parse length
|
||||
length, e := strconv.Atoi(string(data[elementStart:lengthEnd]))
|
||||
if e != nil {
|
||||
return nil, errors.New("guac.Parse: wrong pattern instruction")
|
||||
}
|
||||
|
||||
// Parse element from just after period
|
||||
elementStart = lengthEnd + 1
|
||||
elementEnd := elementStart + length
|
||||
if elementEnd >= len(data) {
|
||||
return nil, errors.New("guac.Parse: invalid length (corrupted instruction?)")
|
||||
}
|
||||
|
||||
element := string(data[elementStart:elementEnd])
|
||||
|
||||
// Append element to list of elements
|
||||
elements = append(elements, element)
|
||||
|
||||
// ReadSome terminator after element
|
||||
elementStart += length
|
||||
|
||||
if elementStart >= len(data) {
|
||||
return nil, errors.New("guac.Parse: invalid length (corrupted instruction?)")
|
||||
}
|
||||
terminator := data[elementStart]
|
||||
|
||||
// Continue reading instructions after terminator
|
||||
elementStart++
|
||||
|
||||
// If we've reached the end of the instruction
|
||||
if terminator == ';' {
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return NewInstruction(elements[0], elements[1:]...), nil
|
||||
}
|
||||
|
||||
// ReadOne takes an instruction from the stream and parses it into an Instruction
|
||||
func ReadOne(stream *Stream) (instruction *Instruction, err error) {
|
||||
var instructionBuffer []byte
|
||||
instructionBuffer, err = stream.ReadSome()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return Parse(instructionBuffer)
|
||||
}
|
||||
56
server/internal/machine/guac/mem_session.go
Normal file
56
server/internal/machine/guac/mem_session.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemorySessionStore is a simple in-memory store of connected sessions that is used by
|
||||
// the WebsocketServer to store active sessions.
|
||||
type MemorySessionStore struct {
|
||||
sync.RWMutex
|
||||
ConnIds map[uint64]Tunnel
|
||||
}
|
||||
|
||||
// NewMemorySessionStore creates a new store
|
||||
func NewMemorySessionStore() *MemorySessionStore {
|
||||
return &MemorySessionStore{
|
||||
ConnIds: map[uint64]Tunnel{},
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a connection by uuid
|
||||
func (s *MemorySessionStore) Get(id uint64) Tunnel {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
return s.ConnIds[id]
|
||||
}
|
||||
|
||||
// Add inserts a new connection by uuid
|
||||
func (s *MemorySessionStore) Add(id uint64, conn *websocket.Conn, req *http.Request, tunnel Tunnel) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
n, ok := s.ConnIds[id]
|
||||
if !ok {
|
||||
s.ConnIds[id] = tunnel
|
||||
return
|
||||
}
|
||||
s.ConnIds[id] = n
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes a connection by uuid
|
||||
func (s *MemorySessionStore) Delete(id uint64, conn *websocket.Conn, req *http.Request, tunnel Tunnel) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
n, ok := s.ConnIds[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n != nil {
|
||||
delete(s.ConnIds, id)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
238
server/internal/machine/guac/server.go
Normal file
238
server/internal/machine/guac/server.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mayfly-go/pkg/logx"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
readPrefix string = "read:"
|
||||
writePrefix string = "write:"
|
||||
readPrefixLength = len(readPrefix)
|
||||
writePrefixLength = len(writePrefix)
|
||||
uuidLength = 36
|
||||
)
|
||||
|
||||
// Server uses HTTP requests to talk to guacd (as opposed to WebSockets in ws_server.go)
|
||||
type Server struct {
|
||||
tunnels *TunnelMap
|
||||
connect func(*http.Request) (Tunnel, error)
|
||||
}
|
||||
|
||||
// NewServer constructor
|
||||
func NewServer(connect func(r *http.Request) (Tunnel, error)) *Server {
|
||||
return &Server{
|
||||
tunnels: NewTunnelMap(),
|
||||
connect: connect,
|
||||
}
|
||||
}
|
||||
|
||||
// Registers the given tunnel such that future read/write requests to that tunnel will be properly directed.
|
||||
func (s *Server) registerTunnel(tunnel Tunnel) {
|
||||
s.tunnels.Put(tunnel.GetUUID(), tunnel)
|
||||
logx.Debugf("Registered tunnel %v.", tunnel.GetUUID())
|
||||
}
|
||||
|
||||
// Deregisters the given tunnel such that future read/write requests to that tunnel will be rejected.
|
||||
func (s *Server) deregisterTunnel(tunnel Tunnel) {
|
||||
s.tunnels.Remove(tunnel.GetUUID())
|
||||
logx.Debugf("Deregistered tunnel %v.", tunnel.GetUUID())
|
||||
}
|
||||
|
||||
// Returns the tunnel with the given UUID.
|
||||
func (s *Server) getTunnel(tunnelUUID string) (ret Tunnel, err error) {
|
||||
var ok bool
|
||||
ret, ok = s.tunnels.Get(tunnelUUID)
|
||||
|
||||
if !ok {
|
||||
err = ErrResourceNotFound.NewError("No such tunnel.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) sendError(response http.ResponseWriter, guacStatus Status, message string) {
|
||||
response.Header().Set("Guacamole-Status-Code", fmt.Sprintf("%v", guacStatus.GetGuacamoleStatusCode()))
|
||||
response.Header().Set("Guacamole-Error-Message", message)
|
||||
response.WriteHeader(guacStatus.GetHTTPStatusCode())
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.handleTunnelRequestCore(w, r)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
guacErr := err.(*ErrGuac)
|
||||
switch guacErr.Kind {
|
||||
case ErrClient:
|
||||
logx.Warnf("HTTP tunnel request rejected: %s", err.Error())
|
||||
s.sendError(w, guacErr.Status, err.Error())
|
||||
default:
|
||||
logx.Errorf("HTTP tunnel request failed: %s", err.Error())
|
||||
s.sendError(w, guacErr.Status, "Internal server error.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleTunnelRequestCore(response http.ResponseWriter, request *http.Request) (err error) {
|
||||
query := request.URL.RawQuery
|
||||
if len(query) == 0 {
|
||||
return ErrClient.NewError("No query string provided.")
|
||||
}
|
||||
|
||||
// Call the supplied connect callback upon HTTP connect request
|
||||
if query == "connect" {
|
||||
tunnel, e := s.connect(request)
|
||||
if e != nil {
|
||||
err = ErrResourceNotFound.NewError("No tunnel created.", e.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.registerTunnel(tunnel)
|
||||
|
||||
// Ensure buggy browsers do not cache response
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
_, e = response.Write([]byte(tunnel.GetUUID()))
|
||||
|
||||
if e != nil {
|
||||
err = ErrServer.NewError(e.Error())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Connect has already been called so we use the UUID to do read and writes to the existing session
|
||||
if strings.HasPrefix(query, readPrefix) && len(query) >= readPrefixLength+uuidLength {
|
||||
err = s.doRead(response, request, query[readPrefixLength:readPrefixLength+uuidLength])
|
||||
} else if strings.HasPrefix(query, writePrefix) && len(query) >= writePrefixLength+uuidLength {
|
||||
err = s.doWrite(response, request, query[writePrefixLength:writePrefixLength+uuidLength])
|
||||
} else {
|
||||
err = ErrClient.NewError("Invalid tunnel operation: " + query)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// doRead takes guacd messages and sends them in the response
|
||||
func (s *Server) doRead(response http.ResponseWriter, request *http.Request, tunnelUUID string) error {
|
||||
tunnel, err := s.getTunnel(tunnelUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := tunnel.AcquireReader()
|
||||
defer tunnel.ReleaseReader()
|
||||
|
||||
// Note that although we are sending text, Webkit browsers will
|
||||
// buffer 1024 bytes before starting a normal stream if we use
|
||||
// anything but application/octet-stream.
|
||||
response.Header().Set("Content-Type", "application/octet-stream")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if v, ok := response.(http.Flusher); ok {
|
||||
v.Flush()
|
||||
}
|
||||
|
||||
err = s.writeSome(response, reader, tunnel)
|
||||
|
||||
if err == nil {
|
||||
// success
|
||||
return err
|
||||
}
|
||||
|
||||
switch err.(*ErrGuac).Kind {
|
||||
// Send end-of-stream marker and close tunnel if connection is closed
|
||||
case ErrConnectionClosed:
|
||||
s.deregisterTunnel(tunnel)
|
||||
tunnel.Close()
|
||||
|
||||
// End-of-instructions marker
|
||||
_, _ = response.Write([]byte("0.;"))
|
||||
if v, ok := response.(http.Flusher); ok {
|
||||
v.Flush()
|
||||
}
|
||||
default:
|
||||
logx.Debugf("Error writing to output, %v", err)
|
||||
s.deregisterTunnel(tunnel)
|
||||
tunnel.Close()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSome drains the guacd buffer holding instructions into the response
|
||||
func (s *Server) writeSome(response http.ResponseWriter, guacd InstructionReader, tunnel Tunnel) (err error) {
|
||||
var message []byte
|
||||
|
||||
for {
|
||||
message, err = guacd.ReadSome()
|
||||
if err != nil {
|
||||
s.deregisterTunnel(tunnel)
|
||||
tunnel.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if len(message) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, e := response.Write(message)
|
||||
if e != nil {
|
||||
err = ErrOther.NewError(e.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !guacd.Available() {
|
||||
if v, ok := response.(http.Flusher); ok {
|
||||
v.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// No more messages another guacd can take over
|
||||
if tunnel.HasQueuedReaderThreads() {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-instructions marker
|
||||
if _, err = response.Write([]byte("0.;")); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := response.(http.Flusher); ok {
|
||||
v.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doWrite takes data from the request and sends it to guacd
|
||||
func (s *Server) doWrite(response http.ResponseWriter, request *http.Request, tunnelUUID string) error {
|
||||
tunnel, err := s.getTunnel(tunnelUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We still need to set the content type to avoid the default of
|
||||
// text/html, as such a content type would cause some browsers to
|
||||
// attempt to parse the result, even though the JavaScript client
|
||||
// does not explicitly request such parsing.
|
||||
response.Header().Set("Content-Type", "application/octet-stream")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
response.Header().Set("Content-Length", "0")
|
||||
|
||||
writer := tunnel.AcquireWriter()
|
||||
defer tunnel.ReleaseWriter()
|
||||
|
||||
_, err = io.Copy(writer, request.Body)
|
||||
|
||||
if err != nil {
|
||||
s.deregisterTunnel(tunnel)
|
||||
if err = tunnel.Close(); err != nil {
|
||||
logx.Debugf("Error closing tunnel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
166
server/internal/machine/guac/status.go
Normal file
166
server/internal/machine/guac/status.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package guac
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
// Undefined Add to instead null
|
||||
Undefined Status = -1
|
||||
|
||||
// Success indicates the operation succeeded.
|
||||
Success Status = iota
|
||||
|
||||
// Unsupported indicates the requested operation is unsupported.
|
||||
Unsupported
|
||||
|
||||
// ServerError indicates the operation could not be performed due to an internal failure.
|
||||
ServerError
|
||||
|
||||
// ServerBusy indicates the operation could not be performed as the server is busy.
|
||||
ServerBusy
|
||||
|
||||
// UpstreamTimeout indicates the operation could not be performed because the upstream server is not responding.
|
||||
UpstreamTimeout
|
||||
|
||||
// UpstreamError indicates the operation was unsuccessful due to an error or otherwise unexpected
|
||||
// condition of the upstream server.
|
||||
UpstreamError
|
||||
|
||||
// ResourceNotFound indicates the operation could not be performed as the requested resource does not exist.
|
||||
ResourceNotFound
|
||||
|
||||
// ResourceConflict indicates the operation could not be performed as the requested resource is already in use.
|
||||
ResourceConflict
|
||||
|
||||
// ResourceClosed indicates the operation could not be performed as the requested resource is now closed.
|
||||
ResourceClosed
|
||||
|
||||
// UpstreamNotFound indicates the operation could not be performed because the upstream server does
|
||||
// not appear to exist.
|
||||
UpstreamNotFound
|
||||
|
||||
// UpstreamUnavailable indicates the operation could not be performed because the upstream server is not
|
||||
// available to service the request.
|
||||
UpstreamUnavailable
|
||||
|
||||
// SessionConflict indicates the session within the upstream server has ended because it conflicted
|
||||
// with another session.
|
||||
SessionConflict
|
||||
|
||||
// SessionTimeout indicates the session within the upstream server has ended because it appeared to be inactive.
|
||||
SessionTimeout
|
||||
|
||||
// SessionClosed indicates the session within the upstream server has been forcibly terminated.
|
||||
SessionClosed
|
||||
|
||||
// ClientBadRequest indicates the operation could not be performed because bad parameters were given.
|
||||
ClientBadRequest
|
||||
|
||||
// ClientUnauthorized indicates the user is not authorized.
|
||||
ClientUnauthorized
|
||||
|
||||
// ClientForbidden indicates the user is not allowed to do the operation.
|
||||
ClientForbidden
|
||||
|
||||
// ClientTimeout indicates the client took too long to respond.
|
||||
ClientTimeout
|
||||
|
||||
// ClientOverrun indicates the client sent too much data.
|
||||
ClientOverrun
|
||||
|
||||
// ClientBadType indicates the client sent data of an unsupported or unexpected type.
|
||||
ClientBadType
|
||||
|
||||
// ClientTooMany indivates the operation failed because the current client is already using too many resources.
|
||||
ClientTooMany
|
||||
)
|
||||
|
||||
type statusData struct {
|
||||
name string
|
||||
// The most applicable HTTP error code.
|
||||
httpCode int
|
||||
|
||||
// The most applicable WebSocket error code.
|
||||
websocketCode int
|
||||
|
||||
// The Guacamole protocol Status code.
|
||||
guacCode int
|
||||
}
|
||||
|
||||
func newStatusData(name string, httpCode, websocketCode, guacCode int) (ret statusData) {
|
||||
ret.name = name
|
||||
ret.httpCode = httpCode
|
||||
ret.websocketCode = websocketCode
|
||||
ret.guacCode = guacCode
|
||||
return
|
||||
}
|
||||
|
||||
var guacamoleStatusMap = map[Status]statusData{
|
||||
Success: newStatusData("Success", 200, 1000, 0x0000),
|
||||
Unsupported: newStatusData("Unsupported", 501, 1011, 0x0100),
|
||||
ServerError: newStatusData("SERVER_ERROR", 500, 1011, 0x0200),
|
||||
ServerBusy: newStatusData("SERVER_BUSY", 503, 1008, 0x0201),
|
||||
UpstreamTimeout: newStatusData("UPSTREAM_TIMEOUT", 504, 1011, 0x0202),
|
||||
UpstreamError: newStatusData("UPSTREAM_ERROR", 502, 1011, 0x0203),
|
||||
ResourceNotFound: newStatusData("RESOURCE_NOT_FOUND", 404, 1002, 0x0204),
|
||||
ResourceConflict: newStatusData("RESOURCE_CONFLICT", 409, 1008, 0x0205),
|
||||
ResourceClosed: newStatusData("RESOURCE_CLOSED", 404, 1002, 0x0206),
|
||||
UpstreamNotFound: newStatusData("UPSTREAM_NOT_FOUND", 502, 1011, 0x0207),
|
||||
UpstreamUnavailable: newStatusData("UPSTREAM_UNAVAILABLE", 502, 1011, 0x0208),
|
||||
SessionConflict: newStatusData("SESSION_CONFLICT", 409, 1008, 0x0209),
|
||||
SessionTimeout: newStatusData("SESSION_TIMEOUT", 408, 1002, 0x020A),
|
||||
SessionClosed: newStatusData("SESSION_CLOSED", 404, 1002, 0x020B),
|
||||
ClientBadRequest: newStatusData("CLIENT_BAD_REQUEST", 400, 1002, 0x0300),
|
||||
ClientUnauthorized: newStatusData("CLIENT_UNAUTHORIZED", 403, 1008, 0x0301),
|
||||
ClientForbidden: newStatusData("CLIENT_FORBIDDEN", 403, 1008, 0x0303),
|
||||
ClientTimeout: newStatusData("CLIENT_TIMEOUT", 408, 1002, 0x0308),
|
||||
ClientOverrun: newStatusData("CLIENT_OVERRUN", 413, 1009, 0x030D),
|
||||
ClientBadType: newStatusData("CLIENT_BAD_TYPE", 415, 1003, 0x030F),
|
||||
ClientTooMany: newStatusData("CLIENT_TOO_MANY", 429, 1008, 0x031D),
|
||||
}
|
||||
|
||||
// String returns the name of the status.
|
||||
func (s Status) String() string {
|
||||
if v, ok := guacamoleStatusMap[s]; ok {
|
||||
return v.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetHTTPStatusCode returns the most applicable HTTP error code.
|
||||
func (s Status) GetHTTPStatusCode() int {
|
||||
if v, ok := guacamoleStatusMap[s]; ok {
|
||||
return v.httpCode
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// GetWebSocketCode returns the most applicable HTTP error code.
|
||||
func (s Status) GetWebSocketCode() int {
|
||||
if v, ok := guacamoleStatusMap[s]; ok {
|
||||
return v.websocketCode
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// GetGuacamoleStatusCode returns the corresponding Guacamole protocol Status code.
|
||||
func (s Status) GetGuacamoleStatusCode() int {
|
||||
if v, ok := guacamoleStatusMap[s]; ok {
|
||||
return v.guacCode
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// FromGuacamoleStatusCode returns the Status corresponding to the given Guacamole protocol Status code.
|
||||
func FromGuacamoleStatusCode(code int) (ret Status) {
|
||||
// Search for a Status having the given Status code
|
||||
for k, v := range guacamoleStatusMap {
|
||||
if v.guacCode == code {
|
||||
ret = k
|
||||
return
|
||||
}
|
||||
}
|
||||
// No such Status found
|
||||
ret = Undefined
|
||||
return
|
||||
|
||||
}
|
||||
279
server/internal/machine/guac/stream.go
Normal file
279
server/internal/machine/guac/stream.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mayfly-go/pkg/logx"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SocketTimeout = 15 * time.Second
|
||||
MaxGuacMessage = 8192 // TODO is this bytes or runes?
|
||||
)
|
||||
|
||||
// Stream wraps the connection to Guacamole providing timeouts and reading
|
||||
// a single instruction at a time (since returning partial instructions
|
||||
// would be an error)
|
||||
type Stream struct {
|
||||
conn net.Conn
|
||||
|
||||
// ConnectionID is the ID Guacamole gives and can be used to reconnect or share sessions
|
||||
ConnectionID string
|
||||
timeout time.Duration
|
||||
|
||||
// if more than a single instruction is read, the rest are buffered here
|
||||
parseStart int
|
||||
buffer []rune
|
||||
reset []rune
|
||||
}
|
||||
|
||||
// NewStream creates a new stream
|
||||
func NewStream(conn net.Conn, timeout time.Duration) (ret *Stream) {
|
||||
buffer := make([]rune, 0, MaxGuacMessage*3)
|
||||
return &Stream{
|
||||
conn: conn,
|
||||
timeout: timeout,
|
||||
buffer: buffer,
|
||||
reset: buffer[:cap(buffer)],
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends messages to Guacamole with a timeout
|
||||
func (s *Stream) Write(data []byte) (n int, err error) {
|
||||
if err = s.conn.SetWriteDeadline(time.Now().Add(s.timeout)); err != nil {
|
||||
logx.Errorf("sends messages to Guacamole error: %v", err)
|
||||
return
|
||||
}
|
||||
return s.conn.Write(data)
|
||||
}
|
||||
|
||||
// Available returns true if there are messages buffered
|
||||
func (s *Stream) Available() bool {
|
||||
return len(s.buffer) > 0
|
||||
}
|
||||
|
||||
// Flush resets the internal buffer
|
||||
func (s *Stream) Flush() {
|
||||
copy(s.reset, s.buffer)
|
||||
s.buffer = s.reset[:len(s.buffer)]
|
||||
}
|
||||
|
||||
// ReadSome takes the next instruction (from the network or from the buffer) and returns it.
|
||||
// io.Reader is not implemented because this seems like the right place to maintain a buffer.
|
||||
func (s *Stream) ReadSome() (instruction []byte, err error) {
|
||||
if err = s.conn.SetReadDeadline(time.Now().Add(s.timeout)); err != nil {
|
||||
logx.Errorf("read messages from Guacamole error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
buffer := make([]byte, MaxGuacMessage)
|
||||
var n int
|
||||
// While we're blocking, or input is available
|
||||
for {
|
||||
// Length of element
|
||||
var elementLength int
|
||||
|
||||
// Resume where we left off
|
||||
i := s.parseStart
|
||||
|
||||
parseLoop:
|
||||
// Parse instruction in buffer
|
||||
for i < len(s.buffer) {
|
||||
// ReadSome character
|
||||
readChar := s.buffer[i]
|
||||
i++
|
||||
|
||||
switch readChar {
|
||||
// If digit, update length
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
elementLength = elementLength*10 + int(readChar-'0')
|
||||
|
||||
// If not digit, check for end-of-length character
|
||||
case '.':
|
||||
if i+elementLength >= len(s.buffer) {
|
||||
// break for i < s.usedLength { ... }
|
||||
// Otherwise, read more data
|
||||
break parseLoop
|
||||
}
|
||||
// Check if element present in buffer
|
||||
terminator := s.buffer[i+elementLength]
|
||||
// Move to character after terminator
|
||||
i += elementLength + 1
|
||||
|
||||
// Reset length
|
||||
elementLength = 0
|
||||
|
||||
// Continue here if necessary
|
||||
s.parseStart = i
|
||||
|
||||
// If terminator is semicolon, we have a full
|
||||
// instruction.
|
||||
switch terminator {
|
||||
case ';':
|
||||
instruction = []byte(string(s.buffer[0:i]))
|
||||
s.parseStart = 0
|
||||
s.buffer = s.buffer[i:]
|
||||
return
|
||||
case ',':
|
||||
// keep going
|
||||
default:
|
||||
err = ErrServer.NewError("Element terminator of instruction was not ';' nor ','")
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Otherwise, parse error
|
||||
err = ErrServer.NewError("Non-numeric character in element length:", string(readChar))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err = s.conn.Read(buffer)
|
||||
if err != nil && n == 0 {
|
||||
switch err.(type) {
|
||||
case net.Error:
|
||||
ex := err.(net.Error)
|
||||
if ex.Timeout() {
|
||||
err = ErrUpstreamTimeout.NewError("Connection to guacd timed out.", err.Error())
|
||||
} else {
|
||||
err = ErrConnectionClosed.NewError("Connection to guacd is closed.", err.Error())
|
||||
}
|
||||
default:
|
||||
err = ErrServer.NewError(err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
err = ErrServer.NewError("read 0 bytes")
|
||||
}
|
||||
runes := []rune(string(buffer[:n]))
|
||||
|
||||
if cap(s.buffer)-len(s.buffer) < len(runes) {
|
||||
s.Flush()
|
||||
}
|
||||
|
||||
n = copy(s.buffer[len(s.buffer):cap(s.buffer)], runes)
|
||||
// must reslice so len is changed
|
||||
s.buffer = s.buffer[:len(s.buffer)+n]
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the underlying network connection
|
||||
func (s *Stream) Close() error {
|
||||
return s.conn.Close()
|
||||
}
|
||||
|
||||
// Handshake configures the guacd session
|
||||
func (s *Stream) Handshake(config *Config) error {
|
||||
// Get protocol / connection ID
|
||||
selectArg := config.ConnectionID
|
||||
if len(selectArg) == 0 {
|
||||
selectArg = config.Protocol
|
||||
}
|
||||
|
||||
// Send requested protocol or connection ID
|
||||
_, err := s.Write(NewInstruction("select", selectArg).Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for server Args
|
||||
args, err := s.AssertOpcode("args")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build Args list off provided names and config
|
||||
argNameS := args.Args
|
||||
argValueS := make([]string, 0, len(argNameS))
|
||||
for _, argName := range argNameS {
|
||||
|
||||
// Retrieve argument name
|
||||
|
||||
// Get defined value for name
|
||||
value := config.Parameters[argName]
|
||||
|
||||
// If value defined, set that value
|
||||
if len(value) == 0 {
|
||||
value = ""
|
||||
}
|
||||
argValueS = append(argValueS, value)
|
||||
}
|
||||
|
||||
// Send size
|
||||
_, err = s.Write(NewInstruction("size",
|
||||
fmt.Sprintf("%v", config.OptimalScreenWidth),
|
||||
fmt.Sprintf("%v", config.OptimalScreenHeight),
|
||||
fmt.Sprintf("%v", config.OptimalResolution)).Byte(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send supported audio formats
|
||||
_, err = s.Write(NewInstruction("audio", config.AudioMimetypes...).Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send supported video formats
|
||||
_, err = s.Write(NewInstruction("video", config.VideoMimetypes...).Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send supported image formats
|
||||
_, err = s.Write(NewInstruction("image", config.ImageMimetypes...).Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// timezone
|
||||
_, err = s.Write(NewInstruction("timezone", "Asia/Shanghai").Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send Args
|
||||
_, err = s.Write(NewInstruction("connect", argValueS...).Byte())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for ready, store ID
|
||||
ready, err := s.AssertOpcode("ready")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
readyArgs := ready.Args
|
||||
if len(readyArgs) == 0 {
|
||||
err = ErrServer.NewError("No connection ID received")
|
||||
return err
|
||||
}
|
||||
|
||||
s.Flush()
|
||||
s.ConnectionID = readyArgs[0]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertOpcode checks the next opcode in the stream matches what is expected. Useful during handshake.
|
||||
func (s *Stream) AssertOpcode(opcode string) (instruction *Instruction, err error) {
|
||||
instruction, err = ReadOne(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(instruction.Opcode) == 0 {
|
||||
err = ErrServer.NewError("End of stream while waiting for \"" + opcode + "\".")
|
||||
return
|
||||
}
|
||||
|
||||
if instruction.Opcode != opcode {
|
||||
err = ErrServer.NewError("Expected \"" + opcode + "\" instruction but instead received \"" + instruction.Opcode + "\".")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
118
server/internal/machine/guac/tunnel.go
Normal file
118
server/internal/machine/guac/tunnel.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"io"
|
||||
)
|
||||
|
||||
// The Guacamole protocol instruction Opcode reserved for arbitrary
|
||||
// internal use by tunnel implementations. The value of this Opcode is
|
||||
// guaranteed to be the empty string (""). Tunnel implementations may use
|
||||
// this Opcode for any purpose. It is currently used by the HTTP tunnel to
|
||||
// mark the end of the HTTP response, and by the WebSocket tunnel to
|
||||
// transmit the tunnel UUID.
|
||||
const InternalDataOpcode = ""
|
||||
|
||||
var internalOpcodeIns = []byte(fmt.Sprint(len(InternalDataOpcode), ".", InternalDataOpcode))
|
||||
|
||||
// InstructionReader provides reading functionality to a Stream
|
||||
type InstructionReader interface {
|
||||
// ReadSome returns the next complete guacd message from the stream
|
||||
ReadSome() ([]byte, error)
|
||||
// Available returns true if there are bytes buffered in the stream
|
||||
Available() bool
|
||||
// Flush resets the internal buffer for reuse
|
||||
Flush()
|
||||
}
|
||||
|
||||
// Tunnel provides a unique identifier and synchronized access to the InstructionReader and Writer
|
||||
// associated with a Stream.
|
||||
type Tunnel interface {
|
||||
// AcquireReader returns a reader to the tunnel if it isn't locked
|
||||
AcquireReader() InstructionReader
|
||||
// ReleaseReader releases the lock on the reader
|
||||
ReleaseReader()
|
||||
// HasQueuedReaderThreads returns true if there is a reader locked
|
||||
HasQueuedReaderThreads() bool
|
||||
// AcquireWriter returns a writer to the tunnel if it isn't locked
|
||||
AcquireWriter() io.Writer
|
||||
// ReleaseWriter releases the lock on the writer
|
||||
ReleaseWriter()
|
||||
// HasQueuedWriterThreads returns true if there is a writer locked
|
||||
HasQueuedWriterThreads() bool
|
||||
// GetUUID returns the uuid of the tunnel
|
||||
GetUUID() string
|
||||
// ConnectionId returns the guacd Connection ID of the tunnel
|
||||
ConnectionID() string
|
||||
// Close closes the tunnel
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Base Tunnel implementation which synchronizes access to the underlying reader and writer with locks
|
||||
type SimpleTunnel struct {
|
||||
stream *Stream
|
||||
/**
|
||||
* The UUID associated with this tunnel. Every tunnel must have a
|
||||
* corresponding UUID such that tunnel read/write requests can be
|
||||
* directed to the proper tunnel.
|
||||
*/
|
||||
uuid uuid.UUID
|
||||
readerLock CountedLock
|
||||
writerLock CountedLock
|
||||
}
|
||||
|
||||
// NewSimpleTunnel creates a new tunnel
|
||||
func NewSimpleTunnel(stream *Stream) *SimpleTunnel {
|
||||
return &SimpleTunnel{
|
||||
stream: stream,
|
||||
uuid: uuid.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// AcquireReader acquires the reader lock
|
||||
func (t *SimpleTunnel) AcquireReader() InstructionReader {
|
||||
t.readerLock.Lock()
|
||||
return t.stream
|
||||
}
|
||||
|
||||
// ReleaseReader releases the reader
|
||||
func (t *SimpleTunnel) ReleaseReader() {
|
||||
t.readerLock.Unlock()
|
||||
}
|
||||
|
||||
// HasQueuedReaderThreads returns true if more than one goroutine is trying to read
|
||||
func (t *SimpleTunnel) HasQueuedReaderThreads() bool {
|
||||
return t.readerLock.HasQueued()
|
||||
}
|
||||
|
||||
// AcquireWriter locks the writer lock
|
||||
func (t *SimpleTunnel) AcquireWriter() io.Writer {
|
||||
t.writerLock.Lock()
|
||||
return t.stream
|
||||
}
|
||||
|
||||
// ReleaseWriter releases the writer lock
|
||||
func (t *SimpleTunnel) ReleaseWriter() {
|
||||
t.writerLock.Unlock()
|
||||
}
|
||||
|
||||
// ConnectionID returns the underlying Guacamole connection ID
|
||||
func (t *SimpleTunnel) ConnectionID() string {
|
||||
return t.stream.ConnectionID
|
||||
}
|
||||
|
||||
// HasQueuedWriterThreads returns true if more than one goroutine is trying to write
|
||||
func (t *SimpleTunnel) HasQueuedWriterThreads() bool {
|
||||
return t.writerLock.HasQueued()
|
||||
}
|
||||
|
||||
// Close closes the underlying stream
|
||||
func (t *SimpleTunnel) Close() (err error) {
|
||||
return t.stream.Close()
|
||||
}
|
||||
|
||||
// GetUUID returns the tunnel's UUID
|
||||
func (t *SimpleTunnel) GetUUID() string {
|
||||
return t.uuid.String()
|
||||
}
|
||||
161
server/internal/machine/guac/tunnel_map.go
Normal file
161
server/internal/machine/guac/tunnel_map.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package guac
|
||||
|
||||
import (
|
||||
"mayfly-go/pkg/logx"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
LastAccessedTunnel tracks the last time a particular Tunnel was accessed.
|
||||
This information is not necessary for tunnels associated with WebSocket
|
||||
connections, as each WebSocket connection has its own read thread which
|
||||
continuously checks the state of the tunnel and which will automatically
|
||||
timeout when the underlying stream times out, but the HTTP tunnel has no
|
||||
such thread. Because the HTTP tunnel requires the stream to be split across
|
||||
multiple requests, tracking of activity on the tunnel must be performed
|
||||
independently of the HTTP requests.
|
||||
*/
|
||||
type LastAccessedTunnel struct {
|
||||
sync.RWMutex
|
||||
Tunnel
|
||||
lastAccessedTime time.Time
|
||||
}
|
||||
|
||||
func NewLastAccessedTunnel(tunnel Tunnel) (ret LastAccessedTunnel) {
|
||||
ret.Tunnel = tunnel
|
||||
ret.Access()
|
||||
return
|
||||
}
|
||||
|
||||
func (t *LastAccessedTunnel) Access() {
|
||||
t.Lock()
|
||||
t.lastAccessedTime = time.Now()
|
||||
t.Unlock()
|
||||
}
|
||||
|
||||
func (t *LastAccessedTunnel) GetLastAccessedTime() time.Time {
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
return t.lastAccessedTime
|
||||
}
|
||||
|
||||
/*
|
||||
TunnelTimeout is the number of seconds to wait between tunnel accesses before timing out.
|
||||
Note that this will be enforced only within a factor of 2. If a tunnel
|
||||
is unused, it will take between TUNNEL_TIMEOUT and TUNNEL_TIMEOUT*2
|
||||
seconds before that tunnel is closed and removed.
|
||||
*/
|
||||
const TunnelTimeout = 15 * time.Second
|
||||
|
||||
/*
|
||||
TunnelMap tracks in-use HTTP tunnels, automatically removing
|
||||
and closing tunnels which have not been used recently. This class is
|
||||
intended for use only within the Server implementation,
|
||||
and has no real utility outside that implementation.
|
||||
*/
|
||||
type TunnelMap struct {
|
||||
sync.RWMutex
|
||||
ticker *time.Ticker
|
||||
|
||||
// tunnelTimeout is the maximum amount of time to allow between accesses to any one HTTP tunnel.
|
||||
tunnelTimeout time.Duration
|
||||
|
||||
// Map of all tunnels that are using HTTP, indexed by tunnel UUID.
|
||||
tunnelMap map[string]*LastAccessedTunnel
|
||||
}
|
||||
|
||||
// NewTunnelMap creates a new TunnelMap and starts the scheduled job with the default timeout.
|
||||
func NewTunnelMap() *TunnelMap {
|
||||
tunnelMap := &TunnelMap{
|
||||
ticker: time.NewTicker(TunnelTimeout),
|
||||
tunnelMap: make(map[string]*LastAccessedTunnel),
|
||||
tunnelTimeout: TunnelTimeout,
|
||||
}
|
||||
go tunnelMap.tunnelTimeoutTask()
|
||||
return tunnelMap
|
||||
}
|
||||
|
||||
func (m *TunnelMap) tunnelTimeoutTask() {
|
||||
for {
|
||||
_, ok := <-m.ticker.C
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
m.tunnelTimeoutTaskRun()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TunnelMap) tunnelTimeoutTaskRun() {
|
||||
timeLine := time.Now().Add(0 - m.tunnelTimeout)
|
||||
|
||||
type pair struct {
|
||||
uuid string
|
||||
tunnel *LastAccessedTunnel
|
||||
}
|
||||
var removeIDs []pair
|
||||
|
||||
m.RLock()
|
||||
for uuid, tunnel := range m.tunnelMap {
|
||||
if tunnel.GetLastAccessedTime().Before(timeLine) {
|
||||
removeIDs = append(removeIDs, pair{uuid: uuid, tunnel: tunnel})
|
||||
}
|
||||
}
|
||||
m.RUnlock()
|
||||
|
||||
m.Lock()
|
||||
for _, double := range removeIDs {
|
||||
logx.Warnf("HTTP tunnel \"%v\" has timed out.", double.uuid)
|
||||
delete(m.tunnelMap, double.uuid)
|
||||
|
||||
if double.tunnel != nil {
|
||||
err := double.tunnel.Close()
|
||||
if err != nil {
|
||||
logx.Debugf("Unable to close expired HTTP tunnel. %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Get returns the Tunnel having the given UUID, wrapped within a LastAccessedTunnel.
|
||||
func (m *TunnelMap) Get(uuid string) (tunnel *LastAccessedTunnel, ok bool) {
|
||||
m.RLock()
|
||||
tunnel, ok = m.tunnelMap[uuid]
|
||||
m.RUnlock()
|
||||
|
||||
if ok && tunnel != nil {
|
||||
tunnel.Access()
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add registers that a new connection has been established using HTTP via the given Tunnel.
|
||||
func (m *TunnelMap) Put(uuid string, tunnel Tunnel) {
|
||||
m.Lock()
|
||||
one := NewLastAccessedTunnel(tunnel)
|
||||
m.tunnelMap[uuid] = &one
|
||||
m.Unlock()
|
||||
}
|
||||
|
||||
// Remove removes the Tunnel having the given UUID, if such a tunnel exists. The original tunnel is returned.
|
||||
func (m *TunnelMap) Remove(uuid string) (*LastAccessedTunnel, bool) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
v, ok := m.tunnelMap[uuid]
|
||||
if ok {
|
||||
delete(m.tunnelMap, uuid)
|
||||
}
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Shutdown stops the ticker to free up resources.
|
||||
func (m *TunnelMap) Shutdown() {
|
||||
m.Lock()
|
||||
m.ticker.Stop()
|
||||
m.Unlock()
|
||||
}
|
||||
@@ -29,6 +29,11 @@ func (m *machineRepoImpl) GetMachineList(condition *entity.MachineQuery, pagePar
|
||||
Like("name", condition.Name).
|
||||
In("code", condition.Codes)
|
||||
|
||||
// 只查询ssh服务器
|
||||
if condition.Ssh == entity.MachineProtocolSsh {
|
||||
qd.Eq("protocol", entity.MachineProtocolSsh)
|
||||
}
|
||||
|
||||
if condition.Ids != "" {
|
||||
// ,分割id转为id数组
|
||||
qd.In("id", collx.ArrayMap[string, uint64](strings.Split(condition.Ids, ","), func(val string) uint64 {
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
|
||||
// 机器信息
|
||||
type MachineInfo struct {
|
||||
Id uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Id uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol int `json:"protocol"`
|
||||
|
||||
Ip string `json:"ip"` // IP地址
|
||||
Port int `json:"-"` // 端口号
|
||||
|
||||
@@ -50,5 +50,8 @@ func InitMachineRouter(router *gin.RouterGroup) {
|
||||
|
||||
// 终端连接
|
||||
machines.GET(":machineId/terminal", m.WsSSH)
|
||||
|
||||
// 终端连接
|
||||
machines.GET(":machineId/rdp", m.WsGuacamole)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,3 +41,16 @@ func ParseSize(sizeStr string) (int64, error) {
|
||||
|
||||
return bytes, nil
|
||||
}
|
||||
|
||||
func FormatSize(size int64) string {
|
||||
switch {
|
||||
case size >= GB:
|
||||
return fmt.Sprintf("%.2fGB", float64(size)/GB)
|
||||
case size >= MB:
|
||||
return fmt.Sprintf("%.2fMB", float64(size)/MB)
|
||||
case size >= KB:
|
||||
return fmt.Sprintf("%.2fKB", float64(size)/KB)
|
||||
default:
|
||||
return fmt.Sprintf("%d", size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ INSERT INTO `t_sys_config` (`name`, `key`, `params`, `value`, `remark`, `permiss
|
||||
ALTER TABLE t_db_instance CHANGE sid extra varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '连接需要的额外参数,如oracle数据库需要sid等';
|
||||
ALTER TABLE t_db_instance MODIFY COLUMN extra varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '连接需要的额外参数,如oracle数据库需要sid等';
|
||||
|
||||
|
||||
-- 数据迁移相关
|
||||
CREATE TABLE `t_db_transfer_task` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`creator_id` bigint(20) NOT NULL COMMENT '创建人id',
|
||||
@@ -49,6 +49,8 @@ INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `we
|
||||
ALTER TABLE t_sys_log ADD extra varchar(5000) NULL;
|
||||
ALTER TABLE t_sys_log MODIFY COLUMN resp text NULL;
|
||||
|
||||
|
||||
|
||||
|
||||
-- rdp相关
|
||||
ALTER TABLE `t_machine` ADD COLUMN `protocol` tinyint(2) NULL COMMENT '机器类型 1、SSH 2、RDP' AFTER `name`;
|
||||
update `t_machine` set `protocol` = 1 where `protocol` is NULL;
|
||||
delete from `t_sys_config` where `key` = 'MachineConfig';
|
||||
INSERT INTO `t_sys_config` (`id`, `name`, `key`, `params`, `value`, `remark`, `permission`, `create_time`, `creator_id`, `creator`, `update_time`, `modifier_id`, `modifier`, `is_deleted`, `delete_time`) VALUES (12, '机器相关配置', 'MachineConfig', '[{\"name\":\"终端回放存储路径\",\"model\":\"terminalRecPath\",\"placeholder\":\"终端回放存储路径\"},{\"name\":\"uploadMaxFileSize\",\"model\":\"uploadMaxFileSize\",\"placeholder\":\"允许上传的最大文件大小(1MB、2GB等)\"},{\"model\":\"termOpSaveDays\",\"name\":\"终端记录保存时间\",\"placeholder\":\"终端记录保存时间(单位天)\"},{\"model\":\"guacdHost\",\"name\":\"guacd服务ip\",\"placeholder\":\"guacd服务ip,默认 127.0.0.1\"},{\"name\":\"guacd服务端口\",\"model\":\"guacdPort\",\"placeholder\":\"guacd服务端口,默认 4822\"},{\"model\":\"guacdFilePath\",\"name\":\"guacd服务文件存储位置\",\"placeholder\":\"guacd服务文件存储位置,用于挂载RDP文件夹\"},{\"name\":\"guacd服务记录存储位置\",\"model\":\"guacdRecPath\",\"placeholder\":\"guacd服务记录存储位置,用于记录rdp操作记录\"}]', '{\"terminalRecPath\":\"./rec\",\"uploadMaxFileSize\":\"1000MB\",\"termOpSaveDays\":\"30\",\"guacdHost\":\"127.0.0.1\",\"guacdPort\":\"4822\",\"guacdFilePath\":\"/Users/leozy/Desktop/developer/service/guacd/rdp-file\",\"guacdRecPath\":\"/Users/leozy/Desktop/developer/service/guacd/rdp-rec\"}', '机器相关配置,如终端回放路径等', 'all', '2023-07-13 16:26:44', 1, 'admin', '2024-04-04 13:11:52', 12, 'liuzongyang', 0, NULL);
|
||||
Reference in New Issue
Block a user