diff --git a/docker-compose.yml b/docker-compose.yml index 4c13960..eeffc58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -version: "3.8" - services: mysql: - image: mysql:8.0 + image: mysql:5.7 container_name: gost-mysql restart: unless-stopped environment: diff --git a/go-gost/main.go b/go-gost/main.go index f5c5dce..bcd9c5a 100644 --- a/go-gost/main.go +++ b/go-gost/main.go @@ -4,7 +4,6 @@ import ( "context" "flag" "fmt" - "github.com/go-gost/gost/traffic" "log" _ "net/http/pprof" "os" @@ -13,6 +12,8 @@ import ( "strings" "sync" + "github.com/go-gost/gost/traffic" + "github.com/go-gost/core/logger" xlogger "github.com/go-gost/x/logger" "github.com/judwhite/go-svc" diff --git a/gost.sql b/gost.sql index 09068ea..d1dcae9 100644 --- a/gost.sql +++ b/gost.sql @@ -96,6 +96,7 @@ CREATE TABLE `tunnel` ( `out_ip_end` int(10) NOT NULL, `type` int(10) NOT NULL, `flow` int(10) NOT NULL, + `protocol` varchar(10) DEFAULT 'tls' COMMENT '协议类型: tls, tcp, mtls', `created_time` bigint(20) NOT NULL, `updated_time` bigint(20) NOT NULL, `status` int(10) NOT NULL diff --git a/install.sh b/install.sh index 828d7f8..d2d2d77 100644 --- a/install.sh +++ b/install.sh @@ -76,6 +76,9 @@ cat > "$INSTALL_DIR/gost.json" < impl // 隧道转发需要创建链和远程服务 if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - R chainResult = createChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort()); + R chainResult = createChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); if (chainResult.getCode() != 0) { updateForwardStatusToError(forward); return chainResult; } - R remoteResult = createRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward); + R remoteResult = createRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward, tunnel.getProtocol()); if (remoteResult.getCode() != 0) { updateForwardStatusToError(forward); return remoteResult; @@ -568,13 +568,13 @@ public class ForwardServiceImpl extends ServiceImpl impl // 隧道转发需要更新链和远程服务 if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - R chainResult = updateChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort()); + R chainResult = updateChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); if (chainResult.getCode() != 0) { updateForwardStatusToError(forward); return chainResult; } - R remoteResult = updateRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward); + R remoteResult = updateRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward, tunnel.getProtocol()); if (remoteResult.getCode() != 0) { updateForwardStatusToError(forward); return remoteResult; @@ -624,20 +624,20 @@ public class ForwardServiceImpl extends ServiceImpl impl /** * 创建链服务 */ - private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort) { + private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { String remoteAddr = outIp + ":" + outPort; - GostDto result = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret()); + GostDto result = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol); return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); } /** * 创建远程服务 */ - private R createRemoteService(Integer outNodeId, String serviceName, Forward forward) { + private R createRemoteService(Integer outNodeId, String serviceName, Forward forward, String protocol) { Node outNode = nodeService.getNodeById(outNodeId.longValue()); GostDto result = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(), serviceName, forward.getOutPort(), - forward.getRemoteAddr(), outNode.getSecret()); + forward.getRemoteAddr(), outNode.getSecret(), protocol); return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); } @@ -654,13 +654,13 @@ public class ForwardServiceImpl extends ServiceImpl impl /** * 更新链服务 */ - private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort) { + private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { // 创建新链 String remoteAddr = outIp + ":" + outPort; - GostDto createResult = GostUtil.UpdateChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret()); + GostDto createResult = GostUtil.UpdateChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol); if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { - createResult = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret()); + createResult = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol); } return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); } @@ -668,7 +668,7 @@ public class ForwardServiceImpl extends ServiceImpl impl /** * 更新远程服务 */ - private R updateRemoteService(Integer outNodeId, String serviceName, Forward forward) { + private R updateRemoteService(Integer outNodeId, String serviceName, Forward forward, String protocol) { Node outNode = nodeService.getNodeById(outNodeId.longValue()); // 创建新远程服务 GostDto createResult = GostUtil.UpdateRemoteService(outNode.getIp() + ":" + outNode.getPort(), @@ -677,7 +677,7 @@ public class ForwardServiceImpl extends ServiceImpl impl if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { createResult = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(), serviceName, forward.getOutPort(), - forward.getRemoteAddr(), outNode.getSecret()); + forward.getRemoteAddr(), outNode.getSecret(),protocol); } return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); } diff --git a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java index 1ed5496..0e9610a 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java @@ -257,6 +257,16 @@ public class TunnelServiceImpl extends ServiceImpl impleme // 设置流量计算类型 tunnel.setFlow(tunnelDto.getFlow()); + // 设置协议类型(仅隧道转发需要) + if (tunnelDto.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + // 隧道转发时,设置协议类型,默认为tls + String protocol = StrUtil.isNotBlank(tunnelDto.getProtocol()) ? tunnelDto.getProtocol() : "tls"; + tunnel.setProtocol(protocol); + } else { + // 端口转发时,协议类型为null + tunnel.setProtocol(null); + } + return tunnel; } @@ -310,6 +320,13 @@ public class TunnelServiceImpl extends ServiceImpl impleme return R.err(ERROR_SAME_NODE_NOT_ALLOWED); } + // 验证协议类型 + String protocol = tunnelDto.getProtocol(); + if (StrUtil.isNotBlank(protocol) && + !protocol.equals("tls") && !protocol.equals("tcp") && !protocol.equals("mtls")) { + return R.err("协议类型只能为tls、tcp或mtls"); + } + // 验证出口节点是否存在 Node outNode = nodeService.getById(tunnelDto.getOutNodeId()); if (outNode == null) { diff --git a/springboot-backend/src/main/resources/mapper/ForwardMapper.xml b/springboot-backend/src/main/resources/mapper/ForwardMapper.xml index 1b0cd17..cba0088 100644 --- a/springboot-backend/src/main/resources/mapper/ForwardMapper.xml +++ b/springboot-backend/src/main/resources/mapper/ForwardMapper.xml @@ -25,7 +25,8 @@ t.out_ip AS outIp, t.out_ip_sta AS outIpSta, t.out_ip_end AS outIpEnd, - t.type + t.type, + t.protocol FROM forward f LEFT JOIN @@ -57,7 +58,8 @@ t.out_ip AS outIp, t.out_ip_sta AS outIpSta, t.out_ip_end AS outIpEnd, - t.type + t.type, + t.protocol FROM forward f LEFT JOIN diff --git a/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml b/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml index 287f161..598cdfd 100644 --- a/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml +++ b/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml @@ -15,6 +15,7 @@ ut.flow_reset_time as flowResetTime, ut.exp_time as expTime, ut.speed_id as speedId, + ut.status as status, t.name as tunnelName, t.flow as tunnelFlow, t.in_ip as inIp, @@ -24,6 +25,7 @@ t.out_ip_sta as outIpSta, t.out_ip_end as outIpEnd, t.type, + t.protocol, sl.name as speedLimitName, sl.speed FROM user_tunnel ut diff --git a/vue-frontend/src/utils/clipboard.js b/vue-frontend/src/utils/clipboard.js new file mode 100644 index 0000000..c93e7c5 --- /dev/null +++ b/vue-frontend/src/utils/clipboard.js @@ -0,0 +1,263 @@ +/** + * 剪贴板工具类 + * 兼容现代浏览器和旧版浏览器的复制功能 + */ + +/** + * 检测是否为Safari浏览器 + * @returns {boolean} + */ +const isSafari = () => { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +}; + +/** + * Safari专用的复制方法 + * @param {string} text - 要复制的文本 + * @returns {Promise} + */ +const safariCopy = (text) => { + return new Promise((resolve, reject) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + + // Safari需要元素可见且可聚焦 + textArea.style.position = 'absolute'; + textArea.style.left = '-9999px'; + textArea.style.top = '-9999px'; + textArea.style.fontSize = '12pt'; + textArea.style.border = '0'; + textArea.style.padding = '0'; + textArea.style.margin = '0'; + textArea.style.outline = 'none'; + textArea.style.boxShadow = 'none'; + textArea.style.background = 'transparent'; + + document.body.appendChild(textArea); + + // Safari需要先聚焦 + textArea.focus(); + textArea.setSelectionRange(0, text.length); + + try { + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + resolve(true); + } else { + reject(new Error('Safari execCommand 复制失败')); + } + } catch (error) { + document.body.removeChild(textArea); + reject(error); + } + }); +}; + +/** + * 复制文本到剪贴板 + * @param {string} text - 要复制的文本 + * @param {string} label - 复制内容的标签,用于提示消息(可选) + * @returns {Promise} - 返回复制是否成功 + */ +export const copyToClipboard = async (text, label = '内容') => { + if (!text) { + throw new Error('复制内容不能为空'); + } + + try { + // Safari浏览器特殊处理 + if (isSafari()) { + // 优先尝试现代API,但Safari对安全上下文要求严格 + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (clipboardError) { + console.warn('Safari Clipboard API失败,降级到execCommand:', clipboardError); + // 降级到Safari专用方法 + await safariCopy(text); + return true; + } + } else { + // 直接使用Safari专用方法 + await safariCopy(text); + return true; + } + } + + // 方法1: 使用现代浏览器的 Clipboard API(非Safari浏览器首选) + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + + // 方法2: 使用 document.execCommand(通用兼容性方案) + const textArea = document.createElement('textarea'); + textArea.value = text; + + // 设置样式,使其不可见且不影响页面布局 + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + textArea.style.opacity = '0'; + textArea.style.pointerEvents = 'none'; + textArea.style.zIndex = '-1'; + + // 添加到 DOM + document.body.appendChild(textArea); + + // 选择文本 + textArea.focus(); + textArea.select(); + + // 兼容iOS设备的选择 + if (navigator.userAgent.match(/ipad|iphone/i)) { + textArea.setSelectionRange(0, text.length); + } + + // 尝试复制 + const successful = document.execCommand('copy'); + + // 清理 DOM + document.body.removeChild(textArea); + + if (successful) { + return true; + } else { + throw new Error('document.execCommand 复制失败'); + } + + } catch (error) { + console.error('复制失败:', error); + throw new Error(`复制${label}失败`); + } +}; + +/** + * 复制文本到剪贴板(带成功提示) + * 适用于 Vue 组件中使用,会显示 Element UI 的消息提示 + * @param {string} text - 要复制的文本 + * @param {string} label - 复制内容的标签 + * @param {object} vm - Vue 组件实例(用于调用 $message) + * @returns {Promise} + */ +export const copyWithMessage = async (text, label = '内容', vm) => { + try { + await copyToClipboard(text, label); + if (vm && vm.$message) { + vm.$message.success(`${label}已复制到剪贴板`); + } + } catch (error) { + console.error('复制失败详情:', error); + + // 为Safari提供更详细的错误信息 + let errorMessage = error.message || `复制${label}失败,请手动复制`; + + if (isSafari() && !window.isSecureContext) { + errorMessage = `复制失败:Safari需要HTTPS环境。请手动复制${label}`; + } else if (isSafari() && error.message.includes('Safari')) { + errorMessage = `Safari复制失败,请手动选择并复制${label}`; + } + + if (vm && vm.$message) { + vm.$message.error(errorMessage); + } else { + console.error(errorMessage); + } + } +}; + +/** + * 检查浏览器是否支持剪贴板操作 + * @returns {object} - 返回支持的功能信息 + */ +export const getClipboardSupport = () => { + const hasClipboardAPI = !!(navigator.clipboard && window.isSecureContext); + const hasExecCommand = !!document.execCommand; + const browserInfo = { + isSafari: isSafari(), + isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), + isSecureContext: window.isSecureContext + }; + + return { + modern: hasClipboardAPI, // 是否支持现代 Clipboard API + legacy: hasExecCommand, // 是否支持 execCommand + supported: hasClipboardAPI || hasExecCommand, // 是否支持任何复制方法 + browser: browserInfo, // 浏览器信息 + recommendedMethod: browserInfo.isSafari ? 'safari-optimized' : + hasClipboardAPI ? 'clipboard-api' : 'exec-command' + }; +}; + +/** + * 显示手动复制提示(当自动复制失败时使用) + * @param {string} text - 要复制的文本 + * @param {string} label - 复制内容的标签 + * @param {object} vm - Vue 组件实例 + */ +export const showManualCopyDialog = (text, label, vm) => { + if (vm && vm.$alert) { + const isSafariBrowser = isSafari(); + const message = isSafariBrowser + ? `Safari浏览器限制,无法自动复制。请手动选择并复制以下${label}:\n\n${text}` + : `请手动复制以下${label}:\n\n${text}`; + + vm.$alert( + message, + isSafariBrowser ? 'Safari复制提示' : '复制提示', + { + confirmButtonText: '已复制', + type: 'info', + dangerouslyUseHTMLString: false, + customClass: 'copy-dialog' + } + ); + } +}; + +/** + * 带降级方案的复制方法 + * 如果自动复制失败,会显示手动复制对话框 + * @param {string} text - 要复制的文本 + * @param {string} label - 复制内容的标签 + * @param {object} vm - Vue 组件实例 + * @returns {Promise} + */ +export const copyWithFallback = async (text, label = '内容', vm) => { + // 如果是Safari并且不在安全上下文中,直接显示手动复制对话框 + if (isSafari() && !window.isSecureContext) { + console.warn('Safari非HTTPS环境,直接使用手动复制'); + showManualCopyDialog(text, label, vm); + return; + } + + try { + await copyToClipboard(text, label); + if (vm && vm.$message) { + vm.$message.success(`${label}已复制到剪贴板`); + } + } catch (error) { + console.error('自动复制失败,显示手动复制对话框:', error); + + // 如果是Safari或自动复制失败,显示手动复制对话框 + if (isSafari() || error.message.includes('失败')) { + showManualCopyDialog(text, label, vm); + } else { + // 其他错误正常提示 + if (vm && vm.$message) { + vm.$message.error(error.message || `复制${label}失败`); + } + } + } +}; + +export default { + copyToClipboard, + copyWithMessage, + copyWithFallback, + showManualCopyDialog, + getClipboardSupport +}; \ No newline at end of file diff --git a/vue-frontend/src/views/Forward.vue b/vue-frontend/src/views/Forward.vue index 5b6f125..0676a67 100644 --- a/vue-frontend/src/views/Forward.vue +++ b/vue-frontend/src/views/Forward.vue @@ -92,13 +92,48 @@
-
- 入口地址: - {{ forward.inIp}}:{{ forward.inPort }} +
+
+
+ 入口地址: + +
+
+ + {{ formatInAddress(forward.inIp, forward.inPort) }} + +
+
-
- 目标地址: - {{ forward.remoteAddr}} +
+ +
+
+
+
+ 目标地址: + +
+
+ + {{ forward.remoteAddr}} + +
+
@@ -216,6 +251,7 @@ import { pauseForwardService, resumeForwardService } from "@/api"; +import { copyWithMessage } from "@/utils/clipboard"; export default { name: "Forward", @@ -570,6 +606,25 @@ export default { return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }, + // 格式化入口地址,IPv6需要用[]包裹 + formatInAddress(ip, port) { + if (!ip || !port) return ''; + + // 检查是否为IPv6地址 + if (ip.includes(':')) { + // IPv6地址需要用方括号包裹 + return `[${ip}]:${port}`; + } else { + // IPv4地址直接拼接 + return `${ip}:${port}`; + } + }, + + // 复制到剪贴板 + async copyToClipboard(text, label = '内容') { + await copyWithMessage(text, label, this); + }, + // 获取状态类型 getStatusType(status) { switch (parseInt(status)) { @@ -806,10 +861,12 @@ export default { flex: 1; display: flex; flex-direction: column; + min-width: 0; /* 确保flex子元素可以收缩 */ } .info-item.full-width { flex: 1; + min-width: 0; /* 确保flex子元素可以收缩 */ } .info-item:not(:last-child) { @@ -900,5 +957,80 @@ export default { color: #c0c4cc !important; } +/* 地址项样式 */ +.address-item { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; /* 确保flex子元素可以收缩 */ +} + +.label-with-copy { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.address-value { + width: 100%; + min-width: 0; /* 确保flex子元素可以收缩 */ +} + +.address-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + max-width: 100%; /* 确保不会超出容器 */ + box-sizing: border-box; +} + +.copy-btn { + flex-shrink: 0; + padding: 2px 6px !important; + margin: 0 !important; + min-width: auto !important; + height: 20px !important; + opacity: 0.6; + transition: opacity 0.3s ease; +} + +.copy-btn:hover { + opacity: 1; + color: #409EFF !important; +} + +.copy-btn .el-icon-copy-document { + font-size: 12px; +} + +/* 地址项悬停效果 */ +.address-item:hover .copy-btn { + opacity: 1; +} + +/* 复制对话框样式 */ +::v-deep .copy-dialog .el-message-box__content { + word-break: break-all; + user-select: text; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; +} + +::v-deep .copy-dialog .el-message-box__message p { + white-space: pre-wrap; + font-family: monospace; + font-size: 12px; + background: #f5f5f5; + padding: 10px; + border-radius: 4px; + margin: 10px 0; + user-select: text; + -webkit-user-select: text; +} + \ No newline at end of file diff --git a/vue-frontend/src/views/Index.vue b/vue-frontend/src/views/Index.vue index 64be002..431ac5b 100644 --- a/vue-frontend/src/views/Index.vue +++ b/vue-frontend/src/views/Index.vue @@ -834,6 +834,10 @@ export default { background: #ebeef5; border-radius: 4px; min-width: 120px; + width: 100%; + white-space: nowrap; /* 不换行 */ + overflow: hidden; /* 超出隐藏 */ + text-overflow: ellipsis; /* 超出显示省略号 */ } .address-source { diff --git a/vue-frontend/src/views/Tunnel.vue b/vue-frontend/src/views/Tunnel.vue index f2630a4..f1c99a3 100644 --- a/vue-frontend/src/views/Tunnel.vue +++ b/vue-frontend/src/views/Tunnel.vue @@ -44,6 +44,11 @@
端口转发 隧道转发 + + {{ tunnel.protocol.toUpperCase() }} + 单向计算 双向计算
@@ -246,6 +251,17 @@