feat: added lots of data to the models and collection process

Next step might actually be the TUI! Or maybe the raw proxy, it would be
nice to be able to just run the proxy.
This commit is contained in:
Hayden Hargreaves 2026-04-14 14:39:27 -07:00
parent e69d3e4a8a
commit 24b00146bf
14 changed files with 590 additions and 645 deletions

View File

@ -4,13 +4,18 @@ package main
// which hits the other and response with the data provided.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
func main() {
@ -41,34 +46,100 @@ func main() {
func startFrontend(upstreamHost string) error {
mux := http.NewServeMux()
mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) {
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
message := req.URL.Query().Get("message")
upstreamURL := fmt.Sprintf("http://%s:3001/echo?message=%s", upstreamHost, url.QueryEscape(message))
resp, err := http.Get(upstreamURL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(frontendHTML))
})
log.Printf("frontend listening on http://127.0.0.1:3000/echo?message=hello")
mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) {
client := &http.Client{Timeout: parseTimeout(req.URL.Query().Get("timeoutMs"))}
switch req.Method {
case http.MethodGet:
message := req.URL.Query().Get("message")
upstreamURL := fmt.Sprintf(
"http://%s:3001/echo?message=%s&code=%s&fail=%s&sleepMs=%s",
upstreamHost,
url.QueryEscape(message),
url.QueryEscape(req.URL.Query().Get("code")),
url.QueryEscape(req.URL.Query().Get("fail")),
url.QueryEscape(req.URL.Query().Get("sleepMs")),
)
resp, err := client.Get(upstreamURL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
case http.MethodPost:
body, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !json.Valid(body) {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
upstreamURL := fmt.Sprintf(
"http://%s:3001/echo?code=%s&fail=%s&sleepMs=%s",
upstreamHost,
url.QueryEscape(req.URL.Query().Get("code")),
url.QueryEscape(req.URL.Query().Get("fail")),
url.QueryEscape(req.URL.Query().Get("sleepMs")),
)
upstreamReq, err := http.NewRequest(http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
upstreamReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(upstreamReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
upstreamBody, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(upstreamBody)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
log.Printf("frontend UI on http://127.0.0.1:3000")
log.Printf("frontend GET example: http://127.0.0.1:3000/echo?message=hello&code=201&sleepMs=200")
log.Printf("frontend POST example: curl -i -X POST 'http://127.0.0.1:3000/echo?code=202&sleepMs=200' -H 'content-type: application/json' -d '{\"message\":\"hello\"}'")
log.Printf("frontend timeout example: http://127.0.0.1:3000/echo?message=late&sleepMs=4000&timeoutMs=1000")
log.Printf("frontend failure examples: fail=true, fail=drop, fail=timeout, fail=status")
log.Printf("frontend calls upstream at http://%s:3001/echo", upstreamHost)
return http.ListenAndServe("127.0.0.1:3000", mux)
}
@ -76,20 +147,291 @@ func startFrontend(upstreamHost string) error {
func startUpstream(upstreamHost string) error {
mux := http.NewServeMux()
mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
code := parseStatusCode(req.URL.Query().Get("code"))
time.Sleep(parseSleep(req.URL.Query().Get("sleepMs")))
if handleFailureMode(w, req, req.URL.Query().Get("fail"), code) {
return
}
message := req.URL.Query().Get("message")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(message))
switch req.Method {
case http.MethodGet:
message := req.URL.Query().Get("message")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
_, _ = w.Write([]byte(message))
case http.MethodPost:
body, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_, _ = w.Write(body)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
log.Printf("upstream listening on http://%s:3001/echo?message=hello", upstreamHost)
log.Printf("upstream listening on http://%s:3001/echo?message=hello&code=201", upstreamHost)
log.Printf("upstream POST example: curl -i -X POST 'http://%s:3001/echo?code=202&sleepMs=200' -H 'content-type: application/json' -d '{\"message\":\"hello\"}'", upstreamHost)
return http.ListenAndServe(":3001", mux)
}
const frontendHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Echo JSON Demo</title>
<style>
body {
margin: 0;
font-family: ui-sans-serif, sans-serif;
background: #f5f6f8;
color: #111827;
display: grid;
place-items: center;
min-height: 100vh;
}
main {
width: min(700px, 92vw);
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.08);
}
h1 {
margin-top: 0;
font-size: 1.25rem;
}
label {
display: block;
margin: 10px 0 6px;
font-weight: 600;
}
textarea, input {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
textarea {
min-height: 140px;
font-family: ui-monospace, monospace;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
button {
margin-top: 12px;
width: 100%;
padding: 10px;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
pre {
margin: 12px 0 0;
background: #111827;
color: #f9fafb;
padding: 12px;
border-radius: 8px;
overflow: auto;
min-height: 80px;
}
</style>
</head>
<body>
<main>
<h1>Echo JSON Through Frontend</h1>
<form id="echo-form">
<label for="payload">JSON payload</label>
<textarea id="payload">{"message":"hello from form"}</textarea>
<div class="row">
<div>
<label for="code">Status code (optional)</label>
<input id="code" placeholder="200">
</div>
<div>
<label for="fail">Fail mode (optional)</label>
<input id="fail" placeholder="false | drop | timeout | status">
</div>
</div>
<div class="row">
<div>
<label for="sleepMs">Upstream sleep ms</label>
<input id="sleepMs" placeholder="0">
</div>
<div>
<label for="timeoutMs">Frontend timeout ms</label>
<input id="timeoutMs" placeholder="5000">
</div>
</div>
<button type="submit">Send JSON</button>
</form>
<pre id="result">Waiting for request...</pre>
</main>
<script>
const form = document.getElementById("echo-form");
const payloadInput = document.getElementById("payload");
const codeInput = document.getElementById("code");
const failInput = document.getElementById("fail");
const sleepInput = document.getElementById("sleepMs");
const timeoutInput = document.getElementById("timeoutMs");
const result = document.getElementById("result");
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
JSON.parse(payloadInput.value);
} catch (err) {
result.textContent = "invalid JSON: " + err.message;
return;
}
const params = new URLSearchParams();
if (codeInput.value.trim()) {
params.set("code", codeInput.value.trim());
}
if (failInput.value.trim()) {
params.set("fail", failInput.value.trim());
}
if (sleepInput.value.trim()) {
params.set("sleepMs", sleepInput.value.trim());
}
if (timeoutInput.value.trim()) {
params.set("timeoutMs", timeoutInput.value.trim());
}
const query = params.toString();
const url = query ? "/echo?" + query : "/echo";
try {
const resp = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: payloadInput.value,
});
const body = await resp.text();
result.textContent = "status: " + resp.status + "\n" + body;
} catch (err) {
result.textContent = "request failed: " + err.message;
}
});
</script>
</body>
</html>
`
func handleFailureMode(w http.ResponseWriter, req *http.Request, raw string, requestedCode int) bool {
mode := strings.ToLower(strings.TrimSpace(raw))
if mode == "" || mode == "false" || mode == "0" || mode == "no" {
return false
}
switch mode {
case "true", "drop":
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "drop mode not supported by server", http.StatusInternalServerError)
return true
}
conn, _, err := hj.Hijack()
if err != nil {
http.Error(w, "failed to drop connection", http.StatusInternalServerError)
return true
}
_ = conn.Close()
return true
case "timeout", "hang":
<-req.Context().Done()
return true
case "status":
status := requestedCode
if status < 400 || status > 599 {
status = http.StatusInternalServerError
}
http.Error(w, fmt.Sprintf("forced failure (%d)", status), status)
return true
default:
if status, ok := parseFailureStatus(mode); ok {
http.Error(w, fmt.Sprintf("forced failure (%d)", status), status)
return true
}
http.Error(w, "invalid fail mode", http.StatusBadRequest)
return true
}
}
func parseFailureStatus(mode string) (int, bool) {
status, err := strconv.Atoi(mode)
if err != nil || status < 400 || status > 599 {
return 0, false
}
return status, true
}
func parseStatusCode(raw string) int {
if raw == "" {
return http.StatusOK
}
code, err := strconv.Atoi(raw)
if err != nil || code < 100 || code > 999 {
return http.StatusOK
}
return code
}
func parseSleep(raw string) time.Duration {
ms, ok := parseMilliseconds(raw, 0)
if !ok {
return 0
}
return time.Duration(ms) * time.Millisecond
}
func parseTimeout(raw string) time.Duration {
ms, ok := parseMilliseconds(raw, 5000)
if !ok {
return 5 * time.Second
}
if ms == 0 {
return 0
}
return time.Duration(ms) * time.Millisecond
}
func parseMilliseconds(raw string, fallback int) (int, bool) {
if raw == "" {
return fallback, true
}
ms, err := strconv.Atoi(raw)
if err != nil || ms < 0 {
return fallback, false
}
return ms, true
}
func findNonLoopbackIPv4() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {

View File

@ -18,13 +18,14 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
proc := process.NewProcess(cmd, addr, ch)
if err := proc.Start(); err != nil {
if err := proc.Exec.Start(); err != nil {
ch <- model.Message{
Type: model.MessageTypeProcessExited,
Body: fmt.Sprintf("%q", err),
}
return
}
process.UpdateStatus(proc, true, ch)
// Listen for SIGTERM from main process
go func() {
@ -32,23 +33,25 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
ch <- model.Message{
Type: model.MessageTypeProcessSignaled,
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Process.Pid),
PID: proc.Process.Pid,
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid,
}
if proc.Process != nil {
_ = proc.Process.Signal(sig)
if proc.Exec != nil {
_ = proc.Exec.Process.Signal(sig)
process.UpdateStatus(proc, false, ch)
}
}()
if err := proc.Wait(); err != nil {
if err := proc.Exec.Wait(); err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
ch <- model.Message{
Type: model.MessageTypeProcessExited,
Body: "process killed itself",
PID: proc.Process.Pid,
Body: fmt.Sprintf("process pid '%d' exited by itself", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid,
ExitCode: exitErr.ExitCode(),
}
process.UpdateStatus(proc, false, ch)
return
}
@ -56,6 +59,7 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
Type: model.MessageTypeFatal,
Body: fmt.Sprintf("%q", err),
}
process.UpdateStatus(proc, false, ch)
return
}

View File

@ -16,7 +16,7 @@ func StartProxy(addr string, ch chan<- model.Message) {
}
return
}
defer proxy.Destory(ps)
defer proxy.Destory(ps, ch)
ch <- model.Message{
Type: model.MessageTypeProxyStarting,

View File

@ -25,10 +25,15 @@ func StartSession(cmd model.Command, addr string) error {
var events []model.Message
var requests []model.Request
for {
select {
case _ = <-sigCh:
fmt.Println("\n\nEVENTS")
printEvents(events)
fmt.Println("\n\nREQUESTS")
printRequests(requests)
return nil
case msg := <-msgs:
{
@ -36,6 +41,21 @@ func StartSession(cmd model.Command, addr string) error {
switch msg.Type {
case model.MessageTypeFatal:
return fmt.Errorf("%s", msg.Body)
case model.MessageTypeRequestStarted:
log.Printf("[%s] (%s) %s", msg.Type, msg.Request.ID.String(), msg.Body)
requests = append(requests, msg.Request)
case model.MessageTypeRequestFinished, model.MessageTypeRequestFailed:
log.Printf("[%s] (%s) %s", msg.Type, msg.Request.ID.String(), msg.Body)
for i := range requests {
if requests[i].ID == msg.Request.ID {
requests[i] = msg.Request
break
}
}
default:
log.Printf("[%s] %s", msg.Type, msg.Body)
}
@ -50,3 +70,12 @@ func printEvents(events []model.Message) {
fmt.Printf("%+v\n", event)
}
}
func printRequests(reqs []model.Request) {
for _, req := range reqs {
fmt.Printf("%+v\n", req)
for k, v := range req.QueryMap {
fmt.Printf("key: %s, vals: %+v\n", k, v)
}
}
}

View File

@ -1,6 +0,0 @@
package model
type Command struct {
Name string
Args []string
}

View File

@ -26,11 +26,9 @@ const (
)
type Message struct {
Type MessageType
Body string
PID int
RequestID string
URL string
Status int
ExitCode int
Type MessageType
Body string
PID int
ExitCode int
Request Request
}

14
internal/model/process.go Normal file
View File

@ -0,0 +1,14 @@
package model
import "os/exec"
type Command struct {
Name string
Args []string
}
type Process struct {
Command Command
Exec *exec.Cmd
Running bool
}

View File

@ -3,6 +3,10 @@ package model
import (
"net"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
)
type ProxyServer struct {
@ -10,3 +14,22 @@ type ProxyServer struct {
Server *http.Server
Url string
}
type Request struct {
ID uuid.UUID
Method string
ResponseData []byte
RequestData []byte
RawURL string
Host string
URL string
QueryString string
QueryMap url.Values
Status int
Duration time.Duration
Pending bool
Failed bool
StartTime time.Time
RequestHeaders http.Header
ResponseHeaders http.Header
}

View File

@ -15,7 +15,7 @@ func CommandString(c model.Command) string {
return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " "))
}
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *exec.Cmd {
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.Process {
proc := exec.Command(cmd.Name, cmd.Args...)
injectEnv(proc, addr)
@ -42,7 +42,11 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *exec.C
go readPipe(stderr, model.MessageTypeProcessStderr, ch)
}
return proc
return &model.Process{
Command: cmd,
Exec: proc,
Running: false,
}
}
func injectEnv(proc *exec.Cmd, addr string) {
@ -70,3 +74,33 @@ func readPipe(pipe io.Reader, t model.MessageType, ch chan<- model.Message) {
}
}
}
func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Message) {
if proc == nil {
return
}
if proc.Running == running {
return
}
proc.Running = running
var (
t model.MessageType
status string
)
if running {
t = model.MessageTypeProcessStarted
status = "running"
} else {
t = model.MessageTypeProcessExited
status = "stopped"
}
ch <- model.Message{
Type: t,
Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status),
PID: proc.Exec.Process.Pid,
}
}

View File

@ -2,13 +2,17 @@ package proxy
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sort"
"strings"
"time"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
@ -40,41 +44,86 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
}
start := time.Now()
// requestPreview, err := readAndRestoreBody(&req.Body)
// if err != nil {
// http.Error(w, "failed to read request body", http.StatusBadRequest)
// log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
// return
// }
request := model.Request{
ID: uuid.New(),
ResponseData: []byte{},
RequestData: []byte{},
URL: "",
Status: -1,
Method: "",
Duration: 0,
Pending: true,
Failed: false,
StartTime: start,
}
requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil {
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("(%s) failed to read request body", request.ID),
Request: request,
}
} else {
request.RequestData = []byte(requestPreview)
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
request.URL = outReq.URL.Path
request.QueryString = outReq.URL.RawQuery
request.QueryMap = outReq.URL.Query()
request.Host = outReq.Host
request.Method = outReq.Method
request.RequestHeaders = outReq.Header
request.RawURL = outReq.URL.String()
ch <- model.Message{
Type: model.MessageTypeRequestStarted,
Body: fmt.Sprintf("-> %s %s", outReq.Method, outReq.URL.String()),
Type: model.MessageTypeRequestStarted,
Body: fmt.Sprintf("-> %+v", request),
Request: request,
}
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
status := statusFromUpstreamError(req, resp, err)
http.Error(w, http.StatusText(status), status)
request.Pending = false
request.Failed = true
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = status
ch <- model.Message{
Type: model.MessageTypeRequestFailed,
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
Type: model.MessageTypeRequestFailed,
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
Request: request,
}
return
}
defer resp.Body.Close()
// responsePreview, err := readAndRestoreBody(&resp.Body)
// if err != nil {
// http.Error(w, "bad gateway", http.StatusBadGateway)
// log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
// return
// }
responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil {
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("(%s) failed to read response body", request.ID),
Request: request,
}
} else {
request.ResponseData = []byte(responsePreview)
}
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
request.Pending = false
request.Failed = true
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = resp.StatusCode
ch <- model.Message{
Type: model.MessageTypeRequestFailed,
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
@ -82,14 +131,15 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
return
}
request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = resp.StatusCode
request.ResponseHeaders = resp.Header
request.Pending = false
ch <- model.Message{
Type: model.MessageTypeRequestFinished,
Body: fmt.Sprintf("<- %s %s %d %s",
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(start).Round(time.Millisecond),
),
Type: model.MessageTypeRequestFinished,
Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)),
Request: request,
}
})
}
@ -140,3 +190,25 @@ func formatHeaders(headers http.Header) string {
return strings.Join(parts, ", ")
}
// BUG: Not sure if this actually works, seems to favor the 502
func statusFromUpstreamError(req *http.Request, resp *http.Response, err error) int {
if resp != nil {
return resp.StatusCode
}
if errors.Is(req.Context().Err(), context.Canceled) {
return http.StatusBadGateway
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return http.StatusGatewayTimeout
}
return http.StatusBadGateway
}

View File

@ -28,11 +28,15 @@ func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, e
}
// BUG: Not sure what all this does
func Destory(ps *model.ProxyServer) {
func Destory(ps *model.ProxyServer, ch chan<- model.Message) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if ps != nil && ps.Server != nil {
_ = ps.Server.Shutdown(ctx)
ch <- model.Message{
Type: model.MessageTypeProxyStopped,
Body: "proxy server was destroyed",
}
}
}

View File

@ -1,224 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func main() {
if err := parseArgs(); err != nil {
panic(err)
}
}
func parseArgs() error {
if len(os.Args) < 3 {
return fmt.Errorf("Must use this right")
}
if os.Args[1] != "run" || os.Args[2] != "--" {
return fmt.Errorf("Must use this right")
}
cmd := os.Args[3:]
return run(cmd)
}
func run(cmd []string) error {
fmt.Printf("%+v\n", cmd)
server, url, err := proxy()
if err != nil {
return err
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
println(url)
env := []string{
"HTTP_PROXY=" + url,
"http_proxy=" + url,
"HTTPS_PROXY=" + url,
"https_proxy=" + url,
"ALL_PROXY=" + url,
"all_proxy=" + url,
"NO_PROXY=",
"no_proxy=",
}
proc := exec.Command(cmd[0], cmd[1:]...)
proc.Stdin = os.Stdin
proc.Stdout = os.Stdout
proc.Stderr = os.Stderr
proc.Env = append(os.Environ(), env...)
if err := proc.Start(); err != nil {
return err
}
if err := proc.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("wait for command: %w", err)
}
return nil
}
func proxy() (*http.Server, string, error) {
addr := "127.0.0.1:8080"
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, "", err
}
server := &http.Server{Handler: handler()}
go func() {
if err := server.Serve(listener); err != nil {
fmt.Printf("%q", err)
}
}()
url := "http://" + listener.Addr().String()
return server, url, nil
}
func handler() http.Handler {
transport := http.DefaultTransport
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
log.Printf("!! CONNECT %s not supported", req.Host)
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String())
return
}
startedAt := time.Now()
id := uuid.New().String()
// requestPreview, err := readAndRestoreBody(&req.Body)
// if err != nil {
// http.Error(w, "failed to read request body", http.StatusBadRequest)
// log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
// return
// }
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
log.Printf(
"[%s] -> %s %s\n",
id,
outReq.Method,
outReq.URL.String(),
// formatHeaders(outReq.Header),
// requestPreview,
)
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
defer resp.Body.Close()
// responsePreview, err := readAndRestoreBody(&resp.Body)
// if err != nil {
// http.Error(w, "bad gateway", http.StatusBadGateway)
// log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
// return
// }
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
log.Printf(
"[%s] <- %s %s %d %s\n",
id,
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(startedAt).Round(time.Millisecond),
)
})
}
const maxPreviewBytes = 1024
func copyHeader(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func readAndRestoreBody(body *io.ReadCloser) (string, error) {
if body == nil || *body == nil {
return "", nil
}
payload, err := io.ReadAll(*body)
if err != nil {
return "", err
}
*body = io.NopCloser(bytes.NewReader(payload))
preview := payload
if len(preview) > maxPreviewBytes {
preview = preview[:maxPreviewBytes]
}
text := strings.ReplaceAll(string(preview), "\n", "\\n")
if len(payload) > maxPreviewBytes {
text += "..."
}
return text, nil
}
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
parts := make([]string, 0, len(headers))
for key, values := range headers {
parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}

View File

@ -1,303 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"syscall"
"time"
)
const maxPreviewBytes = 1024
func test() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "run":
if err := runCommand(os.Args[2:]); err != nil {
log.Fatal(err)
}
case "proxy":
if err := runProxy(os.Args[2:]); err != nil {
log.Fatal(err)
}
default:
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " tap run -- <command> [args...]")
fmt.Fprintln(os.Stderr, " tap proxy [-listen 127.0.0.1:8080]")
}
func runCommand(args []string) error {
runFlags := flag.NewFlagSet("run", flag.ExitOnError)
listenAddr := runFlags.String("listen", "127.0.0.1:0", "proxy listen address")
runFlags.SetOutput(io.Discard)
if err := runFlags.Parse(args); err != nil {
return err
}
commandArgs := runFlags.Args()
if len(commandArgs) == 0 {
return errors.New("run requires a command after `--`")
}
if commandArgs[0] == "--" {
commandArgs = commandArgs[1:]
}
if len(commandArgs) == 0 {
return errors.New("run requires a command after `--`")
}
server, proxyURL, err := startProxy(*listenAddr)
if err != nil {
return err
}
defer shutdownServer(server)
log.Printf("proxy listening on %s", proxyURL)
cmd := exec.Command(commandArgs[0], commandArgs[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = withProxyEnv(os.Environ(), proxyURL)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start command: %w", err)
}
forwardSignals(cmd.Process)
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("wait for command: %w", err)
}
return nil
}
func runProxy(args []string) error {
proxyFlags := flag.NewFlagSet("proxy", flag.ExitOnError)
listenAddr := proxyFlags.String("listen", "127.0.0.1:8080", "proxy listen address")
if err := proxyFlags.Parse(args); err != nil {
return err
}
server, proxyURL, err := startProxy(*listenAddr)
if err != nil {
return err
}
defer shutdownServer(server)
log.Printf("proxy listening on %s", proxyURL)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(stop)
<-stop
return nil
}
func startProxy(listenAddr string) (*http.Server, string, error) {
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, "", fmt.Errorf("listen on %s: %w", listenAddr, err)
}
server := &http.Server{Handler: newForwardProxy()}
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("proxy server error: %v", err)
}
}()
proxyURL := "http://" + listener.Addr().String()
return server, proxyURL, nil
}
func shutdownServer(server *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}
func withProxyEnv(env []string, proxyURL string) []string {
filtered := make([]string, 0, len(env)+5)
for _, entry := range env {
if hasEnvKey(entry, "HTTP_PROXY") || hasEnvKey(entry, "http_proxy") || hasEnvKey(entry, "HTTPS_PROXY") || hasEnvKey(entry, "https_proxy") || hasEnvKey(entry, "ALL_PROXY") || hasEnvKey(entry, "all_proxy") || hasEnvKey(entry, "NO_PROXY") || hasEnvKey(entry, "no_proxy") {
continue
}
filtered = append(filtered, entry)
}
filtered = append(filtered,
"HTTP_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"https_proxy="+proxyURL,
"ALL_PROXY="+proxyURL,
"all_proxy="+proxyURL,
"NO_PROXY=",
"no_proxy=",
)
return filtered
}
func hasEnvKey(entry, key string) bool {
return strings.HasPrefix(entry, key+"=")
}
func forwardSignals(process *os.Process) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
go func() {
for sig := range ch {
_ = process.Signal(sig)
}
}()
}
func newForwardProxy() http.Handler {
transport := http.DefaultTransport
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
log.Printf("!! CONNECT %s not supported", req.Host)
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String())
return
}
startedAt := time.Now()
requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
return
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
log.Printf(
"-> %s %s\n request headers: %s\n request body: %q",
outReq.Method,
outReq.URL.String(),
formatHeaders(outReq.Header),
requestPreview,
)
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
defer resp.Body.Close()
responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
log.Printf(
"<- %s %s %d %s\n response headers: %s\n response body: %q",
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(startedAt).Round(time.Millisecond),
formatHeaders(resp.Header),
responsePreview,
)
})
}
func copyHeader(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func readAndRestoreBody(body *io.ReadCloser) (string, error) {
if body == nil || *body == nil {
return "", nil
}
payload, err := io.ReadAll(*body)
if err != nil {
return "", err
}
*body = io.NopCloser(bytes.NewReader(payload))
preview := payload
if len(preview) > maxPreviewBytes {
preview = preview[:maxPreviewBytes]
}
text := strings.ReplaceAll(string(preview), "\n", "\\n")
if len(payload) > maxPreviewBytes {
text += "..."
}
return text, nil
}
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
parts := make([]string, 0, len(headers))
for key, values := range headers {
parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}

View File

@ -1,42 +0,0 @@
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
if err := startDemoServer("127.0.0.1:3000"); err != nil {
panic(err)
}
}
func startDemoServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/send", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := http.Get("http://example.com")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "sent request to http://example.com\nstatus: %s\nbytes: %d\n", resp.Status, len(body))
})
fmt.Printf("demo server listening on http://%s/send\n", addr)
return http.ListenAndServe(addr, mux)
}