Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions agent/app/api/v2/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
// @Summary Ws local terminal
// @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
Expand All @@ -43,6 +44,7 @@ func (b *BaseApi) WsLocalTerminal(c *gin.Context) {
// @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param title query string false "session title shown in the session list"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
Expand Down Expand Up @@ -146,13 +148,14 @@ func (b *BaseApi) runSSHSession(c *gin.Context, kind string, connect func() (*ss
hostID, _ = strconv.Atoi(c.DefaultQuery("id", "0"))
}
opts := terminal.SessionOptions{
Identity: identity,
Kind: kind,
Title: sanitizeTerminalTitle(c.Query("title")),
HostID: uint(max(hostID, 0)),
Cols: cols,
Rows: rows,
InitCmd: command,
Identity: identity,
Kind: kind,
Title: sanitizeTerminalTitle(c.Query("title")),
Persistent: c.Query("terminalPersistent") == "true",
HostID: uint(max(hostID, 0)),
Cols: cols,
Rows: rows,
InitCmd: command,
}
err := terminal.Serve(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*gossh.Client, error) {
client, err := connect()
Expand Down
60 changes: 18 additions & 42 deletions agent/utils/terminal/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@ import (
gossh "golang.org/x/crypto/ssh"
)

// Lifetime rules. A session outlives its websocket:
// - the client closes its websocket with 1000 -> the shell is closed at once
// - the websocket drops any other way (browser tab closed, network) -> the
// shell waits graceTimeout for a reattach, then is closed
//
// ponytail: all fixed; promote to settings only if someone asks.
const (
graceTimeout = 30 * time.Minute
revalidateInterval = 60 * time.Second
Expand All @@ -31,48 +25,45 @@ const (
pumpInterval = 60 * time.Millisecond
)

// Websocket close codes of the session protocol; the frontend switches on them.
const (
// CloseCodeSessionNotFound: the session is gone or not the caller's; do not retry.
CloseCodeSessionNotFound = 4404
// CloseCodeAttachedElsewhere: a newer websocket took over the session.
CloseCodeSessionNotFound = 4404
CloseCodeAttachedElsewhere = 4409
CloseCodeRevalidate = 4410
)

var errSessionClosed = errors.New("terminal session is closed")

// SessionOptions describes a session that is about to be created.
type SessionOptions struct {
Identity Identity
Kind string
Target string
Title string
HostID uint // 0 = local shell
Cols int
Rows int
InitCmd string
Identity Identity
Kind string
Target string
Title string
Persistent bool
HostID uint // 0 = local shell
Cols int
Rows int
InitCmd string
}

// Info is the client visible snapshot of a session.
type Info struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Persistent bool `json:"persistent"`
HostID uint `json:"hostId"`
Attached bool `json:"attached"`
CreatedAt time.Time `json:"createdAt"`
DetachedAt time.Time `json:"detachedAt"` // zero while attached
}

// Session owns one shell; a websocket is only a detachable attachment.
type Session struct {
ID string
UserID string
AuthSessionID string
Kind string
Target string
Title string
Persistent bool
HostID uint
CreatedAt time.Time

Expand Down Expand Up @@ -104,9 +95,6 @@ type sessionBackend interface {
Close() error
}

// Serve drives ws until it ends: it reattaches to sessionID when given, and
// otherwise opens a fresh shell on the client that connect returns. A returned
// error has not been reported to the client yet.
func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect func() (*gossh.Client, error)) error {
return serve(ws, sessionID, opts, func() (*Session, error) {
client, err := connect()
Expand Down Expand Up @@ -138,7 +126,7 @@ func ServeCommand(ws *websocket.Conn, sessionID string, opts SessionOptions, con
func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func() (*Session, error)) error {
if sessionID != "" {
sess, ok := Lookup(sessionID, opts.Identity)
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.HostID == opts.HostID {
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.Persistent == opts.Persistent && sess.HostID == opts.HostID {
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
if err == nil {
att.Run()
Expand All @@ -154,7 +142,6 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func(
if err != nil {
return err
}
// no sess.Close() on return: a dirty disconnect leaves the shell alive for a reattach
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
if err != nil {
sess.Close()
Expand All @@ -164,7 +151,6 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func(
return nil
}

// Open starts a shell on client and registers the session.
func Open(client *gossh.Client, opts SessionOptions) (*Session, error) {
if err := validateSessionOptions(opts); err != nil {
return nil, err
Expand Down Expand Up @@ -197,6 +183,7 @@ func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions)
Kind: opts.Kind,
Target: opts.Target,
Title: opts.Title,
Persistent: opts.Persistent,
HostID: opts.HostID,
CreatedAt: time.Now(),
cols: opts.Cols,
Expand Down Expand Up @@ -229,8 +216,6 @@ func validateSessionOptions(opts SessionOptions) error {
return nil
}

// Attach binds ws to the session, kicking any previous attachment, and replays
// the retained output tail before any live output.
func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error) {
if ws == nil {
return nil, errors.New("nil websocket connection")
Expand Down Expand Up @@ -299,7 +284,6 @@ func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error
return att, nil
}

// detach unbinds a. A clean detach closes the shell; a dirty one arms the grace timer.
func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
s.mu.Lock()
if s.attached != a {
Expand All @@ -312,15 +296,16 @@ func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
if revalidate {
s.revalidateCursor = cursor
}
if !clean {
shouldClose := clean || (!s.Persistent && !revalidate)
if !shouldClose {
timeout := graceTimeout
if revalidate {
timeout = revalidateGrace
}
s.grace = time.AfterFunc(timeout, s.Close)
}
s.mu.Unlock()
if clean {
if shouldClose {
s.Close()
}
}
Expand Down Expand Up @@ -356,22 +341,21 @@ func (s *Session) doClose() {
}
}

// Info snapshots the session for listing.
func (s *Session) Info() Info {
s.mu.Lock()
defer s.mu.Unlock()
return Info{
ID: s.ID,
Kind: s.Kind,
Title: s.Title,
Persistent: s.Persistent,
HostID: s.HostID,
Attached: s.attached != nil,
CreatedAt: s.CreatedAt,
DetachedAt: s.detachedAt,
}
}

// resize forwards a window size change to the shell.
func (s *Session) resize(cols, rows int) {
s.mu.Lock()
s.cols, s.rows = cols, rows
Expand All @@ -381,14 +365,12 @@ func (s *Session) resize(cols, rows int) {
}
}

// writeInput forwards client input to the shell stdin.
func (s *Session) writeInput(data []byte) {
if _, err := s.backend.Write(data); err != nil {
global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err)
}
}

// ensureAIInterceptor rebuilds the interceptor when AI runtime settings change.
func (s *Session) ensureAIInterceptor() *aiInputInterceptor {
s.mu.Lock()
defer s.mu.Unlock()
Expand All @@ -399,7 +381,6 @@ func (s *Session) ensureAIInterceptor() *aiInputInterceptor {
return s.aiInterceptor
}

// pump forwards new ring output to the current attachment.
func (s *Session) pump() {
defer func() {
if r := recover(); r != nil {
Expand All @@ -418,8 +399,6 @@ func (s *Session) pump() {
}
}

// flush sends everything the attachment has not seen yet. A client that fell
// behind the ring skips ahead and is told so; output is never queued unbounded.
func (s *Session) flush() {
s.mu.Lock()
att := s.attached
Expand Down Expand Up @@ -448,7 +427,6 @@ func (s *Session) flush() {
att.cursor = next
}

// keepaliveLoop probes the shell connection; a failed or stuck probe closes the session.
func (s *Session) keepaliveLoop() {
tick := time.NewTicker(keepaliveInterval)
defer tick.Stop()
Expand Down Expand Up @@ -477,7 +455,6 @@ func (s *Session) keepaliveLoop() {
}
}

// waitBackend closes the session once the shell exits, after a last flush.
func (s *Session) waitBackend() {
_ = s.backend.Wait()
s.flush()
Expand All @@ -489,7 +466,6 @@ func cmdMessage(data []byte) []byte {
return msg
}

// sendClose writes a close frame with code and reason, best effort.
func sendClose(ws *websocket.Conn, code int, reason string) {
_ = ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second))
}
1 change: 1 addition & 0 deletions frontend/src/api/interface/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface TerminalSession {
kind: 'local' | 'ssh' | 'container';
title: string;
hostId: number;
persistent: boolean;
attached: boolean;
createdAt: string;
detachedAt: string;
Expand Down
33 changes: 12 additions & 21 deletions frontend/src/components/terminal/dock/index.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
<template>
<div
v-if="isAdmin && terminalStore.showTerminalButton && !onTerminalPage"
class="terminal-dock-handle"
@click="show"
>
<div v-if="isAdmin && terminalStore.showTerminalButton" class="terminal-dock-handle" @click="show">
<el-badge
:value="store.entries.length"
:hidden="store.entries.length === 0"
Expand Down Expand Up @@ -38,6 +34,7 @@
<li>{{ $t('terminal.sessionRuleDisconnect') }}</li>
<li>{{ $t('terminal.sessionRuleRevalidate') }}</li>
<li>{{ $t('terminal.sessionRuleResources') }}</li>
<li>{{ $t('terminal.sessionRuleDisableShortcut') }}</li>
</ul>
</el-popover>
</div>
Expand Down Expand Up @@ -91,26 +88,17 @@
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import i18n from '@/lang';
import { TerminalSessionStore, TerminalStore } from '@/store';
import { getTerminalInfo } from '@/api/modules/setting';
import { TerminalDockSessionStore, TerminalStore } from '@/store';
import { ElMessageBox } from 'element-plus';
import ConnectionMenu from '@/components/terminal/connection-menu/index.vue';
import type { TerminalConnectionOptions } from '@/components/terminal/connection-menu/types';
import { useGlobalStore } from '@/composables/useGlobalStore';

const store = TerminalSessionStore();
const store = TerminalDockSessionStore();
const terminalStore = TerminalStore();
const { isAdmin } = useGlobalStore();
const route = useRoute();
const onTerminalPage = computed(() => route.path.startsWith('/terminal'));

onMounted(async () => {
const res = await getTerminalInfo();
terminalStore.showTerminalButton = res.data.showTerminalButton !== 'Disable';
});

const open = ref(false);
const active = ref('');
Expand All @@ -130,6 +118,7 @@ const show = async () => {
if (!open.value || !isAdmin.value) return;
claim();
store.sync();
if (timer) clearInterval(timer);
timer = setInterval(store.sync, 5000);
};

Expand All @@ -145,6 +134,12 @@ watch(open, (value) => {
watch(isAdmin, (allowed) => {
if (!allowed) open.value = false;
});
watch(
() => terminalStore.showTerminalButton,
(visible) => {
if (!visible) open.value = false;
},
);
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
Expand Down Expand Up @@ -189,10 +184,6 @@ const closeAll = async () => {
}
open.value = false;
};

watch(onTerminalPage, (v) => {
if (v) open.value = false;
});
</script>

<style scoped lang="scss">
Expand Down
Loading
Loading