feat: Change the local connection mode of the terminal (#8409)

This commit is contained in:
ssongliu 2025-04-16 17:37:07 +08:00 committed by GitHub
parent 0ee7e1d675
commit 9ffc601cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 138 additions and 171 deletions

View file

@ -18,6 +18,55 @@ import (
"github.com/pkg/errors"
)
func (b *BaseApi) WsSSH(c *gin.Context) {
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
global.LOG.Errorf("gin context http handler failed, err: %v", err)
return
}
defer wsConn.Close()
if global.CONF.Base.IsDemo {
if wshandleError(wsConn, errors.New(" demo server, prohibit this operation!")) {
return
}
}
cols, err := strconv.Atoi(c.DefaultQuery("cols", "80"))
if wshandleError(wsConn, errors.WithMessage(err, "invalid param cols in request")) {
return
}
rows, err := strconv.Atoi(c.DefaultQuery("rows", "40"))
if wshandleError(wsConn, errors.WithMessage(err, "invalid param rows in request")) {
return
}
name, err := loadExecutor()
if wshandleError(wsConn, err) {
return
}
slave, err := terminal.NewCommand(name)
if wshandleError(wsConn, err) {
return
}
defer slave.Close()
tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false)
if wshandleError(wsConn, err) {
return
}
quitChan := make(chan bool, 3)
tty.Start(quitChan)
go slave.Wait(quitChan)
<-quitChan
global.LOG.Info("websocket finished")
if wshandleError(wsConn, err) {
return
}
}
func (b *BaseApi) ContainerWsSSH(c *gin.Context) {
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
@ -42,7 +91,7 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) {
}
source := c.Query("source")
var containerID string
var initCmd string
var initCmd []string
switch source {
case "redis":
containerID, initCmd, err = loadRedisInitCmd(c)
@ -59,11 +108,11 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) {
return
}
pidMap := loadMapFromDockerTop(containerID)
slave, err := terminal.NewCommand("clear && " + initCmd)
slave, err := terminal.NewCommand("docker", initCmd...)
if wshandleError(wsConn, err) {
return
}
defer killBash(containerID, strings.ReplaceAll(initCmd, fmt.Sprintf("docker exec -it %s ", containerID), ""), pidMap)
defer killBash(containerID, strings.ReplaceAll(strings.Join(initCmd, " "), fmt.Sprintf("exec -it %s ", containerID), ""), pidMap)
defer slave.Close()
tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false)
@ -83,62 +132,63 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) {
}
}
func loadRedisInitCmd(c *gin.Context) (string, string, error) {
func loadRedisInitCmd(c *gin.Context) (string, []string, error) {
name := c.Query("name")
from := c.Query("from")
commands := "redis-cli"
commands := []string{"exec", "-it"}
database, err := databaseService.Get(name)
if err != nil {
return "", "", fmt.Errorf("no such database in db, err: %v", err)
return "", nil, fmt.Errorf("no such database in db, err: %v", err)
}
if from == "local" {
redisInfo, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Name: name, Type: "redis"})
if err != nil {
return "", "", fmt.Errorf("no such app in db, err: %v", err)
return "", nil, fmt.Errorf("no such app in db, err: %v", err)
}
name = redisInfo.ContainerName
commands = append(commands, []string{name, "redis-cli"}...)
if len(database.Password) != 0 {
commands = "redis-cli -a " + database.Password + " --no-auth-warning"
commands = append(commands, []string{"-a", database.Password, "--no-auth-warning"}...)
}
} else {
commands = fmt.Sprintf("redis-cli -h %s -p %v", database.Address, database.Port)
if len(database.Password) != 0 {
commands = fmt.Sprintf("redis-cli -h %s -p %v -a %s --no-auth-warning", database.Address, database.Port, database.Password)
}
name = "1Panel-redis-cli-tools"
commands = append(commands, []string{name, "redis-cli", "-h", database.Address, "-p", fmt.Sprintf("%v", database.Port)}...)
if len(database.Password) != 0 {
commands = append(commands, []string{"-a", database.Password, "--no-auth-warning"}...)
}
}
return name, fmt.Sprintf("docker exec -it %s %s", name, commands), nil
return name, commands, nil
}
func loadOllamaInitCmd(c *gin.Context) (string, string, error) {
func loadOllamaInitCmd(c *gin.Context) (string, []string, error) {
name := c.Query("name")
if cmd.CheckIllegal(name) {
return "", "", fmt.Errorf("ollama model %s contains illegal characters", name)
return "", nil, fmt.Errorf("ollama model %s contains illegal characters", name)
}
ollamaInfo, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Name: "", Type: "ollama"})
if err != nil {
return "", "", fmt.Errorf("no such app in db, err: %v", err)
return "", nil, fmt.Errorf("no such app in db, err: %v", err)
}
containerName := ollamaInfo.ContainerName
return containerName, fmt.Sprintf("docker exec -it %s ollama run %s", containerName, name), nil
return containerName, []string{"exec", "-it", containerName, "ollama", "run", name}, nil
}
func loadContainerInitCmd(c *gin.Context) (string, string, error) {
func loadContainerInitCmd(c *gin.Context) (string, []string, error) {
containerID := c.Query("containerid")
command := c.Query("command")
user := c.Query("user")
if cmd.CheckIllegal(user, containerID, command) {
return "", "", fmt.Errorf("the command contains illegal characters. command: %s, user: %s, containerID: %s", command, user, containerID)
return "", nil, fmt.Errorf("the command contains illegal characters. command: %s, user: %s, containerID: %s", command, user, containerID)
}
if len(command) == 0 || len(containerID) == 0 {
return "", "", fmt.Errorf("error param of command: %s or containerID: %s", command, containerID)
return "", nil, fmt.Errorf("error param of command: %s or containerID: %s", command, containerID)
}
command = fmt.Sprintf("docker exec -it %s %s", containerID, command)
commands := []string{"exec", "-it", containerID, command}
if len(user) != 0 {
command = fmt.Sprintf("docker exec -it -u %s %s %s", user, containerID, command)
commands = []string{"exec", "-it", "-u", user, containerID, command}
}
return containerID, command, nil
return containerID, commands, nil
}
func wshandleError(ws *websocket.Conn, err error) bool {
@ -204,3 +254,12 @@ var upGrader = websocket.Upgrader{
return true
},
}
func loadExecutor() (string, error) {
std, err := cmd.RunDefaultWithStdoutBashC("echo $SHELL")
if err != nil {
return "", fmt.Errorf("load default executor failed, err: %s", std)
}
return strings.ReplaceAll(std, "\n", ""), nil
}

View file

@ -46,5 +46,7 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) {
hostRouter.POST("/tool/supervisor/process", baseApi.OperateProcess)
hostRouter.GET("/tool/supervisor/process", baseApi.GetProcess)
hostRouter.POST("/tool/supervisor/process/file", baseApi.GetProcessFile)
hostRouter.GET("/exec", baseApi.WsSSH)
}
}

