diff --git a/backend/go.mod b/backend/go.mod index c6aa4de..0862aa8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,4 +20,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect + github.com/coder/websocket v1.8.12 // indirect + golang.org/x/net v0.28.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 345e5aa..3bba07f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,6 +22,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw4 github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,6 +42,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/main.go b/backend/main.go index 6642233..4eb1e26 100644 --- a/backend/main.go +++ b/backend/main.go @@ -20,6 +20,8 @@ func main() { } http.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter())) + http.Handle("/ws/", http.StripPrefix("/ws", router.HandleWebsocket())) + ui.ServeUI() host := utils.GetEnv("HOST", "0.0.0.0") diff --git a/backend/router/router.go b/backend/router/router.go index e295620..2d146e1 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -1,6 +1,8 @@ package router -import "net/http" +import ( + "net/http" +) func HandleApiRouter() *http.ServeMux { router := http.NewServeMux() @@ -21,3 +23,11 @@ func HandleApiRouter() *http.ServeMux { return router } + +func HandleWebsocket() *http.ServeMux { + router := http.NewServeMux() + + router.HandleFunc("/terminal", TerminalHandler) + + return router +} diff --git a/backend/router/terminal.go b/backend/router/terminal.go new file mode 100644 index 0000000..8fedef4 --- /dev/null +++ b/backend/router/terminal.go @@ -0,0 +1,114 @@ +package router + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + + "github.com/coder/websocket" +) + +type Terminal struct { + ws *websocket.Conn + remoteAddr string + cmd *exec.Cmd +} + +func (c *Terminal) NewSession(w http.ResponseWriter, r *http.Request) { + cwd, err := os.Getwd() + if err != nil { + fmt.Printf("Failed to get current working directory: %v\n", err) + } + + cmd := &exec.Cmd{ + // Path: "/bin/bash", + Path: "/usr/bin/fish", + Dir: cwd, + } + c.cmd = cmd + + stdin, err := cmd.StdinPipe() + if err != nil { + fmt.Printf("Failed to create stdin pipe: %v\n", err) + return + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + fmt.Printf("Failed to create stdout pipe: %v\n", err) + return + } + cmd.Stderr = cmd.Stdout + cmd.Stdout = os.Stdout + + if err := cmd.Start(); err != nil { + fmt.Printf("Failed to start command: %v\n", err) + return + } + + go func() { + // if err := cmd.Wait(); err != nil { + // fmt.Printf("Shell exited with error: %v\n", err) + // } + // c.ws.CloseNow() + }() + + go func() { + buf := make([]byte, 1024) + for { + n, err := stdout.Read(buf) + if err != nil { + return + } + + fmt.Printf("Data from stdout: %s\n", string(buf[:n])) + + err = c.ws.Write(context.Background(), websocket.MessageText, buf[:n]) + if err != nil { + fmt.Printf("Failed to write data to websocket: %v\n", err) + return + } + + } + }() + + for { + _, data, err := c.ws.Read(context.Background()) + if err != nil { + return + } + + fmt.Printf("Data from websocket: %s\n", string(data)) + + if _, err := stdin.Write(data); err != nil { + fmt.Printf("Failed to write data to stdin: %v\n", err) + continue + } + } +} + +func (c *Terminal) Close() { + if c.cmd != nil { + c.cmd.Process.Kill() + } + + fmt.Println("Terminal session closed:", c.remoteAddr) + c.ws.Close(websocket.StatusInternalError, "the connection is closed") +} + +func TerminalHandler(w http.ResponseWriter, r *http.Request) { + fmt.Printf("New terminal session: %s\n", r.RemoteAddr) + + c, err := websocket.Accept(w, r, nil) + if err != nil { + fmt.Printf("Failed to accept websocket: %v\n", err) + return + } + + term := &Terminal{ws: c, remoteAddr: r.RemoteAddr} + defer term.Close() + + term.NewSession(w, r) +} diff --git a/package.json b/package.json index e321f2e..418bc71 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.51.23", + "@xterm/addon-attach": "^0.11.0", + "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", "dayjs": "^1.11.12", "lucide-react": "^0.427.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80180ca..2c4271e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@tanstack/react-query': specifier: ^5.51.23 version: 5.51.23(react@18.3.1) + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -675,6 +681,14 @@ packages: peerDependencies: vite: ^4 || ^5 + '@xterm/addon-attach@0.11.0': + resolution: {integrity: sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2224,6 +2238,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@xterm/addon-attach@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + acorn-jsx@5.3.2(acorn@8.12.1): dependencies: acorn: 8.12.1 diff --git a/src/app/router.tsx b/src/app/router.tsx index ba87390..bc84e76 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -8,6 +8,7 @@ const HomePage = lazy(() => import("@/pages/home/page")); const BucketsPage = lazy(() => import("@/pages/buckets/page")); const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); const KeysPage = lazy(() => import("@/pages/keys/page")); +const TerminalPage = lazy(() => import("@/pages/terminal/page")); const router = createBrowserRouter([ { @@ -37,6 +38,10 @@ const router = createBrowserRouter([ path: "keys", Component: KeysPage, }, + { + path: "terminal", + Component: TerminalPage, + }, ], }, ]); diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx index 7b03f40..be39f95 100644 --- a/src/components/containers/sidebar.tsx +++ b/src/components/containers/sidebar.tsx @@ -5,6 +5,7 @@ import { KeySquare, LayoutDashboard, Palette, + SquareTerminal, } from "lucide-react"; import { Dropdown, Menu } from "react-daisyui"; import { Link, useLocation } from "react-router-dom"; @@ -18,6 +19,7 @@ const pages = [ { icon: HardDrive, title: "Cluster", path: "/cluster" }, { icon: ArchiveIcon, title: "Buckets", path: "/buckets" }, { icon: KeySquare, title: "Keys", path: "/keys" }, + { icon: SquareTerminal, title: "Terminal", path: "/terminal" }, ]; const Sidebar = () => { diff --git a/src/pages/terminal/page.tsx b/src/pages/terminal/page.tsx new file mode 100644 index 0000000..b994b29 --- /dev/null +++ b/src/pages/terminal/page.tsx @@ -0,0 +1,42 @@ +import Page from "@/context/page-context"; +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { AttachAddon } from "@xterm/addon-attach"; +import "@xterm/xterm/css/xterm.css"; + +const WS_URL = "ws://" + location.host + "/ws"; + +const TerminalPage = () => { + const terminalContainerRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + const container = terminalContainerRef.current; + if (!container || wsRef.current) { + return; + } + + const url = WS_URL + "/terminal"; + const ws = new WebSocket(url); + wsRef.current = ws; + + const term = new Terminal(); + const attachAddon = new AttachAddon(ws); + term.loadAddon(attachAddon); + term.open(container); + + // return () => { + // term.dispose(); + // ws.close(); + // }; + }, []); + + return ( +
+ +
+
+ ); +}; + +export default TerminalPage; diff --git a/vite.config.ts b/vite.config.ts index 4461ec2..f2f4877 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,10 @@ export default defineConfig(({ mode }) => { target: process.env.VITE_API_URL, changeOrigin: true, }, + "/ws": { + target: process.env.VITE_API_URL?.replace("http", "ws"), + ws: true, + }, }, }, };