proxy: add routes HTML page (#5443)

* proxy: add route portal json

* fix 405 issue

* proxy: add routes HTML page
This commit is contained in:
Caleb Doxsey 2025-01-27 12:13:55 -07:00 committed by GitHub
parent e9786f9719
commit 97ba21b95a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 269 additions and 44 deletions

View file

@ -241,6 +241,7 @@ var internalPathsNeedingLogin = set.From([]string{
"/.pomerium/jwt", "/.pomerium/jwt",
"/.pomerium/user", "/.pomerium/user",
"/.pomerium/webauthn", "/.pomerium/webauthn",
"/.pomerium/routes",
"/.pomerium/api/v1/routes", "/.pomerium/api/v1/routes",
}) })

View file

@ -30,6 +30,7 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) *
if opts.IsRuntimeFlagSet(config.RuntimeFlagPomeriumJWTEndpoint) { if opts.IsRuntimeFlagSet(config.RuntimeFlagPomeriumJWTEndpoint) {
h.Path("/jwt").Handler(httputil.HandlerFunc(p.jwtAssertion)).Methods(http.MethodGet) h.Path("/jwt").Handler(httputil.HandlerFunc(p.jwtAssertion)).Methods(http.MethodGet)
} }
h.Path("/routes").Handler(httputil.HandlerFunc(p.routesPortalHTML)).Methods(http.MethodGet)
h.Path("/sign_out").Handler(httputil.HandlerFunc(p.SignOut)).Methods(http.MethodGet, http.MethodPost) h.Path("/sign_out").Handler(httputil.HandlerFunc(p.SignOut)).Methods(http.MethodGet, http.MethodPost)
h.Path("/user").Handler(httputil.HandlerFunc(p.jsonUserInfo)).Methods(http.MethodGet) h.Path("/user").Handler(httputil.HandlerFunc(p.jsonUserInfo)).Methods(http.MethodGet)
h.Path("/webauthn").Handler(p.webauthn) h.Path("/webauthn").Handler(p.webauthn)

View file

