[release] v0.4.0-unstable3

This commit is contained in:
Yann Stepienik 2023-05-06 19:25:10 +01:00
parent 8ce9d52fbd
commit ac6fbe64e7
33 changed files with 1706 additions and 268 deletions

View file

@ -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

View file

@ -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
};

View file

@ -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)

View file

@ -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, '<br>')
.replace(/ /g, '&nbsp;')
.replace(/<2F>/g, '')
.replace(/\x1b\[([0-9]{1,2}(?:;[0-9]{1,2})*)?m/g, (match, p1) => {
if (!p1) {
return '</span>';
}
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 `<span style="${styles.join(';')}">`;
});
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 <Stack direction={isMobile ? 'column' : 'row'} spacing={1}>
<div style={{color:'grey', fontStyle:'italic', whiteSpace: 'pre'}}>
{parts[0].replace('T', ' ').split('.')[0]}
</div>
<div dangerouslySetInnerHTML={{ __html: restString }} />
</Stack>;
}
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
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;

View file

@ -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) => (
<Tab key={index} label={tab.title} {...a11yProps(index)} />
<Tab
style={{fontWeight: !tab.children ? '1000' : '', }}
disabled={!tab.children} key={index}
label={tab.title} {...a11yProps(index)}
/>
))}
</StyledTabs>
)}
{tabs.map((tab, index) => (
{!isLoading && tabs.map((tab, index) => (
<TabPanel key={index} value={value} index={index}>
{tab.children}
</TabPanel>
))}
{isLoading && (
<Box
display="flex"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
color="text.primary"
p={2}
>
<CircularProgress />
</Box>
)}
</Box>
);
};

View file

@ -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 (

View file

@ -61,4 +61,8 @@
color:white;
background-color: rgba(0,0,0,0.8);
}
}
.darken {
filter: brightness(0.5);
}

View file

@ -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 <div>
<IsLoggedIn />
<h2>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">

View file

