mirror of
https://gitee.com/dromara/mayfly-go
synced 2025-11-04 00:10: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>
|
||||||
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
|
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
|
||||||
<el-dialog
|
<el-dialog
|
||||||
title="终端"
|
title="SSH终端"
|
||||||
v-model="openTerminal.visible"
|
v-model="openTerminal.visible"
|
||||||
top="32px"
|
top="32px"
|
||||||
class="terminal-dialog"
|
class="terminal-dialog"
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs, reactive } from 'vue';
|
import { reactive, toRefs } from 'vue';
|
||||||
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
||||||
import SvgIcon from '@/components/svgIcon/index.vue';
|
import SvgIcon from '@/components/svgIcon/index.vue';
|
||||||
import { TerminalStatus } from './common';
|
import { TerminalStatus } from './common';
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 终端不需要连接系统websocket消息
|
// 终端不需要连接系统websocket消息
|
||||||
if (to.path != '/machine/terminal') {
|
if (to.path != '/machine/terminal' && to.path != '/machine/terminal-rdp') {
|
||||||
syssocket.init();
|
syssocket.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export const staticRoutes: Array<RouteRecordRaw> = [
|
|||||||
titleRename: true,
|
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 插件模块
|
// 申明外部 npm 插件模块
|
||||||
declare module 'sql-formatter';
|
|
||||||
declare module 'jsoneditor';
|
declare module 'jsoneditor';
|
||||||
declare module 'asciinema-player';
|
declare module 'asciinema-player';
|
||||||
declare module 'vue-grid-layout';
|
declare module 'vue-grid-layout';
|
||||||
declare module 'splitpanes';
|
declare module 'splitpanes';
|
||||||
|
declare module 'uuid';
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const getSshTunnelMachines = async () => {
|
const getSshTunnelMachines = async () => {
|
||||||
if (state.sshTunnelMachineList.length == 0) {
|
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;
|
state.sshTunnelMachineList = res.list;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-form-item prop="name" label="名称" required>
|
||||||
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
|
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs, reactive, watch, ref } from 'vue';
|
import { reactive, ref, toRefs, watch } from 'vue';
|
||||||
import { machineApi } from './api';
|
import { machineApi } from './api';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import TagTreeSelect from '../component/TagTreeSelect.vue';
|
import TagTreeSelect from '../component/TagTreeSelect.vue';
|
||||||
@@ -116,6 +123,13 @@ const rules = {
|
|||||||
trigger: ['change', 'blur'],
|
trigger: ['change', 'blur'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
protocol: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请选择机器类型',
|
||||||
|
trigger: ['change', 'blur'],
|
||||||
|
},
|
||||||
|
],
|
||||||
ip: [
|
ip: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@@ -143,18 +157,13 @@ const machineForm: any = ref(null);
|
|||||||
const authCertSelectRef: any = ref(null);
|
const authCertSelectRef: any = ref(null);
|
||||||
const tagSelectRef: any = ref(null);
|
const tagSelectRef: any = ref(null);
|
||||||
|
|
||||||
const state = reactive({
|
const defaultForm = {
|
||||||
dialogVisible: false,
|
|
||||||
tabActiveName: 'basic',
|
|
||||||
sshTunnelMachineList: [] as any,
|
|
||||||
authCerts: [] as any,
|
|
||||||
authType: 1,
|
|
||||||
form: {
|
|
||||||
id: null,
|
id: null,
|
||||||
code: '',
|
code: '',
|
||||||
tagPath: '',
|
tagPath: '',
|
||||||
ip: null,
|
ip: null,
|
||||||
port: 22,
|
port: 22,
|
||||||
|
protocol: 1, // 1.ssh 2.rdp
|
||||||
name: null,
|
name: null,
|
||||||
authCertId: null as any,
|
authCertId: null as any,
|
||||||
username: '',
|
username: '',
|
||||||
@@ -163,7 +172,15 @@ const state = reactive({
|
|||||||
remark: '',
|
remark: '',
|
||||||
sshTunnelMachineId: null as any,
|
sshTunnelMachineId: null as any,
|
||||||
enableRecorder: -1,
|
enableRecorder: -1,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
dialogVisible: false,
|
||||||
|
tabActiveName: 'basic',
|
||||||
|
sshTunnelMachineList: [] as any,
|
||||||
|
authCerts: [] as any,
|
||||||
|
authType: 1,
|
||||||
|
form: defaultForm,
|
||||||
submitForm: {},
|
submitForm: {},
|
||||||
pwd: '',
|
pwd: '',
|
||||||
});
|
});
|
||||||
@@ -176,6 +193,7 @@ const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.save
|
|||||||
watch(props, async (newValue: any) => {
|
watch(props, async (newValue: any) => {
|
||||||
state.dialogVisible = newValue.visible;
|
state.dialogVisible = newValue.visible;
|
||||||
if (!state.dialogVisible) {
|
if (!state.dialogVisible) {
|
||||||
|
state.form = defaultForm;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.tabActiveName = 'basic';
|
state.tabActiveName = 'basic';
|
||||||
@@ -190,7 +208,6 @@ watch(props, async (newValue: any) => {
|
|||||||
state.authType = 1;
|
state.authType = 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.form = { port: 22, tagId: [] } as any;
|
|
||||||
state.authType = 1;
|
state.authType = 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -245,6 +262,16 @@ const getReqForm = () => {
|
|||||||
return reqForm;
|
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 = () => {
|
const cancel = () => {
|
||||||
emit('update:visible', false);
|
emit('update:visible', false);
|
||||||
emit('cancel');
|
emit('cancel');
|
||||||
|
|||||||
@@ -86,10 +86,13 @@
|
|||||||
|
|
||||||
<template #action="{ data }">
|
<template #action="{ data }">
|
||||||
<span v-auth="'machine:terminal'">
|
<span v-auth="'machine:terminal'">
|
||||||
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top">
|
<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>终端</el-button>
|
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>SSH</el-button>
|
||||||
</el-tooltip>
|
</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" />
|
<el-divider direction="vertical" border-style="dashed" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -112,6 +115,8 @@
|
|||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
|
<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: 'edit', data }" v-if="actionBtns[perms.updateMachine]"> 编辑 </el-dropdown-item>
|
||||||
|
|
||||||
<el-dropdown-item :command="{ type: 'process', data }" :disabled="data.status == -1"> 进程 </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-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>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
|
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { machineApi, getMachineTerminalSocketUrl } from './api';
|
import { getMachineTerminalSocketUrl, machineApi } from './api';
|
||||||
import { dateFormat } from '@/common/utils/date';
|
import { dateFormat } from '@/common/utils/date';
|
||||||
import ResourceTags from '../component/ResourceTags.vue';
|
import ResourceTags from '../component/ResourceTags.vue';
|
||||||
import PageTable from '@/components/pagetable/PageTable.vue';
|
import PageTable from '@/components/pagetable/PageTable.vue';
|
||||||
@@ -196,9 +220,11 @@ import { formatByteSize } from '@/common/utils/format';
|
|||||||
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
||||||
import { SearchItem } from '@/components/SearchForm';
|
import { SearchItem } from '@/components/SearchForm';
|
||||||
import { getTagPathSearchItem } from '../component/tag';
|
import { getTagPathSearchItem } from '../component/tag';
|
||||||
|
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
|
||||||
|
|
||||||
// 组件
|
// 组件
|
||||||
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.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 MachineEdit = defineAsyncComponent(() => import('./MachineEdit.vue'));
|
||||||
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
|
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
|
||||||
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
||||||
@@ -270,6 +296,14 @@ const state = reactive({
|
|||||||
machineId: 0,
|
machineId: 0,
|
||||||
title: '',
|
title: '',
|
||||||
},
|
},
|
||||||
|
filesystemDialog: {
|
||||||
|
visible: false,
|
||||||
|
machineId: 0,
|
||||||
|
protocol: 1,
|
||||||
|
title: '',
|
||||||
|
fileId: 0,
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
machineStatsDialog: {
|
machineStatsDialog: {
|
||||||
visible: false,
|
visible: false,
|
||||||
stats: null,
|
stats: null,
|
||||||
@@ -286,9 +320,26 @@ const state = reactive({
|
|||||||
machineId: 0,
|
machineId: 0,
|
||||||
title: '',
|
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 () => {
|
onMounted(async () => {
|
||||||
if (!props.lazy) {
|
if (!props.lazy) {
|
||||||
@@ -323,6 +374,14 @@ const handleCommand = (commond: any) => {
|
|||||||
showRec(data);
|
showRec(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'rdp': {
|
||||||
|
showRDP(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'rdp-blank': {
|
||||||
|
showRDP(data, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,10 +478,21 @@ const submitSuccess = () => {
|
|||||||
search();
|
search();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showFileManage = (selectionData: any) => {
|
const showFileManage = (data: any) => {
|
||||||
|
if (data.protocol === 1) {
|
||||||
|
// ssh
|
||||||
state.fileDialog.visible = true;
|
state.fileDialog.visible = true;
|
||||||
state.fileDialog.machineId = selectionData.id;
|
state.fileDialog.machineId = data.id;
|
||||||
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
|
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) => {
|
const getStatsFontClass = (availavle: number, total: number) => {
|
||||||
@@ -453,6 +523,23 @@ const showRec = (row: any) => {
|
|||||||
state.machineRecDialog.visible = true;
|
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 });
|
defineExpose({ search });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,13 @@
|
|||||||
:tag-path-node-type="NodeTypeTagPath"
|
:tag-path-node-type="NodeTypeTagPath"
|
||||||
>
|
>
|
||||||
<template #prefix="{ data }">
|
<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 && data.params.protocol == 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="var(--el-color-danger)"
|
||||||
|
/>
|
||||||
|
<SvgIcon v-if="data.icon && data.params.protocol != 1" :name="data.icon.name" :color="data.icon.color" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #suffix="{ data }">
|
<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">
|
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
|
||||||
<template #label>
|
<template #label>
|
||||||
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?">
|
<el-popconfirm @confirm="handleReconnect(dt, true)" title="确认重新连接?">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
|
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
|
||||||
><Connection />
|
><Connection />
|
||||||
@@ -59,13 +64,20 @@
|
|||||||
</el-popover>
|
</el-popover>
|
||||||
</template>
|
</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
|
<TerminalBody
|
||||||
|
v-if="dt.params.protocol == 1"
|
||||||
:mount-init="false"
|
:mount-init="false"
|
||||||
@status-change="terminalStatusChange(dt.key, $event)"
|
@status-change="terminalStatusChange(dt.key, $event)"
|
||||||
:ref="(el) => setTerminalRef(el, dt.key)"
|
:ref="(el: any) => setTerminalRef(el, dt.key)"
|
||||||
:socket-url="dt.socketUrl"
|
: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>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -102,7 +114,27 @@
|
|||||||
|
|
||||||
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" />
|
<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" />
|
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
|
||||||
|
|
||||||
@@ -114,24 +146,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useRouter } from 'vue-router';
|
||||||
import { machineApi, getMachineTerminalSocketUrl } from './api';
|
import { getMachineTerminalSocketUrl, machineApi } from './api';
|
||||||
import { dateFormat } from '@/common/utils/date';
|
import { dateFormat } from '@/common/utils/date';
|
||||||
import { hasPerms } from '@/components/auth/auth';
|
import { hasPerms } from '@/components/auth/auth';
|
||||||
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
import { TagResourceTypeEnum } from '@/common/commonEnum';
|
||||||
import { NodeType, TagTreeNode } from '../component/tag';
|
import { NodeType, TagTreeNode } from '../component/tag';
|
||||||
import TagTree from '../component/TagTree.vue';
|
import TagTree from '../component/TagTree.vue';
|
||||||
import { Splitpanes, Pane } from 'splitpanes';
|
import { Pane, Splitpanes } from 'splitpanes';
|
||||||
import { ContextmenuItem } from '@/components/contextmenu/index';
|
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 ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
|
||||||
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
|
||||||
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
|
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
|
||||||
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
|
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
|
||||||
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
|
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
|
||||||
import TerminalBody from '@/components/terminal/TerminalBody.vue';
|
|
||||||
import { TerminalStatus } from '@/components/terminal/common';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -174,8 +208,17 @@ const state = reactive({
|
|||||||
fileDialog: {
|
fileDialog: {
|
||||||
visible: false,
|
visible: false,
|
||||||
machineId: 0,
|
machineId: 0,
|
||||||
|
protocol: 1,
|
||||||
title: '',
|
title: '',
|
||||||
},
|
},
|
||||||
|
filesystemDialog: {
|
||||||
|
visible: false,
|
||||||
|
machineId: 0,
|
||||||
|
protocol: 1,
|
||||||
|
title: '',
|
||||||
|
fileId: 0,
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
machineStatsDialog: {
|
machineStatsDialog: {
|
||||||
visible: false,
|
visible: false,
|
||||||
stats: null,
|
stats: null,
|
||||||
@@ -206,7 +249,7 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
|
|||||||
return res.list.map((x: any) =>
|
return res.list.map((x: any) =>
|
||||||
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
|
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
|
||||||
.withParams(x)
|
.withParams(x)
|
||||||
.withDisabled(x.status == -1)
|
.withDisabled(x.status == -1 && x.protocol == 1)
|
||||||
.withIcon({
|
.withIcon({
|
||||||
name: 'Monitor',
|
name: 'Monitor',
|
||||||
color: '#409eff',
|
color: '#409eff',
|
||||||
@@ -247,6 +290,7 @@ const NodeTypeMachine = (machine: any) => {
|
|||||||
const openTerminal = (machine: any, ex?: boolean) => {
|
const openTerminal = (machine: any, ex?: boolean) => {
|
||||||
// 新窗口打开
|
// 新窗口打开
|
||||||
if (ex) {
|
if (ex) {
|
||||||
|
if (machine.protocol == 1) {
|
||||||
const { href } = router.resolve({
|
const { href } = router.resolve({
|
||||||
path: `/machine/terminal`,
|
path: `/machine/terminal`,
|
||||||
query: {
|
query: {
|
||||||
@@ -256,6 +300,17 @@ const openTerminal = (machine: any, ex?: boolean) => {
|
|||||||
});
|
});
|
||||||
window.open(href, '_blank');
|
window.open(href, '_blank');
|
||||||
return;
|
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;
|
let { name, id, username } = machine;
|
||||||
@@ -268,16 +323,18 @@ const openTerminal = (machine: any, ex?: boolean) => {
|
|||||||
// 只保留name的10个字,超出部分只保留前后4个字符,中间用省略号代替
|
// 只保留name的10个字,超出部分只保留前后4个字符,中间用省略号代替
|
||||||
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
|
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
|
||||||
|
|
||||||
state.tabs.set(key, {
|
let tab = {
|
||||||
key,
|
key,
|
||||||
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
|
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
|
||||||
params: machine,
|
params: machine,
|
||||||
socketUrl: getMachineTerminalSocketUrl(id),
|
socketUrl: getMachineTerminalSocketUrl(id),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
state.tabs.set(key, tab);
|
||||||
state.activeTermName = key;
|
state.activeTermName = key;
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
handleReconnect(key);
|
handleReconnect(tab);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,9 +359,21 @@ const search = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showFileManage = (selectionData: any) => {
|
const showFileManage = (selectionData: any) => {
|
||||||
|
if (selectionData.protocol == 1) {
|
||||||
state.fileDialog.visible = true;
|
state.fileDialog.visible = true;
|
||||||
|
state.fileDialog.protocol = selectionData.protocol;
|
||||||
state.fileDialog.machineId = selectionData.id;
|
state.fileDialog.machineId = selectionData.id;
|
||||||
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
|
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) => {
|
const showInfo = (info: any) => {
|
||||||
@@ -337,7 +406,6 @@ const onRemoveTab = (targetName: string) => {
|
|||||||
} else {
|
} else {
|
||||||
activeTermName = '';
|
activeTermName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let info = state.tabs.get(targetName);
|
let info = state.tabs.get(targetName);
|
||||||
if (info) {
|
if (info) {
|
||||||
terminalRefs[info.key]?.close();
|
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) => {
|
const terminalStatusChange = (key: string, status: TerminalStatus) => {
|
||||||
state.tabs.get(key).status = status;
|
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 = () => {
|
const onResizeTagTree = () => {
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
};
|
};
|
||||||
@@ -372,14 +456,16 @@ const fitTerminal = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
let info = state.tabs.get(state.activeTermName);
|
let info = state.tabs.get(state.activeTermName);
|
||||||
if (info) {
|
if (info) {
|
||||||
terminalRefs[info.key]?.fitTerminal();
|
terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
|
||||||
terminalRefs[info.key]?.focus();
|
terminalRefs[info.key]?.focus && terminalRefs[info.key]?.focus();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReconnect = (key: string) => {
|
const handleReconnect = (tab: any, force = false) => {
|
||||||
terminalRefs[key].init();
|
let width = terminalWrapperRefs[tab.key].offsetWidth;
|
||||||
|
let height = terminalWrapperRefs[tab.key].offsetHeight;
|
||||||
|
terminalRefs[tab.key]?.init(width, height, force);
|
||||||
};
|
};
|
||||||
</script>
|
</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'),
|
addConf: Api.newPost('/machines/{machineId}/files'),
|
||||||
// 删除配置的文件or目录
|
// 删除配置的文件or目录
|
||||||
delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
|
delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
|
||||||
terminal: Api.newGet('/api/machines/{id}/terminal'),
|
|
||||||
// 机器终端操作记录列表
|
// 机器终端操作记录列表
|
||||||
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
|
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
|
||||||
// 机器终端操作记录详情
|
// 机器终端操作记录详情
|
||||||
@@ -69,3 +68,7 @@ export const cronJobApi = {
|
|||||||
export function getMachineTerminalSocketUrl(machineId: any) {
|
export function getMachineTerminalSocketUrl(machineId: any) {
|
||||||
return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`;
|
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-row>
|
||||||
|
|
||||||
<el-dialog destroy-on-close :title="fileDialog.title" v-model="fileDialog.visible" :close-on-click-modal="false" width="70%">
|
<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>
|
</el-dialog>
|
||||||
|
|
||||||
<machine-file-content
|
<machine-file-content
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs, reactive, watch } from 'vue';
|
import { reactive, toRefs, watch } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { machineApi } from '../api';
|
import { machineApi } from '../api';
|
||||||
import { FileTypeEnum } from '../enums';
|
import { FileTypeEnum } from '../enums';
|
||||||
@@ -68,6 +68,7 @@ import MachineFileContent from './MachineFileContent.vue';
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean },
|
visible: { type: Boolean },
|
||||||
|
protocol: { type: Number, default: 1 },
|
||||||
machineId: { type: Number },
|
machineId: { type: Number },
|
||||||
title: { type: String },
|
title: { type: String },
|
||||||
});
|
});
|
||||||
@@ -96,6 +97,7 @@ const state = reactive({
|
|||||||
fileTable: [] as any,
|
fileTable: [] as any,
|
||||||
fileDialog: {
|
fileDialog: {
|
||||||
visible: false,
|
visible: false,
|
||||||
|
protocol: 1,
|
||||||
title: '',
|
title: '',
|
||||||
fileId: 0,
|
fileId: 0,
|
||||||
path: '',
|
path: '',
|
||||||
|
|||||||
@@ -265,26 +265,32 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, toRefs, reactive, onMounted, computed } from 'vue';
|
import { computed, onMounted, reactive, ref, toRefs } from 'vue';
|
||||||
import { ElMessage, ElMessageBox, ElInput } from 'element-plus';
|
import { ElInput, ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { machineApi } from '../api';
|
import { machineApi } from '../api';
|
||||||
|
|
||||||
import { joinClientParams } from '@/common/request';
|
import { joinClientParams } from '@/common/request';
|
||||||
import config from '@/common/config';
|
import config from '@/common/config';
|
||||||
import { isTrue } from '@/common/assert';
|
import { isTrue, notBlank } from '@/common/assert';
|
||||||
import MachineFileContent from './MachineFileContent.vue';
|
import MachineFileContent from './MachineFileContent.vue';
|
||||||
import { notBlank } from '@/common/assert';
|
|
||||||
import { getToken } from '@/common/utils/storage';
|
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';
|
import { getMachineConfig } from '@/common/sysconfig';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
machineId: { type: Number },
|
machineId: { type: Number },
|
||||||
|
protocol: { type: Number, default: 1 },
|
||||||
fileId: { type: Number, default: 0 },
|
fileId: { type: Number, default: 0 },
|
||||||
path: { type: String, default: '' },
|
path: { type: String, default: '' },
|
||||||
isFolder: { type: Boolean, default: true },
|
isFolder: { type: Boolean, default: true },
|
||||||
@@ -415,6 +421,7 @@ const pasteFile = async () => {
|
|||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
path: cmFile.paths,
|
path: cmFile.paths,
|
||||||
toPath: state.nowPath,
|
toPath: state.nowPath,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
ElMessage.success('粘贴成功');
|
ElMessage.success('粘贴成功');
|
||||||
state.copyOrMvFile.paths = [];
|
state.copyOrMvFile.paths = [];
|
||||||
@@ -454,15 +461,14 @@ const fileRename = async (row: any) => {
|
|||||||
notBlank(row.name, '新名称不能为空');
|
notBlank(row.name, '新名称不能为空');
|
||||||
try {
|
try {
|
||||||
await machineApi.renameFile.request({
|
await machineApi.renameFile.request({
|
||||||
machineId: props.machineId,
|
machineId: parseInt(props.machineId + ''),
|
||||||
fileId: props.fileId,
|
fileId: parseInt(props.fileId + ''),
|
||||||
oldname: state.nowPath + pathSep + state.renameFile.oldname,
|
oldname: state.nowPath + pathSep + state.renameFile.oldname,
|
||||||
newname: state.nowPath + pathSep + row.name,
|
newname: state.nowPath + pathSep + row.name,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
ElMessage.success('重命名成功');
|
ElMessage.success('重命名成功');
|
||||||
// 修改路径上的文件名
|
await refresh();
|
||||||
row.path = state.nowPath + pathSep + row.name;
|
|
||||||
state.renameFile.oldname = '';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
row.name = state.renameFile.oldname;
|
row.name = state.renameFile.oldname;
|
||||||
}
|
}
|
||||||
@@ -502,6 +508,7 @@ const lsFile = async (path: string) => {
|
|||||||
const res = await machineApi.lsFile.request({
|
const res = await machineApi.lsFile.request({
|
||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
|
protocol: props.protocol,
|
||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
for (const file of res) {
|
for (const file of res) {
|
||||||
@@ -530,6 +537,7 @@ const getDirSize = async (data: any) => {
|
|||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
path: data.path,
|
path: data.path,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
data.dirSize = res;
|
data.dirSize = res;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -547,6 +555,7 @@ const showFileStat = async (data: any) => {
|
|||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
path: data.path,
|
path: data.path,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
data.stat = res;
|
data.stat = res;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -566,6 +575,7 @@ const createFile = async () => {
|
|||||||
await machineApi.createFile.request({
|
await machineApi.createFile.request({
|
||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
id: props.fileId,
|
id: props.fileId,
|
||||||
|
protocol: props.protocol,
|
||||||
path,
|
path,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
@@ -599,6 +609,7 @@ const deleteFile = async (files: any) => {
|
|||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
path: files.map((x: any) => x.path),
|
path: files.map((x: any) => x.path),
|
||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
ElMessage.success('删除成功');
|
ElMessage.success('删除成功');
|
||||||
refresh();
|
refresh();
|
||||||
@@ -611,7 +622,10 @@ const deleteFile = async (files: any) => {
|
|||||||
|
|
||||||
const downloadFile = (data: any) => {
|
const downloadFile = (data: any) => {
|
||||||
const a = document.createElement('a');
|
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();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -624,6 +638,9 @@ function uploadFolder(e: any) {
|
|||||||
// 把文件夹数据放到formData里面,下面的files和paths字段根据接口来定
|
// 把文件夹数据放到formData里面,下面的files和paths字段根据接口来定
|
||||||
var form = new FormData();
|
var form = new FormData();
|
||||||
form.append('basePath', state.nowPath);
|
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;
|
let totalFileSize = 0;
|
||||||
for (let file of e.target.files) {
|
for (let file of e.target.files) {
|
||||||
@@ -677,6 +694,7 @@ const uploadFile = (content: any) => {
|
|||||||
params.append('file', content.file);
|
params.append('file', content.file);
|
||||||
params.append('path', path);
|
params.append('path', path);
|
||||||
params.append('machineId', props.machineId as any);
|
params.append('machineId', props.machineId as any);
|
||||||
|
params.append('protocol', props.protocol as any);
|
||||||
params.append('fileId', props.fileId as any);
|
params.append('fileId', props.fileId as any);
|
||||||
params.append('token', token);
|
params.append('token', token);
|
||||||
machineApi.uploadFile
|
machineApi.uploadFile
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs, reactive, watch } from 'vue';
|
import { reactive, toRefs, watch } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { machineApi } from '../api';
|
import { machineApi } from '../api';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
|
protocol: { type: Number, default: 1 },
|
||||||
title: { type: String, default: '' },
|
title: { type: String, default: '' },
|
||||||
machineId: { type: Number },
|
machineId: { type: Number },
|
||||||
fileId: { type: Number, default: 0 },
|
fileId: { type: Number, default: 0 },
|
||||||
@@ -63,6 +64,7 @@ const getFileContent = async () => {
|
|||||||
fileId: props.fileId,
|
fileId: props.fileId,
|
||||||
path,
|
path,
|
||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
state.fileType = getFileType(path);
|
state.fileType = getFileType(path);
|
||||||
state.content = res;
|
state.content = res;
|
||||||
@@ -79,6 +81,7 @@ const updateContent = async () => {
|
|||||||
id: props.fileId,
|
id: props.fileId,
|
||||||
path: props.path,
|
path: props.path,
|
||||||
machineId: props.machineId,
|
machineId: props.machineId,
|
||||||
|
protocol: props.protocol,
|
||||||
});
|
});
|
||||||
ElMessage.success('修改成功');
|
ElMessage.success('修改成功');
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ server:
|
|||||||
enable: false
|
enable: false
|
||||||
key-file: ./default.key
|
key-file: ./default.key
|
||||||
cert-file: ./default.pem
|
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:
|
||||||
# jwt key,不设置默认使用随机字符串
|
# jwt key,不设置默认使用随机字符串
|
||||||
key:
|
key:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package form
|
|||||||
|
|
||||||
type MachineForm struct {
|
type MachineForm struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
|
Protocol int `json:"protocol" binding:"required"`
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Ip string `json:"ip" binding:"required"` // IP地址
|
Ip string `json:"ip" binding:"required"` // IP地址
|
||||||
Port int `json:"port" binding:"required"` // 端口号
|
Port int `json:"port" binding:"required"` // 端口号
|
||||||
@@ -40,9 +41,14 @@ type MachineScriptForm struct {
|
|||||||
Script string `json:"script" binding:"required"`
|
Script string `json:"script" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineCreateFileForm struct {
|
type ServerFileOptionForm struct {
|
||||||
Path string `json:"path" binding:"required"`
|
MachineId uint64 `form:"machineId"`
|
||||||
Type string `json:"type" binding:"required"`
|
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 {
|
type MachineFileUpdateForm struct {
|
||||||
@@ -54,9 +60,16 @@ type MachineFileUpdateForm struct {
|
|||||||
type MachineFileOpForm struct {
|
type MachineFileOpForm struct {
|
||||||
Path []string `json:"path" binding:"required"`
|
Path []string `json:"path" binding:"required"`
|
||||||
ToPath string `json:"toPath"`
|
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 {
|
type MachineFileRename struct {
|
||||||
|
MachineId uint64 `json:"machineId" binding:"required"`
|
||||||
|
Protocol int `json:"protocol" binding:"required"`
|
||||||
|
FileId uint64 `json:"fileId" binding:"required"`
|
||||||
|
|
||||||
Oldname string `json:"oldname" binding:"required"`
|
Oldname string `json:"oldname" binding:"required"`
|
||||||
Newname string `json:"newname" binding:"required"`
|
Newname string `json:"newname" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,32 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/may-fly/cast"
|
||||||
"mayfly-go/internal/common/consts"
|
"mayfly-go/internal/common/consts"
|
||||||
"mayfly-go/internal/machine/api/form"
|
"mayfly-go/internal/machine/api/form"
|
||||||
"mayfly-go/internal/machine/api/vo"
|
"mayfly-go/internal/machine/api/vo"
|
||||||
"mayfly-go/internal/machine/application"
|
"mayfly-go/internal/machine/application"
|
||||||
"mayfly-go/internal/machine/config"
|
"mayfly-go/internal/machine/config"
|
||||||
"mayfly-go/internal/machine/domain/entity"
|
"mayfly-go/internal/machine/domain/entity"
|
||||||
|
"mayfly-go/internal/machine/guac"
|
||||||
tagapp "mayfly-go/internal/tag/application"
|
tagapp "mayfly-go/internal/tag/application"
|
||||||
tagentity "mayfly-go/internal/tag/domain/entity"
|
tagentity "mayfly-go/internal/tag/domain/entity"
|
||||||
"mayfly-go/pkg/biz"
|
"mayfly-go/pkg/biz"
|
||||||
"mayfly-go/pkg/errorx"
|
"mayfly-go/pkg/errorx"
|
||||||
|
"mayfly-go/pkg/logx"
|
||||||
"mayfly-go/pkg/model"
|
"mayfly-go/pkg/model"
|
||||||
"mayfly-go/pkg/req"
|
"mayfly-go/pkg/req"
|
||||||
"mayfly-go/pkg/utils/anyx"
|
"mayfly-go/pkg/utils/anyx"
|
||||||
"mayfly-go/pkg/utils/collx"
|
"mayfly-go/pkg/utils/collx"
|
||||||
"mayfly-go/pkg/ws"
|
"mayfly-go/pkg/ws"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Machine struct {
|
type Machine struct {
|
||||||
@@ -204,6 +208,105 @@ func (m *Machine) MachineTermOpRecord(rc *req.Ctx) {
|
|||||||
rc.ResData = base64.StdEncoding.EncodeToString(bytes)
|
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 {
|
func GetMachineId(rc *req.Ctx) uint64 {
|
||||||
machineId, _ := strconv.Atoi(rc.PathParam("machineId"))
|
machineId, _ := strconv.Atoi(rc.PathParam("machineId"))
|
||||||
biz.IsTrue(machineId != 0, "machineId错误")
|
biz.IsTrue(machineId != 0, "machineId错误")
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import (
|
|||||||
"mayfly-go/pkg/utils/collx"
|
"mayfly-go/pkg/utils/collx"
|
||||||
"mayfly-go/pkg/utils/timex"
|
"mayfly-go/pkg/utils/timex"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -60,20 +62,19 @@ func (m *MachineFile) DeleteFile(rc *req.Ctx) {
|
|||||||
/*** sftp相关操作 */
|
/*** sftp相关操作 */
|
||||||
|
|
||||||
func (m *MachineFile) CreateFile(rc *req.Ctx) {
|
func (m *MachineFile) CreateFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
|
||||||
|
|
||||||
form := req.BindJsonAndValid(rc, new(form.MachineCreateFileForm))
|
opForm := req.BindJsonAndValid(rc, new(form.ServerFileOptionForm))
|
||||||
path := form.Path
|
path := opForm.Path
|
||||||
|
|
||||||
attrs := collx.Kvs("path", path)
|
attrs := collx.Kvs("path", path)
|
||||||
var mi *mcm.MachineInfo
|
var mi *mcm.MachineInfo
|
||||||
var err error
|
var err error
|
||||||
if form.Type == dir {
|
if opForm.Type == dir {
|
||||||
attrs["type"] = "目录"
|
attrs["type"] = "目录"
|
||||||
mi, err = m.MachineFileApp.MkDir(fid, form.Path)
|
mi, err = m.MachineFileApp.MkDir(opForm.FileId, opForm.Path, opForm)
|
||||||
} else {
|
} else {
|
||||||
attrs["type"] = "文件"
|
attrs["type"] = "文件"
|
||||||
mi, err = m.MachineFileApp.CreateFile(fid, form.Path)
|
mi, err = m.MachineFileApp.CreateFile(opForm.FileId, opForm.Path, opForm)
|
||||||
}
|
}
|
||||||
attrs["machine"] = mi
|
attrs["machine"] = mi
|
||||||
rc.ReqParam = attrs
|
rc.ReqParam = attrs
|
||||||
@@ -81,10 +82,21 @@ func (m *MachineFile) CreateFile(rc *req.Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
|
func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||||
readPath := rc.Query("path")
|
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)
|
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
|
||||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||||
defer sftpFile.Close()
|
defer sftpFile.Close()
|
||||||
@@ -100,36 +112,56 @@ func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) DownloadFile(rc *req.Ctx) {
|
func (m *MachineFile) DownloadFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||||
readPath := rc.Query("path")
|
|
||||||
|
|
||||||
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)
|
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
|
||||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||||
defer sftpFile.Close()
|
defer sftpFile.Close()
|
||||||
|
|
||||||
// 截取文件名,如/usr/local/test.java -》 test.java
|
rc.Download(sftpFile, fileName)
|
||||||
path := strings.Split(readPath, "/")
|
|
||||||
rc.Download(sftpFile, path[len(path)-1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
|
func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||||
readPath := rc.Query("path")
|
readPath := opForm.Path
|
||||||
rc.ReqParam = fmt.Sprintf("path: %s", readPath)
|
rc.ReqParam = fmt.Sprintf("path: %s", readPath)
|
||||||
|
|
||||||
if !strings.HasSuffix(readPath, "/") {
|
fis, err := m.MachineFileApp.ReadDir(opForm.FileId, opForm)
|
||||||
readPath = readPath + "/"
|
|
||||||
}
|
|
||||||
fis, err := m.MachineFileApp.ReadDir(fid, readPath)
|
|
||||||
biz.ErrIsNilAppendErr(err, "读取目录失败: %s")
|
biz.ErrIsNilAppendErr(err, "读取目录失败: %s")
|
||||||
|
|
||||||
fisVO := make([]vo.MachineFileInfo, 0)
|
fisVO := make([]vo.MachineFileInfo, 0)
|
||||||
for _, fi := range fis {
|
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{
|
fisVO = append(fisVO, vo.MachineFileInfo{
|
||||||
Name: fi.Name(),
|
Name: fi.Name(),
|
||||||
Size: fi.Size(),
|
Size: fi.Size(),
|
||||||
Path: readPath + fi.Name(),
|
Path: path,
|
||||||
Type: getFileType(fi.Mode()),
|
Type: getFileType(fi.Mode()),
|
||||||
Mode: fi.Mode().String(),
|
Mode: fi.Mode().String(),
|
||||||
ModTime: timex.DefaultFormat(fi.ModTime()),
|
ModTime: timex.DefaultFormat(fi.ModTime()),
|
||||||
@@ -141,30 +173,25 @@ func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) GetDirSize(rc *req.Ctx) {
|
func (m *MachineFile) GetDirSize(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||||
readPath := rc.Query("path")
|
|
||||||
|
|
||||||
size, err := m.MachineFileApp.GetDirSize(fid, readPath)
|
size, err := m.MachineFileApp.GetDirSize(opForm.FileId, opForm)
|
||||||
biz.ErrIsNil(err)
|
biz.ErrIsNil(err)
|
||||||
rc.ResData = size
|
rc.ResData = size
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) GetFileStat(rc *req.Ctx) {
|
func (m *MachineFile) GetFileStat(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
|
||||||
readPath := rc.Query("path")
|
res, err := m.MachineFileApp.FileStat(opForm)
|
||||||
|
|
||||||
res, err := m.MachineFileApp.FileStat(fid, readPath)
|
|
||||||
biz.ErrIsNil(err, res)
|
biz.ErrIsNil(err, res)
|
||||||
rc.ResData = res
|
rc.ResData = res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
|
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))
|
mi, err := m.MachineFileApp.WriteFileContent(opForm.FileId, path, []byte(opForm.Content), opForm)
|
||||||
path := form.Path
|
|
||||||
|
|
||||||
mi, err := m.MachineFileApp.WriteFileContent(fid, path, []byte(form.Content))
|
|
||||||
rc.ReqParam = collx.Kvs("machine", mi, "path", path)
|
rc.ReqParam = collx.Kvs("machine", mi, "path", path)
|
||||||
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
|
||||||
}
|
}
|
||||||
@@ -172,6 +199,8 @@ func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
|
|||||||
func (m *MachineFile) UploadFile(rc *req.Ctx) {
|
func (m *MachineFile) UploadFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
fid := GetMachineFileId(rc)
|
||||||
path := rc.PostForm("path")
|
path := rc.PostForm("path")
|
||||||
|
protocol, err := strconv.Atoi(rc.PostForm("protocol"))
|
||||||
|
machineId, err := strconv.Atoi(rc.PostForm("machineId"))
|
||||||
|
|
||||||
fileheader, err := rc.FormFile("file")
|
fileheader, err := rc.FormFile("file")
|
||||||
biz.ErrIsNilAppendErr(err, "读取文件失败: %s")
|
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))
|
rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", path, fileheader.Filename))
|
||||||
biz.ErrIsNilAppendErr(err, "创建文件失败: %s")
|
biz.ErrIsNilAppendErr(err, "创建文件失败: %s")
|
||||||
// 保存消息并发送文件上传成功通知
|
// 保存消息并发送文件上传成功通知
|
||||||
@@ -221,6 +257,18 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
|||||||
|
|
||||||
paths := mf.Value["paths"]
|
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])
|
folderName := filepath.Dir(paths[0])
|
||||||
mcli, err := m.MachineFileApp.GetMachineCli(fid, basePath+"/"+folderName)
|
mcli, err := m.MachineFileApp.GetMachineCli(fid, basePath+"/"+folderName)
|
||||||
biz.ErrIsNil(err)
|
biz.ErrIsNil(err)
|
||||||
@@ -296,38 +344,31 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) RemoveFile(rc *req.Ctx) {
|
func (m *MachineFile) RemoveFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||||
rmForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
|
||||||
|
|
||||||
mi, err := m.MachineFileApp.RemoveFile(fid, rmForm.Path...)
|
mi, err := m.MachineFileApp.RemoveFile(opForm)
|
||||||
rc.ReqParam = collx.Kvs("machine", mi, "path", rmForm.Path)
|
rc.ReqParam = collx.Kvs("machine", mi, "path", opForm)
|
||||||
biz.ErrIsNilAppendErr(err, "删除文件失败: %s")
|
biz.ErrIsNilAppendErr(err, "删除文件失败: %s")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) CopyFile(rc *req.Ctx) {
|
func (m *MachineFile) CopyFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||||
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
mi, err := m.MachineFileApp.Copy(opForm)
|
||||||
|
|
||||||
mi, err := m.MachineFileApp.Copy(fid, cpForm.ToPath, cpForm.Path...)
|
|
||||||
biz.ErrIsNilAppendErr(err, "文件拷贝失败: %s")
|
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) {
|
func (m *MachineFile) MvFile(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
||||||
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
|
mi, err := m.MachineFileApp.Mv(opForm)
|
||||||
|
rc.ReqParam = collx.Kvs("machine", mi, "mv", opForm)
|
||||||
mi, err := m.MachineFileApp.Mv(fid, cpForm.ToPath, cpForm.Path...)
|
|
||||||
rc.ReqParam = collx.Kvs("machine", mi, "mv", cpForm)
|
|
||||||
biz.ErrIsNilAppendErr(err, "文件移动失败: %s")
|
biz.ErrIsNilAppendErr(err, "文件移动失败: %s")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MachineFile) Rename(rc *req.Ctx) {
|
func (m *MachineFile) Rename(rc *req.Ctx) {
|
||||||
fid := GetMachineFileId(rc)
|
renameForm := req.BindJsonAndValid(rc, new(form.MachineFileRename))
|
||||||
rename := req.BindJsonAndValid(rc, new(form.MachineFileRename))
|
mi, err := m.MachineFileApp.Rename(renameForm)
|
||||||
|
rc.ReqParam = collx.Kvs("machine", mi, "rename", renameForm)
|
||||||
mi, err := m.MachineFileApp.Rename(fid, rename.Oldname, rename.Newname)
|
|
||||||
rc.ReqParam = collx.Kvs("machine", mi, "rename", rename)
|
|
||||||
biz.ErrIsNilAppendErr(err, "文件重命名失败: %s")
|
biz.ErrIsNilAppendErr(err, "文件重命名失败: %s")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type MachineVO struct {
|
|||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Protocol int `json:"protocol"`
|
||||||
Ip string `json:"ip"`
|
Ip string `json:"ip"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type Machine interface {
|
|||||||
|
|
||||||
// 获取机器运行时状态信息
|
// 获取机器运行时状态信息
|
||||||
GetMachineStats(machineId uint64) (*mcm.Stats, error)
|
GetMachineStats(machineId uint64) (*mcm.Stats, error)
|
||||||
|
|
||||||
|
ToMachineInfoById(machineId uint64) (*mcm.MachineInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type machineAppImpl struct {
|
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) {
|
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
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
return mi.Conn()
|
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) {
|
func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) {
|
||||||
return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, 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) {
|
func (m *machineAppImpl) GetSshTunnelMachine(machineId int) (*mcm.SshTunnelMachine, error) {
|
||||||
return mcm.GetSshTunnelMachine(machineId, func(mid uint64) (*mcm.MachineInfo, 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("开始定时收集并缓存服务器状态信息...")
|
logx.Debug("开始定时收集并缓存服务器状态信息...")
|
||||||
scheduler.AddFun("@every 2m", func() {
|
scheduler.AddFun("@every 2m", func() {
|
||||||
machineIds := new([]entity.Machine)
|
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 {
|
for _, ma := range *machineIds {
|
||||||
go func(mid uint64) {
|
go func(mid uint64) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -215,12 +217,12 @@ func (m *machineAppImpl) GetMachineStats(machineId uint64) (*mcm.Stats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成机器信息,根据授权凭证id填充用户密码等
|
// 生成机器信息,根据授权凭证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)
|
me, err := m.GetById(new(entity.Machine), machineId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errorx.NewBiz("机器信息不存在")
|
return nil, errorx.NewBiz("机器信息不存在")
|
||||||
}
|
}
|
||||||
if me.Status != entity.MachineStatusEnable {
|
if me.Status != entity.MachineStatusEnable && me.Protocol == 1 {
|
||||||
return nil, errorx.NewBiz("该机器已被停用")
|
return nil, errorx.NewBiz("该机器已被停用")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +242,7 @@ func (m *machineAppImpl) toMachineInfo(me *entity.Machine) (*mcm.MachineInfo, er
|
|||||||
mi.Username = me.Username
|
mi.Username = me.Username
|
||||||
mi.TagPath = m.tagApp.ListTagPathByResource(consts.TagResourceTypeMachine, me.Code)
|
mi.TagPath = m.tagApp.ListTagPathByResource(consts.TagResourceTypeMachine, me.Code)
|
||||||
mi.EnableRecorder = me.EnableRecorder
|
mi.EnableRecorder = me.EnableRecorder
|
||||||
|
mi.Protocol = me.Protocol
|
||||||
|
|
||||||
if me.UseAuthCert() {
|
if me.UseAuthCert() {
|
||||||
ac, err := m.authCertApp.GetById(new(entity.AuthCert), uint64(me.AuthCertId))
|
ac, err := m.authCertApp.GetById(new(entity.AuthCert), uint64(me.AuthCertId))
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"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/entity"
|
||||||
"mayfly-go/internal/machine/domain/repository"
|
"mayfly-go/internal/machine/domain/repository"
|
||||||
"mayfly-go/internal/machine/mcm"
|
"mayfly-go/internal/machine/mcm"
|
||||||
@@ -13,7 +16,10 @@ import (
|
|||||||
"mayfly-go/pkg/errorx"
|
"mayfly-go/pkg/errorx"
|
||||||
"mayfly-go/pkg/logx"
|
"mayfly-go/pkg/logx"
|
||||||
"mayfly-go/pkg/model"
|
"mayfly-go/pkg/model"
|
||||||
|
"mayfly-go/pkg/utils/bytex"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
@@ -36,40 +42,44 @@ type MachineFile interface {
|
|||||||
// 检查文件路径,并返回机器id
|
// 检查文件路径,并返回机器id
|
||||||
GetMachineCli(fileId uint64, path ...string) (*mcm.Cli, error)
|
GetMachineCli(fileId uint64, path ...string) (*mcm.Cli, error)
|
||||||
|
|
||||||
|
GetRdpFilePath(MachineId uint64, path string) string
|
||||||
|
|
||||||
/** sftp 相关操作 **/
|
/** 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
|
// 获取文件stat
|
||||||
FileStat(fid uint64, path string) (string, error)
|
FileStat(opForm *form.ServerFileOptionForm) (string, error)
|
||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
ReadFile(fileId uint64, path string) (*sftp.File, *mcm.MachineInfo, 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 {
|
type machineFileAppImpl struct {
|
||||||
@@ -107,19 +117,49 @@ func (m *machineFileAppImpl) Save(ctx context.Context, mf *entity.MachineFile) e
|
|||||||
return m.Insert(ctx, mf)
|
return m.Insert(ctx, mf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) ReadDir(fid uint64, path string) ([]fs.FileInfo, error) {
|
func (m *machineFileAppImpl) ReadDir(fid uint64, opForm *form.ServerFileOptionForm) ([]fs.FileInfo, error) {
|
||||||
if !strings.HasSuffix(path, "/") {
|
if !strings.HasSuffix(opForm.Path, "/") {
|
||||||
path = 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sftpCli.ReadDir(path)
|
return sftpCli.ReadDir(opForm.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error) {
|
|
||||||
mcli, err := m.GetMachineCli(fid, path)
|
mcli, err := m.GetMachineCli(fid, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -144,19 +184,31 @@ func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error)
|
|||||||
return strings.Split(res, "\t")[0], nil
|
return strings.Split(res, "\t")[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) FileStat(fid uint64, path string) (string, error) {
|
func (m *machineFileAppImpl) FileStat(opForm *form.ServerFileOptionForm) (string, error) {
|
||||||
mcli, err := m.GetMachineCli(fid, path)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
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, "/") {
|
if !strings.HasSuffix(path, "/") {
|
||||||
path = 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)
|
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -166,12 +218,19 @@ func (m *machineFileAppImpl) MkDir(fid uint64, path string) (*mcm.MachineInfo, e
|
|||||||
return mi, err
|
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)
|
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
file, err := sftpCli.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errorx.NewBiz("创建文件失败: %s", err.Error())
|
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)
|
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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, "/") {
|
if !strings.HasSuffix(path, "/") {
|
||||||
path = 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)
|
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -227,16 +309,63 @@ func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, re
|
|||||||
return mi, err
|
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) {
|
func (m *machineFileAppImpl) RemoveFile(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||||
mcli, err := m.GetMachineCli(fileId, path...)
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
minfo := mcli.Info
|
minfo := mcli.Info
|
||||||
|
|
||||||
// 优先使用命令删除(速度快),sftp需要递归遍历删除子文件等
|
// 优先使用命令删除(速度快),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 {
|
if err == nil {
|
||||||
return minfo, nil
|
return minfo, nil
|
||||||
}
|
}
|
||||||
@@ -247,7 +376,7 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
|
|||||||
return minfo, err
|
return minfo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range path {
|
for _, p := range opForm.Path {
|
||||||
err = sftpCli.RemoveAll(p)
|
err = sftpCli.RemoveAll(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
@@ -256,36 +385,82 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
|
|||||||
return minfo, err
|
return minfo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) Copy(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
|
func (m *machineFileAppImpl) Copy(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||||
mcli, err := m.GetMachineCli(fileId, paths...)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mi := mcli.Info
|
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 {
|
if err != nil {
|
||||||
return mi, errors.New(res)
|
return mi, errors.New(res)
|
||||||
}
|
}
|
||||||
return mi, err
|
return mi, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) Mv(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
|
func (m *machineFileAppImpl) Mv(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
|
||||||
mcli, err := m.GetMachineCli(fileId, paths...)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mi := mcli.Info
|
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 {
|
if err != nil {
|
||||||
return mi, errorx.NewBiz(res)
|
return mi, errorx.NewBiz(res)
|
||||||
}
|
}
|
||||||
return mi, err
|
return mi, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *machineFileAppImpl) Rename(fileId uint64, oldname string, newname string) (*mcm.MachineInfo, error) {
|
func (m *machineFileAppImpl) Rename(renameForm *form.MachineFileRename) (*mcm.MachineInfo, error) {
|
||||||
mi, sftpCli, err := m.GetMachineSftpCli(fileId, newname)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -322,3 +497,7 @@ func (m *machineFileAppImpl) GetMachineSftpCli(fid uint64, inputPath ...string)
|
|||||||
|
|
||||||
return mcli.Info, sftpCli, nil
|
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 // 终端操作记录存储位置
|
TerminalRecPath string // 终端操作记录存储位置
|
||||||
UploadMaxFileSize int64 // 允许上传的最大文件size
|
UploadMaxFileSize int64 // 允许上传的最大文件size
|
||||||
TermOpSaveDays int // 终端记录保存天数
|
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.UploadMaxFileSize = uploadMaxFileSize
|
||||||
mc.TermOpSaveDays = cast.ToIntD(jm["termOpSaveDays"], 30)
|
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
|
return mc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Machine struct {
|
|||||||
|
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Protocol int `json:"protocol"` // 连接协议 1.ssh 2.rdp
|
||||||
Ip string `json:"ip"` // IP地址
|
Ip string `json:"ip"` // IP地址
|
||||||
Port int `json:"port"` // 端口号
|
Port int `json:"port"` // 端口号
|
||||||
Username string `json:"username"` // 用户名
|
Username string `json:"username"` // 用户名
|
||||||
@@ -25,6 +26,9 @@ type Machine struct {
|
|||||||
const (
|
const (
|
||||||
MachineStatusEnable int8 = 1 // 启用状态
|
MachineStatusEnable int8 = 1 // 启用状态
|
||||||
MachineStatusDisable int8 = -1 // 禁用状态
|
MachineStatusDisable int8 = -1 // 禁用状态
|
||||||
|
|
||||||
|
MachineProtocolSsh = 1
|
||||||
|
MachineProtocolRdp = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Machine) PwdEncrypt() error {
|
func (m *Machine) PwdEncrypt() error {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type MachineQuery struct {
|
|||||||
Status int8 `json:"status" form:"status"`
|
Status int8 `json:"status" form:"status"`
|
||||||
Ip string `json:"ip" form:"ip"` // IP地址
|
Ip string `json:"ip" form:"ip"` // IP地址
|
||||||
TagPath string `json:"tagPath" form:"tagPath"`
|
TagPath string `json:"tagPath" form:"tagPath"`
|
||||||
|
Ssh int8 `json:"ssh" form:"ssh"`
|
||||||
|
|
||||||
Codes []string
|
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).
|
Like("name", condition.Name).
|
||||||
In("code", condition.Codes)
|
In("code", condition.Codes)
|
||||||
|
|
||||||
|
// 只查询ssh服务器
|
||||||
|
if condition.Ssh == entity.MachineProtocolSsh {
|
||||||
|
qd.Eq("protocol", entity.MachineProtocolSsh)
|
||||||
|
}
|
||||||
|
|
||||||
if condition.Ids != "" {
|
if condition.Ids != "" {
|
||||||
// ,分割id转为id数组
|
// ,分割id转为id数组
|
||||||
qd.In("id", collx.ArrayMap[string, uint64](strings.Split(condition.Ids, ","), func(val string) uint64 {
|
qd.In("id", collx.ArrayMap[string, uint64](strings.Split(condition.Ids, ","), func(val string) uint64 {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
type MachineInfo struct {
|
type MachineInfo struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Protocol int `json:"protocol"`
|
||||||
|
|
||||||
Ip string `json:"ip"` // IP地址
|
Ip string `json:"ip"` // IP地址
|
||||||
Port int `json:"-"` // 端口号
|
Port int `json:"-"` // 端口号
|
||||||
|
|||||||
@@ -50,5 +50,8 @@ func InitMachineRouter(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// 终端连接
|
// 终端连接
|
||||||
machines.GET(":machineId/terminal", m.WsSSH)
|
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
|
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 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等';
|
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` (
|
CREATE TABLE `t_db_transfer_task` (
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
`creator_id` bigint(20) NOT NULL 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 ADD extra varchar(5000) NULL;
|
||||||
ALTER TABLE t_sys_log MODIFY COLUMN resp text 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