@ -8,8 +8,17 @@ import (
"github.com/pomerium/pomerium/internal/handlers" "github.com/pomerium/pomerium/internal/handlers"
"github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/proxy/portal" "github.com/pomerium/pomerium/proxy/portal"
"github.com/pomerium/pomerium/ui"
) )
func (p *Proxy) routesPortalHTML(w http.ResponseWriter, r *http.Request) error {
u := p.getUserInfoData(r)
rs := p.getPortalRoutes(u)
m := u.ToJSON()
m["routes"] = rs
return ui.ServePage(w, r, "Routes", "Routes Portal", m)
}
func (p *Proxy) routesPortalJSON(w http.ResponseWriter, r *http.Request) error { func (p *Proxy) routesPortalJSON(w http.ResponseWriter, r *http.Request) error {
u := p.getUserInfoData(r) u := p.getUserInfoData(r)
rs := p.getPortalRoutes(u) rs := p.getPortalRoutes(u)

View file

@ -4,15 +4,16 @@ import React, { FC, useLayoutEffect } from "react";
import ErrorPage from "./components/ErrorPage"; import ErrorPage from "./components/ErrorPage";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import Header from "./components/Header"; import Header from "./components/Header";
import RoutesPage from "./components/RoutesPage";
import SignOutConfirmPage from "./components/SignOutConfirmPage"; import SignOutConfirmPage from "./components/SignOutConfirmPage";
import SignedOutPage from "./components/SignedOutPage"; import SignedOutPage from "./components/SignedOutPage";
import { ToolbarOffset } from "./components/ToolbarOffset"; import { ToolbarOffset } from "./components/ToolbarOffset";
import UpstreamErrorPage from "./components/UpstreamErrorPage";
import UserInfoPage from "./components/UserInfoPage"; import UserInfoPage from "./components/UserInfoPage";
import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage"; import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage";
import { SubpageContextProvider } from "./context/Subpage"; import { SubpageContextProvider } from "./context/Subpage";
import { createTheme } from "./theme"; import { createTheme } from "./theme";
import { PageData } from "./types"; import { PageData } from "./types";
import UpstreamErrorPage from "./components/UpstreamErrorPage";
const App: FC = () => { const App: FC = () => {
const data = (window["POMERIUM_DATA"] || {}) as PageData; const data = (window["POMERIUM_DATA"] || {}) as PageData;
@ -20,8 +21,11 @@ const App: FC = () => {
const secondary = data?.secondaryColor || "#49AAA1"; const secondary = data?.secondaryColor || "#49AAA1";
const theme = createTheme(primary, secondary); const theme = createTheme(primary, secondary);
let body: React.ReactNode = <></>; let body: React.ReactNode = <></>;
if(data?.page === 'Error' && data?.statusText?.toLowerCase().includes('upstream')) { if (
data.page = 'UpstreamError'; data?.page === "Error" &&
data?.statusText?.toLowerCase().includes("upstream")
) {
data.page = "UpstreamError";
} }
switch (data?.page) { switch (data?.page) {
case "UpstreamError": case "UpstreamError":
@ -30,6 +34,9 @@ const App: FC = () => {
case "Error": case "Error":
body = <ErrorPage data={data} />; body = <ErrorPage data={data} />;
break; break;
case "Routes":
body = <RoutesPage data={data} />;
break;
case "SignOutConfirm": case "SignOutConfirm":
body = <SignOutConfirmPage data={data} />; body = <SignOutConfirmPage data={data} />;
break; break;
@ -64,7 +71,10 @@ const App: FC = () => {
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<SubpageContextProvider page={data?.page}> <SubpageContextProvider page={data?.page}>
<Header includeSidebar={data?.page === "UserInfo"} data={data} /> <Header
includeSidebar={data?.page === "UserInfo" || data?.page === "Routes"}
data={data}
/>
<ToolbarOffset /> <ToolbarOffset />
<Box sx={{ overflow: "hidden", height: "calc(100vh - 120px)" }}> <Box sx={{ overflow: "hidden", height: "calc(100vh - 120px)" }}>
<Box <Box

View file

@ -68,6 +68,11 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
setDrawerOpen(false); setDrawerOpen(false);
}; };
const handleRoutes = (evt: React.MouseEvent): void => {
evt.preventDefault();
window.open("/.pomerium/routes");
};
const handleUserInfo = (evt: React.MouseEvent): void => { const handleUserInfo = (evt: React.MouseEvent): void => {
evt.preventDefault(); evt.preventDefault();
window.open("/.pomerium/"); window.open("/.pomerium/");
@ -145,6 +150,7 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
anchorEl={anchorEl} anchorEl={anchorEl}
> >
<MenuItem onClick={handleUserInfo}>User Info</MenuItem> <MenuItem onClick={handleUserInfo}>User Info</MenuItem>
<MenuItem onClick={handleRoutes}>Routes</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem> <MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu> </Menu>
</Toolbar> </Toolbar>

View file

@ -0,0 +1,145 @@
import {
Avatar,
Box,
Card,
CardActionArea,
CardContent,
CardHeader,
Grid,
IconButton,
Paper,
Snackbar,
Stack,
Typography,
} from "@mui/material";
import React, { FC, useState } from "react";
import { Clipboard, Link } from "react-feather";
import { Route, RoutesPageData } from "../types";
import Section from "./Section";
import SidebarPage from "./SidebarPage";
type RouteCardProps = {
route: Route;
};
const RouteCard: FC<RouteCardProps> = ({ route }) => {
const [showSnackbar, setShowSnackbar] = useState(false);
const handleClick = (evt: React.MouseEvent) => {
if (route.connect_command) {
evt.preventDefault();
navigator.clipboard.writeText(route.connect_command);
setShowSnackbar(true);
}
};
return (
<Card raised={true}>
<CardActionArea href={route.from} target="_blank" onClick={handleClick}>
<CardHeader
avatar={
route.logo_url ? (
<Avatar src={route.logo_url} />
) : route.type === "tcp" ? (
<Avatar>TCP</Avatar>
) : route.type === "udp" ? (
<Avatar>UDP</Avatar>
) : (
<Avatar>
<Link />
</Avatar>
)
}
action={
route.connect_command && (
<IconButton title="Copy Command">
<Clipboard />
</IconButton>
)
}
title={route.name}
/>
<CardContent>
{route.description && (
<Typography variant="body2">{route.description}</Typography>
)}
{route.connect_command && (
<Box
component="span"
sx={{ fontFamily: '"DM Mono"', fontSize: "12px" }}
>
{route.connect_command}
</Box>
)}
</CardContent>
</CardActionArea>
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={showSnackbar}
autoHideDuration={3000}
onClose={() => setShowSnackbar(false)}
message="Copied to Clipboard"
/>
</Card>
);
};
type RoutesSectionProps = {
type: "http" | "tcp" | "udp";
title: string;
allRoutes: Route[];
};
const RoutesSection: FC<RoutesSectionProps> = ({ type, title, allRoutes }) => {
const routes = allRoutes?.filter((r) => r.type === type);
if (routes?.length === 0) {
return <></>;
}
return (
<Section title={title}>
<Grid container spacing={2} justifyContent="center">
{routes?.map((r) => (
<Grid key={r.id} item sx={{ width: 300 }}>
<RouteCard route={r} />
</Grid>
))}
</Grid>
</Section>
);
};
type RoutesPageProps = {
data: RoutesPageData;
};
const RoutesPage: FC<RoutesPageProps> = ({ data }) => {
return (
<SidebarPage>
<Stack spacing={2}>
{data?.routes ? (
<>
<RoutesSection
type={"http"}
title={"HTTP Routes"}
allRoutes={data.routes}
/>
<RoutesSection
type={"tcp"}
title={"TCP Routes"}
allRoutes={data.routes}
/>
<RoutesSection
type={"udp"}
title={"UDP Routes"}
allRoutes={data.routes}
/>
</>
) : (
<Paper sx={{ padding: 3 }}>
<Typography>No accessible routes found</Typography>
</Paper>
)}
</Stack>
</SidebarPage>
);
};
export default RoutesPage;

View file

@ -0,0 +1,44 @@
import { Box, Container, Drawer, useMediaQuery, useTheme } from "@mui/material";
import React, { FC } from "react";
import { ToolbarOffset } from "./ToolbarOffset";
import UserSidebarContent from "./UserSidebarContent";
const SidebarPage: FC = ({ children }) => {
const theme = useTheme();
const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), {
defaultMatches: true,
noSsr: false,
});
return (
<Container maxWidth={false}>
{mdUp && (
<Drawer
anchor="left"
open
PaperProps={{
sx: {
backgroundColor: "neutral.900",
width: 256,
height: "100vh",
},
}}
variant="persistent"
>
<ToolbarOffset />
<UserSidebarContent close={null} />
<ToolbarOffset />
</Drawer>
)}
<Box
sx={{
marginLeft: mdUp ? "256px" : "0px",
}}
>
{children}
</Box>
</Container>
);
};
export default SidebarPage;

View file

@ -1,15 +1,11 @@
import { import {
Button, Button,
Container,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Drawer,
Stack, Stack,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import React, { FC, useContext, useEffect, useState } from "react"; import React, { FC, useContext, useEffect, useState } from "react";
@ -18,18 +14,12 @@ import { UserInfoData } from "../types";
import GroupDetails from "./GroupDetails"; import GroupDetails from "./GroupDetails";
import SessionDetails from "./SessionDetails"; import SessionDetails from "./SessionDetails";
import SessionDeviceCredentials from "./SessionDeviceCredentials"; import SessionDeviceCredentials from "./SessionDeviceCredentials";
import { ToolbarOffset } from "./ToolbarOffset"; import SidebarPage from "./SidebarPage";
import { UserSidebarContent } from "./UserSidebarContent";
type UserInfoPageProps = { type UserInfoPageProps = {
data: UserInfoData & { page: "DeviceEnrolled" | "UserInfo" }; data: UserInfoData & { page: "DeviceEnrolled" | "UserInfo" };
}; };
const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => { const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
const theme = useTheme();
const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), {
defaultMatches: true,
noSsr: false,
});
const { subpage } = useContext(SubpageContext); const { subpage } = useContext(SubpageContext);
const [showDeviceEnrolled, setShowDeviceEnrolled] = useState(false); const [showDeviceEnrolled, setShowDeviceEnrolled] = useState(false);
@ -47,7 +37,7 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
} }
return ( return (
<Container maxWidth={false}> <SidebarPage>
<Dialog open={showDeviceEnrolled} onClose={handleCloseDeviceEnrolled}> <Dialog open={showDeviceEnrolled} onClose={handleCloseDeviceEnrolled}>
<DialogTitle>Device Enrolled</DialogTitle> <DialogTitle>Device Enrolled</DialogTitle>
<DialogContent> <DialogContent>
@ -57,30 +47,7 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
<Button onClick={handleCloseDeviceEnrolled}>OK</Button> <Button onClick={handleCloseDeviceEnrolled}>OK</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{mdUp && ( <Stack spacing={3}>
<Drawer
anchor="left"
open
PaperProps={{
sx: {
backgroundColor: "neutral.900",
width: 256,
height: "100vh",
},
}}
variant="persistent"
>
<ToolbarOffset />
<UserSidebarContent close={null} />
<ToolbarOffset />
</Drawer>
)}
<Stack
spacing={3}
sx={{
marginLeft: mdUp ? "256px" : "0px",
}}
>
{subpage === "User" && ( {subpage === "User" && (
<SessionDetails session={data?.session} profile={data?.profile} /> <SessionDetails session={data?.session} profile={data?.profile} />
)} )}
@ -103,7 +70,7 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
/> />
)} )}
</Stack> </Stack>
</Container> </SidebarPage>
); );
}; };
export default UserInfoPage; export default UserInfoPage;