@ -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 }) => {
</div>
<Stack spacing={2} >
<strong>Description</strong>
<div>{routeConfig.Description}</div>
<div style={info}>{routeConfig.Description}</div>
<strong>URL</strong>
<div><HostChip route={routeConfig} /></div>
<strong>Target</strong>

View file

@ -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.
</div>
<div>
{status && <div>
HTTPS Certificate Mode is currently: <b>{status.HTTPSCertificateMode}</b>
</div>}
{status && <>
<div>
HTTPS Certificate Mode is currently: <b>{status.HTTPSCertificateMode}</b>
</div>
<div>
Hostname is currently: <b>{status.hostname}</b>
</div>
</>}
</div>
<div>
<Formik
@ -245,8 +257,10 @@ const NewInstall = () => {
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" && (
<>
<Alert severity="warning">
If you are using Cloudflare, make sure the DNS record is <strong>NOT</strong> set to <b>Proxied</b> (you should not see the orange cloud but a grey one).
Otherwise Cloudflare will not allow Let's Encrypt to verify your domain.
</Alert>
<CosmosInputText
name="SSLEmail"
label="Let's Encrypt Email"
@ -457,7 +475,12 @@ const NewInstall = () => {
<Button
variant="contained"
startIcon={<LeftOutlined />}
onClick={() => setActiveStep(activeStep - 1)}
onClick={() => {
if(activeStep == 5 && !databaseEnable) {
setActiveStep(activeStep - 2)
}
setActiveStep(activeStep - 1)
}}
disabled={activeStep <= 0}
>Back</Button>
@ -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)

View file

@ -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: <IconButton className="shinyButton" color='primary' onClick={() => {doTo('update')}} size='large'>
<UpCircleOutlined />
</IconButton>
},
{
t: 'Start',
if: ['exited', 'created'],
e: <IconButton onClick={() => {doTo('start')}} size='large'>
<PlaySquareOutlined />
</IconButton>
},
{
t: 'Unpause',
if: ['paused'],
e: <IconButton onClick={() => {doTo('unpause')}} size='large'>
<PlaySquareOutlined />
</IconButton>
},
{
t: 'Pause',
if: ['running'],
e: <IconButton onClick={() => {doTo('pause')}} size='large'>
<PauseCircleOutlined />
</IconButton>
},
{
t: 'Stop',
if: ['paused', 'restarting', 'running'],
e: <IconButton onClick={() => {doTo('stop')}} size='large' variant="outlined">
<StopOutlined />
</IconButton>
},
{
t: 'Restart',
if: ['exited', 'running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('restart')} size='large'>
<ReloadOutlined />
</IconButton>
},
{
t: 'Re-create',
if: ['exited', 'running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('recreate')} color="error" size='large'>
<RollbackOutlined />
</IconButton>
},
{
t: 'Kill',
if: ['running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('kill')} color="error" size='large'>
<CloseSquareOutlined />
</IconButton>
},
{
t: 'Delete',
if: ['exited', 'created'],
e: <IconButton onClick={() => {doTo('remove')}} color="error" size='large'>
<DeleteOutlined />
</IconButton>
}
];
return actions.filter((action) => {
let updateAvailable = false;
return action.if.includes(state) ?? (updateAvailable && action.if.includes('update_available'));
}).map((action) => {
return <Tooltip title={action.t}>{action.e}</Tooltip>
});
}
export default GetActions;

View file

@ -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 <div>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Back />
<div>{containerName}</div>
</Stack>
<IsLoggedIn />
<PrettyTabbedView
isLoading={!container || !config}
tabs={[
{
title: 'Overview',
children: <ContainerOverview refresh={refreshContainer} containerInfo={container} config={config}/>
},
{
title: 'Logs',
children: <Logs containerInfo={container} config={config}/>
},
{
title: 'Terminal',
children: <Logs containerInfo={container} config={config}/>
},
{
title: 'Links',
children: <div>Links</div>
},
// {
// title: 'Advanced'
// },
{
title: 'Setup',
children: <div>Image, Restart Policy, Environment Variables, Labels, etc...</div>
},
{
title: 'Network',
children: <div>Urls, Networks, Ports, etc...</div>
},
{
title: 'Volumes',
children: <div>Volumes</div>
},
{
title: 'Resources',
children: <div>Runtime Resources, Capabilities...</div>
},
]} />
</Stack>
</div>;
}
export default ContainerIndex;

View file

@ -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 (
<Stack spacing={2} sx={{ width: '100%'}}>
<Stack
spacing={2}
direction="column"
>
<Stack direction={screenMin ? 'row' : 'column'} spacing={3}>
<Stack direction="row" spacing={3}>
<Input
label="Search"
value={searchTerm}
placeholder="Search..."
onChange={(e) => {
setHasScrolled(false);
setSearchTerm(e.target.value);
setLastReceivedLogs('');
}}
/>
<Box>
<Checkbox
checked={errorOnly}
onChange={(e) => {
setHasScrolled(false);
setErrorOnly(e.target.checked);
setLastReceivedLogs('');
}}
/>
Error Only
</Box>
</Stack>
<Stack direction="row" spacing={3}>
<Box>
<TextField
label="Limit"
type="number"
value={limit}
onChange={(e) => {
setHasScrolled(false);
setLimit(e.target.value);
setLastReceivedLogs('');
}}
/>
</Box>
<Button
variant="outlined"
onClick={() => {
setHasScrolled(false);
setLastReceivedLogs('');
fetchLogs(true, true);
}}
>
Refresh
</Button>
</Stack>
</Stack>
{loading && <CircularProgress />}
</Stack>
<Box
ref={terminalRef}
sx={{
maxHeight: 'calc(1vh * 80 - 200px)',
overflow: 'auto',
paddingRight: '10px',
paddingLeft: '10px',
paddingBottom: '10px',
wordBreak: 'break-all',
background: '#272d36',
color: '#fff',
borderTop: '3px solid ' + theme.palette.primary.main
}}
onScroll={handleScroll}
>
{logs.map((log, index) => (
<div key={log.index} style={{paddingTop: (!screenMin) ? '10px' : '2px'}}>
<LogLine message={log.output} docker isMobile={!screenMin} />
</div>
))}
{fetching && <CircularProgress sx={{ mt: 1, mb: 2 }} />}
<div ref={bottomRef} />
</Box>
</Stack>
);
};
export default Logs;