View file

@ -25,22 +25,20 @@ type LocalCommand struct {
pty *os.File
}
func NewCommand(initCmd string) (*LocalCommand, error) {
cmd := exec.Command("bash")
func NewCommand(name string, arg ...string) (*LocalCommand, error) {
cmd := exec.Command(name, arg...)
if term := os.Getenv("TERM"); term != "" {
cmd.Env = append(os.Environ(), "TERM="+term)
} else {
cmd.Env = append(os.Environ(), "TERM=xterm")
}
homeDir, _ := os.UserHomeDir()
cmd.Dir = homeDir
pty, err := pty.Start(cmd)
if err != nil {
return nil, errors.Wrapf(err, "failed to start command")
}
if len(initCmd) != 0 {
time.Sleep(100 * time.Millisecond)
_, _ = pty.Write([]byte(initCmd + "\n"))
}
lcmd := &LocalCommand{
closeSignal: DefaultCloseSignal,
@ -99,4 +97,5 @@ func (lcmd *LocalCommand) Wait(quitChan chan bool) {
global.LOG.Errorf("ssh session wait failed, err: %v", err)
setQuit(quitChan)
}
setQuit(quitChan)
}

View file

@ -79,16 +79,9 @@ func (u *HostService) TestLocalConn(id uint) bool {
host model.Host
err error
)
if id == 0 {
host, err = hostRepo.Get(hostRepo.WithByAddr("127.0.0.1"))
if err != nil {
return false
}
} else {
host, err = hostRepo.Get(repo.WithByID(id))
if err != nil {
return false
}
host, err = hostRepo.Get(repo.WithByID(id))
if err != nil {
return false
}
var connInfo ssh.ConnInfo
if err := copier.Copy(&connInfo, &host); err != nil {
@ -206,11 +199,7 @@ func (u *HostService) SearchForTree(search dto.SearchForTree) ([]dto.HostTree, e
func (u *HostService) GetHostByID(id uint) (*dto.HostInfo, error) {
var item dto.HostInfo
var host model.Host
if id == 0 {
host, _ = hostRepo.Get(repo.WithByName("local"))
} else {
host, _ = hostRepo.Get(repo.WithByID(id))
}
host, _ = hostRepo.Get(repo.WithByID(id))
if host.ID == 0 {
return nil, buserr.New("ErrRecordNotFound")
}
@ -246,9 +235,6 @@ func (u *HostService) GetHostByID(id uint) (*dto.HostInfo, error) {
}
func (u *HostService) Create(req dto.HostOperate) (*dto.HostInfo, error) {
if req.Name == "local" {
return nil, buserr.New("ErrRecordExist")
}
hostItem, _ := hostRepo.Get(hostRepo.WithByAddr(req.Addr), hostRepo.WithByUser(req.User), hostRepo.WithByPort(req.Port))
if hostItem.ID != 0 {
return nil, buserr.New("ErrRecordExist")
@ -305,9 +291,6 @@ func (u *HostService) Delete(ids []uint) error {
if host.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
if host.Name == "local" {
return errors.New("the local connection information cannot be deleted!")
}
if err := hostRepo.Delete(repo.WithByID(id)); err != nil {
return err
}

View file

@ -1092,7 +1092,6 @@ const message = {
terminal: {
local: 'Local',
localHelper: 'The `local` name is used only for system local identification',
connLocalErr: 'Unable to automatically authenticate, please fill in the local server login information!',
testConn: 'Test connection',
saveAndConn: 'Save and Connect',
connTestOk: 'Connection information available',

View file

@ -1050,7 +1050,6 @@ const message = {
terminal: {
local: 'ローカル',
localHelper: 'ローカル名はシステムのローカル識別にのみ使用されます',
connLocalErr: '自動的に認証できない場合はローカルサーバーのログイン情報を入力してください',
testConn: 'テスト接続',
saveAndConn: '保存して接続します',
connTestOk: '利用可能な接続情報',

View file

@ -1043,7 +1043,6 @@ const message = {
terminal: {
local: '로컬',
localHelper: '로컬 이름은 시스템 로컬 식별에만 사용됩니다.',
connLocalErr: '자동 인증에 실패했습니다. 로컬 서버 로그인 정보를 입력해주세요.',
testConn: '연결 테스트',
saveAndConn: '저장 연결',
connTestOk: '연결 정보가 유효합니다.',

View file

@ -1079,7 +1079,6 @@ const message = {
terminal: {
local: 'Tempatan',
localHelper: 'Nama tempatan hanya digunakan untuk pengenalan sistem tempatan.',
connLocalErr: 'Tidak dapat mengesahkan secara automatik, sila isi maklumat log masuk pelayan tempatan.',
testConn: 'Uji sambungan',
saveAndConn: 'Simpan dan sambung',
connTestOk: 'Maklumat sambungan tersedia',

View file

@ -1069,8 +1069,6 @@ const message = {
terminal: {
local: 'Local',
localHelper: 'O nome local é usado apenas para identificação local do sistema.',
connLocalErr:
'Não foi possível autenticar automaticamente, por favor, preencha as informações de login do servidor local.',
testConn: 'Testar conexão',
saveAndConn: 'Salvar e conectar',
connTestOk: 'Informações de conexão disponíveis',

View file

@ -1074,8 +1074,6 @@ const message = {
terminal: {
local: 'Локальный',
localHelper: 'Локальное имя используется только для локальной идентификации системы.',
connLocalErr:
'Невозможно автоматически аутентифицироваться, пожалуйста, заполните информацию для входа на локальный сервер.',
testConn: 'Проверить подключение',
saveAndConn: 'Сохранить и подключиться',
connTestOk: 'Информация о подключении доступна',

View file

@ -1040,7 +1040,6 @@ const message = {
terminal: {
local: '本機',
localHelper: 'local 名稱僅用於系統本機標識',
connLocalErr: '無法自動認證請填寫本地服務器的登錄信息',
testConn: '連接測試',
saveAndConn: '保存並連接',
connTestOk: '連接信息可用',

View file

@ -1038,7 +1038,6 @@ const message = {
terminal: {
local: '本机',
localHelper: 'local 名称仅用于系统本机标识',
connLocalErr: '无法自动认证请填写本地服务器的登录信息',
testConn: '连接测试',
saveAndConn: '保存并连接',
connTestOk: '连接信息可用',

View file

@ -34,7 +34,7 @@
:data="data"
@search="search"
>
<el-table-column type="selection" :selectable="selectable" fix />
<el-table-column type="selection" fix />
<el-table-column :label="$t('terminal.ip')" prop="addr" fix />
<el-table-column :label="$t('commons.login.username')" show-overflow-tooltip prop="user" />
<el-table-column :label="$t('commons.table.port')" prop="port" />
@ -106,9 +106,6 @@ const acceptParams = () => {
search();
};
function selectable(row) {
return row.name !== 'local';
}
const dialogRef = ref();
const onOpenDialog = async (
title: string,
@ -174,9 +171,6 @@ const buttons = [
},
{
label: i18n.global.t('commons.button.delete'),
disabled: (row: any) => {
return row.name === 'local';
},
click: (row: Host.Host) => {
onBatchDelete(row);
},

View file

@ -61,8 +61,7 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('commons.table.title')" prop="name">
<el-tag v-if="itemName === 'local'">local</el-tag>
<el-input v-else clearable v-model="dialogData.rowData!.name" />
<el-input clearable v-model="dialogData.rowData!.name" />
</el-form-item>
<el-form-item :label="$t('commons.table.description')" prop="description">
<el-input clearable type="textarea" v-model="dialogData.rowData!.description" />
@ -103,12 +102,10 @@ const drawerVisible = ref(false);
const dialogData = ref<DialogProps>({
title: '',
});
const itemName = ref();
const groupList = ref();
const acceptParams = (params: DialogProps): void => {
dialogData.value = params;
itemName.value = params.rowData.name;
title.value = i18n.global.t('commons.button.' + dialogData.value.title);
drawerVisible.value = true;
loadGroups();
@ -126,14 +123,7 @@ const rules = reactive({
port: [Rules.requiredInput, Rules.port],
user: [Rules.requiredInput],
authMode: [Rules.requiredSelect],
name: [{ validator: checkName, trigger: 'blur' }],
});
function checkName(rule: any, value: any, callback: any) {
if (value === 'local' && dialogData.value.title !== 'edit') {
return callback(new Error(i18n.global.t('terminal.localHelper')));
}
callback();
}
const loadGroups = async () => {
const res = await getGroupList('host');

View file

@ -1,14 +1,6 @@
<template>
<DrawerPro v-model="dialogVisible" :header="$t('terminal.addHost')" @close="handleClose" size="large">
<el-form ref="hostRef" label-width="100px" label-position="top" :model="hostInfo" :rules="rules">
<el-alert
v-if="isLocal"
class="common-prompt"
center
:title="$t('terminal.connLocalErr')"
:closable="false"
type="warning"
/>
<el-form-item :label="$t('terminal.ip')" prop="addr">
<el-input @change="isOK = false" clearable v-model.trim="hostInfo.addr" />
</el-form-item>
@ -55,8 +47,7 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('commons.table.title')" prop="name">
<el-tag v-if="isLocal">local</el-tag>
<el-input v-else clearable v-model="hostInfo.name" />
<el-input clearable v-model="hostInfo.name" />
</el-form-item>
<el-form-item :label="$t('commons.table.description')" prop="description">
<el-input clearable v-model="hostInfo.description" />
@ -80,7 +71,7 @@
import { ElForm } from 'element-plus';
import { Host } from '@/api/interface/host';
import { Rules } from '@/global/form-rules';
import { addHost, editHost, getHostByID, testByInfo } from '@/api/modules/terminal';
import { addHost, testByInfo } from '@/api/modules/terminal';
import i18n from '@/lang';
import { reactive, ref } from 'vue';
import { MsgError, MsgSuccess } from '@/utils/message';
@ -116,21 +107,9 @@ const rules = reactive({
authMode: [Rules.requiredSelect],
password: [Rules.requiredInput],
privateKey: [Rules.requiredInput],
name: [{ validator: checkName, trigger: 'blur' }],
});
function checkName(rule: any, value: any, callback: any) {
if (value === 'local' && !isLocal.value) {
return callback(new Error(i18n.global.t('terminal.localHelper')));
}
callback();
}
const isLocal = ref(false);
interface DialogProps {
isLocal: boolean;
}
const acceptParams = (props: DialogProps) => {
isLocal.value = props.isLocal;
const acceptParams = () => {
loadGroups();
dialogVisible.value = true;
};
@ -150,29 +129,7 @@ const loadGroups = async () => {
break;
}
}
if (isLocal.value) {
loadLocal();
} else {
setDefault();
}
};
const loadLocal = async () => {
await getHostByID(0)
.then((res) => {
hostInfo.id = res.data.id || 0;
hostInfo.addr = res.data.addr || '';
hostInfo.name = 'local';
hostInfo.groupID = res.data.groupID || defaultGroup.value;
hostInfo.port = res.data.port || 22;
hostInfo.user = res.data.user || 'root';
hostInfo.authMode = res.data.authMode || 'password';
hostInfo.password = res.data.password || '';
hostInfo.privateKey = res.data.privateKey || '';
hostInfo.description = res.data.description || '';
})
.catch(() => {
setDefault();
});
setDefault();
};
const setDefault = () => {
@ -204,19 +161,13 @@ const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
});
break;
case 'saveAndConn':
let res;
if (hostInfo.id == 0) {
res = await addHost(hostInfo);
} else {
res = await editHost(hostInfo);
}
const res = await addHost(hostInfo);
dialogVisible.value = false;
let title = res.data.user + '@' + res.data.addr + ':' + res.data.port;
if (res.data.name.length !== 0) {
title = res.data.name + '-' + title;
}
let isLocal = hostInfo.name === 'local';
emit('on-conn-terminal', title, res.data.id, isLocal);
emit('on-conn-terminal', title, res.data.id);
emit('load-host-tree');
break;
}

View file

@ -103,7 +103,7 @@
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span v-if="node.label === 'default'">{{ $t('commons.table.default') }}</span>
<span v-if="node.label === 'Default'">{{ $t('commons.table.default') }}</span>
<div v-else>
<span v-if="node.label.length <= 25">
<a @click="onClickConn(node, data)">{{ node.label }}</a>
@ -173,8 +173,6 @@ const loadTooltip = () => {
return i18n.global.t('commons.button.' + (globalStore.isFullScreen ? 'quitFullscreen' : 'fullscreen'));
};
const localHostID = ref();
let timer: NodeJS.Timer | null = null;
const terminalValue = ref();
const terminalTabs = ref([]) as any;
@ -209,26 +207,7 @@ const acceptParams = async () => {
timer = setInterval(() => {
syncTerminal();
}, 1000 * 5);
for (let gIndex = 0; gIndex < hostTree.value.length; gIndex++) {
if (!hostTree.value[gIndex].children) {
continue;
}
for (let i = 0; i < hostTree.value[gIndex].children.length; i++) {
if (hostTree.value[gIndex].children[i].label.startsWith('local - ')) {
localHostID.value = hostTree.value[gIndex].children[i].id;
hostTree.value[gIndex].children.splice(i, 1);
if (hostTree.value[gIndex].children.length === 0) {
hostTree.value.splice(gIndex, 1);
}
if (terminalTabs.value.length !== 0) {
return;
}
onNewLocal();
return;
}
}
}
onNewLocal();
if (!mobile.value) {
screenfull.on('change', () => {
globalStore.isFullScreen = screenfull.isFullscreen;
@ -329,17 +308,34 @@ function beforeLeave(activeName: string) {
}
const onNewSsh = () => {
dialogRef.value!.acceptParams({ isLocal: false });
dialogRef.value!.acceptParams();
};
const onNewLocal = () => {
onConnTerminal(i18n.global.t('terminal.localhost'), localHostID.value, false);
terminalTabs.value.push({
index: tabIndex,
title: i18n.global.t('terminal.localhost'),
wsID: 0,
status: 'online',
latency: 0,
});
terminalValue.value = tabIndex;
nextTick(() => {
ctx.refs[`t-${terminalValue.value}`] &&
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
endpoint: '/api/v2/hosts/exec',
initCmd: initCmd.value,
error: '',
});
initCmd.value = '';
});
tabIndex++;
};
const onClickConn = (node: Node, data: Tree) => {
if (node.level === 1) {
return;
}
onConnTerminal(node.label, data.id, false);
onConnTerminal(node.label, data.id);
};
const onReconnect = async (item: any) => {
@ -347,6 +343,20 @@ const onReconnect = async (item: any) => {
ctx.refs[`t-${item.index}`] && ctx.refs[`t-${item.index}`][0].onClose();
}
item.Refresh = !item.Refresh;
if (item.wsID === 0) {
nextTick(() => {
ctx.refs[`t-${item.index}`] &&
ctx.refs[`t-${item.index}`][0].acceptParams({
endpoint: '/api/v2/hosts/exec',
initCmd: initCmd.value,
error: '',
});
initCmd.value = '';
});
syncTerminal();
return;
}
const res = await testByID(item.wsID);
nextTick(() => {
ctx.refs[`t-${item.index}`] &&
@ -359,16 +369,8 @@ const onReconnect = async (item: any) => {
syncTerminal();
};
const onConnTerminal = async (title: string, wsID: number, isLocal?: boolean) => {
const onConnTerminal = async (title: string, wsID: number) => {
const res = await testByID(wsID);
if (isLocal) {
for (const tab of terminalTabs.value) {
if (tab.title === i18n.global.t('terminal.localhost')) {
onReconnect(tab);
}
}
return;
}
terminalTabs.value.push({
index: tabIndex,
title: title,
@ -377,9 +379,6 @@ const onConnTerminal = async (title: string, wsID: number, isLocal?: boolean) =>
latency: 0,
});
terminalValue.value = tabIndex;
if (!res.data && title === i18n.global.t('terminal.localhost')) {
dialogRef.value!.acceptParams({ isLocal: true });
}
nextTick(() => {
ctx.refs[`t-${terminalValue.value}`] &&
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({