View file

@ -6,27 +6,36 @@ import {
ListItemText, ListItemText,
} from "@mui/material"; } from "@mui/material";
import React, { FC, ReactNode, useContext } from "react"; import React, { FC, ReactNode, useContext } from "react";
import { User, Users } from "react-feather"; import { Link, User, Users } from "react-feather";
import { SubpageContext } from "../context/Subpage"; import { SubpageContext } from "../context/Subpage";
export interface Subpage { export interface Subpage {
icon: ReactNode; icon: ReactNode;
title: string; title: string;
pathname: string;
} }
export const sectionList: Subpage[] = [ export const sectionList: Subpage[] = [
{ {
title: "User", title: "User",
icon: <User />, icon: <User />,
pathname: "/.pomerium/",
}, },
{ {
title: "Groups Info", title: "Groups Info",
icon: <Users />, icon: <Users />,
pathname: "/.pomerium/",
}, },
{ {
title: "Devices Info", title: "Devices Info",
icon: <Devices />, icon: <Devices />,
pathname: "/.pomerium/",
},
{
title: "Routes",
icon: <Link />,
pathname: "/.pomerium/routes",
}, },
]; ];
type UserSidebarContent = { type UserSidebarContent = {
@ -39,12 +48,17 @@ export const UserSidebarContent: FC<UserSidebarContent> = ({
return ( return (
<List> <List>
{sectionList.map(({ title, icon }) => { {sectionList.map(({ title, icon, pathname }) => {
return ( return (
<ListItemButton <ListItemButton
key={"tab " + title} key={"tab " + title}
selected={title === info.subpage} selected={title === info.subpage}
onClick={() => { onClick={() => {
if (location.pathname !== pathname) {
location.href =
pathname + "#subpage=" + encodeURIComponent(title);
return;
}
info.setSubpage(title); info.setSubpage(title);
!!close && close(); !!close && close();
}} }}

View file

@ -18,11 +18,22 @@ export const SubpageContextProvider: FC<SubpageContextProviderProps> = ({
children, children,
}) => { }) => {
const setSubpage = (subpage: string) => { const setSubpage = (subpage: string) => {
location.hash = "subpage=" + encodeURIComponent(subpage);
setState({ ...state, subpage }); setState({ ...state, subpage });
}; };
const hashParams = new URLSearchParams(location.hash.substring(1));
const initState = { const initState = {
subpage: page === "DeviceEnrolled" ? "Devices Info" : "User", subpage:
page === "DeviceEnrolled"
? "Devices Info"
: page === "Routes"
? "Routes"
: hashParams.get("subpage") === "Groups Info"
? "Groups Info"
: hashParams.get("subpage") === "Devices Info"
? "Devices Info"
: "User",
setSubpage, setSubpage,
}; };

View file

@ -109,6 +109,22 @@ export type DeviceEnrolledPageData = BasePageData &
page: "DeviceEnrolled"; page: "DeviceEnrolled";
}; };
export type Route = {
id: string;
name: string;
type: "http" | "tcp" | "udp";
from: string;
connect_command?: string;
description: string;
logo_url: string;
};
export type RoutesPageData = BasePageData &
UserInfoData & {
page: "Routes";
routes: Route[];
};
export type SignOutConfirmPageData = BasePageData & { export type SignOutConfirmPageData = BasePageData & {
page: "SignOutConfirm"; page: "SignOutConfirm";
url: string; url: string;
@ -135,6 +151,7 @@ export type WebAuthnRegistrationPageData = BasePageData & {
export type PageData = export type PageData =
| ErrorPageData | ErrorPageData
| DeviceEnrolledPageData | DeviceEnrolledPageData
| RoutesPageData
| SignOutConfirmPageData | SignOutConfirmPageData
| SignedOutPageData | SignedOutPageData
| UserInfoPageData | UserInfoPageData