Add project management

This commit is contained in:
David Stotijn
2020-10-11 17:09:39 +02:00
parent ca707d17ea
commit fedb425381
22 changed files with 2080 additions and 322 deletions

View File

@ -12,7 +12,7 @@
- [x] Web interface (Next.js) with proxy log viewer. - [x] Web interface (Next.js) with proxy log viewer.
- [ ] Add scope support to the proxy. - [ ] Add scope support to the proxy.
- [ ] Full text search (with regex) in proxy log viewer. - [ ] Full text search (with regex) in proxy log viewer.
- [ ] Project management. - [x] Project management.
- [ ] Sender module for sending manual HTTP requests, either from scratch or based - [ ] Sender module for sending manual HTTP requests, either from scratch or based
off requests from the proxy log. off requests from the proxy log.
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency - [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
@ -56,12 +56,7 @@ Alternatively, you can run Hetty via Docker. See: [`dstotijn/hetty`](https://hub
on Docker Hub. on Docker Hub.
``` ```
$ docker run \ $ docker run -v $HOME/.hetty:/root/.hetty -p 127.0.0.1:8080:8080 dstotijn/hetty
-v $HOME/.hetty/hetty_key.pem:/root/.hetty/hetty_key.pem \
-v $HOME/.hetty/hetty_cert.pem:/root/.hetty/hetty_cert.pem \
-v $HOME/.hetty/hetty.db:/root/.hetty/hetty.db \
-p 127.0.0.1:8080:8080 \
dstotijn/hetty
``` ```
## Usage ## Usage
@ -80,10 +75,10 @@ Usage of ./hetty:
File path to admin build File path to admin build
-cert string -cert string
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem") CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem")
-db string
Database file path (default "~/.hetty/hetty.db")
-key string -key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem") CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-projects string
Projects directory path (default "~/.hetty/projects")
``` ```
## Certificate Setup and Installation ## Certificate Setup and Installation
@ -115,7 +110,7 @@ certificate with hetty, simply run the command with no arguments
hetty hetty
``` ```
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
`~/.hetty/hetty_cert.pem` respectively. `~/.hetty/hetty_cert.pem` respectively.
#### Generating CA certificates with OpenSSL #### Generating CA certificates with OpenSSL

View File

@ -21,12 +21,15 @@ import MenuIcon from "@material-ui/icons/Menu";
import HomeIcon from "@material-ui/icons/Home"; import HomeIcon from "@material-ui/icons/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@material-ui/icons/Send";
import FolderIcon from "@material-ui/icons/Folder";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight"; import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import clsx from "clsx"; import clsx from "clsx";
export enum Page { export enum Page {
Home, Home,
GetStarted,
Projects,
ProxySetup, ProxySetup,
ProxyLogs, ProxyLogs,
Sender, Sender,
@ -233,6 +236,22 @@ export function Layout({ title, page, children }: Props): JSX.Element {
<ListItemText primary="Sender" /> <ListItemText primary="Sender" />
</ListItem> </ListItem>
</Link> </Link>
<Link href="/projects" passHref>
<ListItem
button
component="a"
key="projects"
selected={page === Page.Projects}
className={classes.listItem}
>
<Tooltip title="Projects">
<ListItemIcon className={classes.listItemIcon}>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItem>
</Link>
</List> </List>
</Drawer> </Drawer>
<main className={classes.content}> <main className={classes.content}>

View File

@ -0,0 +1,122 @@
import { gql, useMutation } from "@apollo/client";
import {
Box,
Button,
CircularProgress,
createStyles,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
})
);
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function NewProject(): JSX.Element {
const classes = useStyles();
const [input, setInput] = useState(null);
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted() {
input.value = "";
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleNewProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleNewProjectForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(error)}
helperText={error && error.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
size="large"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -0,0 +1,311 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import {
Avatar,
Box,
Button,
CircularProgress,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
makeStyles,
Snackbar,
Theme,
Tooltip,
Typography,
} from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import DescriptionIcon from "@material-ui/icons/Description";
import DeleteIcon from "@material-ui/icons/Delete";
import LaunchIcon from "@material-ui/icons/Launch";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectsList: {
backgroundColor: theme.palette.background.paper,
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
deleteProjectButton: {
color: theme.palette.error.main,
},
})
);
const PROJECTS = gql`
query Projects {
projects {
name
isActive
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
const CLOSE_PROJECT = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
const DELETE_PROJECT = gql`
mutation DeleteProject($name: String!) {
deleteProject(name: $name) {
success
}
}
`;
function ProjectList(): JSX.Element {
const classes = useStyles();
const { loading: projLoading, error: projErr, data: projData } = useQuery(
PROJECTS
);
const [
openProject,
{ error: openProjErr, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
activeProject() {
return null;
},
projects(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [
deleteProject,
{ loading: deleteProjLoading, error: deleteProjErr },
] = useMutation(DELETE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
projects(_, { DELETE }) {
return DELETE;
},
},
});
setDeleteDiagOpen(false);
setDeleteNotifOpen(true);
},
});
const [deleteProjName, setDeleteProjName] = useState(null);
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (name: string) => {
setDeleteProjName(name);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { name: deleteProjName } });
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_, reason?: string) => {
if (reason === "clickaway") {
return;
}
setDeleteNotifOpen(false);
};
return (
<div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle>
Delete project <strong>{deleteProjName}</strong>?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes its database file from disk.
This action is irreversible.
</DialogContentText>
{deleteProjErr && (
<Alert severity="error">
Error closing project: {deleteProjErr.message}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus>
Cancel
</Button>
<Button
className={classes.deleteProjectButton}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
>
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProjName}</strong> was deleted.
</Alert>
</Snackbar>
<Box mb={3}>
<Typography variant="h6">Manage projects</Typography>
</Box>
<Box mb={4}>
{projLoading && <CircularProgress />}
{projErr && (
<Alert severity="error">
Error fetching projects: {projErr.message}
</Alert>
)}
{openProjErr && (
<Alert severity="error">
Error opening project: {openProjErr.message}
</Alert>
)}
{closeProjErr && (
<Alert severity="error">
Error closing project: {closeProjErr.message}
</Alert>
)}
</Box>
{projData?.projects.length > 0 && (
<List className={classes.projectsList}>
{projData.projects.map((project) => (
<ListItem key={project.name}>
<ListItemAvatar>
<Avatar
className={
project.isActive ? classes.activeProject : undefined
}
>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText>
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
<CloseIcon />
</IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
onClick={() =>
openProject({
variables: { name: project.name },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span>
<IconButton
onClick={() => handleDeleteButtonClick(project.name)}
disabled={project.isActive}
>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{projData?.projects.length === 0 && (
<Alert severity="info">
There are no projects. Create one to get started.
</Alert>
)}
</div>
);
}
export default ProjectList;

View File

@ -1,7 +1,12 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { useState } from "react"; import Link from "next/link";
import { Box, Typography, CircularProgress } from "@material-ui/core"; import {
Box,
Typography,
CircularProgress,
Link as MaterialLink,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert"; import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList"; import RequestList from "./RequestList";
@ -42,6 +47,17 @@ function LogsOverview(): JSX.Element {
return <CircularProgress />; return <CircularProgress />;
} }
if (error) { if (error) {
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
return (
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="secondary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
);
}
return <Alert severity="error">Error fetching logs: {error.message}</Alert>; return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
} }

View File

@ -1,6 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { concatPagination } from "@apollo/client/utilities";
let apolloClient; let apolloClient;
@ -12,10 +11,8 @@ function createApolloClient() {
}), }),
cache: new InMemoryCache({ cache: new InMemoryCache({
typePolicies: { typePolicies: {
Query: { Project: {
fields: { keyFields: ["name"],
allPosts: concatPagination(),
},
}, },
}, },
}), }),

View File

@ -11,6 +11,12 @@ const theme = createMuiTheme({
secondary: { secondary: {
main: teal["A400"], main: teal["A400"],
}, },
info: {
main: teal["A400"],
},
success: {
main: teal["A400"],
},
}, },
typography: { typography: {
h2: { h2: {

View File

@ -0,0 +1,36 @@
import { Box, Link as MaterialLink, Typography } from "@material-ui/core";
import Link from "next/link";
import React from "react";
import Layout, { Page } from "../../components/Layout";
function Index(): JSX.Element {
return (
<Layout page={Page.GetStarted} title="Get started">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Get started</Typography>
</Box>
<Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM
proxy and review HTTP requests and responses via the{" "}
<Link href="/proxy/logs" passHref>
<MaterialLink color="secondary">Proxy logs</MaterialLink>
</Link>
. Stuck? Ask for help on the{" "}
<MaterialLink
href="https://github.com/dstotijn/hetty/discussions"
color="secondary"
target="_blank"
>
Discussions forum
</MaterialLink>
.
</Typography>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,17 +1,30 @@
import { import {
Avatar,
Box, Box,
Button, Button,
CircularProgress,
createStyles, createStyles,
IconButton, List,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles, makeStyles,
TextField,
Theme, Theme,
Typography, Typography,
} from "@material-ui/core"; } from "@material-ui/core";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; import AddIcon from "@material-ui/icons/Add";
import SendIcon from "@material-ui/icons/Send"; import FolderIcon from "@material-ui/icons/Folder";
import DescriptionIcon from "@material-ui/icons/Description";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { useRouter } from "next/router";
import Layout, { Page } from "../components/Layout"; import Layout, { Page } from "../components/Layout";
import { Alert } from "@material-ui/lab";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -24,14 +37,108 @@ const useStyles = makeStyles((theme: Theme) =>
lineHeight: 2, lineHeight: 2,
marginBottom: theme.spacing(5), marginBottom: theme.spacing(5),
}, },
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: { button: {
marginRight: theme.spacing(2), marginRight: theme.spacing(2),
}, },
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
}) })
); );
const ACTIVE_PROJECT = gql`
query ActiveProject {
activeProject {
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function Index(): JSX.Element { function Index(): JSX.Element {
const classes = useStyles(); const classes = useStyles();
const router = useRouter();
const [input, setInput] = useState(null);
const { error: activeProjErr, data: activeProjData } = useQuery(
ACTIVE_PROJECT,
{
pollInterval: 1000,
}
);
const [
openProject,
{ error: openProjErr, data: openProjData, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted({ openProject }) {
if (openProject) {
router.push("/get-started");
}
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
if (activeProjErr) {
return (
<Layout page={Page.Home} title="">
<Alert severity="error">
Error fetching active project: {activeProjErr.message}
</Alert>
</Layout>
);
}
return ( return (
<Layout page={Page.Home} title=""> <Layout page={Page.Home} title="">
<Box p={4}> <Box p={4}>
@ -42,38 +149,105 @@ function Index(): JSX.Element {
The simple HTTP toolkit for security research. The simple HTTP toolkit for security research.
</Typography> </Typography>
</Box> </Box>
<Typography className={classes.subtitle} paragraph> <Typography className={classes.subtitle} paragraph>
What if security testing was intuitive, powerful, and good looking? What if security testing was intuitive, powerful, and good looking?
What if it was <strong>free</strong>, instead of $400 per year?{" "} What if it was <strong>free</strong>, instead of $400 per year?{" "}
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "} <span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
<code>:8080</code> <code>:8080</code>
</Typography> </Typography>
<Box>
<Link href="/proxy" passHref> {activeProjData?.activeProject?.name ? (
<div>
<Box mb={1}>
<Typography variant="h6">Active project:</Typography>
</Box>
<Box ml={-2} mb={2}>
<List>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.activeProject}>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={activeProjData.activeProject.name} />
</ListItem>
</List>
</Box>
<div>
<Link href="/get-started" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
color="secondary"
size="large"
startIcon={<PlayArrowIcon />}
>
Get started
</Button>
</Link>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Manage projects
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(openProjErr)}
helperText={openProjErr && openProjErr.message}
/>
<Button <Button
className={classes.button} className={classes.button}
type="submit"
variant="contained" variant="contained"
color="secondary" color="secondary"
component="a"
size="large" size="large"
startIcon={<SettingsEthernetIcon />} disabled={
openProjLoading || Boolean(openProjData?.openProject?.name)
}
startIcon={
openProjLoading || openProjData?.openProject ? (
<CircularProgress size={22} />
) : (
<AddIcon />
)
}
> >
Setup proxy Create project
</Button> </Button>
</Link> <Link href="/projects" passHref>
<Link href="/proxy" passHref> <Button
<Button className={classes.button}
className={classes.button} variant="outlined"
variant="contained" component="a"
color="primary" size="large"
component="a" startIcon={<FolderIcon />}
size="large" >
startIcon={<SendIcon />} Open project
> </Button>
Send HTTP requests </Link>
</Button> </form>
</Link> )}
</Box>
</Box> </Box>
</Layout> </Layout>
); );

View File

@ -0,0 +1,33 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core";
import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList";
function Index(): JSX.Element {
return (
<Layout page={Page.Projects} title="Projects">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Projects</Typography>
</Box>
<Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They
are stored as SQLite database files on disk.
</Typography>
<Box my={4}>
<Divider />
</Box>
<Box mb={8}>
<NewProject />
</Box>
<Grid container>
<Grid item xs={12} sm={8} md={6} lg={6}>
<ProjectList />
</Grid>
</Grid>
</Box>
</Layout>
);
}
export default Index;

View File

@ -11,10 +11,9 @@ import (
rice "github.com/GeertJohan/go.rice" rice "github.com/GeertJohan/go.rice"
"github.com/dstotijn/hetty/pkg/api" "github.com/dstotijn/hetty/pkg/api"
"github.com/dstotijn/hetty/pkg/db/sqlite" "github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground" "github.com/99designs/gqlgen/graphql/playground"
@ -25,7 +24,7 @@ import (
var ( var (
caCertFile string caCertFile string
caKeyFile string caKeyFile string
dbFile string projPath string
addr string addr string
adminPath string adminPath string
) )
@ -33,7 +32,7 @@ var (
func main() { func main() {
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist") flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist")
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist") flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist")
flag.StringVar(&dbFile, "db", "~/.hetty/hetty.db", "Database file path") flag.StringVar(&projPath, "projects", "~/.hetty/projects", "Projects directory path")
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"") flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build") flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
flag.Parse() flag.Parse()
@ -47,9 +46,9 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err) log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
} }
dbFile, err := homedir.Expand(dbFile) projPath, err := homedir.Expand(projPath)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err) log.Fatalf("[FATAL] Could not parse projects filepath: %v", err)
} }
// Load existing CA certificate and key from disk, or generate and write // Load existing CA certificate and key from disk, or generate and write
@ -59,16 +58,15 @@ func main() {
log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err) log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err)
} }
db, err := sqlite.New(dbFile) projService, err := proj.NewService(projPath)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not initialize database: %v", err) log.Fatalf("[FATAL] Could not create new project service: %v", err)
} }
defer db.Close() defer projService.Close()
scope := scope.New(nil)
reqLogService := reqlog.NewService(reqlog.Config{ reqLogService := reqlog.NewService(reqlog.Config{
Scope: scope, Scope: projService.Scope,
Repository: db, Repository: projService.Database(),
}) })
p, err := proxy.NewProxy(caCert, caKey) p, err := proxy.NewProxy(caCert, caKey)
@ -103,6 +101,7 @@ func main() {
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/")) adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{ adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
RequestLogService: reqLogService, RequestLogService: reqLogService,
ProjectService: projService,
}}))) }})))
// Admin interface. // Admin interface.

