!112 feat: 机器管理支持ssh+rdp连接win服务器

* feat: rdp 文件管理
* feat: 机器管理支持ssh+rdp连接win服务器
This commit is contained in:
zongyangleo
2024-04-06 04:03:38 +00:00
committed by Coder慌
parent 38ff5152e0
commit 582d879a77
47 changed files with 17604 additions and 196 deletions

View 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>

View 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>

View File

@@ -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>

View 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;

File diff suppressed because it is too large Load Diff

View 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);
}

View 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"
}

View File

@@ -0,0 +1,11 @@
export interface TerminalExpose {
/** 连接 */
init(width: number, height: number, force: boolean): void;
/** 短开连接 */
close(): void;
blur(): void;
focus(): void;
}

View File

@@ -2,7 +2,7 @@
<div>
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
<el-dialog
title="终端"
title="SSH终端"
v-model="openTerminal.visible"
top="32px"
class="terminal-dialog"
@@ -92,7 +92,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive } from 'vue';
import { reactive, toRefs } from 'vue';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { TerminalStatus } from './common';

View File

@@ -92,7 +92,7 @@ router.beforeEach(async (to, from, next) => {
}
// 终端不需要连接系统websocket消息
if (to.path != '/machine/terminal') {
if (to.path != '/machine/terminal' && to.path != '/machine/terminal-rdp') {
syssocket.init();
}

View File

@@ -54,6 +54,17 @@ export const staticRoutes: Array<RouteRecordRaw> = [
titleRename: true,
},
},
{
path: '/machine/terminal-rdp',
name: 'machineTerminalRdp',
component: () => import('@/views/ops/machine/RdpTerminalPage.vue'),
meta: {
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
title: '终端 | {name}',
// 是否根据query对标题名进行参数替换即最终显示为终端_机器名
titleRename: true,
},
},
];
// 错误页面路由

View File

@@ -1,6 +1,6 @@
// 申明外部 npm 插件模块
declare module 'sql-formatter';
declare module 'jsoneditor';
declare module 'asciinema-player';
declare module 'vue-grid-layout';
declare module 'splitpanes';
declare module 'uuid';

View File

@@ -46,7 +46,7 @@ onMounted(async () => {
const getSshTunnelMachines = async () => {
if (state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100, ssh: 1 });
state.sshTunnelMachineList = res.list;
}
};

View File

@@ -18,10 +18,17 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="protocol" label="机器类型" required>
<el-radio-group v-model="form.protocol" @change="handleChangeProtocol">
<el-radio :value="1">SSH</el-radio>
<el-radio :value="2">RDP</el-radio>
<!-- <el-radio :value="3">VNC</el-radio> -->
</el-radio-group>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="ip" label="ip" required>
<el-form-item prop="ip" label="ip" req uired>
<el-col :span="18">
<el-input :disabled="form.id" v-model.trim="form.ip" placeholder="主机ip" auto-complete="off"> </el-input>
</el-col>
@@ -79,7 +86,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, ref } from 'vue';
import { reactive, ref, toRefs, watch } from 'vue';
import { machineApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
@@ -116,6 +123,13 @@ const rules = {
trigger: ['change', 'blur'],
},
],
protocol: [
{
required: true,
message: '请选择机器类型',
trigger: ['change', 'blur'],
},
],
ip: [
{
required: true,
@@ -143,27 +157,30 @@ const machineForm: any = ref(null);
const authCertSelectRef: any = ref(null);
const tagSelectRef: any = ref(null);
const defaultForm = {
id: null,
code: '',
tagPath: '',
ip: null,
port: 22,
protocol: 1, // 1.ssh 2.rdp
name: null,
authCertId: null as any,
username: '',
password: '',
tagId: [],
remark: '',
sshTunnelMachineId: null as any,
enableRecorder: -1,
};
const state = reactive({
dialogVisible: false,
tabActiveName: 'basic',
sshTunnelMachineList: [] as any,
authCerts: [] as any,
authType: 1,
form: {
id: null,
code: '',
tagPath: '',
ip: null,
port: 22,
name: null,
authCertId: null as any,
username: '',
password: '',
tagId: [],
remark: '',
sshTunnelMachineId: null as any,
enableRecorder: -1,
},
form: defaultForm,
submitForm: {},
pwd: '',
});
@@ -176,6 +193,7 @@ const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.save
watch(props, async (newValue: any) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
state.form = defaultForm;
return;
}
state.tabActiveName = 'basic';
@@ -190,7 +208,6 @@ watch(props, async (newValue: any) => {
state.authType = 1;
}
} else {
state.form = { port: 22, tagId: [] } as any;
state.authType = 1;
}
});
@@ -245,6 +262,16 @@ const getReqForm = () => {
return reqForm;
};
const handleChangeProtocol = (val: any) => {
if (val == 1) {
state.form.port = 22;
} else if (val == 2) {
state.form.port = 3389;
} else if (val == 3) {
state.form.port = 5901;
}
};
const cancel = () => {
emit('update:visible', false);
emit('cancel');

View File

@@ -86,10 +86,13 @@
<template #action="{ data }">
<span v-auth="'machine:terminal'">
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button>
<el-tooltip v-if="data.protocol == 1" :show-after="500" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>SSH</el-button>
</el-tooltip>
<el-button v-if="data.protocol == 2" type="primary" @click="showRDP(data)" link>RDP</el-button>
<el-button v-if="data.protocol == 3" type="primary" @click="showRDP(data)" link>VNC</el-button>
<el-divider direction="vertical" border-style="dashed" />
</span>
@@ -112,6 +115,8 @@
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'rdp-blank', data }" v-if="data.protocol == 2"> RDP(新窗口) </el-dropdown-item>
<el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.updateMachine]"> 编辑 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'process', data }" :disabled="data.status == -1"> 进程 </el-dropdown-item>
@@ -179,14 +184,33 @@
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title"></machine-stats>
<machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title"></machine-rec>
<MachineRdpDialog :title="machineRdpDialog.title" v-model:visible="machineRdpDialog.visible" v-model:machineId="machineRdpDialog.machineId">
<template #headerTitle="{ terminalInfo }">
{{ `${(terminalInfo.terminalId + '').slice(-2)}` }}
<el-divider direction="vertical" />
{{ `${terminalInfo.meta.username}@${terminalInfo.meta.ip}:${terminalInfo.meta.port}` }}
<el-divider direction="vertical" />
{{ terminalInfo.meta.name }}
</template>
</MachineRdpDialog>
<el-dialog destroy-on-close :title="filesystemDialog.title" v-model="filesystemDialog.visible" :close-on-click-modal="false" width="70%">
<machine-file
:machine-id="filesystemDialog.machineId"
:protocol="filesystemDialog.protocol"
:file-id="filesystemDialog.fileId"
:path="filesystemDialog.path"
/>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { machineApi, getMachineTerminalSocketUrl } from './api';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { dateFormat } from '@/common/utils/date';
import ResourceTags from '../component/ResourceTags.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -196,9 +220,11 @@ import { formatByteSize } from '@/common/utils/format';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { SearchItem } from '@/components/SearchForm';
import { getTagPathSearchItem } from '../component/tag';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
// 组件
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue'));
const MachineRdpDialog = defineAsyncComponent(() => import('@/components/terminal-rdp/MachineRdpDialog.vue'));
const MachineEdit = defineAsyncComponent(() => import('./MachineEdit.vue'));
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
@@ -270,6 +296,14 @@ const state = reactive({
machineId: 0,
title: '',
},
filesystemDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
fileId: 0,
path: '',
},
machineStatsDialog: {
visible: false,
stats: null,
@@ -286,9 +320,26 @@ const state = reactive({
machineId: 0,
title: '',
},
machineRdpDialog: {
visible: false,
machineId: 0,
title: '',
},
});
const { params, infoDialog, selectionData, serviceDialog, processDialog, fileDialog, machineStatsDialog, machineEditDialog, machineRecDialog } = toRefs(state);
const {
params,
infoDialog,
selectionData,
serviceDialog,
processDialog,
fileDialog,
machineStatsDialog,
machineEditDialog,
machineRecDialog,
machineRdpDialog,
filesystemDialog,
} = toRefs(state);
onMounted(async () => {
if (!props.lazy) {
@@ -323,6 +374,14 @@ const handleCommand = (commond: any) => {
showRec(data);
return;
}
case 'rdp': {
showRDP(data);
return;
}
case 'rdp-blank': {
showRDP(data, true);
return;
}
}
};
@@ -419,10 +478,21 @@ const submitSuccess = () => {
search();
};
const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
const showFileManage = (data: any) => {
if (data.protocol === 1) {
// ssh
state.fileDialog.visible = true;
state.fileDialog.machineId = data.id;
state.fileDialog.title = `${data.name} => ${data.ip}`;
} else if (data.protocol === 2) {
// rdp
state.filesystemDialog.protocol = 2;
state.filesystemDialog.machineId = data.id;
state.filesystemDialog.fileId = data.id;
state.filesystemDialog.path = '/';
state.filesystemDialog.title = `${data.name} => 远程桌面文件`;
state.filesystemDialog.visible = true;
}
};
const getStatsFontClass = (availavle: number, total: number) => {
@@ -453,6 +523,23 @@ const showRec = (row: any) => {
state.machineRecDialog.visible = true;
};
const showRDP = (row: any, blank = false) => {
if (blank) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
id: row.id,
name: row.name,
},
});
window.open(href, '_blank');
return;
}
state.machineRdpDialog.title = `${row.name}[${row.ip}]-远程桌面`;
state.machineRdpDialog.machineId = row.id;
state.machineRdpDialog.visible = true;
};
defineExpose({ search });
</script>

