mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 12:27:59 +00:00
f: feat(cmd/httplog): add proxy and intercept sse
This commit is contained in:
parent
25373a8346
commit
beded855a2
@ -1,96 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/therootcompany/golib/colorjson"
|
||||
)
|
||||
|
||||
var jsonf = colorjson.NewFormatter()
|
||||
const (
|
||||
name = "httplog"
|
||||
licenseYear = "2026"
|
||||
licenseOwner = "AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)"
|
||||
licenseType = "CC0-1.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
jsonf.Indent = 3
|
||||
color.NoColor = false // TODO manual override via flags
|
||||
var (
|
||||
version = "0.1.0"
|
||||
)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", handler)
|
||||
mux.HandleFunc("POST /", handler)
|
||||
mux.HandleFunc("PATCH /", handler)
|
||||
mux.HandleFunc("PUT /", handler)
|
||||
mux.HandleFunc("DELETE /", handler)
|
||||
|
||||
addr := "localhost:8088"
|
||||
fmt.Printf("Listening on %s...\n\n", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
// printVersion displays the version, commit, and build date.
|
||||
func printVersion(w io.Writer) {
|
||||
// _, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date)
|
||||
_, _ = fmt.Fprintf(w, "%s v%s - log HTTP requests - headers, queries, body, etc\n", name, version)
|
||||
_, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
|
||||
_, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType)
|
||||
}
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
type MainConfig struct {
|
||||
Bind string
|
||||
Port int
|
||||
ProxyTarget string
|
||||
ForceColor bool
|
||||
jsonf *colorjson.Formatter
|
||||
}
|
||||
|
||||
func main() {
|
||||
cli := MainConfig{
|
||||
Bind: "0.0.0.0",
|
||||
Port: 8080,
|
||||
ProxyTarget: "",
|
||||
ForceColor: false,
|
||||
jsonf: colorjson.NewFormatter(),
|
||||
}
|
||||
cli.jsonf.Indent = 3
|
||||
|
||||
// Flags
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.IntVar(&cli.Port, "port", cli.Port, "port to listen on")
|
||||
fs.StringVar(&cli.Bind, "address", cli.Bind, "address to bind to (e.g. 127.0.0.1)")
|
||||
fs.StringVar(&cli.ProxyTarget, "proxy-target", cli.ProxyTarget, "upstream target to proxy requests to")
|
||||
fs.BoolVar(&cli.ForceColor, "color", false, "colorize output even if support is not detected (e.g. pipes, files)")
|
||||
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
fs.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
log.Fatalf("flag parse error: %v", err)
|
||||
}
|
||||
|
||||
// Special handling for version/help
|
||||
if len(os.Args) > 1 {
|
||||
arg := os.Args[1]
|
||||
if arg == "-V" || arg == "--version" || arg == "version" {
|
||||
printVersion(os.Stdout)
|
||||
os.Exit(0)
|
||||
}
|
||||
if arg == "help" || arg == "-help" || arg == "--help" {
|
||||
printVersion(os.Stdout)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "")
|
||||
fs.SetOutput(os.Stdout)
|
||||
fs.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
printVersion(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
|
||||
if cli.ForceColor {
|
||||
// this is auto-detected
|
||||
color.NoColor = false
|
||||
}
|
||||
|
||||
run(&cli)
|
||||
}
|
||||
|
||||
func run(cli *MainConfig) {
|
||||
// Build proxy handler
|
||||
handleProxyToTarget := cli.newProxyHandler(cli.ProxyTarget)
|
||||
handler := cli.NewLogger(handleProxyToTarget)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("HEAD /", handler)
|
||||
mux.Handle("OPTIONS /", handler)
|
||||
mux.Handle("GET /", handler)
|
||||
mux.Handle("POST /", handler)
|
||||
mux.Handle("PATCH /", handler)
|
||||
mux.Handle("PUT /", handler)
|
||||
mux.Handle("DELETE /", handler)
|
||||
|
||||
// Server setup
|
||||
srv := &http.Server{
|
||||
Addr: cli.Addr(),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
done := make(chan os.Signal, 1)
|
||||
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-done
|
||||
log.Println("Shutting down server...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("HTTP server shutdown error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
proxyTarget := cli.ProxyTarget
|
||||
if proxyTarget == "" {
|
||||
proxyTarget = "502 Bad Gateway"
|
||||
}
|
||||
log.Printf("Starting %s v%s on %s → %s", name, version, srv.Addr, proxyTarget)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
||||
func (cli *MainConfig) Addr() string {
|
||||
return fmt.Sprintf("%s:%d", cli.Bind, cli.Port)
|
||||
}
|
||||
|
||||
func (cli *MainConfig) NewLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var buf = new(bytes.Buffer)
|
||||
logURI(buf, r)
|
||||
logHeaders(buf, r.Host, r.Header)
|
||||
r.Body, err = cli.logBody(buf, r.Method, r.Header, r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[Request] Query, Headers, and Data:\n%s", buf.Bytes())
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func logURI(out io.Writer, r *http.Request) {
|
||||
// Log method, path, and query
|
||||
var query string
|
||||
if len(r.URL.RawQuery) > 0 {
|
||||
query = "?" + r.URL.RawQuery
|
||||
}
|
||||
log.Printf("%s %s%s", r.Method, r.URL.Path, query)
|
||||
_, _ = fmt.Fprintf(out, "%s %s%s\n", r.Method, r.URL.Path, query)
|
||||
|
||||
// Print Query Params, if any
|
||||
if len(r.URL.RawQuery) > 0 {
|
||||
// Find max query name length for alignment
|
||||
var paramMaxLen int
|
||||
for param := range r.URL.Query() {
|
||||
if len(param) > paramMaxLen {
|
||||
paramMaxLen = len(param)
|
||||
}
|
||||
}
|
||||
paramMaxLen += 1
|
||||
|
||||
queryParams := r.URL.Query()
|
||||
for param := range queryParams {
|
||||
for _, value := range queryParams[param] {
|
||||
_, _ = fmt.Fprintf(out, " %-"+fmt.Sprintf("%d", paramMaxLen+1)+"s %s\n", param+" =", value)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func logHeaders(out io.Writer, host string, header http.Header) {
|
||||
// Find max header name length for alignment
|
||||
maxLen := len("HOST")
|
||||
for name := range r.Header {
|
||||
if len(name) > maxLen {
|
||||
maxLen = len(name)
|
||||
headerMaxLen := len("HOST")
|
||||
for name := range header {
|
||||
if len(name) > headerMaxLen {
|
||||
headerMaxLen = len(name)
|
||||
}
|
||||
}
|
||||
maxLen += 1
|
||||
headerMaxLen += 1
|
||||
|
||||
fmt.Printf(" %-"+fmt.Sprintf("%d", maxLen+1)+"s %s\n", "HOST", r.Host)
|
||||
for name, values := range r.Header {
|
||||
if host != "" {
|
||||
fmt.Fprintf(out, " %-"+fmt.Sprintf("%d", headerMaxLen+1)+"s %s\n", "HOST", host)
|
||||
}
|
||||
for name, values := range header {
|
||||
for _, value := range values {
|
||||
fmt.Printf(" %-"+fmt.Sprintf("%d", maxLen+1)+"s %s\n", name+":", value)
|
||||
fmt.Fprintf(out, " %-"+fmt.Sprintf("%d", headerMaxLen+1)+"s %s\n", name+":", value)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
fmt.Fprintf(out, "\n")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
switch strings.ToUpper(r.Method) {
|
||||
case "GET", "DELETE":
|
||||
if len(body) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Unexpected body:\n%q\n", string(body))
|
||||
func (cli *MainConfig) logBody(out io.Writer, method string, header http.Header, r io.ReadCloser) (io.ReadCloser, error) {
|
||||
rawBody, err := io.ReadAll(r)
|
||||
if method != "" {
|
||||
switch strings.ToUpper(method) {
|
||||
case "HEAD", "OPTIONS", "GET", "DELETE":
|
||||
if len(rawBody) > 0 {
|
||||
fmt.Fprintf(out, "Unexpected body:\n%q\n", string(rawBody))
|
||||
}
|
||||
return
|
||||
return r, nil
|
||||
case "POST", "PATCH", "PUT":
|
||||
break
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unexpected method\n")
|
||||
return
|
||||
fmt.Fprintf(out, "Unexpected method %q\n", method)
|
||||
return r, nil
|
||||
}
|
||||
defer fmt.Println()
|
||||
}
|
||||
|
||||
// Read request body
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read body:\n%q\n", string(body))
|
||||
return
|
||||
fmt.Fprintf(out, "Failed to read body:\n%q\n", string(rawBody))
|
||||
return r, err
|
||||
}
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
nextBody := io.NopCloser(bytes.NewReader(rawBody))
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
// TODO: text/event-stream
|
||||
// Parse and pretty-print JSON, or raw body
|
||||
if strings.Contains(header.Get("Content-Type"), "json") {
|
||||
var text string
|
||||
var data any
|
||||
if err := json.Unmarshal(body, &data); err == nil {
|
||||
body, _ = jsonf.Marshal(data)
|
||||
var b []byte
|
||||
if err := json.Unmarshal(rawBody, &data); err == nil {
|
||||
b, _ = cli.jsonf.Marshal(data)
|
||||
}
|
||||
|
||||
text = string(body)
|
||||
text = string(b)
|
||||
text = prefixLines(text, " ")
|
||||
text = strings.TrimSpace(text)
|
||||
fmt.Printf(" %s\n", text)
|
||||
fmt.Fprintf(out, " %s\n", text)
|
||||
} else if strings.Contains(header.Get("Content-Type"), "text") {
|
||||
fmt.Fprintf(out, " %s\n", string(rawBody))
|
||||
}
|
||||
|
||||
return nextBody, nil
|
||||
}
|
||||
|
||||
func prefixLines(text, prefix string) string {
|
||||
@ -100,3 +274,42 @@ func prefixLines(text, prefix string) string {
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (cli *MainConfig) newProxyHandler(targetURL string) http.Handler {
|
||||
if targetURL == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Logged-By", fmt.Sprintf("%s-v%s", name, version))
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
})
|
||||
}
|
||||
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid proxy target %q: %v", targetURL, err)
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(target)
|
||||
r.Out.Host = r.In.Host // preserve original Host header
|
||||
// X-Forwarded-* headers are preserved from incoming request
|
||||
},
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
var buf = new(bytes.Buffer)
|
||||
logHeaders(buf, "", resp.Header)
|
||||
resp.Body, err = cli.logBody(buf, "", resp.Header, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[Response] Headers & Data:\n%s", buf.Bytes())
|
||||
resp.Header.Set("X-Logged-By", fmt.Sprintf("%s-v%s", name, version))
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("proxy error: %v", err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user