diff --git a/client/src/api/docker.jsx b/client/src/api/docker.jsx index 4ee3da7..bbdb1b5 100644 --- a/client/src/api/docker.jsx +++ b/client/src/api/docker.jsx @@ -157,6 +157,22 @@ function createVolume(values) { })) } +function attachTerminal(containerId) { + let protocol = 'ws://'; + if (window.location.protocol === 'https:') { + protocol = 'wss://'; + } + return new WebSocket(protocol + window.location.host + '/cosmos/api/servapps/' + containerId + '/terminal/attach'); +} + +function createTerminal(containerId) { + let protocol = 'ws://'; + if (window.location.protocol === 'https:') { + protocol = 'wss://'; + } + return new WebSocket(protocol + window.location.host + '/cosmos/api/servapps/' + containerId + '/terminal/new'); +} + export { list, get, @@ -174,4 +190,6 @@ export { attachNetwork, detachNetwork, createVolume, + attachTerminal, + createTerminal, }; \ No newline at end of file diff --git a/client/src/components/logLine.jsx b/client/src/components/logLine.jsx index 34b309c..2f07af5 100644 --- a/client/src/components/logLine.jsx +++ b/client/src/components/logLine.jsx @@ -56,7 +56,6 @@ const LogLine = ({ message, docker, isMobile }) => { if(docker) { let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/) if(!parts) { - console.error('Could not parse log line', html) return
; } let restString = html.replace(parts[0], '') diff --git a/client/src/components/responseiveButton.jsx b/client/src/components/responseiveButton.jsx index 4679061..77c0c48 100644 --- a/client/src/components/responseiveButton.jsx +++ b/client/src/components/responseiveButton.jsx @@ -1,7 +1,7 @@ import { Button, useMediaQuery, IconButton } from "@mui/material"; -const ResponsiveButton = ({ children, startIcon, size, style, ...props }) => { +const ResponsiveButton = ({ children, startIcon, endIcon, size, style, ...props }) => { const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); let newStyle = style || {}; if (isMobile) { @@ -10,8 +10,18 @@ const ResponsiveButton = ({ children, startIcon, size, style, ...props }) => { } return ( - ); } diff --git a/client/src/components/terminal.jsx b/client/src/components/terminal.jsx new file mode 100644 index 0000000..52393be --- /dev/null +++ b/client/src/components/terminal.jsx @@ -0,0 +1,62 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Button, Checkbox, CircularProgress, Input, Stack, TextField, Typography, useMediaQuery } from '@mui/material'; +import * as API from '../api'; +import LogLine from '../components/logLine'; +import { useTheme } from '@emotion/react'; + +const Terminal = ({ logs, setLogs, fetchLogs, docker }) => { + const [hasMore, setHasMore] = useState(true); + const [hasScrolled, setHasScrolled] = useState(false); + const [fetching, setFetching] = useState(false); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const screenMin = useMediaQuery((theme) => theme.breakpoints.up('sm')) + + const bottomRef = useRef(null); + const terminalRef = useRef(null); + + const scrollToBottom = () => { + bottomRef.current.scrollIntoView({}); + }; + + useEffect(() => { + if (!hasScrolled) { + scrollToBottom(); + } + }, [logs]); + + const handleScroll = (event) => { + if (event.target.scrollHeight - event.target.scrollTop === event.target.clientHeight) { + setHasScrolled(false); + }else { + setHasScrolled(true); + } + }; + + return ( + + {logs && logs.map((log, index) => ( +
+ +
+ ))} + {fetching && } +
+ + ); +}; + +export default Terminal; \ No newline at end of file diff --git a/client/src/pages/servapps/containers/index.jsx b/client/src/pages/servapps/containers/index.jsx index 953a83b..2371c9f 100644 --- a/client/src/pages/servapps/containers/index.jsx +++ b/client/src/pages/servapps/containers/index.jsx @@ -16,6 +16,7 @@ import Logs from './logs'; import DockerContainerSetup from './setup'; import NetworkContainerSetup from './network'; import VolumeContainerSetup from './volumes'; +import DockerTerminal from './terminal'; const ContainerIndex = () => { const { containerName } = useParams(); @@ -56,9 +57,7 @@ const ContainerIndex = () => { }, { title: 'Terminal', - children:
- This feature is not yet implemented. It is planned for next version: 0.5.0 -
+ children: }, { title: 'Links', diff --git a/client/src/pages/servapps/containers/setup.jsx b/client/src/pages/servapps/containers/setup.jsx index bdf6cb6..8ad29de 100644 --- a/client/src/pages/servapps/containers/setup.jsx +++ b/client/src/pages/servapps/containers/setup.jsx @@ -32,6 +32,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => { labels: Object.keys(containerInfo.Config.Labels).map((key) => { return { key, value: containerInfo.Config.Labels[key] }; }), + interactive: containerInfo.Config.Tty && containerInfo.Config.OpenStdin, }} validate={(values) => { const errors = {}; @@ -65,6 +66,8 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => { envVars: envVars, labels: labels, }; + realvalues.interactive = realvalues.interactive ? 2 : 1; + return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues) .then((res) => { setStatus({ success: true }); @@ -101,6 +104,12 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => { options={restartPolicies} formik={formik} /> + + {formik.values.envVars.map((envVar, idx) => ( diff --git a/client/src/pages/servapps/containers/terminal.jsx b/client/src/pages/servapps/containers/terminal.jsx new file mode 100644 index 0000000..4718d13 --- /dev/null +++ b/client/src/pages/servapps/containers/terminal.jsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import * as API from '../../../api'; +import { Alert, Input, Stack, useMediaQuery, useTheme } from '@mui/material'; +import Terminal from '../../../components/terminal'; +import { ApiOutlined, SendOutlined } from '@ant-design/icons'; +import ResponsiveButton from '../../../components/responseiveButton'; + +const DockerTerminal = ({containerInfo, refresh}) => { + const { Name, Config, NetworkSettings, State } = containerInfo; + const isInteractive = Config.Tty; + const theme = useTheme(); + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')) + + const [message, setMessage] = useState(''); + const [output, setOutput] = useState([ + { + output: 'Not Connected.', + type: 'stdout' + } + ]); + const ws = useRef(null); + const [isConnected, setIsConnected] = useState(false); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + sendMessage(); + } + }; + + const makeInteractive = () => { + API.docker.updateContainer(Name.slice(1), {interactive: 2}) + .then(() => { + refresh && refresh(); + }).catch((e) => { + console.error(e); + refresh && refresh(); + }); + }; + + const connect = (newProc) => { + if(ws.current) { + ws.current.close(); + } + + ws.current = newProc ? + API.docker.createTerminal(Name.slice(1)) + : API.docker.attachTerminal(Name.slice(1)); + + ws.current.onmessage = (event) => { + try { + let data = JSON.parse(event.data); + setOutput((prevOutput) => [...prevOutput, ...data]); + } catch (e) { + console.error("error", e); + } + }; + + ws.current.onclose = () => { + setIsConnected(false); + let terminalBoldRed = '\x1b[1;31m'; + setOutput((prevOutput) => [...prevOutput, + {output: terminalBoldRed + 'Disconnected from ' + (newProc ? 'bash' : 'main process TTY'), type: 'stdout'}]); + }; + + ws.current.onopen = () => { + setIsConnected(true); + let terminalBoldGreen = '\x1b[1;32m'; + setOutput((prevOutput) => [...prevOutput, + {output: terminalBoldGreen + 'Connected to ' + (newProc ? 'bash' : 'main process TTY'), type: 'stdout'}]); + }; + + return () => { + setIsConnected(false); + ws.current.close(); + }; + }; + + useEffect(() => { + }, []); + + + const sendMessage = () => { + if (ws.current) { + ws.current.send(message); + setMessage(''); + } + }; + + return ( +
+ {(!isInteractive) && ( + + This container is not interactive. + If you want to connect to the main process, + + + )} + + + + + +
+ { + isConnected ? ( + + ) : ( + + ) + } +
+ setMessage(e.target.value)} + multiline + fullWidth + placeholder={isMobile ? "Enter command" : "Enter command (CTRL+Enter to send)"} + style={{ + fontSize: '125%', + padding: '10px 10px', + background: 'rgba(0,0,0,0.1)', + }} + disableUnderline + disabled={!isConnected} + /> + + + }>Send +
+ + + + + + {isConnected && ( + + )} + + +
+
+ ); +}; + +export default DockerTerminal; \ No newline at end of file diff --git a/go.mod b/go.mod index c0dafba..0c83526 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( github.com/googleapis/gax-go/v2 v2.0.5 // indirect github.com/gophercloud/gophercloud v0.15.0 // indirect github.com/gophercloud/utils v0.0.0-20210113034859-6f548432055a // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.1 // indirect github.com/hashicorp/go-retryablehttp v0.6.8 // indirect github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect diff --git a/go.sum b/go.sum index 41d5f6d..2ab12bf 100644 --- a/go.sum +++ b/go.sum @@ -326,6 +326,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= diff --git a/package.json b/package.json index 9a9b57b..46d19b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmos-server", - "version": "0.4.3", + "version": "0.5.0-unstable", "description": "", "main": "test-server.js", "bugs": { diff --git a/src/docker/api_getlogs.go b/src/docker/api_getlogs.go index ccfb5a7..51d5544 100644 --- a/src/docker/api_getlogs.go +++ b/src/docker/api_getlogs.go @@ -21,10 +21,10 @@ type LogOutput struct { Output string `json:"output"` } -// parseDockerLogHeader parses the first 8 bytes of a Docker log message +// ParseDockerLogHeader parses the first 8 bytes of a Docker log message // and returns the stream type, size, and the rest of the message as output. // It also checks if the message contains a log header and extracts the log message from it. -func parseDockerLogHeader(data []byte) (LogOutput) { +func ParseDockerLogHeader(data []byte) (LogOutput) { var logOutput LogOutput logOutput.StreamType = 1 // assume stdout if header not present logOutput.Size = uint32(len(data)) @@ -65,11 +65,11 @@ func FilterLogs(logReader io.Reader, searchQuery string, limit int) []LogOutput for scanner.Scan() { line := scanner.Text() - if len(searchQuery) > 0 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) { + if len(searchQuery) > 3 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) { continue } - logLines = append(logLines, parseDockerLogHeader(([]byte)(line))) + logLines = append(logLines, ParseDockerLogHeader(([]byte)(line))) } from := utils.Max(len(logLines)-limit, 0) diff --git a/src/docker/api_managecont.go b/src/docker/api_managecont.go index 8c5a2d5..51438ea 100644 --- a/src/docker/api_managecont.go +++ b/src/docker/api_managecont.go @@ -25,8 +25,6 @@ func ManageContainerRoute(w http.ResponseWriter, req *http.Request) { return } - - vars := mux.Vars(req) containerName := utils.SanitizeSafe(vars["containerId"]) // stop, start, restart, kill, remove, pause, unpause, recreate diff --git a/src/docker/api_terminal.go b/src/docker/api_terminal.go new file mode 100644 index 0000000..534e903 --- /dev/null +++ b/src/docker/api_terminal.go @@ -0,0 +1,194 @@ +package docker + +import ( + "context" + "github.com/gorilla/mux" + "github.com/docker/docker/api/types" + "github.com/gorilla/websocket" + "net/http" + "strings" + "encoding/json" + + "github.com/azukaar/cosmos-server/src/utils" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func splitIntoChunks(input string, chunkSize int) [][]LogOutput { + lines := strings.Split(input, "\n") + var chunks [][]LogOutput + + for i := 0; i < len(lines); i += chunkSize { + end := i + chunkSize + + // Avoid going over the end of the array + if end > len(lines) { + end = len(lines) + } + + var chunk []LogOutput + for j := i; j < end; j++ { + chunk = append(chunk, ParseDockerLogHeader(([]byte)( + lines[j], + ))) + } + chunks = append(chunks, chunk) + } + + return chunks +} + +func TerminalRoute(w http.ResponseWriter, r *http.Request) { + if utils.AdminOnly(w, r) != nil { + return + } + utils.Log("Attempting to attach container") + + upgrader.ReadBufferSize = 1024 * 4 // Increase the buffer size as needed + // Upgrade initial GET request to a websocket + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + utils.Error("Failed to set websocket upgrade: ", err) + http.Error(w, "Failed to set websocket upgrade: "+err.Error(), http.StatusInternalServerError) + return + } + defer ws.Close() + + ctx := context.Background() + errD := Connect() + if errD != nil { + utils.Error("ManageContainer", errD) + utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "DS002") + return + } + + vars := mux.Vars(r) + containerID := utils.SanitizeSafe(vars["containerId"]) + + if containerID == "" { + utils.Error("containerID is required: ", nil) + http.Error(w, "containerID is required", http.StatusBadRequest) + return + } + + action := utils.Sanitize(vars["action"]) + + utils.Log("Attaching container " + containerID + " to websocket") + + var resp types.HijackedResponse + + if action == "new" { + execConfig := types.ExecConfig{ + Tty: true, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Cmd: []string{"/bin/sh"}, + } + + execStart := types.ExecStartCheck{ + Tty: true, + } + + execResp, errExec := DockerClient.ContainerExecCreate(ctx, containerID, execConfig) + if errExec != nil { + utils.Error("ContainerExecCreate failed: ", errExec) + http.Error(w, "ContainerExecCreate failed: "+errExec.Error(), http.StatusInternalServerError) + return + } + + resp, err = DockerClient.ContainerExecAttach(ctx, execResp.ID, execStart) + if err != nil { + utils.Error("ContainerExecAttach failed: ", err) + http.Error(w, "ContainerExecAttach failed: "+err.Error(), http.StatusInternalServerError) + return + } + + utils.Log("Created new shell and attached to it in container " + containerID) + } else { + options := types.ContainerAttachOptions{ + Stream: true, + Stdin: true, + Stdout: true, + Stderr: true, + } + + // Attach to the container + resp, err = DockerClient.ContainerAttach(ctx, containerID, options) + if err != nil { + utils.Error("ContainerAttach failed: ", err) + http.Error(w, "ContainerAttach failed: "+err.Error(), http.StatusInternalServerError) + return + } + + utils.Log("Attached to existing process in container " + containerID) + } + defer resp.Close() + + utils.Log("Attached container " + containerID + " to websocket") + + var WSChan = make(chan []byte, 1024*1024*4) + var DockerChan = make(chan []byte, 1024*1024*4) + + // Start a goroutine to read from our websocket and write to the container + go (func() { + for { + utils.Debug("Waiting for message from websocket") + _, message, err := ws.ReadMessage() + utils.Debug("Got message from websocket") + if err != nil { + utils.Error("Failed to read from websocket: ", err) + break + } + WSChan <- []byte((string)(message) + "\n") + } + })() + + // Start a goroutine to read from the container and write to our websocket + go (func() { + for { + buf := make([]byte, 1024*1024*4) + utils.Debug("Waiting for message from container") + n, err := resp.Reader.Read(buf) + utils.Debug("Got message from container") + if err != nil { + utils.Error("Failed to read from container: ", err) + break + } + DockerChan <- buf[:n] + } + })() + + for { + select { + case message := <-WSChan: + utils.Debug("Writing message to container") + _, err := resp.Conn.Write(message) + if err != nil { + utils.Error("Failed to write to container: ", err) + return + } + utils.Debug("Wrote message to container") + case message := <-DockerChan: + utils.Debug("Writing message to websocket") + + messages := splitIntoChunks(string(message), 5) + for _, messageSplit := range messages { + messageJSON, err := json.Marshal(messageSplit) + if err != nil { + utils.Error("Failed to marshal message: ", err) + return + } + err = ws.WriteMessage(websocket.TextMessage, messageJSON) + if err != nil { + utils.Error("Failed to write to websocket: ", err) + return + } + } + utils.Debug("Wrote message to websocket") + } + } +} \ No newline at end of file diff --git a/src/docker/api_updateContainer.go b/src/docker/api_updateContainer.go index da46241..9c20bc7 100644 --- a/src/docker/api_updateContainer.go +++ b/src/docker/api_updateContainer.go @@ -19,6 +19,8 @@ type ContainerForm struct { Labels map[string]string `json:"labels"` PortBindings nat.PortMap `json:"portBindings"` Volumes []mount.Mount `json:"Volumes"` + // we make this a int so that we can ignore 0 + Interactive int `json:"interactive"` } func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) { @@ -84,6 +86,10 @@ func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) { container.HostConfig.Mounts = form.Volumes container.HostConfig.Binds = []string{} } + if(form.Interactive != 0) { + container.Config.Tty = form.Interactive == 2 + container.Config.OpenStdin = form.Interactive == 2 + } _, err = EditContainer(container.ID, container) if err != nil { diff --git a/src/httpServer.go b/src/httpServer.go index 351d445..9b467c7 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -227,11 +227,13 @@ func StartServer() { srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute) + srapi.HandleFunc("/api/servapps/{containerId}/terminal/{action}", docker.TerminalRoute) srapi.HandleFunc("/api/servapps/{containerId}/update", docker.UpdateContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes) srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes) srapi.HandleFunc("/api/servapps", docker.ContainersRoute) + if(!config.HTTPConfig.AcceptAllInsecureHostname) { srapi.Use(utils.EnsureHostname) diff --git a/vite.config.js b/vite.config.js index 90b4275..cc8210f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,6 +14,7 @@ export default defineConfig({ '/cosmos/api': { target: 'https://localhost:8443', secure: false, + ws: true, } } }