5
go.mod
View File

@ -3,16 +3,15 @@ module github.com/dstotijn/hetty
go 1.15 go 1.15
require ( require (
github.com/99designs/gqlgen v0.11.3 github.com/99designs/gqlgen v0.13.0
github.com/GeertJohan/go.rice v1.0.0 github.com/GeertJohan/go.rice v1.0.0
github.com/Masterminds/squirrel v1.4.0 github.com/Masterminds/squirrel v1.4.0
github.com/gorilla/mux v1.7.4 github.com/gorilla/mux v1.7.4
github.com/gorilla/websocket v1.4.0 // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0
github.com/mattn/go-sqlite3 v1.14.4 github.com/mattn/go-sqlite3 v1.14.4
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/vektah/gqlparser/v2 v2.0.1 github.com/vektah/gqlparser/v2 v2.1.0
google.golang.org/appengine v1.6.6 // indirect google.golang.org/appengine v1.6.6 // indirect
) )

6
go.sum
View File

@ -1,5 +1,7 @@
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4= github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4= github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg= github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
@ -39,6 +41,8 @@ github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTM
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
@ -105,6 +109,8 @@ github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWp
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o= github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,14 @@ import (
"time" "time"
) )
type CloseProjectResult struct {
Success bool `json:"success"`
}
type DeleteProjectResult struct {
Success bool `json:"success"`
}
type HTTPHeader struct { type HTTPHeader struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`
@ -34,6 +42,11 @@ type HTTPResponseLog struct {
Headers []HTTPHeader `json:"headers"` Headers []HTTPHeader `json:"headers"`
} }
type Project struct {
Name string `json:"name"`
IsActive bool `json:"isActive"`
}
type HTTPMethod string type HTTPMethod string
const ( const (

View File

@ -7,20 +7,35 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/99designs/gqlgen/graphql"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/vektah/gqlparser/v2/gqlerror"
) )
type Resolver struct { type Resolver struct {
RequestLogService *reqlog.Service RequestLogService *reqlog.Service
ProjectService *proj.Service
} }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) { func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
opts := reqlog.FindRequestsOptions{OmitOutOfScope: false} opts := reqlog.FindRequestsOptions{OmitOutOfScope: false}
reqs, err := r.RequestLogService.FindRequests(ctx, opts) reqs, err := r.RequestLogService.FindRequests(ctx, opts)
if err == reqlog.ErrNoProject {
return nil, &gqlerror.Error{
Path: graphql.GetPath(ctx),
Message: "No active project.",
Extensions: map[string]interface{}{
"code": "no_active_project",
},
}
}
if err != nil { if err != nil {
return nil, fmt.Errorf("could not query repository for requests: %v", err) return nil, fmt.Errorf("could not query repository for requests: %v", err)
} }
@ -116,3 +131,65 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
return log, nil return log, nil
} }
func (r *mutationResolver) OpenProject(ctx context.Context, name string) (*Project, error) {
p, err := r.ProjectService.Open(name)
if err == proj.ErrInvalidName {
return nil, gqlerror.Errorf("Project name must only contain alphanumeric or space chars.")
}
if err != nil {
return nil, fmt.Errorf("could not open project: %v", err)
}
return &Project{
Name: p.Name,
IsActive: p.IsActive,
}, nil
}
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
p, err := r.ProjectService.ActiveProject()
if err == proj.ErrNoProject {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("could not open project: %v", err)
}
return &Project{
Name: p.Name,
IsActive: p.IsActive,
}, nil
}
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
p, err := r.ProjectService.Projects()
if err != nil {
return nil, fmt.Errorf("could not get projects: %v", err)
}
projects := make([]Project, len(p))
for i, proj := range p {
projects[i] = Project{
Name: proj.Name,
IsActive: proj.IsActive,
}
}
return projects, nil
}
func (r *mutationResolver) CloseProject(ctx context.Context) (*CloseProjectResult, error) {
if err := r.ProjectService.Close(); err != nil {
return nil, fmt.Errorf("could not close project: %v", err)
}
return &CloseProjectResult{true}, nil
}
func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error) {
if err := r.ProjectService.Delete(name); err != nil {
return nil, fmt.Errorf("could not delete project: %v", err)
}
return &DeleteProjectResult{
Success: true,
}, nil
}

View File

@ -23,9 +23,30 @@ type HttpHeader {
value: String! value: String!
} }
type Project {
name: String!
isActive: Boolean!
}
type CloseProjectResult {
success: Boolean!
}
type DeleteProjectResult {
success: Boolean!
}
type Query { type Query {
httpRequestLog(id: ID!): HttpRequestLog httpRequestLog(id: ID!): HttpRequestLog
httpRequestLogs: [HttpRequestLog!]! httpRequestLogs: [HttpRequestLog!]!
activeProject: Project
projects: [Project!]!
}
type Mutation {
openProject(name: String!): Project
closeProject: CloseProjectResult!
deleteProject(name: String!): DeleteProjectResult!
} }
enum HttpMethod { enum HttpMethod {

View File

@ -3,11 +3,10 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"time" "time"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
@ -33,13 +32,10 @@ type httpRequestLogsQuery struct {
joinResponse bool joinResponse bool
} }
// New returns a new Client. // Open opens a database.
func New(filename string) (*Client, error) { func (c *Client) Open(filename string) error {
// Create directory for DB if it doesn't exist yet. if c.db != nil {
if dbDir, _ := filepath.Split(filename); dbDir != "" { return errors.New("sqlite: database already open")
if _, err := os.Stat(dbDir); os.IsNotExist(err) {
os.Mkdir(dbDir, 0755)
}
} }
opts := make(url.Values) opts := make(url.Values)
@ -48,24 +44,24 @@ func New(filename string) (*Client, error) {
dsn := fmt.Sprintf("file:%v?%v", filename, opts.Encode()) dsn := fmt.Sprintf("file:%v?%v", filename, opts.Encode())
db, err := sqlx.Open("sqlite3", dsn) db, err := sqlx.Open("sqlite3", dsn)
if err != nil { if err != nil {
return nil, err return fmt.Errorf("sqlite: could not open database: %v", err)
} }
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("sqlite: could not ping database: %v", err) return fmt.Errorf("sqlite: could not ping database: %v", err)
} }
c := &Client{db: db} if err := prepareSchema(db); err != nil {
return fmt.Errorf("sqlite: could not prepare schema: %v", err)
if err := c.prepareSchema(); err != nil {
return nil, fmt.Errorf("sqlite: could not prepare schema: %v", err)
} }
return &Client{db: db}, nil c.db = db
return nil
} }
func (c Client) prepareSchema() error { func prepareSchema(db *sqlx.DB) error {
_, err := c.db.Exec(`CREATE TABLE IF NOT EXISTS http_requests ( _, err := db.Exec(`CREATE TABLE IF NOT EXISTS http_requests (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
proto TEXT, proto TEXT,
url TEXT, url TEXT,
@ -77,7 +73,7 @@ func (c Client) prepareSchema() error {
return fmt.Errorf("could not create http_requests table: %v", err) return fmt.Errorf("could not create http_requests table: %v", err)
} }
_, err = c.db.Exec(`CREATE TABLE IF NOT EXISTS http_responses ( _, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_responses (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE, req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
proto TEXT, proto TEXT,
@ -90,7 +86,7 @@ func (c Client) prepareSchema() error {
return fmt.Errorf("could not create http_responses table: %v", err) return fmt.Errorf("could not create http_responses table: %v", err)
} }
_, err = c.db.Exec(`CREATE TABLE IF NOT EXISTS http_headers ( _, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_headers (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE, req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
res_id INTEGER REFERENCES http_responses(id) ON DELETE CASCADE, res_id INTEGER REFERENCES http_responses(id) ON DELETE CASCADE,
@ -104,9 +100,16 @@ func (c Client) prepareSchema() error {
return nil return nil
} }
// Close uses the underlying database. // Close uses the underlying database if it's open.
func (c *Client) Close() error { func (c *Client) Close() error {
return c.db.Close() if c.db == nil {
return nil
}
if err := c.db.Close(); err != nil {
return fmt.Errorf("sqlite: could not close database: %v", err)
}
c.db = nil
return nil
} }
var reqFieldToColumnMap = map[string]string{ var reqFieldToColumnMap = map[string]string{
@ -136,6 +139,10 @@ func (c *Client) FindRequestLogs(
opts reqlog.FindRequestsOptions, opts reqlog.FindRequestsOptions,
scope *scope.Scope, scope *scope.Scope,
) (reqLogs []reqlog.Request, err error) { ) (reqLogs []reqlog.Request, err error) {
if c.db == nil {
return nil, reqlog.ErrNoProject
}
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx) httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
reqQuery := sq. reqQuery := sq.
@ -178,6 +185,9 @@ func (c *Client) FindRequestLogs(
} }
func (c *Client) FindRequestLogByID(ctx context.Context, id int64) (reqlog.Request, error) { func (c *Client) FindRequestLogByID(ctx context.Context, id int64) (reqlog.Request, error) {
if c.db == nil {
return reqlog.Request{}, reqlog.ErrNoProject
}
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx) httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
reqQuery := sq. reqQuery := sq.
@ -218,6 +228,9 @@ func (c *Client) AddRequestLog(
body []byte, body []byte,
timestamp time.Time, timestamp time.Time,
) (*reqlog.Request, error) { ) (*reqlog.Request, error) {
if c.db == nil {
return nil, reqlog.ErrNoProject
}
reqLog := &reqlog.Request{ reqLog := &reqlog.Request{
Request: req, Request: req,
@ -289,6 +302,10 @@ func (c *Client) AddResponseLog(
body []byte, body []byte,
timestamp time.Time, timestamp time.Time,
) (*reqlog.Response, error) { ) (*reqlog.Response, error) {
if c.db == nil {
return nil, reqlog.ErrNoProject
}
resLog := &reqlog.Response{ resLog := &reqlog.Response{
RequestID: reqID, RequestID: reqID,
Response: res, Response: res,
@ -495,3 +512,7 @@ func (c *Client) queryHeaders(
return nil return nil
} }
func (c *Client) IsOpen() bool {
return c.db != nil
}

135
pkg/proj/proj.go Normal file
View File

@ -0,0 +1,135 @@
package proj
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/dstotijn/hetty/pkg/db/sqlite"
"github.com/dstotijn/hetty/pkg/scope"
)
// Service is used for managing projects.
type Service struct {
dbPath string
db *sqlite.Client
name string
Scope *scope.Scope
}
type Project struct {
Name string
IsActive bool
}
var (
ErrNoProject = errors.New("proj: no open project")
ErrInvalidName = errors.New("proj: invalid name, must be alphanumeric or whitespace chars")
)
var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
// NewService returns a new Service.
func NewService(dbPath string) (*Service, error) {
// Create directory for DBs if it doesn't exist yet.
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if err := os.MkdirAll(dbPath, 0755); err != nil {
return nil, fmt.Errorf("proj: could not create project directory: %v", err)
}
}
return &Service{
dbPath: dbPath,
db: &sqlite.Client{},
Scope: scope.New(nil),
}, nil
}
// Close closes the currently open project database (if there is one).
func (svc *Service) Close() error {
if err := svc.db.Close(); err != nil {
return fmt.Errorf("proj: could not close project: %v", err)
}
svc.name = ""
return nil
}
// Delete removes a project database file from disk (if there is one).
func (svc *Service) Delete(name string) error {
if name == "" {
return errors.New("proj: name cannot be empty")
}
if svc.name == name {
return fmt.Errorf("proj: project (%v) is active", name)
}
if err := os.Remove(filepath.Join(svc.dbPath, name+".db")); err != nil {
return fmt.Errorf("proj: could not remove database file: %v", err)
}
return nil
}
// Database returns the currently open database. If no database is open, it will
// return `nil`.
func (svc *Service) Database() *sqlite.Client {
return svc.db
}
// Open opens a database identified with `name`. If a database with this
// identifier doesn't exist yet, it will be automatically created.
func (svc *Service) Open(name string) (Project, error) {
if !nameRegexp.MatchString(name) {
return Project{}, ErrInvalidName
}
if err := svc.db.Close(); err != nil {
return Project{}, fmt.Errorf("proj: could not close previously open database: %v", err)
}
dbPath := filepath.Join(svc.dbPath, name+".db")
err := svc.db.Open(dbPath)
if err != nil {
return Project{}, fmt.Errorf("proj: could not open database: %v", err)
}
svc.name = name
return Project{
Name: name,
IsActive: true,
}, nil
}
func (svc *Service) ActiveProject() (Project, error) {
if !svc.db.IsOpen() {
return Project{}, ErrNoProject
}
return Project{
Name: svc.name,
}, nil
}
func (svc *Service) Projects() ([]Project, error) {
files, err := ioutil.ReadDir(svc.dbPath)
if err != nil {
return nil, fmt.Errorf("proj: could not read projects directory: %v", err)
}
projects := make([]Project, len(files))
for i, file := range files {
projName := strings.TrimSuffix(file.Name(), ".db")
projects[i] = Project{
Name: projName,
IsActive: svc.name == projName,
}
}
return projects, nil
}

View File

@ -83,13 +83,13 @@ func LoadOrCreateCA(caKeyFile, caCertFile string) (*x509.Certificate, *rsa.Priva
keyDir, _ := filepath.Split(caKeyFile) keyDir, _ := filepath.Split(caKeyFile)
if keyDir != "" { if keyDir != "" {
if _, err := os.Stat(keyDir); os.IsNotExist(err) { if _, err := os.Stat(keyDir); os.IsNotExist(err) {
os.Mkdir(keyDir, 0755) os.MkdirAll(keyDir, 0755)
} }
} }
keyDir, _ = filepath.Split(caCertFile) keyDir, _ = filepath.Split(caCertFile)
if keyDir != "" { if keyDir != "" {
if _, err := os.Stat("keyDir"); os.IsNotExist(err) { if _, err := os.Stat("keyDir"); os.IsNotExist(err) {
os.Mkdir(keyDir, 0755) os.MkdirAll(keyDir, 0755)
} }
} }

View File

@ -8,6 +8,10 @@ import (
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
) )
type RepositoryProvider interface {
Repository() Repository
}
type Repository interface { type Repository interface {
FindRequestLogs(ctx context.Context, opts FindRequestsOptions, scope *scope.Scope) ([]Request, error) FindRequestLogs(ctx context.Context, opts FindRequestsOptions, scope *scope.Scope) ([]Request, error)
FindRequestLogByID(ctx context.Context, id int64) (Request, error) FindRequestLogByID(ctx context.Context, id int64) (Request, error)

View File

@ -19,7 +19,10 @@ type contextKey int
const LogBypassedKey contextKey = 0 const LogBypassedKey contextKey = 0
var ErrRequestNotFound = errors.New("reqlog: request not found") var (
ErrRequestNotFound = errors.New("reqlog: request not found")
ErrNoProject = errors.New("reqlog: no project")
)
type Request struct { type Request struct {
ID int64 ID int64
@ -133,6 +136,11 @@ func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
} }
reqLog, err := svc.addRequest(req.Context(), *clone, body, now) reqLog, err := svc.addRequest(req.Context(), *clone, body, now)
if err == ErrNoProject {
ctx := context.WithValue(req.Context(), LogBypassedKey, true)
*req = *req.WithContext(ctx)
return
}
if err != nil { if err != nil {
log.Printf("[ERROR] Could not store request log: %v", err) log.Printf("[ERROR] Could not store request log: %v", err)
return return