From ac6fbe64e74b584b3d268c52d3e2d0e7afc08f81 Mon Sep 17 00:00:00 2001 From: Yann Stepienik Date: Sat, 6 May 2023 19:25:10 +0100 Subject: [PATCH] [release] v0.4.0-unstable3 --- changelog.md | 3 +- client/src/api/docker.jsx | 72 ++++- client/src/components/hostChip.jsx | 3 +- client/src/components/logLine.jsx | 103 +++++++ .../src/components/tabbedView/tabbedView.jsx | 25 +- .../components/tableView/prettyTableView.jsx | 2 + client/src/index.css | 4 + client/src/pages/config/routeConfigPage.jsx | 2 + .../src/pages/config/routes/routeoverview.jsx | 9 +- client/src/pages/newInstall/newInstall.jsx | 43 ++- client/src/pages/servapps/actionBar.jsx | 93 +++++++ .../src/pages/servapps/containers/index.jsx | 86 ++++++ client/src/pages/servapps/containers/logs.jsx | 193 +++++++++++++ .../pages/servapps/containers/overview.jsx | 153 ++++++++++ client/src/pages/servapps/exposeModal.jsx | 98 +++++++ client/src/pages/servapps/index.jsx | 41 +++ client/src/pages/servapps/networks.jsx | 118 ++++++++ client/src/pages/servapps/servapps.jsx | 262 ++++-------------- client/src/pages/servapps/volumes.jsx | 116 ++++++++ client/src/routes/MainRoutes.jsx | 9 +- client/src/utils/routes.jsx | 11 + package-lock.json | 73 ++++- package.json | 7 +- src/docker/api_getcontainers.go | 46 +++ src/docker/api_getlogs.go | 140 ++++++++++ src/docker/api_managecont.go | 1 - src/docker/api_networks.go | 80 ++++++ src/docker/api_secureContainer.go | 2 +- src/docker/api_volumes.go | 79 ++++++ src/httpServer.go | 36 ++- src/status.go | 1 + src/utils/middleware.go | 34 +++ src/utils/utils.go | 29 +- 33 files changed, 1706 insertions(+), 268 deletions(-) create mode 100644 client/src/components/logLine.jsx create mode 100644 client/src/pages/servapps/actionBar.jsx create mode 100644 client/src/pages/servapps/containers/index.jsx create mode 100644 client/src/pages/servapps/containers/logs.jsx create mode 100644 client/src/pages/servapps/containers/overview.jsx create mode 100644 client/src/pages/servapps/exposeModal.jsx create mode 100644 client/src/pages/servapps/index.jsx create mode 100644 client/src/pages/servapps/networks.jsx create mode 100644 client/src/pages/servapps/volumes.jsx create mode 100644 src/docker/api_getcontainers.go create mode 100644 src/docker/api_getlogs.go create mode 100644 src/docker/api_networks.go create mode 100644 src/docker/api_volumes.go diff --git a/changelog.md b/changelog.md index 8c6fd95..a532ef6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,8 @@ ## Version 0.4.0 - Protect server against direct IP access + - Improvements to installer to make it more robust + - Fix bug where you can't complete the setup if you don't have a database - Stop / Start / Restart / Remove / Kill containers - - ## Version 0.3.0 - Implement 2 FA diff --git a/client/src/api/docker.jsx b/client/src/api/docker.jsx index 563e755..77db7d1 100644 --- a/client/src/api/docker.jsx +++ b/client/src/api/docker.jsx @@ -9,6 +9,70 @@ function list() { })) } +function get(containerName) { + return wrap(fetch('/cosmos/api/servapps/' + containerName, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + +function getContainerLogs(containerId, searchQuery, limit, lastReceivedLogs, errorOnly) { + + if(limit < 50) limit = 50; + + const queryParams = new URLSearchParams({ + search: searchQuery || "", + limit: limit || "", + lastReceivedLogs: lastReceivedLogs || "", + errorOnly: errorOnly || "", + }); + + return wrap(fetch(`/cosmos/api/servapps/${containerId}/logs?${queryParams}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + })); +} + +function volumeList() { + return wrap(fetch('/cosmos/api/volumes', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + +function volumeDelete(name) { + return wrap(fetch(`/cosmos/api/volume/${name}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + +function networkList() { + return wrap(fetch('/cosmos/api/networks', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + +function networkDelete(name) { + return wrap(fetch(`/cosmos/api/network/${name}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + function secure(id, res) { return wrap(fetch('/cosmos/api/servapps/' + id + '/secure/'+res, { method: 'GET', @@ -38,7 +102,13 @@ const manageContainer = (id, action) => { export { list, + get, newDB, secure, - manageContainer + manageContainer, + volumeList, + volumeDelete, + networkList, + networkDelete, + getContainerLogs }; \ No newline at end of file diff --git a/client/src/components/hostChip.jsx b/client/src/components/hostChip.jsx index 6b6ad43..d8bd085 100644 --- a/client/src/components/hostChip.jsx +++ b/client/src/components/hostChip.jsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { getOrigin, getFullOrigin } from "../utils/routes"; import { useTheme } from '@mui/material/styles'; -const HostChip = ({route, settings}) => { +const HostChip = ({route, settings, style}) => { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; const [isOnline, setIsOnline] = useState(null); @@ -27,6 +27,7 @@ const HostChip = ({route, settings}) => { style={{ paddingRight: '4px', textDecoration: isOnline ? 'none' : 'underline wavy red', + ...style }} onClick={() => { if(route.UseHost) diff --git a/client/src/components/logLine.jsx b/client/src/components/logLine.jsx new file mode 100644 index 0000000..436a05b --- /dev/null +++ b/client/src/components/logLine.jsx @@ -0,0 +1,103 @@ +import { Stack } from '@mui/material'; +import React from 'react'; + +function decodeUnicode(str) { + return str.replace(/\\u([0-9a-zA-Z]{3-5})/g, (match, p1) => { + return String.fromCharCode(parseInt(p1, 16)); + }); +} + +const LogLine = ({ message, docker, isMobile }) => { + let html = decodeUnicode(message) + .replace('\u0001\u0000\u0000\u0000\u0000\u0000\u0000', '') + .replace(/(?:\r\n|\r|\n)/g, '
') + .replace(/ /g, ' ') + .replace(/�/g, '') + .replace(/\x1b\[([0-9]{1,2}(?:;[0-9]{1,2})*)?m/g, (match, p1) => { + if (!p1) { + return ''; + } + const codes = p1.split(';'); + const styles = []; + for (const code of codes) { + switch (code) { + case '1': + styles.push('font-weight:bold'); + break; + case '3': + styles.push('font-style:italic'); + break; + case '4': + styles.push('text-decoration:underline'); + break; + case '30': + case '31': + case '32': + case '33': + case '34': + case '35': + case '36': + case '37': + case '90': + case '91': + case '92': + case '93': + case '94': + case '95': + case '96': + case '97': + styles.push(`color:${getColor(code)}`); + break; + } + } + return ``; + }); + + if(docker) { + let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/) + let restString = html.replace(parts[0], '') + + return +
+ {parts[0].replace('T', ' ').split('.')[0]} +
+
+ ; + } + + + return
; +}; + +const getColor = (code) => { + switch (code) { + case '30': + case '90': + return 'black'; + case '31': + case '91': + return 'red'; + case '32': + case '92': + return 'green'; + case '33': + case '93': + return 'yellow'; + case '34': + case '94': + return 'blue'; + case '35': + case '95': + return 'magenta'; + case '36': + case '96': + return 'cyan'; + case '37': + case '97': + return 'white'; + default: + return 'inherit'; + } +}; + +export default LogLine; \ No newline at end of file diff --git a/client/src/components/tabbedView/tabbedView.jsx b/client/src/components/tabbedView/tabbedView.jsx index a43727a..4cee7a7 100644 --- a/client/src/components/tabbedView/tabbedView.jsx +++ b/client/src/components/tabbedView/tabbedView.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Box, Tab, Tabs, Typography, MenuItem, Select, useMediaQuery } from '@mui/material'; +import { Box, Tab, Tabs, Typography, MenuItem, Select, useMediaQuery, CircularProgress } from '@mui/material'; import { styled } from '@mui/system'; const StyledTabs = styled(Tabs)` @@ -36,7 +36,7 @@ const a11yProps = (index) => { }; }; -const PrettyTabbedView = ({ tabs }) => { +const PrettyTabbedView = ({ tabs, isLoading }) => { const [value, setValue] = useState(0); const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); @@ -67,15 +67,32 @@ const PrettyTabbedView = ({ tabs }) => { aria-label="Vertical tabs" > {tabs.map((tab, index) => ( - + ))} )} - {tabs.map((tab, index) => ( + {!isLoading && tabs.map((tab, index) => ( {tab.children} ))} + {isLoading && ( + + + + )} ); }; diff --git a/client/src/components/tableView/prettyTableView.jsx b/client/src/components/tableView/prettyTableView.jsx index d5f5f71..bb63856 100644 --- a/client/src/components/tableView/prettyTableView.jsx +++ b/client/src/components/tableView/prettyTableView.jsx @@ -20,6 +20,8 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => { sm: useMediaQuery((theme) => theme.breakpoints.up('sm')), md: useMediaQuery((theme) => theme.breakpoints.up('md')), lg: useMediaQuery((theme) => theme.breakpoints.up('lg')), + xl: useMediaQuery((theme) => theme.breakpoints.up('xl')), + xxl: useMediaQuery((theme) => theme.breakpoints.up('xxl')), } return ( diff --git a/client/src/index.css b/client/src/index.css index de814ac..a2edbe1 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -61,4 +61,8 @@ color:white; background-color: rgba(0,0,0,0.8); } +} + +.darken { + filter: brightness(0.5); } \ No newline at end of file diff --git a/client/src/pages/config/routeConfigPage.jsx b/client/src/pages/config/routeConfigPage.jsx index 26d1b03..63f3f8c 100644 --- a/client/src/pages/config/routeConfigPage.jsx +++ b/client/src/pages/config/routeConfigPage.jsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import * as API from "../../api"; import RouteSecurity from "./routes/routeSecurity"; import RouteOverview from "./routes/routeoverview"; +import IsLoggedIn from "../../isLoggedIn"; const RouteConfigPage = () => { const { routeName } = useParams(); @@ -28,6 +29,7 @@ const RouteConfigPage = () => { }, []); return
+

diff --git a/client/src/pages/config/routes/routeoverview.jsx b/client/src/pages/config/routes/routeoverview.jsx index c643a33..925b5ed 100644 --- a/client/src/pages/config/routes/routeoverview.jsx +++ b/client/src/pages/config/routes/routeoverview.jsx @@ -7,6 +7,13 @@ import { RouteMode, RouteSecurity } from '../../../components/routeComponents'; import { getFaviconURL } from '../../../utils/routes'; import * as API from '../../../api'; import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons"; +import IsLoggedIn from '../../../isLoggedIn'; + +const info = { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + padding: '10px', + borderRadius: '5px', +} const RouteOverview = ({ routeConfig }) => { const [openModal, setOpenModal] = React.useState(false); @@ -35,7 +42,7 @@ const RouteOverview = ({ routeConfig }) => {

Description -
{routeConfig.Description}
+
{routeConfig.Description}
URL
Target diff --git a/client/src/pages/newInstall/newInstall.jsx b/client/src/pages/newInstall/newInstall.jsx index 330f585..ef947ad 100644 --- a/client/src/pages/newInstall/newInstall.jsx +++ b/client/src/pages/newInstall/newInstall.jsx @@ -23,6 +23,9 @@ const NewInstall = () => { const [activeStep, setActiveStep] = useState(0); const [status, setStatus] = useState(null); const [counter, setCounter] = useState(0); + let [hostname, setHostname] = useState(''); + const [databaseEnable, setDatabaseEnable] = useState(true); + const refreshStatus = async () => { try { const res = await API.getStatus() @@ -34,7 +37,7 @@ const NewInstall = () => { if (typeof status !== 'undefined') { setTimeout(() => { setCounter(counter + 1); - }, 2000); + }, 2500); } } @@ -43,7 +46,7 @@ const NewInstall = () => { }, [counter]); useEffect(() => { - if(activeStep == 4 && status && !status.database) { + if(activeStep == 4 && status && !databaseEnable) { setActiveStep(5); } }, [activeStep, status]); @@ -122,8 +125,12 @@ const NewInstall = () => { MongoDBMode: values.DBMode, MongoDB: values.MongoDB, }); - if(res.status == "OK") + if(res.status == "OK") { + if(values.DBMode === "DisableUserManagement") { + setDatabaseEnable(false); + } setStatus({ success: true }); + } } catch (error) { setStatus({ success: false }); setErrors({ submit: error.message }); @@ -205,9 +212,14 @@ const NewInstall = () => { If you enable HTTPS, it will be effective after the next restart.
- {status &&
- HTTPS Certificate Mode is currently: {status.HTTPSCertificateMode} -
} + {status && <> +
+ HTTPS Certificate Mode is currently: {status.HTTPSCertificateMode} +
+
+ Hostname is currently: {status.hostname} +
+ }
{ TLSCert: values.HTTPSCertificateMode === "PROVIDED" ? values.TLSCert : '', Hostname: values.Hostname, }); - if(res.status == "OK") + if(res.status == "OK") { setStatus({ success: true }); + setHostname((values.HTTPSCertificateMode == "DISABLED" ? "http://" : "https://") + values.Hostname); + } } catch (error) { setStatus({ success: false }); setErrors({ submit: "Please check you have filled all the inputs properly" }); @@ -264,11 +278,15 @@ const NewInstall = () => { ["LETSENCRYPT", "Use Let's Encrypt automatic HTTPS (recommended)"], ["PROVIDED", "Supply my own HTTPS certificate"], ["SELFSIGNED", "Generate a self-signed certificate"], - ["DISABLE", "Use HTTP only (not recommended)"], + ["DISABLED", "Use HTTP only (not recommended)"], ]} /> {formik.values.HTTPSCertificateMode === "LETSENCRYPT" && ( <> + + If you are using Cloudflare, make sure the DNS record is NOT set to Proxied (you should not see the orange cloud but a grey one). + Otherwise Cloudflare will not allow Let's Encrypt to verify your domain. + { @@ -471,7 +494,7 @@ const NewInstall = () => { step: "5", }) setTimeout(() => { - window.location.href = "/ui/login"; + window.location.href = hostname + "/ui/login"; }, 500); } else setActiveStep(activeStep + 1) diff --git a/client/src/pages/servapps/actionBar.jsx b/client/src/pages/servapps/actionBar.jsx new file mode 100644 index 0000000..a213e35 --- /dev/null +++ b/client/src/pages/servapps/actionBar.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { CloseSquareOutlined, DeleteOutlined, PauseCircleOutlined, PlaySquareOutlined, ReloadOutlined, RollbackOutlined, StopOutlined, UpCircleOutlined } from '@ant-design/icons'; +import * as API from '../../api'; + +const GetActions = ({ + Id, + state, + refreshServeApps, + setIsUpdatingId +}) => { + const doTo = (action) => { + setIsUpdatingId(Id, true); + API.docker.manageContainer(Id, action).then((res) => { + refreshServeApps(); + }); + }; + + let actions = [ + { + t: 'Update Available', + if: ['update_available'], + e: {doTo('update')}} size='large'> + + + }, + { + t: 'Start', + if: ['exited', 'created'], + e: {doTo('start')}} size='large'> + + + }, + { + t: 'Unpause', + if: ['paused'], + e: {doTo('unpause')}} size='large'> + + + }, + { + t: 'Pause', + if: ['running'], + e: {doTo('pause')}} size='large'> + + + }, + { + t: 'Stop', + if: ['paused', 'restarting', 'running'], + e: {doTo('stop')}} size='large' variant="outlined"> + + + }, + { + t: 'Restart', + if: ['exited', 'running', 'paused', 'created', 'restarting'], + e: doTo('restart')} size='large'> + + + }, + { + t: 'Re-create', + if: ['exited', 'running', 'paused', 'created', 'restarting'], + e: doTo('recreate')} color="error" size='large'> + + + }, + { + t: 'Kill', + if: ['running', 'paused', 'created', 'restarting'], + e: doTo('kill')} color="error" size='large'> + + + }, + { + t: 'Delete', + if: ['exited', 'created'], + e: {doTo('remove')}} color="error" size='large'> + + + } + ]; + + return actions.filter((action) => { + let updateAvailable = false; + return action.if.includes(state) ?? (updateAvailable && action.if.includes('update_available')); + }).map((action) => { + return {action.e} + }); +} + +export default GetActions; \ No newline at end of file diff --git a/client/src/pages/servapps/containers/index.jsx b/client/src/pages/servapps/containers/index.jsx new file mode 100644 index 0000000..ace3f0e --- /dev/null +++ b/client/src/pages/servapps/containers/index.jsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import MainCard from '../../../components/MainCard'; +import RestartModal from '../../config/users/restart'; +import { Chip, Divider, Stack, useMediaQuery } from '@mui/material'; +import HostChip from '../../../components/hostChip'; +import { RouteMode, RouteSecurity } from '../../../components/routeComponents'; +import { getFaviconURL } from '../../../utils/routes'; +import * as API from '../../../api'; +import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons"; +import IsLoggedIn from '../../../isLoggedIn'; +import PrettyTabbedView from '../../../components/tabbedView/tabbedView'; +import Back from '../../../components/back'; +import { useParams } from 'react-router'; +import ContainerOverview from './overview'; +import Logs from './logs'; + +const ContainerIndex = () => { + const { containerName } = useParams(); + const [container, setContainer] = React.useState(null); + const [config, setConfig] = React.useState(null); + + const refreshContainer = () => { + return Promise.all([API.docker.get(containerName).then((res) => { + setContainer(res.data); + }), + API.config.get().then((res) => { + setConfig(res.data); + })]); + }; + + React.useEffect(() => { + refreshContainer(); + }, []); + + return
+ + + +
{containerName}
+
+ + + + }, + { + title: 'Logs', + children: + }, + { + title: 'Terminal', + children: + }, + { + title: 'Links', + children:
Links
+ }, + // { + // title: 'Advanced' + // }, + { + title: 'Setup', + children:
Image, Restart Policy, Environment Variables, Labels, etc...
+ }, + { + title: 'Network', + children:
Urls, Networks, Ports, etc...
+ }, + { + title: 'Volumes', + children:
Volumes
+ }, + { + title: 'Resources', + children:
Runtime Resources, Capabilities...
+ }, + ]} /> +
+
; +} + +export default ContainerIndex; \ No newline at end of file diff --git a/client/src/pages/servapps/containers/logs.jsx b/client/src/pages/servapps/containers/logs.jsx new file mode 100644 index 0000000..1f99a3e --- /dev/null +++ b/client/src/pages/servapps/containers/logs.jsx @@ -0,0 +1,193 @@ +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 { ReactTerminal } from "react-terminal"; +import LogLine from '../../../components/logLine'; +import { useTheme } from '@emotion/react'; + +const Logs = ({ containerInfo }) => { + const { Name, Config, NetworkSettings, State } = containerInfo; + const containerName = Name; + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [errorOnly, setErrorOnly] = useState(false); + const [limit, setLimit] = useState(100); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(''); + const [hasMore, setHasMore] = useState(true); + const [hasScrolled, setHasScrolled] = useState(false); + const [fetching, setFetching] = useState(false); + const [forceUpdate, setForceUpdate] = useState(false); + const [lastReceivedLogs, setLastReceivedLogs] = useState(''); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const [scrollToMe, setScrollToMe] = useState(null); + const screenMin = useMediaQuery((theme) => theme.breakpoints.up('sm')) + + const bottomRef = useRef(null); + const topRef = useRef(null); + const terminalRef = useRef(null); + + const scrollToBottom = () => { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + }; + + const fetchLogs = async (reset, ignoreState) => { + setLoading(true); + try { + const response = await API.docker.getContainerLogs( + containerName, + searchTerm, + limit, + ignoreState ? '' : lastReceivedLogs, + errorOnly + ); + const { data } = response; + if (data.length > 0) { + const date = data[0].output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)[0]; + if (date) { + date.replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.)(\d+)Z/, (match, p1, p2) => { + const newNumber = parseInt(p2) - 1; + const newDate = `${p1}${newNumber}Z`; + setLastReceivedLogs(newDate); + }); + } else { + console.error('Could not parse date from log: ', data[0]); + setLastReceivedLogs(''); + } + } + if(reset) { + setLogs(data); + } else { + // const current = topRef.current; + // setScrollToMe(() => current); + setLogs((logs) => [...data, ...logs]); + // calculate the height of the new logs and scroll to that position + // OK I will fix this later + // const newHeight = 999999999; + // terminalRef.current.scrollTop = newHeight; + } + setHasMore(true); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + setFetching(false); + } + }; + + useEffect(() => { + fetchLogs(true); + }, [searchTerm, errorOnly, limit]); + + useEffect(() => { + if (!fetching) return; + fetchLogs(); + }, [fetching]); + + useEffect(() => { + if (!hasScrolled) { + scrollToBottom(); + } else { + // scrollToMe && scrollToMe.scrollIntoView({ }); + // setScrollToMe(null); + } + }, [logs]); + + const handleScroll = (event) => { + const { scrollTop } = event.target; + setHasScrolled(true); + if (scrollTop === 0) { + if(!hasMore) return; + setFetching(true); + setHasMore(false); + } else { + setHasMore(true); + } + }; + + return ( + + + + + { + setHasScrolled(false); + setSearchTerm(e.target.value); + setLastReceivedLogs(''); + }} + /> + + { + setHasScrolled(false); + setErrorOnly(e.target.checked); + setLastReceivedLogs(''); + }} + /> + Error Only + + + + + { + setHasScrolled(false); + setLimit(e.target.value); + setLastReceivedLogs(''); + }} + /> + + + + + {loading && } + + + {logs.map((log, index) => ( +
+ +
+ ))} + {fetching && } +
+ + + ); +}; + +export default Logs; \ No newline at end of file diff --git a/client/src/pages/servapps/containers/overview.jsx b/client/src/pages/servapps/containers/overview.jsx new file mode 100644 index 0000000..70650be --- /dev/null +++ b/client/src/pages/servapps/containers/overview.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { Checkbox, Chip, CircularProgress, Stack, Typography, useMediaQuery } from '@mui/material'; +import MainCard from '../../../components/MainCard'; +import { ContainerOutlined, DesktopOutlined, InfoCircleOutlined, NodeExpandOutlined, PlayCircleOutlined, PlusCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons'; +import { getFaviconURL, getContainersRoutes } from '../../../utils/routes'; +import HostChip from '../../../components/hostChip'; +import ExposeModal from '../exposeModal'; +import * as API from '../../../api'; +import RestartModal from '../../config/users/restart'; +import GetActions from '../actionBar'; + +const info = { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + padding: '10px', + borderRadius: '5px', +} + +const ContainerOverview = ({ containerInfo, config, refresh }) => { + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); + const [openModal, setOpenModal] = React.useState(false); + const [openRestartModal, setOpenRestartModal] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); + + const { Name, Config, NetworkSettings, State } = containerInfo; + const Image = Config.Image; + const IPAddress = NetworkSettings.Networks?.[Object.keys(NetworkSettings.Networks)[0]]?.IPAddress; + const Health = State.Health; + const healthStatus = Health ? Health.Status : 'Healthy'; + const healthIconColor = healthStatus === 'Healthy' ? 'green' : 'red'; + const routes = getContainersRoutes(config, Name.replace('/', '')); + + let refreshAll = refresh && (() => refresh().then(() => { + setIsUpdating(false); + })); + + const updateRoutes = (newRoute) => { + API.config.addRoute(newRoute).then(() => { + refreshAll(); + }); + } + + const addNewRoute = async () => { + const apps = (await API.docker.list()).data; + const app = apps.find((a) => a.Names[0] === Name); + setOpenModal(app); + } + + return ( +
+ + { + updateRoutes(_newRoute); + setOpenModal(false); + setOpenRestartModal(true); + } + } + /> + {Name}
}> + + +
+ + {isUpdating ? ( + + ) : null} +
+
+ {({ + "created": , + "restarting": , + "running": , + "removing": , + "paused": , + "exited": , + "dead": , + })[State.Status]} +
+
+ + + + { + refreshAll() + }} + setIsUpdatingId={() => { + setIsUpdating(true); + }} + /> + + Image +
{Image}
+ Name +
{Name}
+ IP Address +
{IPAddress}
+ + Health + +
{healthStatus}
+ Settings {State.Status !== 'running' ? '(Start container to edit)' : ''} + + { + setIsUpdating(true); + API.docker.secure(Name, e.target.checked).then(() => { + setTimeout(() => { + refreshAll(); + }, 3000); + }) + }} + /> Force Secure Network + + URLs +
+ {routes.map((route) => { + return + })} +
+ } + onClick={() => { + addNewRoute(); + }} + onDelete={() => { + addNewRoute(); + }} + /> +
+
+
+ +
+ ); +}; + +export default ContainerOverview; \ No newline at end of file diff --git a/client/src/pages/servapps/exposeModal.jsx b/client/src/pages/servapps/exposeModal.jsx new file mode 100644 index 0000000..1bf6c7f --- /dev/null +++ b/client/src/pages/servapps/exposeModal.jsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack } from '@mui/material'; +import { Alert } from '@mui/material'; +import RouteManagement from '../config/routes/routeman'; +import { ValidateRoute, getFaviconURL, sanitizeRoute, getContainersRoutes } from '../../utils/routes'; +import * as API from '../../api'; + +const getHostnameFromName = (name) => { + return name.replace('/', '').replace(/_/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().replace(/\s/g, '-') + '.' + window.location.origin.split('://')[1] +} + +const ExposeModal = ({ openModal, setOpenModal, config, updateRoutes, container }) => { + const [submitErrors, setSubmitErrors] = useState([]); + const [newRoute, setNewRoute] = useState(null); + + let containerName = openModal && (openModal.Names[0]); + + const hasCosmosNetwork = () => { + return container && container.NetworkSettings.Networks && Object.keys(container.NetworkSettings.Networks).some((network) => { + if(network.startsWith('cosmos-network')) + return true; + }) + } + + return setOpenModal(false)}> + Expose ServApp + {openModal && <> + + + +
+ Welcome to the URL Wizard. This interface will help you expose your ServApp securely to the internet by creating a new URL. +
+
+ {openModal && !hasCosmosNetwork(containerName) && This ServApp does not appear to be connected to a Cosmos Network, so the hostname might not be accessible. The easiest way to fix this is to check the box "Force Secure Network" or manually create a sub-network in Docker.} +
+
+ r.Name)} + setRouteConfig={(_newRoute) => { + setNewRoute(sanitizeRoute(_newRoute)); + }} + up={() => {}} + down={() => {}} + deleteRoute={() => {}} + noControls + lockTarget + /> +
+
+
+
+ + {submitErrors && submitErrors.length > 0 && + {submitErrors.map((err) => { + return
{err}
+ })}
+
} + + +
+ } +
+}; + +export default ExposeModal; \ No newline at end of file diff --git a/client/src/pages/servapps/index.jsx b/client/src/pages/servapps/index.jsx new file mode 100644 index 0000000..883ac81 --- /dev/null +++ b/client/src/pages/servapps/index.jsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import MainCard from '../../components/MainCard'; +import RestartModal from '../config/users/restart'; +import { Chip, Divider, Stack, useMediaQuery } from '@mui/material'; +import HostChip from '../../components/hostChip'; +import { RouteMode, RouteSecurity } from '../../components/routeComponents'; +import { getFaviconURL } from '../../utils/routes'; +import * as API from '../../api'; +import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons"; +import IsLoggedIn from '../../isLoggedIn'; +import PrettyTabbedView from '../../components/tabbedView/tabbedView'; +import ServeApps from './servapps'; +import VolumeManagementList from './volumes'; +import NetworkManagementList from './networks'; + +const ServappsIndex = () => { + return
+ + + , + path: 'containers' + }, + { + title: 'Volumes', + children: , + path: 'volumes' + }, + { + title: 'Networks', + children: , + path: 'networks' + }, + ]}/> + +
; +} + +export default ServappsIndex; \ No newline at end of file diff --git a/client/src/pages/servapps/networks.jsx b/client/src/pages/servapps/networks.jsx new file mode 100644 index 0000000..2a59257 --- /dev/null +++ b/client/src/pages/servapps/networks.jsx @@ -0,0 +1,118 @@ +// material-ui +import { CloseSquareOutlined, DeleteOutlined, PlusCircleOutlined, SyncOutlined } from '@ant-design/icons'; +import { Button, Chip, CircularProgress, Stack, useTheme } from '@mui/material'; +import { useEffect, useState } from 'react'; + +import * as API from '../../api'; +import PrettyTableView from '../../components/tableView/prettyTableView'; + +const NetworkManagementList = () => { + const [isLoading, setIsLoading] = useState(false); + const [rows, setRows] = useState(null); + const [tryDelete, setTryDelete] = useState(null); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + function refresh() { + setIsLoading(true); + API.docker.networkList() + .then(data => { + setRows(data.data); + setIsLoading(false); + }); + } + + useEffect(() => { + refresh(); + }, []) + + return ( + <> + + + + + {isLoading && (
+
+
+ +
+
+ )} + + {!isLoading && rows && ( + { }} + getKey={(r) => r.Id} + columns={[ + { + title: 'Network Name', + field: (r) => +
{r.Name}

+
{r.Driver} driver
+
, + search: (r) => r.Name, + }, + { + title: 'Properties', + screenMin: 'md', + field: (r) => ( + + + {r.Internal && } + {r.Attachable && } + {r.Ingress && } + + ), + }, + { + title: 'IPAM gateway / mask', + screenMin: 'lg', + field: (r) => r.IPAM.Config.map((config, index) => ( +
+ {config.Gateway} / {config.Subnet} +
+ )), + }, + { + title: 'Created At', + screenMin: 'lg', + field: (r) => new Date(r.Created).toLocaleString(), + }, + { + title: '', + clickable: true, + field: (r) => ( + <> + + + ), + }, + ]} + /> + )} + + ); +} + +export default NetworkManagementList; \ No newline at end of file diff --git a/client/src/pages/servapps/servapps.jsx b/client/src/pages/servapps/servapps.jsx index 57c0d87..32fad95 100644 --- a/client/src/pages/servapps/servapps.jsx +++ b/client/src/pages/servapps/servapps.jsx @@ -11,8 +11,11 @@ import * as API from '../../api'; import IsLoggedIn from '../../isLoggedIn'; import RestartModal from '../config/users/restart'; import RouteManagement from '../config/routes/routeman'; -import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../utils/routes'; +import { ValidateRoute, getFaviconURL, sanitizeRoute, getContainersRoutes } from '../../utils/routes'; import HostChip from '../../components/hostChip'; +import { Link } from 'react-router-dom'; +import ExposeModal from './exposeModal'; +import GetActions from './actionBar'; const Item = styled(Paper)(({ theme }) => ({ backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff', @@ -39,16 +42,6 @@ const ServeApps = () => { const [submitErrors, setSubmitErrors] = useState([]); const [openRestartModal, setOpenRestartModal] = useState(false); - const hasCosmosNetwork = (containerName) => { - const container = serveApps.find((app) => { - return app.Names[0].replace('/', '') === containerName.replace('/', ''); - }); - return container && container.NetworkSettings.Networks && Object.keys(container.NetworkSettings.Networks).some((network) => { - if(network.startsWith('cosmos-network')) - return true; - }) - } - const refreshServeApps = () => { API.docker.list().then((res) => { setServeApps(res.data); @@ -66,22 +59,11 @@ const ServeApps = () => { }); } - const getContainersRoutes = (containerName) => { - return (config && config.HTTPConfig && config.HTTPConfig.ProxyConfig.Routes.filter((route) => { - let reg = new RegExp(`^(([a-z]+):\/\/)?${containerName}(:?[0-9]+)?$`, 'i'); - return route.Mode == "SERVAPP" && reg.test(route.Target) - // ( - // route.Target.startsWith(containerName) || - // route.Target.split('://')[1].startsWith(containerName) - // ) - })) || []; - } - useEffect(() => { refreshServeApps(); }, []); - function updateRoutes() { + function updateRoutes(newRoute) { let con = { ...config, HTTPConfig: { @@ -112,12 +94,15 @@ const ServeApps = () => { }, }; - const getHostnameFromName = (name) => { - return name.replace('/', '').replace(/_/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().replace(/\s/g, '-') + '.' + window.location.origin.split('://')[1] + const selectable = { + cursor: 'pointer', + "&:hover": { + textDecoration: 'underline', + } } const getFirstRouteFavIcon = (app) => { - let routes = getContainersRoutes(app.Names[0].replace('/', '')); + let routes = getContainersRoutes(config, app.Names[0].replace('/', '')); if(routes.length > 0) { let url = getFaviconURL(routes[0]); return url; @@ -126,162 +111,21 @@ const ServeApps = () => { } } - const getActions = (app) => { - const doTo = (action) => { - setIsUpdatingId(app.Id, true); - API.docker.manageContainer(app.Id, action).then((res) => { - refreshServeApps(); - }); - }; - - let actions = [ - { - t: 'Update Available', - if: ['update_available'], - e: {doTo('update')}} size='large'> - - - }, - { - t: 'Start', - if: ['exited'], - e: {doTo('start')}} size='large'> - - - }, - { - t: 'Unpause', - if: ['paused'], - e: {doTo('unpause')}} size='large'> - - - }, - { - t: 'Pause', - if: ['running'], - e: {doTo('pause')}} size='large'> - - - }, - { - t: 'Stop', - if: ['created', 'paused', 'restarting', 'running'], - e: {doTo('stop')}} size='large' variant="outlined"> - - - }, - { - t: 'Restart', - if: ['exited', 'running', 'paused', 'created', 'restarting'], - e: doTo('restart')} size='large'> - - - }, - { - t: 'Re-create', - if: ['exited', 'running', 'paused', 'created', 'restarting'], - e: doTo('recreate')} color="error" size='large'> - - - }, - { - t: 'Delete', - if: ['exited'], - e: {doTo('remove')}} color="error" size='large'> - - - }, - { - t: 'Kill', - if: ['running', 'paused', 'created', 'restarting'], - e: doTo('kill')} color="error" size='large'> - - - } - ]; - - return actions.filter((action) => { - let updateAvailable = false; - return action.if.includes(app.State) ?? (updateAvailable && action.if.includes('update_available')); - }).map((action) => { - return {action.e} - }); - } - return
- - setOpenModal(false)}> - Expose ServApp - {openModal && <> - - - -
- Welcome to the URL Wizard. This interface will help you expose your ServApp securely to the internet by creating a new URL. -
-
- {openModal && !hasCosmosNetwork(openModal.Names[0]) && This ServApp does not appear to be connected to a Cosmos Network, so the hostname might not be accessible. The easiest way to fix this is to check the box "Force Secure Network" or manually create a sub-network in Docker.} -
-
- r.Name)} - setRouteConfig={(_newRoute) => { - setNewRoute(sanitizeRoute(_newRoute)); - }} - up={() => {}} - down={() => {}} - deleteRoute={() => {}} - noControls - lockTarget - /> -
-
-
-
- - {submitErrors && submitErrors.length > 0 && - {submitErrors.map((err) => { - return
{err}
- })}
-
} - - -
- } -
+ { + return app.Names[0].replace('/', '') === openModal && openModal.Names[0].replace('/', ''); + })} + config={config} + updateRoutes={ + (_newRoute) => { + updateRoutes(_newRoute); + } + } + /> @@ -306,7 +150,7 @@ const ServeApps = () => { - + {serveApps && serveApps.filter(app => search.length < 2 || app.Names[0].toLowerCase().includes(search.toLowerCase())).map((app) => { return @@ -342,8 +186,7 @@ const ServeApps = () => { {/* */} - - {getActions(app)} + @@ -369,35 +212,39 @@ const ServeApps = () => { {isUpdating[app.Id] ?
- -
: - - - Settings - - - { - setIsUpdatingId(app.Id, true); - API.docker.secure(app.Id, e.target.checked).then(() => { - setTimeout(() => { - setIsUpdatingId(app.Id, false); - refreshServeApps(); - }, 3000); - }) - }} - /> Force Secure Network - } + +
+ : + + + Settings {app.State !== 'running' ? '(Start container to edit)' : ''} + + + { + setIsUpdatingId(app.Id, true); + API.docker.secure(app.Id, e.target.checked).then(() => { + setTimeout(() => { + setIsUpdatingId(app.Id, false); + refreshServeApps(); + }, 3000); + }) + }} + /> Force Secure Network + + + } URLs - {getContainersRoutes(app.Names[0].replace('/', '')).map((route) => { + {getContainersRoutes(config, app.Names[0].replace('/', '')).map((route) => { return })} - {/* {getContainersRoutes(app.Names[0].replace('/', '')).length == 0 && */} + {/* {getContainersRoutes(config, app.Names[0].replace('/', '')).length == 0 && */} { {/* } */} +
+ + + +
{/* + + + {isLoading && (
+
+
+ +
+
+ )} + + {!isLoading && rows && ( + {}} + getKey={(r) => r.Name} + columns={[ + { + title: 'Volume Name', + field: (r) => +
{r.Name}

+
{r.Mountpoint}
+
, + search: (r) => r.Name, + }, + { + title: 'Driver', + screenMin: 'lg', + field: (r) => r.Driver, + }, + { + title: 'Scope', + screenMin: 'lg', + field: (r) => r.Scope, + }, + { + title: 'Created At', + screenMin: 'lg', + field: (r) => new Date(r.CreatedAt).toLocaleString(), + }, + { + title: '', + clickable: true, + field: (r) => ( + <> + + + ), + }, + ]} + /> + )} + + ); +}; + +export default VolumeManagementList; \ No newline at end of file diff --git a/client/src/routes/MainRoutes.jsx b/client/src/routes/MainRoutes.jsx index 0850ffb..8fe4543 100644 --- a/client/src/routes/MainRoutes.jsx +++ b/client/src/routes/MainRoutes.jsx @@ -6,11 +6,12 @@ import MainLayout from '../layout/MainLayout'; import UserManagement from '../pages/config/users/usermanagement'; import ConfigManagement from '../pages/config/users/configman'; import ProxyManagement from '../pages/config/users/proxyman'; -import ServeApps from '../pages/servapps/servapps'; +import ServeAppsIndex from '../pages/servapps/'; import { Navigate } from 'react-router'; import RouteConfigPage from '../pages/config/routeConfigPage'; import logo from '../assets/images/icons/cosmos.png'; import HomePage from '../pages/home'; +import ContainerIndex from '../pages/servapps/containers'; // render - dashboard @@ -52,7 +53,7 @@ const MainRoutes = { }, { path: '/ui/servapps', - element: + element: }, { path: '/ui/config-users', @@ -70,6 +71,10 @@ const MainRoutes = { path: '/ui/config-url/:routeName', element: , }, + { + path: '/ui/servapps/containers/:containerName', + element: , + }, ] }; diff --git a/client/src/utils/routes.jsx b/client/src/utils/routes.jsx index c6870dd..655d18c 100644 --- a/client/src/utils/routes.jsx +++ b/client/src/utils/routes.jsx @@ -52,6 +52,10 @@ export const getFaviconURL = (route) => { return demoicons[route.Name] || logogray; } + if(!route) { + return logogray; + } + const addRemote = (url) => { return '/cosmos/api/favicon?q=' + encodeURIComponent(url) } @@ -102,4 +106,11 @@ export const ValidateRoute = (routeConfig, config) => { return ['Route Name already exists. Name must be unique.']; } return []; +} + +export const getContainersRoutes = (config, containerName) => { + return (config && config.HTTPConfig && config.HTTPConfig.ProxyConfig.Routes.filter((route) => { + let reg = new RegExp(`^(([a-z]+):\/\/)?${containerName}(:?[0-9]+)?$`, 'i'); + return route.Mode == "SERVAPP" && reg.test(route.Target) + })) || []; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b645d5e..1f554b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cosmos-server", - "version": "0.3.0-unstable", + "version": "0.4.0-unstable2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cosmos-server", - "version": "0.3.0-unstable", + "version": "0.4.0-unstable2", "dependencies": { "@ant-design/colors": "^6.0.0", "@ant-design/icons": "^4.7.0", @@ -41,6 +41,7 @@ "react-router": "^6.4.1", "react-router-dom": "^6.4.1", "react-syntax-highlighter": "^15.5.0", + "react-terminal": "^1.3.1", "react-window": "^1.8.7", "redux": "^4.2.0", "simplebar": "^5.3.8", @@ -8116,6 +8117,50 @@ "react": ">= 0.14.0" } }, + "node_modules/react-terminal": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-terminal/-/react-terminal-1.3.1.tgz", + "integrity": "sha512-lbkrih1be0nlJptZR7uwV6YF8PMuxKJOKhGN+GVuFKp9dY/qpSYF76KMGZZJnWbbxAt5Bkf+aUt4iyy5F8NBdQ==", + "dependencies": { + "prop-types": "^15.7.2", + "react-device-detect": "2.1.2" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/react-terminal/node_modules/react-device-detect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.1.2.tgz", + "integrity": "sha512-N42xttwez3ECgu4KpOL2ICesdfoz8NCBfmc1rH9FRYSjH7NmMyANPSrQ3EvAtJyj/6TzJNhrANSO38iXjCB2Ug==", + "dependencies": { + "ua-parser-js": "^0.7.30" + }, + "peerDependencies": { + "react": ">= 0.14.0 < 18.0.0", + "react-dom": ">= 0.14.0 < 18.0.0" + } + }, + "node_modules/react-terminal/node_modules/ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -15065,6 +15110,30 @@ "refractor": "^3.6.0" } }, + "react-terminal": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-terminal/-/react-terminal-1.3.1.tgz", + "integrity": "sha512-lbkrih1be0nlJptZR7uwV6YF8PMuxKJOKhGN+GVuFKp9dY/qpSYF76KMGZZJnWbbxAt5Bkf+aUt4iyy5F8NBdQ==", + "requires": { + "prop-types": "^15.7.2", + "react-device-detect": "2.1.2" + }, + "dependencies": { + "react-device-detect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.1.2.tgz", + "integrity": "sha512-N42xttwez3ECgu4KpOL2ICesdfoz8NCBfmc1rH9FRYSjH7NmMyANPSrQ3EvAtJyj/6TzJNhrANSO38iXjCB2Ug==", + "requires": { + "ua-parser-js": "^0.7.30" + } + }, + "ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==" + } + } + }, "react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/package.json b/package.json index 4e99b4e..cb2d4a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmos-server", - "version": "0.4.0-unstable2", + "version": "0.4.0-unstable3", "description": "", "main": "test-server.js", "bugs": { @@ -41,6 +41,7 @@ "react-router": "^6.4.1", "react-router-dom": "^6.4.1", "react-syntax-highlighter": "^15.5.0", + "react-terminal": "^1.3.1", "react-window": "^1.8.7", "redux": "^4.2.0", "simplebar": "^5.3.8", @@ -53,11 +54,11 @@ "scripts": { "client": "vite", "client-build": "vite build --base=/ui/", - "start": "env CONFIG_FILE=./config_dev.json EZ=UTC build/cosmos", + "start": "env COSMOS_HOSTNAME=localhost CONFIG_FILE=./config_dev.json EZ=UTC build/cosmos", "build": " sh build.sh", "dev": "npm run build && npm run start", "dockerdevbuild": "sh build.sh && npm run client-build && docker build --tag cosmos-dev .", - "dockerdevrun": "docker stop cosmos-dev; docker rm cosmos-dev; docker run -d -p 80:80 -p 443:443 -e DOCKER_HOST=tcp://host.docker.internal:2375 -e COSMOS_HOSTNAME=localhost -e COSMOS_MONGODB=$MONGODB -e COSMOS_LOG_LEVEL=DEBUG --restart=unless-stopped -h cosmos-dev --name cosmos-dev cosmos-dev", + "dockerdevrun": "docker stop cosmos-dev; docker rm cosmos-dev; docker run -d -p 80:80 -p 443:443 -e DOCKER_HOST=tcp://host.docker.internal:2375 -e COSMOS_MONGODB=$MONGODB -e COSMOS_LOG_LEVEL=DEBUG --restart=unless-stopped -h cosmos-dev --name cosmos-dev cosmos-dev", "dockerdev": "npm run dockerdevbuild && npm run dockerdevrun", "demo": "vite build --base=/ui/ --mode demo", "devdemo": "vite --mode demo" diff --git a/src/docker/api_getcontainers.go b/src/docker/api_getcontainers.go new file mode 100644 index 0000000..b5711b6 --- /dev/null +++ b/src/docker/api_getcontainers.go @@ -0,0 +1,46 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/azukaar/cosmos-server/src/utils" +) + +func GetContainerRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + vars := mux.Vars(req) + containerId := vars["containerId"] + + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("GetContainerRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + // get Docker container + container, err := DockerClient.ContainerInspect(context.Background(), containerId) + if err != nil { + utils.Error("GetContainerRoute: Error while getting container", err) + utils.HTTPError(w, "Container Get Error: " + err.Error(), http.StatusInternalServerError, "LN002") + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": container, + }) + } else { + utils.Error("GetContainerRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} \ No newline at end of file diff --git a/src/docker/api_getlogs.go b/src/docker/api_getlogs.go new file mode 100644 index 0000000..ccfb5a7 --- /dev/null +++ b/src/docker/api_getlogs.go @@ -0,0 +1,140 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "bufio" + "io" + "strings" + "encoding/binary" + + "github.com/docker/docker/api/types" + "github.com/gorilla/mux" + "github.com/azukaar/cosmos-server/src/utils" +) + +type LogOutput struct { + StreamType byte `json:"streamType"` + Size uint32 `json:"size"` + Output string `json:"output"` +} + +// 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) { + var logOutput LogOutput + logOutput.StreamType = 1 // assume stdout if header not present + logOutput.Size = uint32(len(data)) + logOutput.Output = string(data) + + if len(data) < 8 { + return logOutput + } + + // check if the output contains a log header + hasHeader := true + streamType := data[0] + if(!(streamType >= 0 && streamType <= 2)) { + hasHeader = false + } + if(data[1] != 0 || data[2] != 0 || data[3] != 0) { + hasHeader = false + } + if hasHeader { + sizeBytes := data[4:8] + size := binary.BigEndian.Uint32(sizeBytes) + + output := string(data[8:]) + + logOutput.StreamType = streamType + logOutput.Size = size + logOutput.Output = output + } + + return logOutput +} + +func FilterLogs(logReader io.Reader, searchQuery string, limit int) []LogOutput { + scanner := bufio.NewScanner(logReader) + logLines := make([]LogOutput, 0) + + // Read all logs into a slice + for scanner.Scan() { + line := scanner.Text() + + if len(searchQuery) > 0 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) { + continue + } + + logLines = append(logLines, parseDockerLogHeader(([]byte)(line))) + } + + from := utils.Max(len(logLines)-limit, 0) + logLines = logLines[from:] + + return logLines +} + +func GetContainerLogsRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + vars := mux.Vars(req) + containerId := vars["containerId"] + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("GetContainerLogsRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + query := req.URL.Query() + limit := 100 + lastReceivedLogs := "" + + if query.Get("limit") != "" { + limit, _ = strconv.Atoi(query.Get("limit")) + } + + if query.Get("lastReceivedLogs") != "" { + lastReceivedLogs = query.Get("lastReceivedLogs") + } + + errorOnly := false + if query.Get("errorOnly") != "" { + errorOnly, _ = strconv.ParseBool(query.Get("errorOnly")) + } + + options := types.ContainerLogsOptions{ + ShowStdout: !errorOnly, + ShowStderr: true, + Timestamps: true, + Until: lastReceivedLogs, + } + + logReader, err := DockerClient.ContainerLogs(context.Background(), containerId, options) + if err != nil { + utils.Error("GetContainerLogsRoute: Error while getting container logs", err) + utils.HTTPError(w, "Container Logs Error: "+err.Error(), http.StatusInternalServerError, "LN002") + return + } + defer logReader.Close() + + lines := FilterLogs(logReader, query.Get("search"), limit) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": lines, + }) + } else { + utils.Error("GetContainerLogsRoute: Method not allowed "+req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} \ No newline at end of file diff --git a/src/docker/api_managecont.go b/src/docker/api_managecont.go index 35cdd28..d03c0e8 100644 --- a/src/docker/api_managecont.go +++ b/src/docker/api_managecont.go @@ -9,7 +9,6 @@ import ( "github.com/azukaar/cosmos-server/src/utils" "github.com/gorilla/mux" - // "github.com/docker/docker/client" contstuff "github.com/docker/docker/api/types/container" doctype "github.com/docker/docker/api/types" ) diff --git a/src/docker/api_networks.go b/src/docker/api_networks.go new file mode 100644 index 0000000..9bad5ac --- /dev/null +++ b/src/docker/api_networks.go @@ -0,0 +1,80 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/azukaar/cosmos-server/src/utils" + "github.com/docker/docker/api/types" +) + +func ListNetworksRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("ListNetworksRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + // List Docker networks + networks, err := DockerClient.NetworkList(context.Background(), types.NetworkListOptions{}) + if err != nil { + utils.Error("ListNetworksRoute: Error while getting networks", err) + utils.HTTPError(w, "Networks Get Error: " + err.Error(), http.StatusInternalServerError, "LN002") + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": networks, + }) + } else { + utils.Error("ListNetworksRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} + + +func DeleteNetworkRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + if req.Method == "DELETE" { + // Get the network ID from URL + vars := mux.Vars(req) + networkID := vars["networkID"] + + errD := Connect() + if errD != nil { + utils.Error("DeleteNetworkRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DN001") + return + } + + // Delete the specified Docker network + err := DockerClient.NetworkRemove(context.Background(), networkID) + if err != nil { + utils.Error("DeleteNetworkRoute: Error while deleting network", err) + utils.HTTPError(w, "Network Deletion Error: " + err.Error(), http.StatusInternalServerError, "DN002") + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "message": "Network deleted successfully", + }) + } else { + utils.Error("DeleteNetworkRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} \ No newline at end of file diff --git a/src/docker/api_secureContainer.go b/src/docker/api_secureContainer.go index c63ca25..6328204 100644 --- a/src/docker/api_secureContainer.go +++ b/src/docker/api_secureContainer.go @@ -35,7 +35,7 @@ func SecureContainerRoute(w http.ResponseWriter, req *http.Request) { _, errEdit := EditContainer(container.ID, container) if errEdit != nil { utils.Error("ContainerSecureEdit", errEdit) - utils.HTTPError(w, "Internal server error: " + err.Error(), http.StatusInternalServerError, "DS003") + utils.HTTPError(w, "Internal server error: " + errEdit.Error(), http.StatusInternalServerError, "DS003") return } diff --git a/src/docker/api_volumes.go b/src/docker/api_volumes.go new file mode 100644 index 0000000..5a1d6ad --- /dev/null +++ b/src/docker/api_volumes.go @@ -0,0 +1,79 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/azukaar/cosmos-server/src/utils" + filters "github.com/docker/docker/api/types/filters" +) + +func ListVolumeRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("ManageContainer", errD) + utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "LV001") + return + } + + // List Docker volumes + volumes, err := DockerClient.VolumeList(context.Background(), filters.Args{}) + if err != nil { + utils.Error("ListVolumeRoute: Error while getting volumes", err) + utils.HTTPError(w, "Volumes Get Error", http.StatusInternalServerError, "LV002") + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": volumes, + }) + } else { + utils.Error("ListVolumeRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} + +func DeleteVolumeRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + if req.Method == "DELETE" { + // Get the volume name from URL + vars := mux.Vars(req) + volumeName := vars["volumeName"] + + errD := Connect() + if errD != nil { + utils.Error("DeleteVolumeRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DV001") + return + } + + // Delete the specified Docker volume + err := DockerClient.VolumeRemove(context.Background(), volumeName, true) + if err != nil { + utils.Error("DeleteVolumeRoute: Error while deleting volume", err) + utils.HTTPError(w, "Volume Deletion Error", http.StatusInternalServerError, "DV002") + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "message": "Volume deleted successfully", + }) + } else { + utils.Error("DeleteVolumeRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} \ No newline at end of file diff --git a/src/httpServer.go b/src/httpServer.go index 0e85493..8315872 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -24,7 +24,7 @@ var serverPortHTTP = "" var serverPortHTTPS = "" func startHTTPServer(router *mux.Router) { - utils.Log("Listening to HTTP on :" + serverPortHTTP) + utils.Log("Listening to HTTP on : 0.0.0.0:" + serverPortHTTP) err := http.ListenAndServe("0.0.0.0:" + serverPortHTTP, router) @@ -35,15 +35,14 @@ func startHTTPServer(router *mux.Router) { func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) { config := utils.GetMainConfig() - serverHostname := "0.0.0.0" cfg := simplecert.Default cfg.Domains = utils.GetAllHostnames() cfg.CacheDir = "/config/certificates" cfg.SSLEmail = config.HTTPConfig.SSLEmail - cfg.HTTPAddress = serverHostname+":"+serverPortHTTP - cfg.TLSAddress = serverHostname+":"+serverPortHTTPS + cfg.HTTPAddress = "0.0.0.0:"+serverPortHTTP + cfg.TLSAddress = "0.0.0.0:"+serverPortHTTPS if config.HTTPConfig.DNSChallengeProvider != "" { cfg.DNSProvider = config.HTTPConfig.DNSChallengeProvider @@ -59,7 +58,7 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) { certReloader, errSimCert = simplecert.Init(cfg, nil) if errSimCert != nil { // Temporary before we have a better way to handle this - utils.Error("simplecert init failed, HTTPS wont renew", errSimCert) + utils.Error("Failed to Init Let's Encrypt. HTTPS wont renew", errSimCert) startHTTPServer(router) return } @@ -106,7 +105,7 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) { server := http.Server{ TLSConfig: tlsConf, - Addr: serverHostname + ":" + serverPortHTTPS, + Addr: "0.0.0.0:" + serverPortHTTPS, ReadTimeout: 0, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 0, @@ -150,7 +149,6 @@ func StartServer() { HTTPConfig := config.HTTPConfig serverPortHTTP = HTTPConfig.HTTPPort serverPortHTTPS = HTTPConfig.HTTPSPort - // serverHostname := HTTPConfig.Hostname var tlsCert = HTTPConfig.TLSCert var tlsKey= HTTPConfig.TLSKey @@ -219,14 +217,22 @@ func StartServer() { srapi.HandleFunc("/api/users/{nickname}", user.UsersIdRoute) srapi.HandleFunc("/api/users", user.UsersRoute) + + srapi.HandleFunc("/api/volume/{volumeName}", docker.DeleteVolumeRoute) + srapi.HandleFunc("/api/volumes", docker.ListVolumeRoute) + + srapi.HandleFunc("/api/network/{networkID}", docker.DeleteNetworkRoute) + srapi.HandleFunc("/api/networks", docker.ListNetworksRoute) 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}/", docker.GetContainerRoute) srapi.HandleFunc("/api/servapps", docker.ContainersRoute) - // if(!config.HTTPConfig.AcceptAllInsecureHostname) { - // srapi.Use(utils.EnsureHostname(serverHostname)) - // } + if(!config.HTTPConfig.AcceptAllInsecureHostname) { + srapi.Use(utils.EnsureHostname) + } srapi.Use(tokenMiddleware) srapi.Use(proxy.SmartShieldMiddleware( @@ -236,10 +242,10 @@ func StartServer() { PerUserRequestLimit: 5000, }, )) - srapi.Use(utils.MiddlewareTimeout(20 * time.Second)) + srapi.Use(utils.MiddlewareTimeout(30 * time.Second)) srapi.Use(utils.BlockPostWithoutReferer) srapi.Use(proxy.BotDetectionMiddleware) - srapi.Use(httprate.Limit(60, 1*time.Minute, + srapi.Use(httprate.Limit(120, 1*time.Minute, httprate.WithKeyFuncs(httprate.KeyByIP), httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { utils.Error("Too many requests. Throttling", nil) @@ -258,9 +264,9 @@ func StartServer() { fs := spa.SpaHandler(pwd + "/static", "index.html") - // if(!config.HTTPConfig.AcceptAllInsecureHostname) { - // fs = utils.EnsureHostname(serverHostname)(fs) - // } + if(!config.HTTPConfig.AcceptAllInsecureHostname) { + fs = utils.EnsureHostname(fs) + } router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", fs)) diff --git a/src/status.go b/src/status.go index cfe5155..be3553f 100644 --- a/src/status.go +++ b/src/status.go @@ -45,6 +45,7 @@ func StatusRoute(w http.ResponseWriter, req *http.Request) { "HTTPSCertificateMode": utils.GetMainConfig().HTTPConfig.HTTPSCertificateMode, "needsRestart": utils.NeedsRestart, "newVersionAvailable": utils.NewVersionAvailable, + "hostname": utils.GetMainConfig().HTTPConfig.Hostname, }, }) } else { diff --git a/src/utils/middleware.go b/src/utils/middleware.go index dfa0801..ecaa143 100644 --- a/src/utils/middleware.go +++ b/src/utils/middleware.go @@ -166,3 +166,37 @@ func BlockPostWithoutReferer(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func EnsureHostname(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Debug("Request requested resource from : " + r.Host) + + og := GetMainConfig().HTTPConfig.Hostname + ni := GetMainConfig().NewInstall + + if ni || og == "0.0.0.0" { + next.ServeHTTP(w, r) + return + } + + port := "" + if (IsHTTPS && MainConfig.HTTPConfig.HTTPSPort != "443") { + port = ":" + MainConfig.HTTPConfig.HTTPSPort + } else if (!IsHTTPS && MainConfig.HTTPConfig.HTTPPort != "80") { + port = ":" + MainConfig.HTTPConfig.HTTPPort + } + + hostnames := GetAllHostnames() + + for _, hostname := range hostnames { + if r.Host != hostname + port { + Error("Invalid Hostname " + r.Host + "for request. Expecting " + hostname, nil) + w.WriteHeader(http.StatusBadRequest) + http.Error(w, "Bad Request: Invalid hostname.", http.StatusBadRequest) + return + } + } + + next.ServeHTTP(w, r) + }) +} \ No newline at end of file diff --git a/src/utils/utils.go b/src/utils/utils.go index f57e20d..fe6d0e4 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -68,7 +68,7 @@ var DefaultConfig = Config{ GenerateMissingAuthCert: true, HTTPPort: "80", HTTPSPort: "443", - Hostname: "localhost", + Hostname: "0.0.0.0", ProxyConfig: ProxyConfig{ Routes: []ProxyRouteConfig{}, }, @@ -230,26 +230,6 @@ func GetConfigFileName() string { return configFile } -func EnsureHostname(hostname string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Debug("Request requested resource from : " + r.Host) - port := "" - if (IsHTTPS && MainConfig.HTTPConfig.HTTPSPort != "443") { - port = ":" + MainConfig.HTTPConfig.HTTPSPort - } else if (!IsHTTPS && MainConfig.HTTPConfig.HTTPPort != "80") { - port = ":" + MainConfig.HTTPConfig.HTTPPort - } - if r.Host != hostname + port { - Error("Invalid Hostname " + r.Host + "for request. Expecting " + hostname, nil) - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, "Bad Request.") - return - } - next.ServeHTTP(w, r) - }) - } -} func CreateDefaultConfigFileIfNecessary() bool { configFile := GetConfigFileName() @@ -420,3 +400,10 @@ func ImageToBase64(path string) (string, error) { dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encodedData) return dataURI, nil } + +func Max(x, y int) int { + if x < y { + return y + } + return x +} \ No newline at end of file