View file

@ -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 (
<div style={{ maxWidth: '1000px', width: '100%' }}>
<RestartModal openModal={openRestartModal} setOpenModal={setOpenRestartModal} />
<ExposeModal
openModal={openModal}
setOpenModal={setOpenModal}
container={containerInfo}
config={config}
updateRoutes={
(_newRoute) => {
updateRoutes(_newRoute);
setOpenModal(false);
setOpenRestartModal(true);
}
}
/>
<MainCard name={Name} title={<div>{Name}</div>}>
<Stack spacing={2} direction={isMobile ? 'column' : 'row'} alignItems={isMobile ? 'center' : 'flex-start'}>
<Stack spacing={2} direction={'column'} justifyContent={'center'} alignItems={'center'}>
<div style={{ position: 'relative' }}>
<img className={isUpdating ? 'darken' : ''} src={getFaviconURL(routes && routes[0])} width="128px" />
{isUpdating ? (
<CircularProgress
style={{ position: 'absolute', top: 'calc(50% - 22.5px)', left: 'calc(50% - 22.5px)' }}
width="128px"
/>
) : null}
</div>
<div>
{({
"created": <Chip label="Created" color="warning" />,
"restarting": <Chip label="Restarting" color="warning" />,
"running": <Chip label="Running" color="success" />,
"removing": <Chip label="Removing" color="error" />,
"paused": <Chip label="Paused" color="info" />,
"exited": <Chip label="Exited" color="error" />,
"dead": <Chip label="Dead" color="error" />,
})[State.Status]}
</div>
</Stack>
<Stack spacing={2} style={{ width: '100%' }} >
<Stack spacing={2} direction={'row'} >
<GetActions
Id={containerInfo.Id}
state={State.Status}
refreshServeApps={() => {
refreshAll()
}}
setIsUpdatingId={() => {
setIsUpdating(true);
}}
/>
</Stack>
<strong><ContainerOutlined /> Image</strong>
<div style={info}>{Image}</div>
<strong><DesktopOutlined /> Name</strong>
<div style={info}>{Name}</div>
<strong><InfoCircleOutlined /> IP Address</strong>
<div style={info}>{IPAddress}</div>
<strong>
<SafetyCertificateOutlined/> Health
</strong>
<div style={info}>{healthStatus}</div>
<strong><SettingOutlined /> Settings {State.Status !== 'running' ? '(Start container to edit)' : ''}</strong>
<Stack style={{ fontSize: '80%' }} direction={"row"} alignItems="center">
<Checkbox
checked={Config.Labels['cosmos-force-network-secured'] === 'true'}
disabled={State.Status !== 'running' || isUpdating}
onChange={(e) => {
setIsUpdating(true);
API.docker.secure(Name, e.target.checked).then(() => {
setTimeout(() => {
refreshAll();
}, 3000);
})
}}
/> Force Secure Network
</Stack>
<strong><NodeExpandOutlined /> URLs</strong>
<div>
{routes.map((route) => {
return <HostChip route={route} settings style={{margin: '5px'}}/>
})}
<br />
<Chip
label="New"
color="primary"
style={{paddingRight: '4px', margin: '5px'}}
deleteIcon={<PlusCircleOutlined />}
onClick={() => {
addNewRoute();
}}
onDelete={() => {
addNewRoute();
}}
/>
</div>
</Stack>
</Stack>
</MainCard>
</div>
);
};
export default ContainerOverview;

View file

