From 8828a586a1addf367499c00789f11e1c2e8aea2a Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Wed, 23 Sep 2020 23:43:20 +0200 Subject: [PATCH] Scaffold homepage, small style tweaks --- admin/next.config.js | 1 + admin/package.json | 2 +- admin/public/style.css | 91 +++ admin/src/components/Layout.tsx | 66 +- admin/src/components/reqlog/Editor.tsx | 7 +- .../components/reqlog/HttpHeadersTable.tsx | 29 +- .../src/components/reqlog/HttpStatusCode.tsx | 2 +- admin/src/components/reqlog/LogDetail.tsx | 8 + admin/src/components/reqlog/LogsOverview.tsx | 18 +- admin/src/components/reqlog/RequestDetail.tsx | 10 +- admin/src/components/reqlog/RequestList.tsx | 70 +- .../src/components/reqlog/ResponseDetail.tsx | 12 +- admin/src/lib/theme.ts | 33 +- admin/src/pages/_app.tsx | 2 +- admin/src/pages/_document.tsx | 5 + admin/src/pages/index.tsx | 79 ++- admin/src/pages/proxy/index.tsx | 27 +- admin/src/pages/sender/index.tsx | 16 + go.mod | 1 + go.sum | 2 + modd.conf | 4 +- pkg/proxy/reverseproxy.go | 611 ++++++++++++++++++ 22 files changed, 1041 insertions(+), 55 deletions(-) create mode 100644 admin/public/style.css create mode 100644 admin/src/pages/sender/index.tsx create mode 100644 pkg/proxy/reverseproxy.go diff --git a/admin/next.config.js b/admin/next.config.js index f6ac86a..8a029e9 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -2,6 +2,7 @@ const withCSS = require("@zeit/next-css"); const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin"); module.exports = withCSS({ + trailingSlash: true, async rewrites() { return [ { diff --git a/admin/package.json b/admin/package.json index 06e1a0a..03b880e 100644 --- a/admin/package.json +++ b/admin/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "export": "next build && next export -o build" + "export": "next build && next export" }, "dependencies": { "@apollo/client": "^3.2.0", diff --git a/admin/public/style.css b/admin/public/style.css new file mode 100644 index 0000000..aae47ff --- /dev/null +++ b/admin/public/style.css @@ -0,0 +1,91 @@ +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold-Italic.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Bold-Italic.woff") + format("woff"); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Bold.woff") + format("woff"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-ExtraBold-Italic.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-ExtraBold-Italic.woff") + format("woff"); + font-weight: 800; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-ExtraBold.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-ExtraBold.woff") + format("woff"); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Italic.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Italic.woff") + format("woff"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Medium-Italic.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Medium-Italic.woff") + format("woff"); + font-weight: 500; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Medium.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Medium.woff") + format("woff"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Regular.woff2") + format("woff2"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Regular.woff") + format("woff"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +code { + font-family: "JetBrains Mono", monospace; +} diff --git a/admin/src/components/Layout.tsx b/admin/src/components/Layout.tsx index 435379a..8b4f653 100644 --- a/admin/src/components/Layout.tsx +++ b/admin/src/components/Layout.tsx @@ -14,12 +14,13 @@ import { ListItem, ListItemIcon, ListItemText, - Box, Tooltip, } from "@material-ui/core"; +import Link from "next/link"; import MenuIcon from "@material-ui/icons/Menu"; import HomeIcon from "@material-ui/icons/Home"; import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; +import SendIcon from "@material-ui/icons/Send"; import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; import ChevronRightIcon from "@material-ui/icons/ChevronRight"; import clsx from "clsx"; @@ -136,7 +137,7 @@ export function Layout(props: { children: React.ReactNode }): JSX.Element { - ‍🧑‍🔧Hetty + Hetty:// @@ -164,22 +165,51 @@ export function Layout(props: { children: React.ReactNode }): JSX.Element { - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/admin/src/components/reqlog/Editor.tsx b/admin/src/components/reqlog/Editor.tsx index c3f989f..b72fefe 100644 --- a/admin/src/components/reqlog/Editor.tsx +++ b/admin/src/components/reqlog/Editor.tsx @@ -12,7 +12,10 @@ const monacoOptions = { type language = "html" | "typescript" | "json"; function editorDidMount() { - return (window.MonacoEnvironment.getWorkerUrl = (moduleId, label) => { + return ((window as any).MonacoEnvironment.getWorkerUrl = ( + moduleId, + label + ) => { if (label === "json") return "/_next/static/json.worker.js"; if (label === "html") return "/_next/static/html.worker.js"; if (label === "javascript") return "/_next/static/ts.worker.js"; @@ -26,8 +29,10 @@ function languageForContentType(contentType: string): language { case "text/html": return "html"; case "application/json": + case "application/json; charset=utf-8": return "json"; case "application/javascript": + case "application/javascript; charset=utf-8": return "typescript"; default: return; diff --git a/admin/src/components/reqlog/HttpHeadersTable.tsx b/admin/src/components/reqlog/HttpHeadersTable.tsx index 233c249..a4bd9e5 100644 --- a/admin/src/components/reqlog/HttpHeadersTable.tsx +++ b/admin/src/components/reqlog/HttpHeadersTable.tsx @@ -9,25 +9,36 @@ import { TableRow, } from "@material-ui/core"; -const useStyles = makeStyles((theme: Theme) => - createStyles({ +const useStyles = makeStyles((theme: Theme) => { + const paddingX = 0; + const paddingY = theme.spacing(1) / 3; + const tableCell = { + paddingLeft: paddingX, + paddingRight: paddingX, + paddingTop: paddingY, + paddingBottom: paddingY, + verticalAlign: "top", + border: "none", + }; + return createStyles({ table: { tableLayout: "fixed", width: "100%", }, keyCell: { - verticalAlign: "top", - width: "30%", + ...tableCell, + width: "40%", fontWeight: "bold", }, valueCell: { - width: "70%", - verticalAlign: "top", + ...tableCell, + width: "60%", + border: "none", wordBreak: "break-all", whiteSpace: "pre-wrap", }, - }) -); + }); +}); interface Props { headers: Array<{ key: string; value: string }>; @@ -42,7 +53,7 @@ function HttpHeadersTable({ headers }: Props): JSX.Element { {headers.map(({ key, value }, index) => ( - {key} + {key}: {value} diff --git a/admin/src/components/reqlog/HttpStatusCode.tsx b/admin/src/components/reqlog/HttpStatusCode.tsx index aa07fa6..4155d90 100644 --- a/admin/src/components/reqlog/HttpStatusCode.tsx +++ b/admin/src/components/reqlog/HttpStatusCode.tsx @@ -6,7 +6,7 @@ function HttpStatusIcon({ status }: { status: number }): JSX.Element { switch (Math.floor(status / 100)) { case 2: case 3: - return ; + return ; case 4: return ; case 5: diff --git a/admin/src/components/reqlog/LogDetail.tsx b/admin/src/components/reqlog/LogDetail.tsx index 01a9e3f..58fca16 100644 --- a/admin/src/components/reqlog/LogDetail.tsx +++ b/admin/src/components/reqlog/LogDetail.tsx @@ -51,6 +51,14 @@ function LogDetail({ requestId: id }: Props): JSX.Element { ); } + if (!data.httpRequestLog) { + return ( + + Request {id} was not found. + + ); + } + const { method, url, proto, headers, body, response } = data.httpRequestLog; return ( diff --git a/admin/src/components/reqlog/LogsOverview.tsx b/admin/src/components/reqlog/LogsOverview.tsx index 97dcdda..b71ca1b 100644 --- a/admin/src/components/reqlog/LogsOverview.tsx +++ b/admin/src/components/reqlog/LogsOverview.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { gql, useQuery } from "@apollo/client"; import { useState } from "react"; import { Box, Typography, CircularProgress } from "@material-ui/core"; @@ -23,10 +24,17 @@ const HTTP_REQUEST_LOGS = gql` `; function LogsOverview(): JSX.Element { + const router = useRouter(); + const detailReqLogId = router.query.id as string; + console.log(detailReqLogId); + const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS); - const [detailReqLogId, setDetailReqLogId] = useState(null); - const handleLogClick = (reqId: string) => setDetailReqLogId(reqId); + const handleLogClick = (reqId: string) => { + router.push("/proxy/logs?id=" + reqId, undefined, { + shallow: false, + }); + }; if (loading) { return ; @@ -40,7 +48,11 @@ function LogsOverview(): JSX.Element { return (
- + {detailReqLogId && } diff --git a/admin/src/components/reqlog/RequestDetail.tsx b/admin/src/components/reqlog/RequestDetail.tsx index 94f3aa8..1235605 100644 --- a/admin/src/components/reqlog/RequestDetail.tsx +++ b/admin/src/components/reqlog/RequestDetail.tsx @@ -67,7 +67,11 @@ function RequestDetail({ request }: Props): JSX.Element { {method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "} - + {proto} @@ -75,7 +79,9 @@ function RequestDetail({ request }: Props): JSX.Element { - + + + {body && }
diff --git a/admin/src/components/reqlog/RequestList.tsx b/admin/src/components/reqlog/RequestList.tsx index e1b389b..1c2d444 100644 --- a/admin/src/components/reqlog/RequestList.tsx +++ b/admin/src/components/reqlog/RequestList.tsx @@ -6,23 +6,64 @@ import { TableRow, TableCell, TableBody, - CircularProgress, Typography, Box, + createStyles, + makeStyles, + Theme, + withTheme, } from "@material-ui/core"; import HttpStatusIcon from "./HttpStatusCode"; import CenteredPaper from "../CenteredPaper"; +const useStyles = makeStyles((theme: Theme) => + createStyles({ + requestTitle: { + width: "calc(100% - 80px)", + fontSize: "1rem", + wordBreak: "break-all", + whiteSpace: "pre-wrap", + }, + headersTable: { + tableLayout: "fixed", + width: "100%", + }, + headerKeyCell: { + verticalAlign: "top", + width: "30%", + fontWeight: "bold", + }, + headerValueCell: { + width: "70%", + verticalAlign: "top", + wordBreak: "break-all", + whiteSpace: "pre-wrap", + }, + }) +); + interface Props { logs: Array; + selectedReqLogId?: string; onLogClick(requestId: string): void; + theme: Theme; } -function RequestList({ logs, onLogClick }: Props): JSX.Element { +function RequestList({ + logs, + onLogClick, + selectedReqLogId, + theme, +}: Props): JSX.Element { return (
- + {logs.length === 0 && ( @@ -36,12 +77,16 @@ function RequestList({ logs, onLogClick }: Props): JSX.Element { interface RequestListTableProps { logs?: any; + selectedReqLogId?: string; onLogClick(requestId: string): void; + theme: Theme; } function RequestListTable({ logs, + selectedReqLogId, onLogClick, + theme, }: RequestListTableProps): JSX.Element { return ( onLogClick(id)}> + onLogClick(id)} + > - {method} + {method} {origin} @@ -85,7 +141,7 @@ function RequestListTable({ {response && (
{" "} - {response.status} + {response.status}
)}
@@ -98,4 +154,4 @@ function RequestListTable({ ); } -export default RequestList; +export default withTheme(RequestList); diff --git a/admin/src/components/reqlog/ResponseDetail.tsx b/admin/src/components/reqlog/ResponseDetail.tsx index 1fe10e4..0b11a11 100644 --- a/admin/src/components/reqlog/ResponseDetail.tsx +++ b/admin/src/components/reqlog/ResponseDetail.tsx @@ -34,7 +34,13 @@ function ResponseDetail({ response }: Props): JSX.Element { > {" "} - {response.proto} + + {response.proto} + {" "} {response.status} @@ -42,7 +48,9 @@ function ResponseDetail({ response }: Props): JSX.Element { - + + + {response.body && ( diff --git a/admin/src/lib/theme.ts b/admin/src/lib/theme.ts index 1291989..76c11fa 100644 --- a/admin/src/lib/theme.ts +++ b/admin/src/lib/theme.ts @@ -1,6 +1,6 @@ import { createMuiTheme } from "@material-ui/core/styles"; import grey from "@material-ui/core/colors/grey"; -import green from "@material-ui/core/colors/green"; +import teal from "@material-ui/core/colors/teal"; const theme = createMuiTheme({ palette: { @@ -9,7 +9,36 @@ const theme = createMuiTheme({ main: grey[900], }, secondary: { - main: green[500], + main: teal["A400"], + }, + }, + typography: { + h2: { + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + }, + h3: { + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + }, + h4: { + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + }, + h5: { + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + }, + h6: { + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + }, + }, + overrides: { + MuiTableCell: { + stickyHeader: { + backgroundColor: grey[900], + }, }, }, }); diff --git a/admin/src/pages/_app.tsx b/admin/src/pages/_app.tsx index 0da64f2..7947827 100644 --- a/admin/src/pages/_app.tsx +++ b/admin/src/pages/_app.tsx @@ -22,7 +22,7 @@ function App({ Component, pageProps }: AppProps): JSX.Element { return ( - Hetty + Hetty:// + +
diff --git a/admin/src/pages/index.tsx b/admin/src/pages/index.tsx index 37579ff..6495602 100644 --- a/admin/src/pages/index.tsx +++ b/admin/src/pages/index.tsx @@ -1,8 +1,81 @@ +import { + Box, + Button, + createStyles, + IconButton, + makeStyles, + Theme, + Typography, +} from "@material-ui/core"; +import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; +import SendIcon from "@material-ui/icons/Send"; +import Link from "next/link"; + +import Layout from "../components/Layout"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + titleHighlight: { + color: theme.palette.secondary.main, + }, + subtitle: { + fontSize: "1.6rem", + width: "60%", + lineHeight: 2, + marginBottom: theme.spacing(5), + }, + button: { + marginRight: theme.spacing(2), + }, + }) +); + function Index(): JSX.Element { + const classes = useStyles(); return ( -
-

Hetty123

-
+ + + + + Hetty:// +
+ The simple HTTP toolkit for security research. +
+
+ + What if security testing was intuitive, powerful, and good looking? + What if it was free, instead of $400 per year?{" "} + Hetty is listening on{" "} + :8080… + + + + + + + + + +
+
); } diff --git a/admin/src/pages/proxy/index.tsx b/admin/src/pages/proxy/index.tsx index c7a0de5..ed9b986 100644 --- a/admin/src/pages/proxy/index.tsx +++ b/admin/src/pages/proxy/index.tsx @@ -1,8 +1,29 @@ +import React from "react"; +import { Box, Button, Typography } from "@material-ui/core"; +import ListIcon from "@material-ui/icons/List"; +import Link from "next/link"; + +import Layout from "../../components/Layout"; + function Index(): JSX.Element { return ( -
-

Proxy123

-
+ + + Proxy setup + + Coming soon… + + + + ); } diff --git a/admin/src/pages/sender/index.tsx b/admin/src/pages/sender/index.tsx new file mode 100644 index 0000000..38042e2 --- /dev/null +++ b/admin/src/pages/sender/index.tsx @@ -0,0 +1,16 @@ +import { Box, Typography } from "@material-ui/core"; + +import Layout from "../../components/Layout"; + +function Index(): JSX.Element { + return ( + + + Sender + + Coming soon… + + ); +} + +export default Index; diff --git a/go.mod b/go.mod index 63dfa2e..4caed08 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require ( github.com/google/uuid v1.1.2 github.com/gorilla/mux v1.7.4 github.com/vektah/gqlparser/v2 v2.0.1 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859 ) diff --git a/go.sum b/go.sum index 1480409..51b04f2 100644 --- a/go.sum +++ b/go.sum @@ -71,12 +71,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd h1:oMEQDWVXVNpceQoVd1JN3CQ7LYJJzs5qWqZIUcxXHHw= diff --git a/modd.conf b/modd.conf index cdb77db..78a5dc7 100644 --- a/modd.conf +++ b/modd.conf @@ -1,7 +1,7 @@ @cert = $HOME/.ssh/hetty_cert.pem @key = $HOME/.ssh/hetty_key.pem -@dev = true -@adminPath = $PWD/admin/build +@dev = false +@adminPath = $PWD/admin/out **/*.go { daemon +sigterm: go run ./cmd \ diff --git a/pkg/proxy/reverseproxy.go b/pkg/proxy/reverseproxy.go new file mode 100644 index 0000000..a53b023 --- /dev/null +++ b/pkg/proxy/reverseproxy.go @@ -0,0 +1,611 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// HTTP reverse proxy handler + +package proxy + +import ( + "context" + "fmt" + "io" + "log" + "net" + "net/http" + "net/textproto" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/net/http/httpguts" +) + +// ReverseProxy is an HTTP Handler that takes an incoming request and +// sends it to another server, proxying the response back to the +// client. +// +// ReverseProxy by default sets the client IP as the value of the +// X-Forwarded-For header. +// +// If an X-Forwarded-For header already exists, the client IP is +// appended to the existing values. As a special case, if the header +// exists in the Request.Header map but has a nil value (such as when +// set by the Director func), the X-Forwarded-For header is +// not modified. +// +// To prevent IP spoofing, be sure to delete any pre-existing +// X-Forwarded-For header coming from the client or +// an untrusted proxy. +type ReverseProxy struct { + // Director must be a function which modifies + // the request into a new request to be sent + // using Transport. Its response is then copied + // back to the original client unmodified. + // Director must not access the provided Request + // after returning. + Director func(*http.Request) + + // The transport used to perform proxy requests. + // If nil, http.DefaultTransport is used. + Transport http.RoundTripper + + // FlushInterval specifies the flush interval + // to flush to the client while copying the + // response body. + // If zero, no periodic flushing is done. + // A negative value means to flush immediately + // after each write to the client. + // The FlushInterval is ignored when ReverseProxy + // recognizes a response as a streaming response; + // for such responses, writes are flushed to the client + // immediately. + FlushInterval time.Duration + + // ErrorLog specifies an optional logger for errors + // that occur when attempting to proxy the request. + // If nil, logging is done via the log package's standard logger. + ErrorLog *log.Logger + + // BufferPool optionally specifies a buffer pool to + // get byte slices for use by io.CopyBuffer when + // copying HTTP response bodies. + BufferPool BufferPool + + // ModifyResponse is an optional function that modifies the + // Response from the backend. It is called if the backend + // returns a response at all, with any HTTP status code. + // If the backend is unreachable, the optional ErrorHandler is + // called without any call to ModifyResponse. + // + // If ModifyResponse returns an error, ErrorHandler is called + // with its error value. If ErrorHandler is nil, its default + // implementation is used. + ModifyResponse func(*http.Response) error + + // ErrorHandler is an optional function that handles errors + // reaching the backend or errors from ModifyResponse. + // + // If nil, the default is to log the provided error and return + // a 502 Status Bad Gateway response. + ErrorHandler func(http.ResponseWriter, *http.Request, error) +} + +// A BufferPool is an interface for getting and returning temporary +// byte slices for use by io.CopyBuffer. +type BufferPool interface { + Get() []byte + Put([]byte) +} + +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +func joinURLPath(a, b *url.URL) (path, rawpath string) { + if a.RawPath == "" && b.RawPath == "" { + return singleJoiningSlash(a.Path, b.Path), "" + } + // Same as singleJoiningSlash, but uses EscapedPath to determine + // whether a slash should be added + apath := a.EscapedPath() + bpath := b.EscapedPath() + + aslash := strings.HasSuffix(apath, "/") + bslash := strings.HasPrefix(bpath, "/") + + switch { + case aslash && bslash: + return a.Path + b.Path[1:], apath + bpath[1:] + case !aslash && !bslash: + return a.Path + "/" + b.Path, apath + "/" + bpath + } + return a.Path + b.Path, apath + bpath +} + +// NewSingleHostReverseProxy returns a new ReverseProxy that routes +// URLs to the scheme, host, and base path provided in target. If the +// target's path is "/base" and the incoming request was for "/dir", +// the target request will be for /base/dir. +// NewSingleHostReverseProxy does not rewrite the Host header. +// To rewrite Host headers, use ReverseProxy directly with a custom +// Director policy. +func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + return &ReverseProxy{Director: director} +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +// Hop-by-hop headers. These are removed when sent to the backend. +// As of RFC 7230, hop-by-hop headers are required to appear in the +// Connection header field. These are the headers defined by the +// obsoleted RFC 2616 (section 13.5.1) and are used for backward +// compatibility. +var hopHeaders = []string{ + "Connection", + "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", // canonicalized version of "TE" + "Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522 + "Transfer-Encoding", + "Upgrade", +} + +func (p *ReverseProxy) defaultErrorHandler(rw http.ResponseWriter, req *http.Request, err error) { + p.logf("http: proxy error: %v", err) + rw.WriteHeader(http.StatusBadGateway) +} + +func (p *ReverseProxy) getErrorHandler() func(http.ResponseWriter, *http.Request, error) { + if p.ErrorHandler != nil { + return p.ErrorHandler + } + return p.defaultErrorHandler +} + +// modifyResponse conditionally runs the optional ModifyResponse hook +// and reports whether the request should proceed. +func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response, req *http.Request) bool { + if p.ModifyResponse == nil { + return true + } + if err := p.ModifyResponse(res); err != nil { + res.Body.Close() + p.getErrorHandler()(rw, req, err) + return false + } + return true +} + +func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + transport := p.Transport + if transport == nil { + transport = http.DefaultTransport + } + + ctx := req.Context() + if cn, ok := rw.(http.CloseNotifier); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + notifyChan := cn.CloseNotify() + go func() { + select { + case <-notifyChan: + cancel() + case <-ctx.Done(): + } + }() + } + + outreq := req.Clone(ctx) + if req.ContentLength == 0 { + outreq.Body = nil // Issue 16036: nil Body for http.Transport retries + } + if outreq.Header == nil { + outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate + } + + p.Director(outreq) + outreq.Close = false + + reqUpType := upgradeType(outreq.Header) + removeConnectionHeaders(outreq.Header) + + // Remove hop-by-hop headers to the backend. Especially + // important is "Connection" because we want a persistent + // connection, regardless of what the client sent to us. + for _, h := range hopHeaders { + hv := outreq.Header.Get(h) + if hv == "" { + continue + } + if h == "Te" && hv == "trailers" { + // Issue 21096: tell backend applications that + // care about trailer support that we support + // trailers. (We do, but we don't go out of + // our way to advertise that unless the + // incoming client request thought it was + // worth mentioning) + continue + } + outreq.Header.Del(h) + } + + // After stripping all the hop-by-hop connection headers above, add back any + // necessary for protocol upgrades, such as for websockets. + if reqUpType != "" { + outreq.Header.Set("Connection", "Upgrade") + outreq.Header.Set("Upgrade", reqUpType) + } + + if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { + // If we aren't the first proxy retain prior + // X-Forwarded-For information as a comma+space + // separated list and fold multiple headers into one. + prior, ok := outreq.Header["X-Forwarded-For"] + omit := ok && prior == nil // Issue 38079: nil now means don't populate the header + if len(prior) > 0 { + clientIP = strings.Join(prior, ", ") + ", " + clientIP + } + if !omit { + outreq.Header.Set("X-Forwarded-For", clientIP) + } + } + + res, err := transport.RoundTrip(outreq) + if err != nil { + p.getErrorHandler()(rw, outreq, err) + return + } + + // Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc) + if res.StatusCode == http.StatusSwitchingProtocols { + if !p.modifyResponse(rw, res, outreq) { + return + } + p.handleUpgradeResponse(rw, outreq, res) + return + } + + removeConnectionHeaders(res.Header) + + for _, h := range hopHeaders { + res.Header.Del(h) + } + + if !p.modifyResponse(rw, res, outreq) { + return + } + + copyHeader(rw.Header(), res.Header) + + // The "Trailer" header isn't included in the Transport's response, + // at least for *http.Transport. Build it up from Trailer. + announcedTrailers := len(res.Trailer) + if announcedTrailers > 0 { + trailerKeys := make([]string, 0, len(res.Trailer)) + for k := range res.Trailer { + trailerKeys = append(trailerKeys, k) + } + rw.Header().Add("Trailer", strings.Join(trailerKeys, ", ")) + } + + rw.WriteHeader(res.StatusCode) + + err = p.copyResponse(rw, res.Body, p.flushInterval(req, res)) + if err != nil { + defer res.Body.Close() + // Since we're streaming the response, if we run into an error all we can do + // is abort the request. Issue 23643: ReverseProxy should use ErrAbortHandler + // on read error while copying body. + if !shouldPanicOnCopyError(req) { + p.logf("suppressing panic for copyResponse error in test; copy error: %v", err) + return + } + panic(http.ErrAbortHandler) + } + res.Body.Close() // close now, instead of defer, to populate res.Trailer + + if len(res.Trailer) > 0 { + // Force chunking if we saw a response trailer. + // This prevents net/http from calculating the length for short + // bodies and adding a Content-Length. + if fl, ok := rw.(http.Flusher); ok { + fl.Flush() + } + } + + if len(res.Trailer) == announcedTrailers { + copyHeader(rw.Header(), res.Trailer) + return + } + + for k, vv := range res.Trailer { + k = http.TrailerPrefix + k + for _, v := range vv { + rw.Header().Add(k, v) + } + } +} + +var inOurTests bool // whether we're in our own tests + +// shouldPanicOnCopyError reports whether the reverse proxy should +// panic with http.ErrAbortHandler. This is the right thing to do by +// default, but Go 1.10 and earlier did not, so existing unit tests +// weren't expecting panics. Only panic in our own tests, or when +// running under the HTTP server. +func shouldPanicOnCopyError(req *http.Request) bool { + if inOurTests { + // Our tests know to handle this panic. + return true + } + if req.Context().Value(http.ServerContextKey) != nil { + // We seem to be running under an HTTP server, so + // it'll recover the panic. + return true + } + // Otherwise act like Go 1.10 and earlier to not break + // existing tests. + return false +} + +// removeConnectionHeaders removes hop-by-hop headers listed in the "Connection" header of h. +// See RFC 7230, section 6.1 +func removeConnectionHeaders(h http.Header) { + for _, f := range h["Connection"] { + for _, sf := range strings.Split(f, ",") { + if sf = textproto.TrimString(sf); sf != "" { + h.Del(sf) + } + } + } +} + +// flushInterval returns the p.FlushInterval value, conditionally +// overriding its value for a specific request/response. +func (p *ReverseProxy) flushInterval(req *http.Request, res *http.Response) time.Duration { + resCT := res.Header.Get("Content-Type") + + // For Server-Sent Events responses, flush immediately. + // The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream + if resCT == "text/event-stream" { + return -1 // negative means immediately + } + + // TODO: more specific cases? e.g. res.ContentLength == -1? + return p.FlushInterval +} + +func (p *ReverseProxy) copyResponse(dst io.Writer, src io.Reader, flushInterval time.Duration) error { + if flushInterval != 0 { + if wf, ok := dst.(writeFlusher); ok { + mlw := &maxLatencyWriter{ + dst: wf, + latency: flushInterval, + } + defer mlw.stop() + + // set up initial timer so headers get flushed even if body writes are delayed + mlw.flushPending = true + mlw.t = time.AfterFunc(flushInterval, mlw.delayedFlush) + + dst = mlw + } + } + + var buf []byte + if p.BufferPool != nil { + buf = p.BufferPool.Get() + defer p.BufferPool.Put(buf) + } + _, err := p.copyBuffer(dst, src, buf) + return err +} + +// copyBuffer returns any write errors or non-EOF read errors, and the amount +// of bytes written. +func (p *ReverseProxy) copyBuffer(dst io.Writer, src io.Reader, buf []byte) (int64, error) { + if len(buf) == 0 { + buf = make([]byte, 32*1024) + } + var written int64 + for { + nr, rerr := src.Read(buf) + if rerr != nil && rerr != io.EOF && rerr != context.Canceled { + p.logf("httputil: ReverseProxy read error during body copy: %v", rerr) + } + if nr > 0 { + nw, werr := dst.Write(buf[:nr]) + if nw > 0 { + written += int64(nw) + } + if werr != nil { + return written, werr + } + if nr != nw { + return written, io.ErrShortWrite + } + } + if rerr != nil { + if rerr == io.EOF { + rerr = nil + } + return written, rerr + } + } +} + +func (p *ReverseProxy) logf(format string, args ...interface{}) { + if p.ErrorLog != nil { + p.ErrorLog.Printf(format, args...) + } else { + log.Printf(format, args...) + } +} + +type writeFlusher interface { + io.Writer + http.Flusher +} + +type maxLatencyWriter struct { + dst writeFlusher + latency time.Duration // non-zero; negative means to flush immediately + + mu sync.Mutex // protects t, flushPending, and dst.Flush + t *time.Timer + flushPending bool +} + +func (m *maxLatencyWriter) Write(p []byte) (n int, err error) { + m.mu.Lock() + defer m.mu.Unlock() + n, err = m.dst.Write(p) + if m.latency < 0 { + m.dst.Flush() + return + } + if m.flushPending { + return + } + if m.t == nil { + m.t = time.AfterFunc(m.latency, m.delayedFlush) + } else { + m.t.Reset(m.latency) + } + m.flushPending = true + return +} + +func (m *maxLatencyWriter) delayedFlush() { + m.mu.Lock() + defer m.mu.Unlock() + if !m.flushPending { // if stop was called but AfterFunc already started this goroutine + return + } + m.dst.Flush() + m.flushPending = false +} + +func (m *maxLatencyWriter) stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.flushPending = false + if m.t != nil { + m.t.Stop() + } +} + +func upgradeType(h http.Header) string { + if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") { + return "" + } + return strings.ToLower(h.Get("Upgrade")) +} + +func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) { + reqUpType := upgradeType(req.Header) + resUpType := upgradeType(res.Header) + if reqUpType != resUpType { + p.getErrorHandler()(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType)) + return + } + + copyHeader(res.Header, rw.Header()) + + hj, ok := rw.(http.Hijacker) + if !ok { + p.getErrorHandler()(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw)) + return + } + backConn, ok := res.Body.(io.ReadWriteCloser) + if !ok { + p.getErrorHandler()(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body")) + return + } + + backConnCloseCh := make(chan bool) + go func() { + // Ensure that the cancelation of a request closes the backend. + // See issue https://golang.org/issue/35559. + select { + case <-req.Context().Done(): + case <-backConnCloseCh: + } + backConn.Close() + }() + + defer close(backConnCloseCh) + + conn, brw, err := hj.Hijack() + if err != nil { + p.getErrorHandler()(rw, req, fmt.Errorf("Hijack failed on protocol switch: %v", err)) + return + } + defer conn.Close() + res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above + if err := res.Write(brw); err != nil { + p.getErrorHandler()(rw, req, fmt.Errorf("response write: %v", err)) + return + } + if err := brw.Flush(); err != nil { + p.getErrorHandler()(rw, req, fmt.Errorf("response flush: %v", err)) + return + } + errc := make(chan error, 1) + spc := switchProtocolCopier{user: conn, backend: backConn} + go spc.copyToBackend(errc) + go spc.copyFromBackend(errc) + <-errc + return +} + +// switchProtocolCopier exists so goroutines proxying data back and +// forth have nice names in stacks. +type switchProtocolCopier struct { + user, backend io.ReadWriter +} + +func (c switchProtocolCopier) copyFromBackend(errc chan<- error) { + _, err := io.Copy(c.user, c.backend) + errc <- err +} + +func (c switchProtocolCopier) copyToBackend(errc chan<- error) { + _, err := io.Copy(c.backend, c.user) + errc <- err +}