View File

@@ -10,8 +10,13 @@
:tag-path-node-type="NodeTypeTagPath"
>
<template #prefix="{ data }">
<SvgIcon v-if="data.icon && data.params.status == 1" :name="data.icon.name" :color="data.icon.color" />
<SvgIcon v-if="data.icon && data.params.status == -1" :name="data.icon.name" color="var(--el-color-danger)" />
<SvgIcon v-if="data.icon && data.params.status == 1 && data.params.protocol == 1" :name="data.icon.name" :color="data.icon.color" />
<SvgIcon
v-if="data.icon && data.params.status == -1 && data.params.protocol == 1"
:name="data.icon.name"
color="var(--el-color-danger)"
/>
<SvgIcon v-if="data.icon && data.params.protocol != 1" :name="data.icon.name" :color="data.icon.color" />
</template>
<template #suffix="{ data }">
@@ -35,7 +40,7 @@
>
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label>
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?">
<el-popconfirm @confirm="handleReconnect(dt, true)" title="确认重新连接?">
<template #reference>
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
><Connection />
@@ -59,13 +64,20 @@
</el-popover>
</template>
<div class="terminal-wrapper" style="height: calc(100vh - 155px)">
<div :ref="(el: any) => setTerminalWrapperRef(el, dt.key)" class="terminal-wrapper" style="height: calc(100vh - 155px)">
<TerminalBody
v-if="dt.params.protocol == 1"
:mount-init="false"
@status-change="terminalStatusChange(dt.key, $event)"
:ref="(el) => setTerminalRef(el, dt.key)"
:ref="(el: any) => setTerminalRef(el, dt.key)"
:socket-url="dt.socketUrl"
/>
<machine-rdp
v-if="dt.params.protocol != 1"
:machine-id="dt.params.id"
:ref="(el: any) => setTerminalRef(el, dt.key)"
@status-change="terminalStatusChange(dt.key, $event)"
/>
</div>
</el-tab-pane>
</el-tabs>
@@ -102,7 +114,27 @@
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" />
<file-conf-list :title="fileDialog.title" v-model:visible="fileDialog.visible" v-model:machineId="fileDialog.machineId" />
<file-conf-list
:title="fileDialog.title"
v-model:visible="fileDialog.visible"
v-model:machineId="fileDialog.machineId"
:protocol="fileDialog.protocol"
/>
<el-dialog
destroy-on-close
:title="state.filesystemDialog.title"
v-model="state.filesystemDialog.visible"
:close-on-click-modal="false"
width="70%"
>
<machine-file
:machine-id="state.filesystemDialog.machineId"
:protocol="state.filesystemDialog.protocol"
:file-id="state.filesystemDialog.fileId"
:path="state.filesystemDialog.path"
/>
</el-dialog>
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
@@ -114,24 +146,26 @@
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, defineAsyncComponent, nextTick } from 'vue';
import { defineAsyncComponent, nextTick, reactive, ref, toRefs, watch } from 'vue';
import { useRouter } from 'vue-router';
import { machineApi, getMachineTerminalSocketUrl } from './api';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { dateFormat } from '@/common/utils/date';
import { hasPerms } from '@/components/auth/auth';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { NodeType, TagTreeNode } from '../component/tag';
import TagTree from '../component/TagTree.vue';
import { Splitpanes, Pane } from 'splitpanes';
import { Pane, Splitpanes } from 'splitpanes';
import { ContextmenuItem } from '@/components/contextmenu/index';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
// 组件
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
const router = useRouter();
@@ -174,8 +208,17 @@ const state = reactive({
fileDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
},
filesystemDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
fileId: 0,
path: '',
},
machineStatsDialog: {
visible: false,
stats: null,
@@ -206,7 +249,7 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
return res.list.map((x: any) =>
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
.withParams(x)
.withDisabled(x.status == -1)
.withDisabled(x.status == -1 && x.protocol == 1)
.withIcon({
name: 'Monitor',
color: '#409eff',
@@ -247,15 +290,27 @@ const NodeTypeMachine = (machine: any) => {
const openTerminal = (machine: any, ex?: boolean) => {
// 新窗口打开
if (ex) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return;
if (machine.protocol == 1) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return;
} else if (machine.protocol == 2) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
}
let { name, id, username } = machine;
@@ -268,16 +323,18 @@ const openTerminal = (machine: any, ex?: boolean) => {
// 只保留name的10个字超出部分只保留前后4个字符中间用省略号代替
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
state.tabs.set(key, {
let tab = {
key,
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
params: machine,
socketUrl: getMachineTerminalSocketUrl(id),
});
};
state.tabs.set(key, tab);
state.activeTermName = key;
nextTick(() => {
handleReconnect(key);
handleReconnect(tab);
});
};
@@ -302,9 +359,21 @@ const search = async () => {
};
const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
if (selectionData.protocol == 1) {
state.fileDialog.visible = true;
state.fileDialog.protocol = selectionData.protocol;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
}
if (selectionData.protocol == 2) {
state.filesystemDialog.protocol = 2;
state.filesystemDialog.machineId = selectionData.id;
state.filesystemDialog.fileId = selectionData.id;
state.filesystemDialog.path = '/';
state.filesystemDialog.title = `远程桌面文件管理`;
state.filesystemDialog.visible = true;
}
};
const showInfo = (info: any) => {
@@ -337,7 +406,6 @@ const onRemoveTab = (targetName: string) => {
} else {
activeTermName = '';
}
let info = state.tabs.get(targetName);
if (info) {
terminalRefs[info.key]?.close();
@@ -349,6 +417,15 @@ const onRemoveTab = (targetName: string) => {
}
};
watch(
() => state.activeTermName,
(newValue, oldValue) => {
console.log('oldValue', oldValue);
oldValue && terminalRefs[oldValue]?.blur && terminalRefs[oldValue]?.blur();
terminalRefs[newValue]?.focus && terminalRefs[newValue]?.focus();
}
);
const terminalStatusChange = (key: string, status: TerminalStatus) => {
state.tabs.get(key).status = status;
};
@@ -360,6 +437,13 @@ const setTerminalRef = (el: any, key: any) => {
}
};
const terminalWrapperRefs: any = {};
const setTerminalWrapperRef = (el: any, key: any) => {
if (key) {
terminalWrapperRefs[key] = el;
}
};
const onResizeTagTree = () => {
fitTerminal();
};
@@ -372,14 +456,16 @@ const fitTerminal = () => {
setTimeout(() => {
let info = state.tabs.get(state.activeTermName);
if (info) {
terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus();
terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus && terminalRefs[info.key]?.focus();
}
}, 100);
};
const handleReconnect = (key: string) => {
terminalRefs[key].init();
const handleReconnect = (tab: any, force = false) => {
let width = terminalWrapperRefs[tab.key].offsetWidth;
let height = terminalWrapperRefs[tab.key].offsetHeight;
terminalRefs[tab.key]?.init(width, height, force);
};
</script>

View 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>

View File

@@ -42,7 +42,6 @@ export const machineApi = {
addConf: Api.newPost('/machines/{machineId}/files'),
// 删除配置的文件or目录
delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
terminal: Api.newGet('/api/machines/{id}/terminal'),
// 机器终端操作记录列表
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
// 机器终端操作记录详情
@@ -69,3 +68,7 @@ export const cronJobApi = {
export function getMachineTerminalSocketUrl(machineId: any) {
return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`;
}
export function getMachineRdpSocketUrl(machineId: any) {
return `${config.baseWsUrl}/machines/${machineId}/rdp`;
}

View File

@@ -44,7 +44,7 @@
</el-row>
<el-dialog destroy-on-close :title="fileDialog.title" v-model="fileDialog.visible" :close-on-click-modal="false" width="70%">
<machine-file :title="fileDialog.title" :machine-id="machineId" :file-id="fileDialog.fileId" :path="fileDialog.path" />
<machine-file :title="fileDialog.title" :machine-id="machineId" :file-id="fileDialog.fileId" :path="fileDialog.path" :protocol="protocol" />
</el-dialog>
<machine-file-content
@@ -59,7 +59,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch } from 'vue';
import { reactive, toRefs, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { machineApi } from '../api';
import { FileTypeEnum } from '../enums';
@@ -68,6 +68,7 @@ import MachineFileContent from './MachineFileContent.vue';
const props = defineProps({
visible: { type: Boolean },
protocol: { type: Number, default: 1 },
machineId: { type: Number },
title: { type: String },
});
@@ -96,6 +97,7 @@ const state = reactive({
fileTable: [] as any,
fileDialog: {
visible: false,
protocol: 1,
title: '',
fileId: 0,
path: '',

View File

@@ -265,26 +265,32 @@
</template>
</el-dialog>
<machine-file-content v-model:visible="fileContent.contentVisible" :machine-id="machineId" :file-id="fileId" :path="fileContent.path" />
<machine-file-content
v-model:visible="fileContent.contentVisible"
:machine-id="machineId"
:file-id="fileId"
:path="fileContent.path"
:protocol="protocol"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox, ElInput } from 'element-plus';
import { computed, onMounted, reactive, ref, toRefs } from 'vue';
import { ElInput, ElMessage, ElMessageBox } from 'element-plus';
import { machineApi } from '../api';
import { joinClientParams } from '@/common/request';
import config from '@/common/config';
import { isTrue } from '@/common/assert';
import { isTrue, notBlank } from '@/common/assert';
import MachineFileContent from './MachineFileContent.vue';
import { notBlank } from '@/common/assert';
import { getToken } from '@/common/utils/storage';
import { formatByteSize, convertToBytes } from '@/common/utils/format';
import { convertToBytes, formatByteSize } from '@/common/utils/format';
import { getMachineConfig } from '@/common/sysconfig';
const props = defineProps({
machineId: { type: Number },
protocol: { type: Number, default: 1 },
fileId: { type: Number, default: 0 },
path: { type: String, default: '' },
isFolder: { type: Boolean, default: true },
@@ -415,6 +421,7 @@ const pasteFile = async () => {
fileId: props.fileId,
path: cmFile.paths,
toPath: state.nowPath,
protocol: props.protocol,
});
ElMessage.success('粘贴成功');
state.copyOrMvFile.paths = [];
@@ -454,15 +461,14 @@ const fileRename = async (row: any) => {
notBlank(row.name, '新名称不能为空');
try {
await machineApi.renameFile.request({
machineId: props.machineId,
fileId: props.fileId,
machineId: parseInt(props.machineId + ''),
fileId: parseInt(props.fileId + ''),
oldname: state.nowPath + pathSep + state.renameFile.oldname,
newname: state.nowPath + pathSep + row.name,
protocol: props.protocol,
});
ElMessage.success('重命名成功');
// 修改路径上的文件名
row.path = state.nowPath + pathSep + row.name;
state.renameFile.oldname = '';
await refresh();
} catch (e) {
row.name = state.renameFile.oldname;
}
@@ -502,6 +508,7 @@ const lsFile = async (path: string) => {
const res = await machineApi.lsFile.request({
fileId: props.fileId,
machineId: props.machineId,
protocol: props.protocol,
path,
});
for (const file of res) {
@@ -530,6 +537,7 @@ const getDirSize = async (data: any) => {
machineId: props.machineId,
fileId: props.fileId,
path: data.path,
protocol: props.protocol,
});
data.dirSize = res;
} finally {
@@ -547,6 +555,7 @@ const showFileStat = async (data: any) => {
machineId: props.machineId,
fileId: props.fileId,
path: data.path,
protocol: props.protocol,
});
data.stat = res;
} finally {
@@ -566,6 +575,7 @@ const createFile = async () => {
await machineApi.createFile.request({
machineId: props.machineId,
id: props.fileId,
protocol: props.protocol,
path,
type,
});
@@ -599,6 +609,7 @@ const deleteFile = async (files: any) => {
fileId: props.fileId,
path: files.map((x: any) => x.path),
machineId: props.machineId,
protocol: props.protocol,
});
ElMessage.success('删除成功');
refresh();
@@ -611,7 +622,10 @@ const deleteFile = async (files: any) => {
const downloadFile = (data: any) => {
const a = document.createElement('a');
a.setAttribute('href', `${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&${joinClientParams()}`);
a.setAttribute(
'href',
`${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&machineId=${props.machineId}&protocol=${props.protocol}&${joinClientParams()}`
);
a.click();
};
@@ -624,6 +638,9 @@ function uploadFolder(e: any) {
// 把文件夹数据放到formData里面下面的files和paths字段根据接口来定
var form = new FormData();
form.append('basePath', state.nowPath);
form.append('machineId', props.machineId as any);
form.append('protocol', props.protocol as any);
form.append('fileId', props.fileId as any);
let totalFileSize = 0;
for (let file of e.target.files) {
@@ -677,6 +694,7 @@ const uploadFile = (content: any) => {
params.append('file', content.file);
params.append('path', path);
params.append('machineId', props.machineId as any);
params.append('protocol', props.protocol as any);
params.append('fileId', props.fileId as any);
params.append('token', token);
machineApi.uploadFile

View File

@@ -24,7 +24,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch } from 'vue';
import { reactive, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { machineApi } from '../api';
@@ -32,6 +32,7 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
const props = defineProps({
visible: { type: Boolean, default: false },
protocol: { type: Number, default: 1 },
title: { type: String, default: '' },
machineId: { type: Number },
fileId: { type: Number, default: 0 },
@@ -63,6 +64,7 @@ const getFileContent = async () => {
fileId: props.fileId,
path,
machineId: props.machineId,
protocol: props.protocol,
});
state.fileType = getFileType(path);
state.content = res;
@@ -79,6 +81,7 @@ const updateContent = async () => {
id: props.fileId,
path: props.path,
machineId: props.machineId,
protocol: props.protocol,
});
ElMessage.success('修改成功');
handleClose();

View File

@@ -9,6 +9,11 @@ server:
enable: false
key-file: ./default.key
cert-file: ./default.pem
machine:
# 如果需要添加rdp服务器需要安装guacd服务docker跑一个就行 docker run --name guacd -d -p 4822:4822 guacamole/guacd
# 如果连接频繁中断重启一下guacd
guacd-host: 127.0.0.1
guacd-port: 4822
jwt:
# jwt key不设置默认使用随机字符串
key:

View File

@@ -1,10 +1,11 @@
package form
type MachineForm struct {
Id uint64 `json:"id"`
Name string `json:"name" binding:"required"`
Ip string `json:"ip" binding:"required"` // IP地址
Port int `json:"port" binding:"required"` // 端口号
Id uint64 `json:"id"`
Protocol int `json:"protocol" binding:"required"`
Name string `json:"name" binding:"required"`
Ip string `json:"ip" binding:"required"` // IP地址
Port int `json:"port" binding:"required"` // 端口号
// 资产授权凭证信息列表
AuthCertId int `json:"authCertId"`
@@ -40,9 +41,14 @@ type MachineScriptForm struct {
Script string `json:"script" binding:"required"`
}
type MachineCreateFileForm struct {
Path string `json:"path" binding:"required"`
Type string `json:"type" binding:"required"`
type ServerFileOptionForm struct {
MachineId uint64 `form:"machineId"`
Protocol int `form:"protocol"`
Path string `form:"path"`
Type string `form:"type"`
Content string `form:"content"`
Id uint64 `form:"id"`
FileId uint64 `form:"fileId"`
}
type MachineFileUpdateForm struct {
@@ -52,12 +58,19 @@ type MachineFileUpdateForm struct {
}
type MachineFileOpForm struct {
Path []string `json:"path" binding:"required"`
ToPath string `json:"toPath"`
Path []string `json:"path" binding:"required"`
ToPath string `json:"toPath"`
MachineId uint64 `json:"machineId" binding:"required"`
Protocol int `json:"protocol" binding:"required"`
FileId uint64 `json:"fileId" binding:"required"`
}
type MachineFileRename struct {
Oldname string `json:"oldname" binding:"required"`
MachineId uint64 `json:"machineId" binding:"required"`
Protocol int `json:"protocol" binding:"required"`
FileId uint64 `json:"fileId" binding:"required"`
Oldname string `json:"oldname" binding:"required"`
Newname string `json:"newname" binding:"required"`
}

View File

@@ -3,28 +3,32 @@ package api
import (
"encoding/base64"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/may-fly/cast"
"mayfly-go/internal/common/consts"
"mayfly-go/internal/machine/api/form"
"mayfly-go/internal/machine/api/vo"
"mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/guac"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/anyx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/ws"
"net/http"
"os"
"path"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"time"
)
type Machine struct {
@@ -204,6 +208,105 @@ func (m *Machine) MachineTermOpRecord(rc *req.Ctx) {
rc.ResData = base64.StdEncoding.EncodeToString(bytes)
}
const (
SocketTimeout = 15 * time.Second
MaxGuacMessage = 8192
websocketReadBufferSize = MaxGuacMessage
websocketWriteBufferSize = MaxGuacMessage * 2
)
var (
sessions = guac.NewMemorySessionStore()
)
func (m *Machine) WsGuacamole(g *gin.Context) {
upgrader := websocket.Upgrader{
ReadBufferSize: websocketReadBufferSize,
WriteBufferSize: websocketWriteBufferSize,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
wsConn, err := upgrader.Upgrade(g.Writer, g.Request, nil)
biz.ErrIsNil(err)
rc := req.NewCtxWithGin(g).WithRequiredPermission(req.NewPermission("machine:terminal"))
machineId := GetMachineId(rc)
mi, err := m.MachineApp.ToMachineInfoById(machineId)
if err != nil {
return
}
err = mi.IfUseSshTunnelChangeIpPort()
if err != nil {
return
}
params := make(map[string]string)
params["hostname"] = mi.Ip
params["port"] = strconv.Itoa(mi.Port)
params["username"] = mi.Username
params["password"] = mi.Password
params["ignore-cert"] = "true"
if mi.Protocol == 2 {
params["scheme"] = "rdp"
} else if mi.Protocol == 3 {
params["scheme"] = "vnc"
}
if mi.EnableRecorder == 1 {
// 操作记录 查看文档https://guacamole.apache.org/doc/gug/configuring-guacamole.html#graphical-recording
params["recording-path"] = fmt.Sprintf("/rdp-rec/%d", machineId)
params["create-recording-path"] = "true"
params["recording-include-keys"] = "true"
}
defer func() {
if err = wsConn.Close(); err != nil {
logx.Warnf("Error closing websocket: %v", err)
}
}()
query := g.Request.URL.Query()
if query.Get("force") != "" {
// 判断是否强制连接,是的话,查询是否有正在连接的会话,有的话强制关闭
if cast.ToBool(query.Get("force")) {
tn := sessions.Get(machineId)
if tn != nil {
_ = tn.Close()
}
}
}
tunnel, err := guac.DoConnect(query, params, machineId)
if err != nil {
return
}
defer func() {
if err = tunnel.Close(); err != nil {
logx.Warnf("Error closing tunnel: %v", err)
}
}()
sessions.Add(machineId, wsConn, g.Request, tunnel)
defer sessions.Delete(machineId, wsConn, g.Request, tunnel)
writer := tunnel.AcquireWriter()
reader := tunnel.AcquireReader()
defer tunnel.ReleaseWriter()
defer tunnel.ReleaseReader()
go guac.WsToGuacd(wsConn, tunnel, writer)
guac.GuacdToWs(wsConn, tunnel, reader)
//OnConnect
//OnDisconnect
}
func GetMachineId(rc *req.Ctx) uint64 {
machineId, _ := strconv.Atoi(rc.PathParam("machineId"))
biz.IsTrue(machineId != 0, "machineId错误")

View File

@@ -20,8 +20,10 @@ import (
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/timex"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
)
@@ -60,20 +62,19 @@ func (m *MachineFile) DeleteFile(rc *req.Ctx) {
/*** sftp相关操作 */
func (m *MachineFile) CreateFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
form := req.BindJsonAndValid(rc, new(form.MachineCreateFileForm))
path := form.Path
opForm := req.BindJsonAndValid(rc, new(form.ServerFileOptionForm))
path := opForm.Path
attrs := collx.Kvs("path", path)
var mi *mcm.MachineInfo
var err error
if form.Type == dir {
if opForm.Type == dir {
attrs["type"] = "目录"
mi, err = m.MachineFileApp.MkDir(fid, form.Path)
mi, err = m.MachineFileApp.MkDir(opForm.FileId, opForm.Path, opForm)
} else {
attrs["type"] = "文件"
mi, err = m.MachineFileApp.CreateFile(fid, form.Path)
mi, err = m.MachineFileApp.CreateFile(opForm.FileId, opForm.Path, opForm)
}
attrs["machine"] = mi
rc.ReqParam = attrs
@@ -81,10 +82,21 @@ func (m *MachineFile) CreateFile(rc *req.Ctx) {
}
func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
fid := GetMachineFileId(rc)
readPath := rc.Query("path")
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
readPath := opForm.Path
// 特殊处理rdp文件
if opForm.Protocol == entity.MachineProtocolRdp {
path := m.MachineFileApp.GetRdpFilePath(opForm.MachineId, opForm.Path)
fi, err := os.Stat(path)
biz.ErrIsNilAppendErr(err, "读取文件内容失败: %s")
biz.IsTrue(fi.Size() < max_read_size, "文件超过1m请使用下载查看")
datas, err := os.ReadFile(path)
biz.ErrIsNilAppendErr(err, "读取文件内容失败: %s")
rc.ResData = string(datas)
return
}
sftpFile, mi, err := m.MachineFileApp.ReadFile(fid, readPath)
sftpFile, mi, err := m.MachineFileApp.ReadFile(opForm.FileId, readPath)
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
defer sftpFile.Close()
@@ -100,36 +112,56 @@ func (m *MachineFile) ReadFileContent(rc *req.Ctx) {
}
func (m *MachineFile) DownloadFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
readPath := rc.Query("path")
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
sftpFile, mi, err := m.MachineFileApp.ReadFile(fid, readPath)
readPath := opForm.Path
// 截取文件名,如/usr/local/test.java -》 test.java
path := strings.Split(readPath, "/")
fileName := path[len(path)-1]
if opForm.Protocol == entity.MachineProtocolRdp {
path := m.MachineFileApp.GetRdpFilePath(opForm.MachineId, opForm.Path)
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
rc.Download(file, fileName)
return
}
sftpFile, mi, err := m.MachineFileApp.ReadFile(opForm.FileId, readPath)
rc.ReqParam = collx.Kvs("machine", mi, "path", readPath)
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
defer sftpFile.Close()
// 截取文件名,如/usr/local/test.java -》 test.java
path := strings.Split(readPath, "/")
rc.Download(sftpFile, path[len(path)-1])
rc.Download(sftpFile, fileName)
}
func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
fid := GetMachineFileId(rc)
readPath := rc.Query("path")
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
readPath := opForm.Path
rc.ReqParam = fmt.Sprintf("path: %s", readPath)
if !strings.HasSuffix(readPath, "/") {
readPath = readPath + "/"
}
fis, err := m.MachineFileApp.ReadDir(fid, readPath)
fis, err := m.MachineFileApp.ReadDir(opForm.FileId, opForm)
biz.ErrIsNilAppendErr(err, "读取目录失败: %s")
fisVO := make([]vo.MachineFileInfo, 0)
for _, fi := range fis {
name := fi.Name()
if !strings.HasPrefix(name, "/") {
name = "/" + name
}
path := name
if readPath != "/" && readPath != "" {
path = readPath + name
}
fisVO = append(fisVO, vo.MachineFileInfo{
Name: fi.Name(),
Size: fi.Size(),
Path: readPath + fi.Name(),
Path: path,
Type: getFileType(fi.Mode()),
Mode: fi.Mode().String(),
ModTime: timex.DefaultFormat(fi.ModTime()),
@@ -141,30 +173,25 @@ func (m *MachineFile) GetDirEntry(rc *req.Ctx) {
}
func (m *MachineFile) GetDirSize(rc *req.Ctx) {
fid := GetMachineFileId(rc)
readPath := rc.Query("path")
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
size, err := m.MachineFileApp.GetDirSize(fid, readPath)
size, err := m.MachineFileApp.GetDirSize(opForm.FileId, opForm)
biz.ErrIsNil(err)
rc.ResData = size
}
func (m *MachineFile) GetFileStat(rc *req.Ctx) {
fid := GetMachineFileId(rc)
readPath := rc.Query("path")
res, err := m.MachineFileApp.FileStat(fid, readPath)
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
res, err := m.MachineFileApp.FileStat(opForm)
biz.ErrIsNil(err, res)
rc.ResData = res
}
func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
fid := GetMachineFileId(rc)
opForm := req.BindQuery(rc, new(form.ServerFileOptionForm))
path := opForm.Path
form := req.BindJsonAndValid(rc, new(form.MachineFileUpdateForm))
path := form.Path
mi, err := m.MachineFileApp.WriteFileContent(fid, path, []byte(form.Content))
mi, err := m.MachineFileApp.WriteFileContent(opForm.FileId, path, []byte(opForm.Content), opForm)
rc.ReqParam = collx.Kvs("machine", mi, "path", path)
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
}
@@ -172,6 +199,8 @@ func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
func (m *MachineFile) UploadFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
path := rc.PostForm("path")
protocol, err := strconv.Atoi(rc.PostForm("protocol"))
machineId, err := strconv.Atoi(rc.PostForm("machineId"))
fileheader, err := rc.FormFile("file")
biz.ErrIsNilAppendErr(err, "读取文件失败: %s")
@@ -190,7 +219,14 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
}
}()
mi, err := m.MachineFileApp.UploadFile(fid, path, fileheader.Filename, file)
opForm := &form.ServerFileOptionForm{
FileId: fid,
MachineId: uint64(machineId),
Protocol: protocol,
Path: path,
}
mi, err := m.MachineFileApp.UploadFile(fid, path, fileheader.Filename, file, opForm)
rc.ReqParam = collx.Kvs("machine", mi, "path", fmt.Sprintf("%s/%s", path, fileheader.Filename))
biz.ErrIsNilAppendErr(err, "创建文件失败: %s")
// 保存消息并发送文件上传成功通知
@@ -221,6 +257,18 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
paths := mf.Value["paths"]
// protocol
protocol, err := strconv.Atoi(mf.Value["protocol"][0])
if protocol == entity.MachineProtocolRdp {
machineId, _ := strconv.Atoi(mf.Value["machineId"][0])
opForm := &form.ServerFileOptionForm{
MachineId: uint64(machineId),
Protocol: protocol,
}
m.MachineFileApp.UploadFiles(basePath, fileheaders, paths, opForm)
return
}
folderName := filepath.Dir(paths[0])
mcli, err := m.MachineFileApp.GetMachineCli(fid, basePath+"/"+folderName)
biz.ErrIsNil(err)
@@ -296,38 +344,31 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
}
func (m *MachineFile) RemoveFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
rmForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
mi, err := m.MachineFileApp.RemoveFile(fid, rmForm.Path...)
rc.ReqParam = collx.Kvs("machine", mi, "path", rmForm.Path)
mi, err := m.MachineFileApp.RemoveFile(opForm)
rc.ReqParam = collx.Kvs("machine", mi, "path", opForm)
biz.ErrIsNilAppendErr(err, "删除文件失败: %s")
}
func (m *MachineFile) CopyFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
mi, err := m.MachineFileApp.Copy(fid, cpForm.ToPath, cpForm.Path...)
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
mi, err := m.MachineFileApp.Copy(opForm)
biz.ErrIsNilAppendErr(err, "文件拷贝失败: %s")
rc.ReqParam = collx.Kvs("machine", mi, "cp", cpForm)
rc.ReqParam = collx.Kvs("machine", mi, "cp", opForm)
}
func (m *MachineFile) MvFile(rc *req.Ctx) {
fid := GetMachineFileId(rc)
cpForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
mi, err := m.MachineFileApp.Mv(fid, cpForm.ToPath, cpForm.Path...)
rc.ReqParam = collx.Kvs("machine", mi, "mv", cpForm)
opForm := req.BindJsonAndValid(rc, new(form.MachineFileOpForm))
mi, err := m.MachineFileApp.Mv(opForm)
rc.ReqParam = collx.Kvs("machine", mi, "mv", opForm)
biz.ErrIsNilAppendErr(err, "文件移动失败: %s")
}
func (m *MachineFile) Rename(rc *req.Ctx) {
fid := GetMachineFileId(rc)
rename := req.BindJsonAndValid(rc, new(form.MachineFileRename))
mi, err := m.MachineFileApp.Rename(fid, rename.Oldname, rename.Newname)
rc.ReqParam = collx.Kvs("machine", mi, "rename", rename)
renameForm := req.BindJsonAndValid(rc, new(form.MachineFileRename))
mi, err := m.MachineFileApp.Rename(renameForm)
rc.ReqParam = collx.Kvs("machine", mi, "rename", renameForm)
biz.ErrIsNilAppendErr(err, "文件重命名失败: %s")
}

View File

@@ -18,6 +18,7 @@ type MachineVO struct {
Id uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Protocol int `json:"protocol"`
Ip string `json:"ip"`
Port int `json:"port"`
Username string `json:"username"`

View File

@@ -50,6 +50,8 @@ type Machine interface {
// 获取机器运行时状态信息
GetMachineStats(machineId uint64) (*mcm.Stats, error)
ToMachineInfoById(machineId uint64) (*mcm.MachineInfo, error)
}
type machineAppImpl struct {
@@ -162,7 +164,7 @@ func (m *machineAppImpl) Delete(ctx context.Context, id uint64) error {
}
func (m *machineAppImpl) NewCli(machineId uint64) (*mcm.Cli, error) {
if mi, err := m.toMachineInfoById(machineId); err != nil {
if mi, err := m.ToMachineInfoById(machineId); err != nil {
return nil, err
} else {
return mi.Conn()
@@ -171,13 +173,13 @@ func (m *machineAppImpl) NewCli(machineId uint64) (*mcm.Cli, error) {
func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) {
return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
return m.toMachineInfoById(mid)
return m.ToMachineInfoById(mid)
})
}
func (m *machineAppImpl) GetSshTunnelMachine(machineId int) (*mcm.SshTunnelMachine, error) {
return mcm.GetSshTunnelMachine(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
return m.toMachineInfoById(mid)
return m.ToMachineInfoById(mid)
})
}
@@ -185,7 +187,7 @@ func (m *machineAppImpl) TimerUpdateStats() {
logx.Debug("开始定时收集并缓存服务器状态信息...")
scheduler.AddFun("@every 2m", func() {
machineIds := new([]entity.Machine)
m.GetRepo().ListByCond(&entity.Machine{Status: entity.MachineStatusEnable}, machineIds, "id")
m.GetRepo().ListByCond(&entity.Machine{Status: entity.MachineStatusEnable, Protocol: entity.MachineProtocolSsh}, machineIds, "id")
for _, ma := range *machineIds {
go func(mid uint64) {
defer func() {
@@ -215,12 +217,12 @@ func (m *machineAppImpl) GetMachineStats(machineId uint64) (*mcm.Stats, error) {
}
// 生成机器信息根据授权凭证id填充用户密码等
func (m *machineAppImpl) toMachineInfoById(machineId uint64) (*mcm.MachineInfo, error) {
func (m *machineAppImpl) ToMachineInfoById(machineId uint64) (*mcm.MachineInfo, error) {
me, err := m.GetById(new(entity.Machine), machineId)
if err != nil {
return nil, errorx.NewBiz("机器信息不存在")
}
if me.Status != entity.MachineStatusEnable {
if me.Status != entity.MachineStatusEnable && me.Protocol == 1 {
return nil, errorx.NewBiz("该机器已被停用")
}
@@ -240,6 +242,7 @@ func (m *machineAppImpl) toMachineInfo(me *entity.Machine) (*mcm.MachineInfo, er
mi.Username = me.Username
mi.TagPath = m.tagApp.ListTagPathByResource(consts.TagResourceTypeMachine, me.Code)
mi.EnableRecorder = me.EnableRecorder
mi.Protocol = me.Protocol
if me.UseAuthCert() {
ac, err := m.authCertApp.GetById(new(entity.AuthCert), uint64(me.AuthCertId))

View File

@@ -6,6 +6,9 @@ import (
"fmt"
"io"
"io/fs"
"io/ioutil"
"mayfly-go/internal/machine/api/form"
"mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/domain/repository"
"mayfly-go/internal/machine/mcm"
@@ -13,7 +16,10 @@ import (
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/bytex"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/pkg/sftp"
@@ -36,40 +42,44 @@ type MachineFile interface {
// 检查文件路径并返回机器id
GetMachineCli(fileId uint64, path ...string) (*mcm.Cli, error)
GetRdpFilePath(MachineId uint64, path string) string
/** sftp 相关操作 **/
// 创建目录
MkDir(fid uint64, path string) (*mcm.MachineInfo, error)
MkDir(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
// 创建文件
CreateFile(fid uint64, path string) (*mcm.MachineInfo, error)
CreateFile(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
// 读取目录
ReadDir(fid uint64, path string) ([]fs.FileInfo, error)
ReadDir(fid uint64, opForm *form.ServerFileOptionForm) ([]fs.FileInfo, error)
// 获取指定目录内容大小
GetDirSize(fid uint64, path string) (string, error)
GetDirSize(fid uint64, opForm *form.ServerFileOptionForm) (string, error)
// 获取文件stat
FileStat(fid uint64, path string) (string, error)
FileStat(opForm *form.ServerFileOptionForm) (string, error)
// 读取文件内容
ReadFile(fileId uint64, path string) (*sftp.File, *mcm.MachineInfo, error)
// 写文件
WriteFileContent(fileId uint64, path string, content []byte) (*mcm.MachineInfo, error)
WriteFileContent(fileId uint64, path string, content []byte, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
// 文件上传
UploadFile(fileId uint64, path, filename string, reader io.Reader) (*mcm.MachineInfo, error)
UploadFile(fileId uint64, path, filename string, reader io.Reader, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
UploadFiles(basePath string, fileHeaders []*multipart.FileHeader, paths []string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error)
// 移除文件
RemoveFile(fileId uint64, path ...string) (*mcm.MachineInfo, error)
RemoveFile(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
Copy(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error)
Copy(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
Mv(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error)
Mv(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error)
Rename(fileId uint64, oldname string, newname string) (*mcm.MachineInfo, error)
Rename(renameForm *form.MachineFileRename) (*mcm.MachineInfo, error)
}
type machineFileAppImpl struct {
@@ -107,19 +117,49 @@ func (m *machineFileAppImpl) Save(ctx context.Context, mf *entity.MachineFile) e
return m.Insert(ctx, mf)
}
func (m *machineFileAppImpl) ReadDir(fid uint64, path string) ([]fs.FileInfo, error) {
if !strings.HasSuffix(path, "/") {
path = path + "/"
func (m *machineFileAppImpl) ReadDir(fid uint64, opForm *form.ServerFileOptionForm) ([]fs.FileInfo, error) {
if !strings.HasSuffix(opForm.Path, "/") {
opForm.Path = opForm.Path + "/"
}
_, sftpCli, err := m.GetMachineSftpCli(fid, path)
// 如果是rdp则直接读取本地文件
if opForm.Protocol == entity.MachineProtocolRdp {
opForm.Path = m.GetRdpFilePath(opForm.MachineId, opForm.Path)
return ioutil.ReadDir(opForm.Path)
}
_, sftpCli, err := m.GetMachineSftpCli(fid, opForm.Path)
if err != nil {
return nil, err
}
return sftpCli.ReadDir(path)
return sftpCli.ReadDir(opForm.Path)
}
func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error) {
func (m *machineFileAppImpl) GetDirSize(fid uint64, opForm *form.ServerFileOptionForm) (string, error) {
path := opForm.Path
if opForm.Protocol == entity.MachineProtocolRdp {
dirPath := m.GetRdpFilePath(opForm.MachineId, path)
// 递归计算目录下文件大小
var totalSize int64
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil
}
// 忽略目录本身
if path != dirPath {
totalSize += info.Size()
}
return nil
})
if err != nil {
return "", err
}
return bytex.FormatSize(totalSize), nil
}
mcli, err := m.GetMachineCli(fid, path)
if err != nil {
return "", err
@@ -144,19 +184,31 @@ func (m *machineFileAppImpl) GetDirSize(fid uint64, path string) (string, error)
return strings.Split(res, "\t")[0], nil
}
func (m *machineFileAppImpl) FileStat(fid uint64, path string) (string, error) {
mcli, err := m.GetMachineCli(fid, path)
func (m *machineFileAppImpl) FileStat(opForm *form.ServerFileOptionForm) (string, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
path := m.GetRdpFilePath(opForm.MachineId, opForm.Path)
stat, err := os.Stat(path)
return fmt.Sprintf("%v", stat), err
}
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path)
if err != nil {
return "", err
}
return mcli.Run(fmt.Sprintf("stat -L %s", path))
return mcli.Run(fmt.Sprintf("stat -L %s", opForm.Path))
}
func (m *machineFileAppImpl) MkDir(fid uint64, path string) (*mcm.MachineInfo, error) {
func (m *machineFileAppImpl) MkDir(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
if !strings.HasSuffix(path, "/") {
path = path + "/"
}
if opForm.Protocol == entity.MachineProtocolRdp {
path = m.GetRdpFilePath(opForm.MachineId, path)
os.MkdirAll(path, os.ModePerm)
return nil, nil
}
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
if err != nil {
return nil, err
@@ -166,12 +218,19 @@ func (m *machineFileAppImpl) MkDir(fid uint64, path string) (*mcm.MachineInfo, e
return mi, err
}
func (m *machineFileAppImpl) CreateFile(fid uint64, path string) (*mcm.MachineInfo, error) {
func (m *machineFileAppImpl) CreateFile(fid uint64, path string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
mi, sftpCli, err := m.GetMachineSftpCli(fid, path)
if err != nil {
return nil, err
}
if opForm.Protocol == entity.MachineProtocolRdp {
path = m.GetRdpFilePath(opForm.MachineId, path)
file, err := os.Create(path)
defer file.Close()
return nil, err
}
file, err := sftpCli.Create(path)
if err != nil {
return nil, errorx.NewBiz("创建文件失败: %s", err.Error())
@@ -192,7 +251,19 @@ func (m *machineFileAppImpl) ReadFile(fileId uint64, path string) (*sftp.File, *
}
// 写文件内容
func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, content []byte) (*mcm.MachineInfo, error) {
func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, content []byte, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
path = m.GetRdpFilePath(opForm.MachineId, path)
file, err := os.Create(path)
defer file.Close()
if err != nil {
return nil, err
}
file.Write(content)
return nil, err
}
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
if err != nil {
return nil, err
@@ -208,11 +279,22 @@ func (m *machineFileAppImpl) WriteFileContent(fileId uint64, path string, conten
}
// 上传文件
func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, reader io.Reader) (*mcm.MachineInfo, error) {
func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, reader io.Reader, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
if !strings.HasSuffix(path, "/") {
path = path + "/"
}
if opForm.Protocol == entity.MachineProtocolRdp {
path = m.GetRdpFilePath(opForm.MachineId, path)
file, err := os.Create(path + filename)
defer file.Close()
if err != nil {
return nil, err
}
io.Copy(file, reader)
return nil, nil
}
mi, sftpCli, err := m.GetMachineSftpCli(fileId, path)
if err != nil {
return nil, err
@@ -227,16 +309,63 @@ func (m *machineFileAppImpl) UploadFile(fileId uint64, path, filename string, re
return mi, err
}
func (m *machineFileAppImpl) UploadFiles(basePath string, fileHeaders []*multipart.FileHeader, paths []string, opForm *form.ServerFileOptionForm) (*mcm.MachineInfo, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
baseFolder := m.GetRdpFilePath(opForm.MachineId, basePath)
for i, fileHeader := range fileHeaders {
file, err := fileHeader.Open()
if err != nil {
return nil, err
}
defer file.Close()
// 创建文件夹
rdpBaseDir := basePath
if !strings.HasSuffix(rdpBaseDir, "/") {
rdpBaseDir = rdpBaseDir + "/"
}
rdpDir := filepath.Dir(rdpBaseDir + paths[i])
m.MkDir(0, rdpDir, opForm)
// 创建文件
if !strings.HasSuffix(baseFolder, "/") {
baseFolder = baseFolder + "/"
}
fileAbsPath := baseFolder + paths[i]
createFile, err := os.Create(fileAbsPath)
if err != nil {
return nil, err
}
defer createFile.Close()
// 复制文件内容
io.Copy(createFile, file)
}
}
return nil, nil
}
// 删除文件
func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.MachineInfo, error) {
mcli, err := m.GetMachineCli(fileId, path...)
func (m *machineFileAppImpl) RemoveFile(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
for _, pt := range opForm.Path {
pt = m.GetRdpFilePath(opForm.MachineId, pt)
os.RemoveAll(pt)
}
return nil, nil
}
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
if err != nil {
return nil, err
}
minfo := mcli.Info
// 优先使用命令删除速度快sftp需要递归遍历删除子文件等
res, err := mcli.Run(fmt.Sprintf("rm -rf %s", strings.Join(path, " ")))
res, err := mcli.Run(fmt.Sprintf("rm -rf %s", strings.Join(opForm.Path, " ")))
if err == nil {
return minfo, nil
}
@@ -247,7 +376,7 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
return minfo, err
}
for _, p := range path {
for _, p := range opForm.Path {
err = sftpCli.RemoveAll(p)
if err != nil {
break
@@ -256,36 +385,82 @@ func (m *machineFileAppImpl) RemoveFile(fileId uint64, path ...string) (*mcm.Mac
return minfo, err
}
func (m *machineFileAppImpl) Copy(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
mcli, err := m.GetMachineCli(fileId, paths...)
func (m *machineFileAppImpl) Copy(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
for _, pt := range opForm.Path {
srcPath := m.GetRdpFilePath(opForm.MachineId, pt)
targetPath := m.GetRdpFilePath(opForm.MachineId, opForm.ToPath+pt)
// 打开源文件
srcFile, err := os.Open(srcPath)
if err != nil {
fmt.Println("Error opening source file:", err)
return nil, err
}
// 创建目标文件
destFile, err := os.Create(targetPath)
if err != nil {
fmt.Println("Error creating destination file:", err)
return nil, err
}
io.Copy(destFile, srcFile)
}
return nil, nil
}
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
if err != nil {
return nil, err
}
mi := mcli.Info
res, err := mcli.Run(fmt.Sprintf("cp -r %s %s", strings.Join(paths, " "), toPath))
res, err := mcli.Run(fmt.Sprintf("cp -r %s %s", strings.Join(opForm.Path, " "), opForm.ToPath))
if err != nil {
return mi, errors.New(res)
}
return mi, err
}
func (m *machineFileAppImpl) Mv(fileId uint64, toPath string, paths ...string) (*mcm.MachineInfo, error) {
mcli, err := m.GetMachineCli(fileId, paths...)
func (m *machineFileAppImpl) Mv(opForm *form.MachineFileOpForm) (*mcm.MachineInfo, error) {
if opForm.Protocol == entity.MachineProtocolRdp {
for _, pt := range opForm.Path {
// 获取文件名
filename := filepath.Base(pt)
topath := opForm.ToPath
if !strings.HasSuffix(topath, "/") {
topath += "/"
}
srcPath := m.GetRdpFilePath(opForm.MachineId, pt)
targetPath := m.GetRdpFilePath(opForm.MachineId, topath+filename)
os.Rename(srcPath, targetPath)
}
return nil, nil
}
mcli, err := m.GetMachineCli(opForm.FileId, opForm.Path...)
if err != nil {
return nil, err
}
mi := mcli.Info
res, err := mcli.Run(fmt.Sprintf("mv %s %s", strings.Join(paths, " "), toPath))
res, err := mcli.Run(fmt.Sprintf("mv %s %s", strings.Join(opForm.Path, " "), opForm.ToPath))
if err != nil {
return mi, errorx.NewBiz(res)
}
return mi, err
}
func (m *machineFileAppImpl) Rename(fileId uint64, oldname string, newname string) (*mcm.MachineInfo, error) {
mi, sftpCli, err := m.GetMachineSftpCli(fileId, newname)
func (m *machineFileAppImpl) Rename(renameForm *form.MachineFileRename) (*mcm.MachineInfo, error) {
oldname := renameForm.Oldname
newname := renameForm.Newname
if renameForm.Protocol == entity.MachineProtocolRdp {
oldname = m.GetRdpFilePath(renameForm.MachineId, renameForm.Oldname)
newname = m.GetRdpFilePath(renameForm.MachineId, renameForm.Newname)
return nil, os.Rename(oldname, newname)
}
mi, sftpCli, err := m.GetMachineSftpCli(renameForm.FileId, newname)
if err != nil {
return nil, err
}
@@ -322,3 +497,7 @@ func (m *machineFileAppImpl) GetMachineSftpCli(fid uint64, inputPath ...string)
return mcli.Info, sftpCli, nil
}
func (m *machineFileAppImpl) GetRdpFilePath(MachineId uint64, path string) string {
return fmt.Sprintf("%s/%d%s", config.GetMachine().GuacdFilePath, MachineId, path)
}

View File

@@ -16,6 +16,10 @@ type Machine struct {
TerminalRecPath string // 终端操作记录存储位置
UploadMaxFileSize int64 // 允许上传的最大文件size
TermOpSaveDays int // 终端记录保存天数
GuacdHost string // guacd服务地址 默认 127.0.0.1
GuacdPort int // guacd服务端口 默认 4822
GuacdFilePath string // guacd服务文件存储位置用于挂载RDP文件夹
GuacdRecPath string // guacd服务记录存储位置用于记录rdp操作记录
}
// 获取机器相关配置
@@ -43,5 +47,11 @@ func GetMachine() *Machine {
}
mc.UploadMaxFileSize = uploadMaxFileSize
mc.TermOpSaveDays = cast.ToIntD(jm["termOpSaveDays"], 30)
// guacd
mc.GuacdHost = cast.ToStringD(jm["guacdHost"], "127.0.0.1")
mc.GuacdPort = cast.ToIntD(jm["guacdPort"], 4822)
mc.GuacdFilePath = cast.ToStringD(jm["guacdFilePath"], "")
mc.GuacdRecPath = cast.ToStringD(jm["guacdRecPath"], "")
return mc
}

View File

@@ -11,6 +11,7 @@ type Machine struct {
Code string `json:"code"`
Name string `json:"name"`
Protocol int `json:"protocol"` // 连接协议 1.ssh 2.rdp
Ip string `json:"ip"` // IP地址
Port int `json:"port"` // 端口号
Username string `json:"username"` // 用户名
@@ -25,6 +26,9 @@ type Machine struct {
const (
MachineStatusEnable int8 = 1 // 启用状态
MachineStatusDisable int8 = -1 // 禁用状态
MachineProtocolSsh = 1
MachineProtocolRdp = 2
)
func (m *Machine) PwdEncrypt() error {

View File

@@ -8,6 +8,7 @@ type MachineQuery struct {
Status int8 `json:"status" form:"status"`
Ip string `json:"ip" form:"ip"` // IP地址
TagPath string `json:"tagPath" form:"tagPath"`
Ssh int8 `json:"ssh" form:"ssh"`
Codes []string
}

View 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),
}
}

View 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
}

View 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,
}
}

View 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()
}
}
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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()
}

View 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()
}

View File

@@ -29,6 +29,11 @@ func (m *machineRepoImpl) GetMachineList(condition *entity.MachineQuery, pagePar
Like("name", condition.Name).
In("code", condition.Codes)
// 只查询ssh服务器
if condition.Ssh == entity.MachineProtocolSsh {
qd.Eq("protocol", entity.MachineProtocolSsh)
}
if condition.Ids != "" {
// ,分割id转为id数组
qd.In("id", collx.ArrayMap[string, uint64](strings.Split(condition.Ids, ","), func(val string) uint64 {

View File

@@ -13,8 +13,9 @@ import (
// 机器信息
type MachineInfo struct {
Id uint64 `json:"id"`
Name string `json:"name"`
Id uint64 `json:"id"`
Name string `json:"name"`
Protocol int `json:"protocol"`
Ip string `json:"ip"` // IP地址
Port int `json:"-"` // 端口号

View File

@@ -50,5 +50,8 @@ func InitMachineRouter(router *gin.RouterGroup) {
// 终端连接
machines.GET(":machineId/terminal", m.WsSSH)
// 终端连接
machines.GET(":machineId/rdp", m.WsGuacamole)
}
}

View File

@@ -41,3 +41,16 @@ func ParseSize(sizeStr string) (int64, error) {
return bytes, nil
}
func FormatSize(size int64) string {
switch {
case size >= GB:
return fmt.Sprintf("%.2fGB", float64(size)/GB)
case size >= MB:
return fmt.Sprintf("%.2fMB", float64(size)/MB)
case size >= KB:
return fmt.Sprintf("%.2fKB", float64(size)/KB)
default:
return fmt.Sprintf("%d", size)
}
}

View File

@@ -8,7 +8,7 @@ INSERT INTO `t_sys_config` (`name`, `key`, `params`, `value`, `remark`, `permiss
ALTER TABLE t_db_instance CHANGE sid extra varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '连接需要的额外参数如oracle数据库需要sid等';
ALTER TABLE t_db_instance MODIFY COLUMN extra varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '连接需要的额外参数如oracle数据库需要sid等';
-- 数据迁移相关
CREATE TABLE `t_db_transfer_task` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`creator_id` bigint(20) NOT NULL COMMENT '创建人id',
@@ -49,6 +49,8 @@ INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `we
ALTER TABLE t_sys_log ADD extra varchar(5000) NULL;
ALTER TABLE t_sys_log MODIFY COLUMN resp text NULL;
-- rdp相关
ALTER TABLE `t_machine` ADD COLUMN `protocol` tinyint(2) NULL COMMENT '机器类型 1、SSH 2、RDP' AFTER `name`;
update `t_machine` set `protocol` = 1 where `protocol` is NULL;
delete from `t_sys_config` where `key` = 'MachineConfig';
INSERT INTO `t_sys_config` (`id`, `name`, `key`, `params`, `value`, `remark`, `permission`, `create_time`, `creator_id`, `creator`, `update_time`, `modifier_id`, `modifier`, `is_deleted`, `delete_time`) VALUES (12, '机器相关配置', 'MachineConfig', '[{\"name\":\"终端回放存储路径\",\"model\":\"terminalRecPath\",\"placeholder\":\"终端回放存储路径\"},{\"name\":\"uploadMaxFileSize\",\"model\":\"uploadMaxFileSize\",\"placeholder\":\"允许上传的最大文件大小(1MB、2GB等)\"},{\"model\":\"termOpSaveDays\",\"name\":\"终端记录保存时间\",\"placeholder\":\"终端记录保存时间(单位天)\"},{\"model\":\"guacdHost\",\"name\":\"guacd服务ip\",\"placeholder\":\"guacd服务ip默认 127.0.0.1\"},{\"name\":\"guacd服务端口\",\"model\":\"guacdPort\",\"placeholder\":\"guacd服务端口默认 4822\"},{\"model\":\"guacdFilePath\",\"name\":\"guacd服务文件存储位置\",\"placeholder\":\"guacd服务文件存储位置用于挂载RDP文件夹\"},{\"name\":\"guacd服务记录存储位置\",\"model\":\"guacdRecPath\",\"placeholder\":\"guacd服务记录存储位置用于记录rdp操作记录\"}]', '{\"terminalRecPath\":\"./rec\",\"uploadMaxFileSize\":\"1000MB\",\"termOpSaveDays\":\"30\",\"guacdHost\":\"127.0.0.1\",\"guacdPort\":\"4822\",\"guacdFilePath\":\"/Users/leozy/Desktop/developer/service/guacd/rdp-file\",\"guacdRecPath\":\"/Users/leozy/Desktop/developer/service/guacd/rdp-rec\"}', '机器相关配置,如终端回放路径等', 'all', '2023-07-13 16:26:44', 1, 'admin', '2024-04-04 13:11:52', 12, 'liuzongyang', 0, NULL);