@ -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 <Dialog open={openModal} onClose={() => setOpenModal(false)}>
<DialogTitle>Expose ServApp</DialogTitle>
{openModal && <>
<DialogContent>
<DialogContentText>
<Stack spacing={2}>
<div>
Welcome to the URL Wizard. This interface will help you expose your ServApp securely to the internet by creating a new URL.
</div>
<div>
{openModal && !hasCosmosNetwork(containerName) && <Alert severity="warning">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.</Alert>}
</div>
<div>
<RouteManagement TargetContainer={openModal}
routeConfig={{
Target: "http://"+containerName.replace('/', '') + ":",
Mode: "SERVAPP",
Name: containerName.replace('/', ''),
Description: "Expose " + containerName.replace('/', '') + " to the internet",
UseHost: true,
Host: getHostnameFromName(containerName),
UsePathPrefix: false,
PathPrefix: '',
CORSOrigin: '',
StripPathPrefix: false,
AuthEnabled: false,
Timeout: 14400000,
ThrottlePerMinute: 10000,
BlockCommonBots: true,
SmartShield: {
Enabled: true,
}
}}
routeNames={config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name)}
setRouteConfig={(_newRoute) => {
setNewRoute(sanitizeRoute(_newRoute));
}}
up={() => {}}
down={() => {}}
deleteRoute={() => {}}
noControls
lockTarget
/>
</div>
</Stack>
</DialogContentText>
</DialogContent>
<DialogActions>
{submitErrors && submitErrors.length > 0 && <Stack spacing={2} direction={"column"}>
<Alert severity="error">{submitErrors.map((err) => {
return <div>{err}</div>
})}</Alert>
</Stack>}
<Button onClick={() => setOpenModal(false)}>Cancel</Button>
<Button onClick={() => {
let errors = ValidateRoute(newRoute, config);
if (errors && errors.length > 0) {
errors = errors.map((err) => {
return `${err}`;
});
setSubmitErrors(errors);
return true;
} else {
setSubmitErrors([]);
updateRoutes(newRoute);
}
}}>Confirm</Button>
</DialogActions>
</>}
</Dialog>
};
export default ExposeModal;

View file

@ -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 <div>
<IsLoggedIn />
<PrettyTabbedView path="/ui/servapps/:tab" tabs={[
{
title: 'Containers',
children: <ServeApps />,
path: 'containers'
},
{
title: 'Volumes',
children: <VolumeManagementList />,
path: 'volumes'
},
{
title: 'Networks',
children: <NetworkManagementList />,
path: 'networks'
},
]}/>
</div>;
}
export default ServappsIndex;

View file

@ -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 (
<>
<Stack direction='row' spacing={1} style={{ marginBottom: '20px' }}>
<Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={refresh}>
Refresh
</Button>
</Stack>
{isLoading && (<div style={{ height: '550px' }}>
<center>
<br />
<CircularProgress />
</center>
</div>
)}
{!isLoading && rows && (
<PrettyTableView
data={rows}
onRowClick={() => { }}
getKey={(r) => r.Id}
columns={[
{
title: 'Network Name',
field: (r) => <Stack direction='column'>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize:'125%', color: isDark ? theme.palette.primary.light : theme.palette.primary.dark}}>{r.Name}</div><br/>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize: '90%', opacity: '90%'}}>{r.Driver} driver</div>
</Stack>,
search: (r) => r.Name,
},
{
title: 'Properties',
screenMin: 'md',
field: (r) => (
<Stack direction="row" spacing={1}>
<Chip label={r.Scope} color="primary" />
{r.Internal && <Chip label="Internal" color="secondary" />}
{r.Attachable && <Chip label="Attachable" color="success" />}
{r.Ingress && <Chip label="Ingress" color="warning" />}
</Stack>
),
},
{
title: 'IPAM gateway / mask',
screenMin: 'lg',
field: (r) => r.IPAM.Config.map((config, index) => (
<div key={index}>
{config.Gateway} / {config.Subnet}
</div>
)),
},
{
title: 'Created At',
screenMin: 'lg',
field: (r) => new Date(r.Created).toLocaleString(),
},
{
title: '',
clickable: true,
field: (r) => (
<>
<Button
variant="contained"
color="error"
startIcon={<DeleteOutlined />}
onClick={() => {
if (tryDelete === r.Id) {
setIsLoading(true);
API.docker.networkDelete(r.Id).then(() => {
refresh();
setIsLoading(false);
});
} else {
setTryDelete(r.Id);
}
}}
>
{tryDelete === r.Id ? "Really?" : "Delete"}
</Button>
</>
),
},
]}
/>
)}
</>
);
}
export default NetworkManagementList;

