diff --git a/go.mod b/go.mod index 3fb361f7..66685ced 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/gookit/color v1.5.4 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-version v1.7.0 + github.com/klauspost/compress v1.17.9 github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/file v1.1.2 github.com/knadh/koanf/v2 v2.1.1 @@ -79,12 +80,11 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jaevor/go-nanoid v1.4.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index a23495ed..b61156ea 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,9 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go index 8de54bf4..3412f422 100644 --- a/internal/bootstrap/http.go +++ b/internal/bootstrap/http.go @@ -23,6 +23,7 @@ func initHttp() { // add route route.Http(app.Http) + route.Ws(app.Http) apps.Boot(app.Http) srv := &http.Server{ diff --git a/internal/http/request/ws.go b/internal/http/request/ws.go new file mode 100644 index 00000000..725b8fc2 --- /dev/null +++ b/internal/http/request/ws.go @@ -0,0 +1 @@ +package request diff --git a/internal/route/http.go b/internal/route/http.go index 5e1f60e5..f5489cc2 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -161,7 +161,6 @@ func Http(r chi.Router) { ssh := service.NewSSHService() r.Get("/info", ssh.GetInfo) r.Post("/info", ssh.UpdateInfo) - r.Get("/session", ssh.Session) }) r.Route("/container", func(r chi.Router) { diff --git a/internal/route/ws.go b/internal/route/ws.go new file mode 100644 index 00000000..6022a99b --- /dev/null +++ b/internal/route/ws.go @@ -0,0 +1,17 @@ +package route + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/internal/http/middleware" + "github.com/TheTNB/panel/internal/service" +) + +func Ws(r chi.Router) { + r.Route("/api/ws", func(r chi.Router) { + r.Use(middleware.MustLogin) + ws := service.NewWsService() + r.Get("/ssh", ws.Session) + r.Get("/exec", ws.Exec) + }) +} diff --git a/internal/service/ssh.go b/internal/service/ssh.go index 584426c6..0053383d 100644 --- a/internal/service/ssh.go +++ b/internal/service/ssh.go @@ -1,20 +1,11 @@ package service import ( - "context" "net/http" - "sync" - "time" - "github.com/gorilla/websocket" - "github.com/spf13/cast" - "go.uber.org/zap" - - "github.com/TheTNB/panel/internal/app" "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/internal/data" "github.com/TheTNB/panel/internal/http/request" - "github.com/TheTNB/panel/pkg/ssh" ) type SSHService struct { @@ -49,65 +40,3 @@ func (s *SSHService) UpdateInfo(w http.ResponseWriter, r *http.Request) { return } } - -func (s *SSHService) Session(w http.ResponseWriter, r *http.Request) { - info, err := s.sshRepo.GetInfo() - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - upGrader := websocket.Upgrader{ - ReadBufferSize: 4096, - WriteBufferSize: 4096, - } - - ws, err := upGrader.Upgrade(w, r, nil) - if err != nil { - ErrorSystem(w) - return - } - defer ws.Close() - - config := ssh.ClientConfigPassword( - cast.ToString(info["host"])+":"+cast.ToString(info["port"]), - cast.ToString(info["user"]), - cast.ToString(info["password"]), - ) - client, err := ssh.NewSSHClient(config) - if err != nil { - _ = ws.WriteControl(websocket.CloseMessage, - []byte(err.Error()), time.Now().Add(time.Second)) - return - } - defer client.Close() - - turn, err := ssh.NewTurn(ws, client) - if err != nil { - _ = ws.WriteControl(websocket.CloseMessage, - []byte(err.Error()), time.Now().Add(time.Second)) - return - } - defer turn.Close() - - ctx, cancel := context.WithCancel(context.Background()) - wg := sync.WaitGroup{} - wg.Add(2) - - go func() { - defer wg.Done() - if err = turn.Handle(ctx); err != nil { - app.Logger.Error("读取 ssh 数据失败", zap.Error(err)) - return - } - }() - go func() { - defer wg.Done() - if err = turn.Wait(); err != nil { - app.Logger.Error("保持 ssh 会话失败", zap.Error(err)) - } - cancel() - }() - - wg.Wait() -} diff --git a/internal/service/ws.go b/internal/service/ws.go new file mode 100644 index 00000000..9d67d952 --- /dev/null +++ b/internal/service/ws.go @@ -0,0 +1,140 @@ +package service + +import ( + "bufio" + "context" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/spf13/cast" + + "github.com/TheTNB/panel/internal/app" + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/data" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/ssh" +) + +type WsService struct { + sshRepo biz.SSHRepo +} + +func NewWsService() *WsService { + return &WsService{ + sshRepo: data.NewSSHRepo(), + } +} + +func (s *WsService) Session(w http.ResponseWriter, r *http.Request) { + info, err := s.sshRepo.GetInfo() + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + ws, err := s.upgrade(w, r) + if err != nil { + ErrorSystem(w) + return + } + defer ws.Close() + + config := ssh.ClientConfigPassword( + cast.ToString(info["host"])+":"+cast.ToString(info["port"]), + cast.ToString(info["user"]), + cast.ToString(info["password"]), + ) + client, err := ssh.NewSSHClient(config) + if err != nil { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error())) + return + } + defer client.Close() + + turn, err := ssh.NewTurn(ws, client) + if err != nil { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error())) + return + } + defer turn.Close() + + ctx, cancel := context.WithCancel(context.Background()) + wg := sync.WaitGroup{} + wg.Add(2) + + go func() { + defer wg.Done() + _ = turn.Handle(ctx) + }() + go func() { + defer wg.Done() + _ = turn.Wait() + }() + + wg.Wait() + cancel() +} + +func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) { + ws, err := s.upgrade(w, r) + if err != nil { + ErrorSystem(w) + return + } + defer ws.Close() + + // 第一条消息是命令 + _, cmd, err := ws.ReadMessage() + if err != nil { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "failed to read command")) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + out, err := shell.ExecfWithPipe(ctx, string(cmd)) + if err != nil { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "failed to run command")) + cancel() + return + } + + go func() { + scanner := bufio.NewScanner(out) + for scanner.Scan() { + line := scanner.Text() + _ = ws.WriteMessage(websocket.TextMessage, []byte(line)) + } + if err = scanner.Err(); err != nil { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "failed to read command output")) + } + }() + + s.readLoop(ws) + cancel() +} + +func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { + upGrader := websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + } + + // debug 模式下不校验 origin,方便 vite 代理调试 + if app.Conf.Bool("app.debug") { + upGrader.CheckOrigin = func(r *http.Request) bool { + return true + } + } + + return upGrader.Upgrade(w, r, nil) +} + +// readLoop 阻塞直到客户端关闭连接 +func (s *WsService) readLoop(c *websocket.Conn) { + for { + if _, _, err := c.NextReader(); err != nil { + c.Close() + break + } + } +} diff --git a/pkg/shell/exec.go b/pkg/shell/exec.go index e716c237..8353e1f9 100644 --- a/pkg/shell/exec.go +++ b/pkg/shell/exec.go @@ -2,8 +2,10 @@ package shell import ( "bytes" + "context" "errors" "fmt" + "io" "os" "os/exec" "strings" @@ -90,3 +92,19 @@ func ExecfWithOutput(shell string, args ...any) error { return cmd.Run() } + +// ExecfWithPipe 执行 shell 命令并返回管道 +func ExecfWithPipe(ctx context.Context, shell string, args ...any) (out io.ReadCloser, err error) { + _ = os.Setenv("LC_ALL", "C") + + cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf(shell, args...)) + + out, err = cmd.StdoutPipe() + if err != nil { + return + } + + cmd.Stderr = cmd.Stdout + err = cmd.Start() + return +} diff --git a/pkg/ssh/turn.go b/pkg/ssh/turn.go index 1314ab01..92a762b0 100644 --- a/pkg/ssh/turn.go +++ b/pkg/ssh/turn.go @@ -80,6 +80,7 @@ func (t *Turn) Handle(context context.Context) error { default: _, data, err := t.ws.ReadMessage() if err != nil { + // 通常是客户端关闭连接 return fmt.Errorf("reading ws message err: %v", err) } diff --git a/web/settings/proxy-config.example.ts b/web/settings/proxy-config.example.ts index 357a72ed..a31072fd 100644 --- a/web/settings/proxy-config.example.ts +++ b/web/settings/proxy-config.example.ts @@ -1,20 +1,25 @@ const proxyConfigMappings: Record = { dev: [ + { + prefix: '/api/ws', + target: 'ws://localhost:8888/api/ws', + secure: false + }, { prefix: '/api', - target: 'http://localhost:8080' + target: 'http://localhost:8080/api' } ], test: [ { prefix: '/api', - target: 'http://localhost:8080' + target: 'http://localhost:8080/api' } ], prod: [ { prefix: '/api', - target: 'http://localhost:8080' + target: 'http://localhost:8080/api' } ] } diff --git a/web/src/api/ws/index.ts b/web/src/api/ws/index.ts new file mode 100644 index 00000000..45f7f018 --- /dev/null +++ b/web/src/api/ws/index.ts @@ -0,0 +1,24 @@ +const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' +const base = `${protocol}://${window.location.host}/api/ws/` + +export default { + // 执行命令 + exec: (cmd: string): Promise => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base + 'exec') + ws.onopen = () => { + ws.send(cmd) + resolve(ws) + } + ws.onerror = (e) => reject(e) + }) + }, + // 连接SSH + ssh: (): Promise => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base + 'ssh') + ws.onopen = () => resolve(ws) + ws.onerror = (e) => reject(e) + }) + } +} diff --git a/web/src/views/ssh/IndexView.vue b/web/src/views/ssh/IndexView.vue index 518c9a36..44f10ec6 100644 --- a/web/src/views/ssh/IndexView.vue +++ b/web/src/views/ssh/IndexView.vue @@ -3,6 +3,7 @@ defineOptions({ name: 'ssh-index' }) +import ws from '@/api/ws' import { AttachAddon } from '@xterm/addon-attach' import { ClipboardAddon } from '@xterm/addon-clipboard' import { FitAddon } from '@xterm/addon-fit' @@ -25,12 +26,8 @@ const model = ref({ const terminal = ref(null) const term = ref() -const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' -const ws = new WebSocket(`${protocol}://${window.location.host}/api/ssh/session`) -const attachAddon = new AttachAddon(ws) +let sshWs: WebSocket | null = null const fitAddon = new FitAddon() -const clipboardAddon = new ClipboardAddon() -const webLinksAddon = new WebLinksAddon() const webglAddon = new WebglAddon() const handleSave = () => { @@ -51,26 +48,27 @@ const getInfo = () => { } const openSession = () => { - term.value = new Terminal({ - lineHeight: 1.2, - fontSize: 14, - fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace", - cursorBlink: true, - cursorStyle: 'underline', - tabStopWidth: 4, - theme: { background: '#111', foreground: '#fff' } - }) + ws.ssh().then((ws) => { + sshWs = ws + term.value = new Terminal({ + lineHeight: 1.2, + fontSize: 14, + fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace", + cursorBlink: true, + cursorStyle: 'underline', + tabStopWidth: 4, + theme: { background: '#111', foreground: '#fff' } + }) - term.value.loadAddon(attachAddon) - term.value.loadAddon(fitAddon) - term.value.loadAddon(clipboardAddon) - term.value.loadAddon(webLinksAddon) - term.value.loadAddon(webglAddon) - webglAddon.onContextLoss(() => { - webglAddon.dispose() - }) + term.value.loadAddon(new AttachAddon(ws)) + term.value.loadAddon(fitAddon) + term.value.loadAddon(new ClipboardAddon()) + term.value.loadAddon(new WebLinksAddon()) + term.value.loadAddon(webglAddon) + webglAddon.onContextLoss(() => { + webglAddon.dispose() + }) - ws.onopen = () => { term.value.open(terminal.value!) fitAddon.fit() term.value.focus() @@ -81,33 +79,33 @@ const openSession = () => { }, false ) - } - ws.onclose = () => { - term.value.write('\r\n连接已关闭,请刷新重试。') - term.value.write('\r\nConnection closed. Please refresh.') - window.removeEventListener('resize', () => { - fitAddon.fit() - }) - } - - ws.onerror = (event) => { - term.value.write('\r\n连接发生错误,请刷新重试。') - term.value.write('\r\nConnection error. Please refresh .') - console.error(event) - ws.close() - } - - term.value.onResize(({ cols, rows }: { cols: number; rows: number }) => { - if (ws.readyState === 1) { - ws.send( - JSON.stringify({ - resize: true, - columns: cols, - rows: rows - }) - ) + ws.onclose = () => { + term.value.write('\r\n连接已关闭,请刷新重试。') + term.value.write('\r\nConnection closed. Please refresh.') + window.removeEventListener('resize', () => { + fitAddon.fit() + }) } + + ws.onerror = (event) => { + term.value.write('\r\n连接发生错误,请刷新重试。') + term.value.write('\r\nConnection error. Please refresh .') + console.error(event) + ws.close() + } + + term.value.onResize(({ cols, rows }: { cols: number; rows: number }) => { + if (ws.readyState === 1) { + ws.send( + JSON.stringify({ + resize: true, + columns: cols, + rows: rows + }) + ) + } + }) }) } @@ -129,6 +127,12 @@ onMounted(() => { getInfo() openSession() }) + +onUnmounted(() => { + if (sshWs) { + sshWs.close() + } +})