View file

@ -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: <IconButton className="shinyButton" color='primary' onClick={() => {doTo('update')}} size='large'>
<UpCircleOutlined />
</IconButton>
},
{
t: 'Start',
if: ['exited'],
e: <IconButton onClick={() => {doTo('start')}} size='large'>
<PlaySquareOutlined />
</IconButton>
},
{
t: 'Unpause',
if: ['paused'],
e: <IconButton onClick={() => {doTo('unpause')}} size='large'>
<PlaySquareOutlined />
</IconButton>
},
{
t: 'Pause',
if: ['running'],
e: <IconButton onClick={() => {doTo('pause')}} size='large'>
<PauseCircleOutlined />
</IconButton>
},
{
t: 'Stop',
if: ['created', 'paused', 'restarting', 'running'],
e: <IconButton onClick={() => {doTo('stop')}} size='large' variant="outlined">
<StopOutlined />
</IconButton>
},
{
t: 'Restart',
if: ['exited', 'running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('restart')} size='large'>
<ReloadOutlined />
</IconButton>
},
{
t: 'Re-create',
if: ['exited', 'running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('recreate')} color="error" size='large'>
<RollbackOutlined />
</IconButton>
},
{
t: 'Delete',
if: ['exited'],
e: <IconButton onClick={() => {doTo('remove')}} color="error" size='large'>
<DeleteOutlined />
</IconButton>
},
{
t: 'Kill',
if: ['running', 'paused', 'created', 'restarting'],
e: <IconButton onClick={() => doTo('kill')} color="error" size='large'>
<CloseSquareOutlined />
</IconButton>
}
];
return actions.filter((action) => {
let updateAvailable = false;
return action.if.includes(app.State) ?? (updateAvailable && action.if.includes('update_available'));
}).map((action) => {
return <Tooltip title={action.t}>{action.e}</Tooltip>
});
}
return <div>
<IsLoggedIn />
<RestartModal openModal={openRestartModal} setOpenModal={setOpenRestartModal} />
<Dialog open={openModal} onClose={() => setOpenModal(false)}>
<DialogTitle>Expose ServApp</DialogTitle>
{openModal && <>
<DialogContent>
<DialogContentText>
<Stack spacing={2}>
<div>
Welcome to the URL Wizard. This interface will help you expose your ServApp securely to the internet by creating a new URL.
</div>
<div>
{openModal && !hasCosmosNetwork(openModal.Names[0]) && <Alert severity="warning">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.</Alert>}
</div>
<div>
<RouteManagement TargetContainer={openModal}
routeConfig={{
Target: "http://"+openModal.Names[0].replace('/', '') + ":",
Mode: "SERVAPP",
Name: openModal.Names[0].replace('/', ''),
Description: "Expose " + openModal.Names[0].replace('/', '') + " to the internet",
UseHost: true,
Host: getHostnameFromName(openModal.Names[0]),
UsePathPrefix: false,
PathPrefix: '',
CORSOrigin: '',
StripPathPrefix: false,
AuthEnabled: false,
Timeout: 14400000,
ThrottlePerMinute: 10000,
BlockCommonBots: true,
SmartShield: {
Enabled: true,
}
}}
routeNames={config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name)}
setRouteConfig={(_newRoute) => {
setNewRoute(sanitizeRoute(_newRoute));
}}
up={() => {}}
down={() => {}}
deleteRoute={() => {}}
noControls
lockTarget
/>
</div>
</Stack>
</DialogContentText>
</DialogContent>
<DialogActions>
{submitErrors && submitErrors.length > 0 && <Stack spacing={2} direction={"column"}>
<Alert severity="error">{submitErrors.map((err) => {
return <div>{err}</div>
})}</Alert>
</Stack>}
<Button onClick={() => setOpenModal(false)}>Cancel</Button>
<Button onClick={() => {
let errors = ValidateRoute(newRoute, config);
if (errors && errors.length > 0) {
errors = errors.map((err) => {
return `${err}`;
});
setSubmitErrors(errors);
return true;
} else {
setSubmitErrors([]);
updateRoutes();
}
}}>Confirm</Button>
</DialogActions>
</>}
</Dialog>
<ExposeModal
openModal={openModal}
setOpenModal={setOpenModal}
container={serveApps.find((app) => {
return app.Names[0].replace('/', '') === openModal && openModal.Names[0].replace('/', '');
})}
config={config}
updateRoutes={
(_newRoute) => {
updateRoutes(_newRoute);
}
}
/>
<Stack spacing={2}>
<Stack direction="row" spacing={2}>
@ -306,7 +150,7 @@ const ServeApps = () => {
</Tooltip>
</Stack>
<Grid2 container spacing={2}>
<Grid2 container spacing={2}>
{serveApps && serveApps.filter(app => search.length < 2 || app.Names[0].toLowerCase().includes(search.toLowerCase())).map((app) => {
return <Grid2 style={gridAnim} xs={12} sm={6} md={6} lg={6} xl={4}>
<Item>
@ -342,8 +186,7 @@ const ServeApps = () => {
{/* <Button variant="contained" size="small" onClick={() => {}}>
Update
</Button> */}
{getActions(app)}
<GetActions Id={app.Id} state={app.State} setIsUpdatingId={setIsUpdatingId} refreshServeApps={refreshServeApps} />
</Stack>
</Stack>
<Stack margin={1} direction="column" spacing={1} alignItems="flex-start">
@ -369,35 +212,39 @@ const ServeApps = () => {
</Stack>
</Stack>
{isUpdating[app.Id] ? <div>
<CircularProgress color="inherit" />
</div> :
<Stack margin={1} direction="column" spacing={1} alignItems="flex-start">
<Typography variant="h6" color="text.secondary">
Settings
</Typography>
<Stack style={{ fontSize: '80%' }} direction={"row"} alignItems="center">
<Checkbox
checked={app.Labels['cosmos-force-network-secured'] === 'true'}
onChange={(e) => {
setIsUpdatingId(app.Id, true);
API.docker.secure(app.Id, e.target.checked).then(() => {
setTimeout(() => {
setIsUpdatingId(app.Id, false);
refreshServeApps();
}, 3000);
})
}}
/> Force Secure Network
</Stack></Stack>}
<CircularProgress color="inherit" />
</div>
:
<Stack margin={1} direction="column" spacing={1} alignItems="flex-start">
<Typography variant="h6" color="text.secondary">
Settings {app.State !== 'running' ? '(Start container to edit)' : ''}
</Typography>
<Stack style={{ fontSize: '80%' }} direction={"row"} alignItems="center">
<Checkbox
checked={app.Labels['cosmos-force-network-secured'] === 'true'}
disabled={app.State !== 'running'}
onChange={(e) => {
setIsUpdatingId(app.Id, true);
API.docker.secure(app.Id, e.target.checked).then(() => {
setTimeout(() => {
setIsUpdatingId(app.Id, false);
refreshServeApps();
}, 3000);
})
}}
/> Force Secure Network
</Stack>
</Stack>
}
<Stack margin={1} direction="column" spacing={1} alignItems="flex-start">
<Typography variant="h6" color="text.secondary">
URLs
</Typography>
<Stack style={noOver} spacing={2} direction="row">
{getContainersRoutes(app.Names[0].replace('/', '')).map((route) => {
{getContainersRoutes(config, app.Names[0].replace('/', '')).map((route) => {
return <HostChip route={route} settings/>
})}
{/* {getContainersRoutes(app.Names[0].replace('/', '')).length == 0 && */}
{/* {getContainersRoutes(config, app.Names[0].replace('/', '')).length == 0 && */}
<Chip
label="New"
color="primary"
@ -413,6 +260,11 @@ const ServeApps = () => {
{/* } */}
</Stack>
</Stack>
<div>
<Link to={`/ui/servapps/containers/${app.Names[0].replace('/', '')}`}>
<Button variant="outlined" color="primary" fullWidth>Details</Button>
</Link>
</div>
{/* <Stack>
<Button variant="contained" color="primary" onClick={() => {
setOpenModal(app);

View file

@ -0,0 +1,116 @@
// material-ui
import { AppstoreAddOutlined, CloseSquareOutlined, DeleteOutlined, PauseCircleOutlined, PlaySquareOutlined, PlusCircleOutlined, ReloadOutlined, RollbackOutlined, SearchOutlined, SettingOutlined, StopOutlined, SyncOutlined, UpCircleOutlined, UpSquareFilled } from '@ant-design/icons';
import { Alert, Badge, Button, Card, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, IconButton, Input, InputAdornment, TextField, Tooltip, Typography, useTheme } from '@mui/material';
import Grid2 from '@mui/material/Unstable_Grid2/Grid2';
import { Stack } from '@mui/system';
import { useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import { styled } from '@mui/material/styles';
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 HostChip from '../../components/hostChip';
import PrettyTableView from '../../components/tableView/prettyTableView';
const VolumeManagementList = () => {
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.volumeList()
.then(data => {
setRows(data.data.Volumes);
setIsLoading(false);
});
}
useEffect(() => {
refresh();
}, [])
return (
<>
<Stack direction='row' spacing={1} style={{ marginBottom: '20px' }}>
<Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={refresh}>
Refresh
</Button>
</Stack>
{isLoading && (<div style={{height: '550px'}}>
<center>
<br />
<CircularProgress />
</center>
</div>
)}
{!isLoading && rows && (
<PrettyTableView
data={rows}
onRowClick={() => {}}
getKey={(r) => r.Name}
columns={[
{
title: 'Volume Name',
field: (r) => <Stack direction='column'>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize:'125%', color: isDark ? theme.palette.primary.light : theme.palette.primary.dark}}>{r.Name}</div><br/>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize: '90%', opacity: '90%'}}>{r.Mountpoint}</div>
</Stack>,
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) => (
<>
<Button
variant="contained"
color="error"
startIcon={<DeleteOutlined />}
onClick={() => {
if(tryDelete === r.Name) {
setIsLoading(true);
API.docker.volumeDelete(r.Name).then(() => {
refresh();
setIsLoading(false);
});
} else {
setTryDelete(r.Name);
}
}}
>
{tryDelete === r.Name ? "Really?" : "Delete"}
</Button>
</>
),
},
]}
/>
)}
</>
);
};
export default VolumeManagementList;

View file

@ -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: <ServeApps />
element: <ServeAppsIndex />
},
{
path: '/ui/config-users',
@ -70,6 +71,10 @@ const MainRoutes = {
path: '/ui/config-url/:routeName',
element: <RouteConfigPage />,
},
{
path: '/ui/servapps/containers/:containerName',
element: <ContainerIndex />,
},
]
};

View file

@ -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)
})) || [];
}

73
package-lock.json generated
View file

@ -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",

View file

@ -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"

View file

@ -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
}
}

140
src/docker/api_getlogs.go Normal file
View file

@ -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
}
}

View file

@ -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"
)

View file

@ -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
}
}

View file

@ -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
}

79
src/docker/api_volumes.go Normal file
View file

@ -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
}
}

View file

@ -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))

View file

@ -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 {

View file

@ -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)
})
}

View file

@ -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
}