Compare commits

...

114 commits
v0.2.0 ... main

Author SHA1 Message Date
Simon Vieille 92aa6b341f Merge branch 'develop'
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-09-29 15:56:20 +02:00
Simon Vieille 1f089236dc
fix ci syntax
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-09-29 15:55:59 +02:00
Simon Vieille ee1bc68760
update readme 2023-09-29 15:55:50 +02:00
Simon Vieille 09cd37aa3b
update ci
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2023-09-01 09:26:55 +02:00
Simon Vieille 8a11deed01
update build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2023-09-01 09:14:29 +02:00
Simon Vieille 6c05d9f126 Merge branch 'develop'
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-08-03 18:23:04 +02:00
Simon Vieille 5291903718
add build/ in docker ignored files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2023-08-03 18:22:56 +02:00
Simon Vieille c08af75dc5 Merge branch 'develop'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-03 18:21:24 +02:00
Simon Vieille 5fac42f17b
fix tests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-03 15:44:38 +02:00
Simon Vieille 9993d9a8ef
add option to skip TLS verification
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-08-03 15:42:22 +02:00
Simon Vieille d6e0b063e0
rename build binaries 2023-08-03 15:37:42 +02:00
Simon Vieille 0afdce8c5f
update changelog
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-08-03 15:15:08 +02:00
Simon Vieille bcc4a9f4ab
fix ci build
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-08-03 15:14:01 +02:00
Simon Vieille 3e6600113c
fix ci build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-03 15:12:52 +02:00
Simon Vieille bd0781aec7
add makefile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
update build params
2023-08-03 15:09:12 +02:00
Simon Vieille 174a06e8b5 Merge branch 'develop'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2023-06-09 11:32:10 +02:00
Simon Vieille cf97958081
optimze the docker build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-09 11:31:49 +02:00
Simon Vieille 94537e9238 Merge branch 'develop'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-06 22:49:01 +02:00
Simon Vieille 528b3c1da8 Merge branch 'feature/docker' into develop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-06 22:48:41 +02:00
Simon Vieille 989e7689fc
update documentation 2023-06-06 22:48:38 +02:00
Simon Vieille baf19d935c
fix ci
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2023-06-06 22:15:49 +02:00
Simon Vieille 57654afc2d
fix ci 2023-06-06 22:15:29 +02:00
Simon Vieille c5392885bd
fix ci
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline failed
2023-06-06 22:12:59 +02:00
Simon Vieille 3d9aeaf2d3
fix ci 2023-06-06 22:11:38 +02:00
Simon Vieille be35ef8809
fix ci vy adding volumes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline failed
2023-06-06 22:09:47 +02:00
Simon Vieille 97c5ce0b7b
use golang builder
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline failed
update ci
2023-06-06 22:07:35 +02:00
Simon Vieille 4fee95a165 Merge pull request 'develop' (#1) from develop into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2023-06-06 14:10:45 +02:00
Simon Vieille d1a1058f28
update changelog
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
ci/woodpecker/pr/woodpecker Pipeline was successful
2023-06-06 14:07:07 +02:00
Simon Vieille 67ec75e6ea
add config param to set the configuration file
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-06 14:03:12 +02:00
Simon Vieille 2c0904ff1a Merge branch 'feature/docker' into develop 2023-06-06 13:39:54 +02:00
Simon Vieille 26f7c9c40e
add dockerfile 2023-06-06 13:39:50 +02:00
Simon Vieille d1dd93ee23
update urls 2023-06-06 13:39:41 +02:00
Simon Vieille 8f1f62787a
update changelog
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-29 17:08:28 +02:00
Simon Vieille 9deb71e082
allow to use a configuration file
parse .capture.ini

add information os start
2023-05-29 17:07:39 +02:00
Simon Vieille cd5d927e7a
update logo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-29 11:58:13 +02:00
Simon Vieille 06a8ee8fe2
update documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-29 11:39:33 +02:00
Simon Vieille ef3a1d88dd
update documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-29 11:06:21 +02:00
Simon Vieille 4a3b19497f
replace the clear button with a SVG
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-29 10:58:44 +02:00
Simon Vieille d84612a425
add query string in the request log
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-27 18:11:40 +02:00
Simon Vieille 5346e68c5c
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-27 17:11:11 +02:00
Simon Vieille 61b1cd18cd
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 17:15:34 +02:00
Simon Vieille 26ddb4746a
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 17:14:37 +02:00
Simon Vieille 12a3e66061
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 17:13:28 +02:00
Simon Vieille 9fef304bab
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 17:03:45 +02:00
Simon Vieille b013a59df4
update ci configuration 2023-05-26 17:01:39 +02:00
Simon Vieille 7247b9658e
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 17:00:00 +02:00
Simon Vieille 5c73822c91
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 16:53:53 +02:00
Simon Vieille bb1cef5dbc
update ci configuration 2023-05-26 16:53:38 +02:00
Simon Vieille ff6750aa5b
update ci configuration 2023-05-26 16:52:58 +02:00
Simon Vieille fcfc38db63
update ci configuration 2023-05-26 16:50:37 +02:00
Simon Vieille e671ce666b
update ci configuration 2023-05-26 16:49:01 +02:00
Simon Vieille 50f8bb6e20
add changelog
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
2023-05-26 16:46:35 +02:00
Simon Vieille 190c9738c3
update ci configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 16:41:10 +02:00
Simon Vieille 6d1722b302
update documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 15:58:51 +02:00
Simon Vieille 667643445b
add artifacts in ci
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 14:19:19 +02:00
Simon Vieille 957e3ed211
move the button to retry on the list 2023-05-26 14:19:05 +02:00
Simon Vieille c1fdabeb2c
update ci
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-26 11:50:35 +02:00
Fabricio 958917cbf2 Fix deprecated styles 2021-04-06 10:33:56 -03:00
Fabricio 485363bdc8 Rename dashboard title 2021-04-06 10:18:58 -03:00
Fabricio bb03f4419c Style scrollbar 2021-04-06 10:18:35 -03:00
Fabricio a659cd4fd9 Update dashboard preview image 2021-04-06 09:54:22 -03:00
Fabricio af79036aa1 Prevent code repetition 2021-04-06 07:33:34 -03:00
Fabricio 3f79d04161 Fix comments 2021-04-06 07:19:16 -03:00
Fabricio cad33f7d09 Send config via sse 2021-04-06 07:13:58 -03:00
Fabricio 06eb4564f1
Simplify build command 2021-02-27 13:35:59 -03:00
Fabricio 4e9b748375 fix: dashboard access from invalid paths; fixes #5 2021-02-27 10:20:15 -03:00
Fabricio 71e2d6b2e6 Update README 2021-02-24 09:35:10 -03:00
Fabricio 2e4b06c595 refactor: embed html 2021-02-24 08:55:24 -03:00
Fabricio 69e0914a1f update flag description 2019-12-24 06:42:44 -03:00
Fabricio 71bff2f012 update build cmd to create 64bit version 2019-12-20 18:14:15 -03:00
Fabricio bbf3529d46 rename var 2019-12-20 07:55:45 -03:00
Fabricio 0fbf5a39b2 fix build status url 2019-12-19 19:27:49 -03:00
Fabricio c6699d57bd remove extra slash from paths 2019-12-19 19:20:20 -03:00
Fabricio 99b09db77a prevent retry cache 2019-12-19 19:19:47 -03:00
Fabricio f942faf2f0 add curl button feedback 2019-12-19 18:52:36 -03:00
Fabricio f2b91be8ec remove unnecessary props 2019-11-23 11:34:20 -03:00
Fabricio c47cccf7e9 rename struct 2019-11-23 11:00:26 -03:00
Fabricio ca04024bb1 change flex to grid 2019-11-23 11:00:09 -03:00
Fabricio c8a22ab589 convert space to tab 2019-11-21 18:35:45 -03:00
Fabricio 027f3de7d2 replace angularjs with vuejs 2019-11-21 18:17:03 -03:00
Fabricio 85d0a71d13 change ReadAll to TeeReader 2019-11-16 13:50:13 -03:00
Fabricio d907a55566 save req and res with basic types only 2019-11-16 13:47:13 -03:00
Fabricio 604aa43eb5 fetch depth 2019-11-13 20:28:23 -03:00
Fabricio 044ce345f6 remove travis integration 2019-11-13 20:24:34 -03:00
Fabricio 80ce964505 add github ci badge 2019-11-13 20:23:52 -03:00
Fabricio a1453230df
Create build.yml 2019-11-13 20:16:21 -03:00
Fabricio 026a2ccb83
change docker img 2019-11-11 19:53:18 -03:00
Fabricio e2f74b6ba5 remove unnecessary handler 2019-11-10 14:43:02 -03:00
Fabricio e7f996607d
smaller size title 2019-11-09 08:20:05 -03:00
Fabricio 70523217ba add depth 1 2019-11-08 20:58:14 -03:00
Fabricio f18e99f017 fix space 2019-11-08 20:49:50 -03:00
Fabricio 83216f3b9e rename flag 2019-11-08 20:49:29 -03:00
Fabricio 4e095c9035 drop ready to use executables 2019-11-08 20:35:26 -03:00
Fabricio 760639465e dashboard on different port, closes #1 2019-11-08 20:29:35 -03:00
Fabricio b49eba8fe1 better url path parsing 2019-07-27 10:57:16 -03:00
Fabricio 693e540640 update capture description 2019-06-24 19:22:18 -03:00
Fabricio 55e6bf6c9a remove stripping from build command 2019-06-20 21:13:04 -03:00
Fabricio 80bb6bb623 removes drain func 2019-06-20 21:06:50 -03:00
Fabricio a675ea390e more better names to stuff 2019-06-20 20:44:42 -03:00
Fabricio 0e08d14d1f encapsulate capture handler 2019-06-20 20:25:57 -03:00
Fabricio 0613222b4b give better names to stuff 2019-06-20 20:20:44 -03:00
Fabricio b94d20865d move handler path 2019-03-24 10:47:41 -03:00
Fabricio c707138a53 organize dashboard config 2019-03-24 10:46:02 -03:00
Fabricio c4c99dfac5 remove not found 2019-03-24 10:34:38 -03:00
Fabricio d312f00d0d add request elapsed time 2019-03-23 19:13:16 -03:00
Fabricio 857808cd37 fix plugin file path 2019-03-12 19:39:18 -03:00
Fabricio b983f2dcf4 technical name 2019-03-10 14:32:35 -03:00
Fabricio a485829e55 add instruction about plugins 2019-03-10 14:28:14 -03:00
Fabricio 6f8bb9e9da exit on plugin failure 2019-03-10 13:05:05 -03:00
Fabricio 61fcffb47f fix deprecated warning 2019-03-10 12:46:38 -03:00
Fabricio e61695f5a5 load many plugins 2019-03-10 12:16:04 -03:00
Fabricio b84118c421 remove GOARCH 2019-03-10 12:08:05 -03:00
Fabricio d330fe01f9 change plugin handler order 2019-03-10 12:05:21 -03:00
Fabricio 177ab87a54 add idea to gitignore 2019-03-10 12:04:53 -03:00
16 changed files with 1052 additions and 665 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
/build
/.woodpecker.yml
/.git
/.gitignore

6
.gitignore vendored
View file

@ -1,5 +1 @@
debug
debug.test
*.exe
capture
/build

View file

@ -1,3 +0,0 @@
language: go
go:
- "1.x"

43
.woodpecker.yml Normal file
View file

@ -0,0 +1,43 @@
variables:
- &golang "golang:1.19"
- &volumes
- /var/www/html/artifacts/deblan/capture:/artifacts
steps:
test:
image: *golang
commands:
- go test -v
build:
image: *golang
volumes: *volumes
commands:
- make
- cp build/* /artifacts
push_release:
image: plugins/gitea-release
volumes: *volumes
settings:
api_key:
from_secret: gitnet_api_key
base_url: https://gitnet.fr
note: ${CI_COMMIT_MESSAGE}
files: build/*
when:
event: [tag]
docker_build_push:
image: docker:dind
secrets:
- registry_user
- registry_password
commands:
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin
- "docker build -t deblan/capture:${CI_PIPELINE_DEPLOY_TARGET} ."
- "docker push deblan/capture:${CI_PIPELINE_DEPLOY_TARGET}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
when:
event: [deployment]

29
CHANGELOG.md Normal file
View file

@ -0,0 +1,29 @@
## [Unreleased]
## v1.4.0
### Added
- add option to skil TLS verification
### Changed
- build binaries are nenamed
## v1.3.1
### Added
- add makefile to manage build
## v1.3.0
### Added
- add argument to define the configuration file
## v1.2.0
### Added
- allow to use a configuration file
- add logo
- replace the clear button with a SVG
## v1.1.0
### Added
- add query string in the request log
## v1.0.0
### Added
- remove "retry" button and add a button for each request in the list

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.21-bullseye as builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN BUILD_DIR=/app make
FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /app/capture-linux-amd64 /usr/bin/capture
ENTRYPOINT ["/usr/bin/capture"]

24
Makefile Normal file
View file

@ -0,0 +1,24 @@
CGO_ENABLED = 0
CC = go build
CFLAGS = -trimpath
LDFLAGS = all=-w -s
GCFLAGS = all=
ASMFLAGS = all=
GOARCH = amd64
BUILD_DIR ?= build
LINUX_BIN ?= capture-linux-amd64
WIN_BIN ?= capture-window-amd64.exe
all: build
deps:
go install github.com/GeertJohan/go.rice/rice@latest
rice embed-go
.PHONY:
build: deps
export CGO_ENABLED=$(CGO_ENABLED)
export GOARCH=$(GOARCH)
GOOS=linux $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(LINUX_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)"
GOOS=windows $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(WIN_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)"

View file

@ -1,65 +1,91 @@
**Capture** is a reverse proxy that takes an incoming HTTP request and sends it to another server,
proxying the response back to the client, while showing them in a dashboard.
**Capture** is a reverse proxy that captures the network traffic and shows it in a dashboard
[![Build Status](https://travis-ci.com/ofabricio/capture.svg?branch=master)](https://travis-ci.com/ofabricio/capture)
[![Github Release](https://img.shields.io/github/release/ofabricio/capture.svg)](https://github.com/ofabricio/capture/releases)
## Binaries / Executables
For ready-to-use executables for *Windows*, *Linux* and *Mac*, see [Releases](https://github.com/ofabricio/capture/releases) page
Forked from [ofabricio/capture](https://github.com/ofabricio/capture).
[![status-badge](https://ci.gitnet.fr/api/badges/deblan/capture/status.svg)](https://ci.gitnet.fr/deblan/capture)
## Running
./capture -url=https://example.com/
```
./capture -url=https://example.com/ -port 9000 -dashboard 9001 -captures 16
```
Via docker:
### Configurations
```
docker run -p 9000:9000 -p 9001:9001 deblan/capture -url=https://example.com/ -port 9000 -dashboard 9001 -captures 16
```
| param | description |
|-----------------|-------------|
| `-url` | **Required.** Set the base url you want to capture |
| `-port` | Set the proxy port. Default: *9000* |
| `-dashboard` | Set the dashboard's name. Default: *dashboard* |
| `-max-captures` | Set the max number of captures to show in the dashboard. Default: *16* |
#### Settings
| param | description |
| -------------- | ------------- |
| `-url` | **Required.** Set the url you want to proxy |
| `-port` | Set the proxy port. Default: *9000* |
| `-dashboard` | Set the dashboard port. Default: *9001* |
| `-captures` | Set how many captures to show in the dashboard. Default: *16* |
| `-tls-skip-verify` | Skip TLS vertificaton. Default: *false* |
| `-config` | Set the configuration file. Default: *.capture.ini* |
You can create a file named `.capture.ini` and set the configuration inside:
```
url = https://example.com/
port = 9000
dashboard = 9001
captures = 16
tls_skip_verify = false
```
## Using
If you set your base url as `http://example.com/api`, now `http://localhost:9000` points to that
address. Hence, calling `http://localhost:9000/users/1` is like calling `http://example.com/api/users/1`
*Capture* saves all requests and responses so that you can see them in the dashboard
*Capture* saves all requests and responses so that you can see them in the dashboard.
## Dashboard
To access the dashboard go to `http://localhost:9000/dashboard`
The path `/dashboard/**` is reserved, that means if your api has a path like that it will be ignored
in favor of the dashboard. However, you can change the dashboard's name with `-dashboard`
To access the dashboard go to `http://127.0.0.1:9001/`
##### Preview
![dashboard](https://i.imgur.com/5pbLRRY.png)
![dashboard](https://upload.deblan.org/u/2023-05/64746afd.png)
## Building
Manually:
git clone https://github.com/ofabricio/capture.git
cd capture
go build -o capture .
```
git clone --depth 1 https://gitnet.fr/deblan/capture.git
cd capture
go build
```
Via docker:
git clone https://github.com/ofabricio/capture.git
cd capture
docker run --rm -v "${PWD}:/src" -w /src -e GOOS=darwin -e GOARCH=386 golang:latest go build -ldflags="-s -w" -o capture .
```
git clone --depth 1 https://gitnet.fr/deblan/capture.git
cd capture
docker run --rm -v $PWD:/src -w /src -e GOOS=darwin -e GOARCH=amd64 golang:alpine go build
```
Now you have an executable binary in your folder
Now you have an executable binary in your directory.
**Note:** you can change `GOOS=darwin` to `linux` or `windows`
**Note:** change `GOOS=darwin` to `linux` or `windows` to create an executable for your corresponding Operating System.
## Plugins
Put [plugin](https://golang.org/pkg/plugin/) files in the current directory.
They are loaded sorted by filename on startup.
Plugins must export the following function:
```go
func Handler(proxy http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
proxy(w, r)
}
}
```

View file

@ -4,78 +4,93 @@ import (
"net/http"
"strconv"
"sync"
"time"
)
var captureID int
var captures CaptureList
// CaptureList stores all captures
type CaptureList struct {
// CaptureService handles captures.
type CaptureService struct {
items []Capture
mu sync.RWMutex
maxItems int
updated chan struct{} // signals any change in "items"
updated chan struct{} // signals any change in "items".
}
// Capture saves our traffic data
// Capture is our traffic data.
type Capture struct {
ID int
Req *http.Request
Res *http.Response
Req Req
Res Res
// Elapsed time of the request, in milliseconds.
Elapsed time.Duration
}
// CaptureMetadata is the data for each list item in the dashboard
type CaptureMetadata struct {
ID int `json:"id"`
Path string `json:"path"`
Method string `json:"method"`
Status int `json:"status"`
type Req struct {
Proto string `json:"proto"`
Method string `json:"method"`
Url string `json:"url"`
Path string `json:"path"`
Query string `json:"query"`
Header http.Header `json:"header"`
Body []byte `json:"body"`
}
// CaptureDump saves all the dumps shown in the dashboard
type CaptureDump struct {
type Res struct {
Proto string
Status string
Code int
Header http.Header
Body []byte
}
// CaptureInfo is the capture info shown in the dashboard.
type CaptureInfo struct {
Request string `json:"request"`
Response string `json:"response"`
Curl string `json:"curl"`
}
// Metadata returns the metadada of a capture
func (c *Capture) Metadata() CaptureMetadata {
return CaptureMetadata{
ID: c.ID,
Path: c.Req.URL.Path,
Method: c.Req.Method,
Status: c.Res.StatusCode,
}
// DashboardItem is an item in the dashboard's list.
type DashboardItem struct {
ID int `json:"id"`
Path string `json:"path"`
Query string `json:"query"`
Method string `json:"method"`
Status int `json:"status"`
Elapsed time.Duration `json:"elapsed"`
}
// NewCaptureList creates a new list of captures
func NewCaptureList(maxItems int) *CaptureList {
return &CaptureList{
// NewCaptureService creates a new service of captures.
func NewCaptureService(maxItems int) *CaptureService {
return &CaptureService{
maxItems: maxItems,
updated: make(chan struct{}),
}
}
// Insert adds a new capture
func (c *CaptureList) Insert(capture Capture) {
c.mu.Lock()
defer c.mu.Unlock()
// Insert inserts a new capture.
func (s *CaptureService) Insert(capture Capture) {
s.mu.Lock()
defer s.mu.Unlock()
captureID++
capture.ID = captureID
c.items = append(c.items, capture)
if len(c.items) > c.maxItems {
c.items = c.items[1:]
s.items = append(s.items, capture)
if len(s.items) > s.maxItems {
s.items = s.items[1:]
}
c.signalsChange()
s.signalsUpdate()
}
// Find finds a capture by its id
func (c *CaptureList) Find(captureID string) *Capture {
c.mu.RLock()
defer c.mu.RUnlock()
// Find finds a capture by its ID.
func (s *CaptureService) Find(captureID string) *Capture {
s.mu.RLock()
defer s.mu.RUnlock()
idInt, _ := strconv.Atoi(captureID)
for _, c := range c.items {
for _, c := range s.items {
if c.ID == idInt {
return &c
}
@ -83,40 +98,45 @@ func (c *CaptureList) Find(captureID string) *Capture {
return nil
}
// RemoveAll removes all the captures
func (c *CaptureList) RemoveAll() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = nil
c.signalsChange()
// RemoveAll removes all the captures.
func (s *CaptureService) RemoveAll() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = nil
s.signalsUpdate()
}
// Items returns all the captures
func (c *CaptureList) Items() []Capture {
c.mu.RLock()
defer c.mu.RUnlock()
return c.items
}
// DashboardItems returns the dashboard's list of items.
func (s *CaptureService) DashboardItems() []DashboardItem {
s.mu.RLock()
defer s.mu.RUnlock()
// ItemsAsMetadata returns all the captures as metadata
func (c *CaptureList) ItemsAsMetadata() []CaptureMetadata {
c.mu.RLock()
defer c.mu.RUnlock()
metadatas := make([]CaptureMetadata, len(c.items))
for i, capture := range c.items {
metadatas[i] = capture.Metadata()
metadatas := make([]DashboardItem, len(s.items))
for i, capture := range s.items {
metadatas[i] = DashboardItem{
ID: capture.ID,
Path: capture.Req.Path,
Query: capture.Req.Query,
Method: capture.Req.Method,
Status: capture.Res.Code,
Elapsed: capture.Elapsed,
}
}
return metadatas
}
func (c *CaptureList) signalsChange() {
close(c.updated)
c.updated = make(chan struct{})
// signalsUpdate fires an update signal.
func (s *CaptureService) signalsUpdate() {
close(s.updated)
s.updated = make(chan struct{})
}
// Updated signals any change in the list
func (c *CaptureList) Updated() <-chan struct{} {
c.mu.RLock()
defer c.mu.RUnlock()
return c.updated
// Updated signals any change in this service,
// like inserting or removing captures.
func (s *CaptureService) Updated() <-chan struct{} {
s.mu.RLock()
defer s.mu.RUnlock()
return s.updated
}

View file

@ -3,44 +3,61 @@ package main
import (
"flag"
"fmt"
"gopkg.in/ini.v1"
"os"
)
// Config has all the configuration parsed from the command line
// Config has all the configuration parsed from the command line.
type Config struct {
TargetURL string `json:"targetURL"`
ProxyPort string `json:"proxyPort"`
MaxCaptures int `json:"maxCaptures"`
Dashboard string `json:"dashboard"`
DashboardPath string `json:"dashboardPath"`
DashboardConnPath string `json:"dashboardConnPath"`
DashboardClearPath string `json:"dashboardClearPath"`
DashboardRetryPath string `json:"dashboardRetryPath"`
DashboardItemInfoPath string `json:"dashboardItemInfoPath"`
TargetURL string
ProxyPort string
DashboardPort string
TLSSkipVerify bool
MaxCaptures int
}
// ReadConfig reads the arguments from the command line
// ReadConfig reads the arguments from the command line.
func ReadConfig() Config {
targetURL := flag.String("url", "https://jsonplaceholder.typicode.com", "Required. Set the base url you want to capture")
proxyPort := flag.String("port", "9000", "Set the proxy port")
dashboard := flag.String("dashboard", "dashboard", "Set the dashboard name")
maxCaptures := flag.Int("max-captures", 16, "Set the max number of captures to show in the dashboard")
defaultTargetURL := "https://jsonplaceholder.typicode.com"
defaultProxyPort := "9000"
defaultDashboardPort := "9001"
defaultMaxCaptures := 16
defaultConfigFile := ".capture.ini"
defaultTLSSkipVerify := false
targetURL := flag.String("url", defaultTargetURL, "Required. Set the url you want to proxy")
configFile := flag.String("config", defaultConfigFile, "Set the configuration file")
proxyPort := flag.String("port", defaultProxyPort, "Set the proxy port")
dashboardPort := flag.String("dashboard", defaultDashboardPort, "Set the dashboard port")
maxCaptures := flag.Int("captures", defaultMaxCaptures, "Set how many captures to show in the dashboard")
TLSSkipVerify := flag.Bool("tls-skip-verify", defaultTLSSkipVerify, "Skip TLS vertification")
flag.Parse()
dashboardPath := fmt.Sprintf("/%s/", *dashboard)
dashboardConnPath := fmt.Sprintf("/%s/conn/", *dashboard)
dashboardClearPath := fmt.Sprintf("/%s/clear/", *dashboard)
dashboardRetryPath := fmt.Sprintf("/%s/retry/", *dashboard)
dashboardItemInfoPath := fmt.Sprintf("/%s/items/", *dashboard)
if _, err := os.Stat(*configFile); err == nil {
cfg, err := ini.Load(*configFile)
if err != nil {
fmt.Printf("Fail to read file %s: %v", *configFile, err)
os.Exit(1)
}
section := cfg.Section("")
return Config{
TargetURL: section.Key("url").MustString(*targetURL),
ProxyPort: section.Key("port").MustString(*proxyPort),
MaxCaptures: section.Key("captures").MustInt(*maxCaptures),
DashboardPort: section.Key("dashboard").MustString(*dashboardPort),
TLSSkipVerify: section.Key("tls_skip_verify").MustBool(*TLSSkipVerify),
}
}
return Config{
TargetURL: *targetURL,
ProxyPort: *proxyPort,
MaxCaptures: *maxCaptures,
Dashboard: *dashboard,
DashboardPath: dashboardPath,
DashboardConnPath: dashboardConnPath,
DashboardClearPath: dashboardClearPath,
DashboardRetryPath: dashboardRetryPath,
DashboardItemInfoPath: dashboardItemInfoPath,
TargetURL: *targetURL,
ProxyPort: *proxyPort,
MaxCaptures: *maxCaptures,
DashboardPort: *dashboardPort,
TLSSkipVerify: *TLSSkipVerify,
}
}

View file

@ -1,295 +0,0 @@
package main
const dashboardHTML = `
<!DOCTYPE html>
<html ng-app="app">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.2/angular.min.js"></script>
<link href="https://fonts.googleapis.com/css?family=Inconsolata:400,700" rel="stylesheet">
<title>Dashboard</title>
<style>
:root {
--bg: #282c34;
--list-item-bg: #2c313a;
--list-item-fg: #abb2bf;
--list-item-sel-bg: hsl(219, 22%, 25%);
--req-res-bg: #2c313a;
--req-res-fg: #abb2bf;
--links: #55b5c1;
--method-get: #98c379;
--method-post: #c678dd;
--method-put: #d19a66;
--method-patch: #a7afbc;
--method-delete: #e06c75;
--status-ok: #98c379;
--status-warn: #d19a66;
--status-error: #e06c75;
--btn-bg: var(--list-item-bg);
--btn-hover: var(--list-item-sel-bg);
}
* { padding: 0; margin: 0; box-sizing: border-box }
html, body, .dashboard {
height: 100%;
font-family: 'Inconsolata', monospace;
font-size: 1em;
font-weight: 400;
background: var(--bg);
}
body { padding: .5rem; }
div { display: flex; position: relative }
.list, .req, .res {
flex: 0 0 37%;
overflow: auto;
flex-direction: column;
padding: .5rem;
}
.list, .req { padding-right: .5rem; }
.req, .res { padding-left: .5rem; }
.list-inner, .req-inner, .res-inner {
overflow-x: hidden;
overflow-y: auto;
flex: 1;
}
.req-inner, .res-inner {
background: var(--req-res-bg);
}
.req, .res {
color: var(--req-res-fg);
}
.list { flex: 0 0 26% }
.list-inner { flex-direction: column }
.list-item {
flex-shrink: 0;
font-size: 1.2em;
font-weight: 400;
height: 50px;
padding: 1rem;
background: var(--list-item-bg);
color: var(--list-item-fg);
cursor: pointer;
margin-bottom: .5rem;
align-items: center;
transition: background .15s linear;
}
.list-item:hover { }
.list-item, .req-inner, .res-inner {
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.1);
}
.list-item.selected {
background: var(--list-item-sel-bg);
}
.GET { color: var(--method-get) }
.POST { color: var(--method-post) }
.PUT { color: var(--method-put) }
.PATCH { color: var(--method-patch) }
.DELETE { color: var(--method-delete) }
.ok { color: var(--status-ok) }
.warn { color: var(--status-warn) }
.error { color: var(--status-error) }
.method { font-size: 0.7em; margin-right: 1rem; padding: .25rem .5rem }
.status { font-size: 0.8em; padding-left: 1rem }
.path { font-size: 0.8em; flex: 1; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl }
pre {
flex: 1;
word-break: normal; word-wrap: break-word; white-space: pre-wrap;
z-index: 2;
padding: 1rem;
width: 100%;
font-family: inherit;
font-weight: 400;
line-height: 1.2em;
}
.req:before, .res:before {
bottom: .5rem;
left: 1rem;
font-size: 5em;
color: var(--bg);
position: absolute;
font-weight: 700;
z-index: 1;
}
.req:before {
content: "↑REQUEST";
}
.res:before {
content: "↓RESPONSE";
}
.controls {
margin-bottom: .5rem;
}
.controls > * {
margin-right: .5rem;
}
button {
background: var(--btn-bg);
border: 0;
padding: .5rem 1rem;
font-size: .75em;
font-family: inherit;
color: var(--links);
cursor: pointer;
outline: 0;
}
button:disabled {
color: hsl(187, 5%, 50%);
cursor: default;
}
button:hover:enabled {
background: var(--btn-hover);
}
.welcome {
position: absolute;
background: rgba(0, 0, 0, .5);
justify-content: center;
line-height: 1.5rem;
z-index: 9;
color: #fff;
font-size: 2em;
top: 50%;
right: 1rem;
left: 1rem;
transform: translate(0%, -50%);
padding: 3rem;
box-shadow: 0px 0px 20px 10px rgba(0, 0, 0, 0.1);
}
.welcome span {
font-size: .5em;
color: #999;
}
</style>
</head>
<body>
<div class="dashboard" ng-controller="controller">
<div class="list">
<div class="controls">
<button ng-disabled="items.length == 0" ng-click="clearDashboard()">clear</button>
</div>
<div class="list-inner">
<div class="list-item" ng-repeat="item in items | orderBy: '-id' track by $index" ng-click="show(item)"
ng-class="{selected: selectedItem.id == item.id}">
<span class="method" ng-class="item.method">{{item.method}}</span>
<span class="path">&lrm;{{item.path}}&lrm;</span>
<span class="status" ng-class="statusColor(item)">{{item.status == 999 ? 'failed' : item.status}}</span>
</div>
</div>
</div>
<div class="req">
<div class="controls">
<button ng-disabled="!canPrettifyBody('request')" ng-click="prettifyBody('request')">prettify</button>
<button ng-disabled="selectedItem.id == null" ng-click="copyCurl()">curl</button>
<button ng-disabled="selectedItem.id == null" ng-click="retry()">retry</button>
</div>
<div class="req-inner">
<pre>{{selectedItem.request}}</pre>
</div>
</div>
<div class="res">
<div class="controls">
<button ng-disabled="!canPrettifyBody('response')" ng-click="prettifyBody('response')">prettify</button>
</div>
<div class="res-inner">
<pre ng-class="{error: selectedItem.status == 999}">{{selectedItem.response}}</pre>
</div>
</div>
<div class="welcome" ng-show="items.length == 0">
<p>Waiting for requests on http://localhost:<<.ProxyPort>>/<br>
<span>Proxying <<.TargetURL>></span></p>
</div>
</div>
<script type="text/javascript">
angular.module('app', [])
.controller('controller', function($scope, $http) {
$scope.selectedItem = {};
$scope.show = item => {
$scope.selectedItem.id = item.id;
$scope.selectedItem.status = item.status;
$http.get(<<.DashboardItemInfoPath>> + item.id).then(r => {
$scope.selectedItem.request = r.data.request;
$scope.selectedItem.response = r.data.response;
$scope.selectedItem.curl = r.data.curl;
});
}
$scope.statusColor = item => {
if (item.status < 300) return 'ok';
if (item.status < 400) return 'warn';
return 'error';
}
$scope.clearDashboard = () => {
$http.get(<<.DashboardClearPath>>)
.then(() => $scope.selectedItem = {});
}
$scope.canPrettifyBody = name => {
if (!$scope.selectedItem[name]) return false;
return $scope.selectedItem[name].indexOf('Content-Type: application/json') != -1;
}
$scope.copyCurl = () => {
let e = document.createElement('textarea');
e.value = $scope.selectedItem.curl;
document.body.appendChild(e);
e.select();
document.execCommand('copy');
document.body.removeChild(e);
}
$scope.retry = () => {
$http.get(<<.DashboardRetryPath>> + $scope.selectedItem.id)
.then(() => $scope.show($scope.items[$scope.items.length - 1]));
}
$scope.prettifyBody = key => {
let regex = /\n([\{\[](.*\s*)*[\}\]])/;
let data = $scope.selectedItem[key];
let match = regex.exec(data);
let body = match[1];
let prettyBody = JSON.stringify(JSON.parse(body), null, ' ');
$scope.selectedItem[key] = data.replace(body, prettyBody);
}
const evt = new EventSource(<<.DashboardConnPath>>);
evt.addEventListener('captures', e => {
$scope.items = JSON.parse(e.data);
if (!$scope.items.find(i => i.id == $scope.selectedItem.id)) {
$scope.selectedItem = {}
};
$scope.$apply();
});
evt.onerror = () => {
$scope.items = [];
$scope.selectedItem = {};
$scope.$apply();
};
});
</script>
</body>
</html>`

469
dashboard.html Normal file
View file

@ -0,0 +1,469 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="icon" href="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgd2lkdGg9IjEzMC40MDQxN21tIgogICBoZWlnaHQ9IjEzMC40MDQxN21tIgogICB2aWV3Qm94PSIwIDAgMTMwLjQwNDE3IDEzMC40MDQxNyIKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnOCIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wLjIgKGU4NmM4NzA4NzksIDIwMjEtMDEtMTUpIgogICBzb2RpcG9kaTpkb2NuYW1lPSJsb2dvLnN2ZyI+CiAgPGRlZnMKICAgICBpZD0iZGVmczIiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlkPSJiYXNlIgogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIKICAgICBib3JkZXJjb2xvcj0iIzY2NjY2NiIKICAgICBib3JkZXJvcGFjaXR5PSIxLjAiCiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAuMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjAuNDk0OTc0NzUiCiAgICAgaW5rc2NhcGU6Y3g9IjE0NjEuNTcwNCIKICAgICBpbmtzY2FwZTpjeT0iMzc2Ljk1MDY0IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJtbSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgaW5rc2NhcGU6ZG9jdW1lbnQtcm90YXRpb249IjAiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkxOCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDM4IgogICAgIGlua3NjYXBlOndpbmRvdy14PSIwIgogICAgIGlua3NjYXBlOndpbmRvdy15PSIyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIiAvPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTUiPgogICAgPHJkZjpSREY+CiAgICAgIDxjYzpXb3JrCiAgICAgICAgIHJkZjphYm91dD0iIj4KICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3N2Zyt4bWw8L2RjOmZvcm1hdD4KICAgICAgICA8ZGM6dHlwZQogICAgICAgICAgIHJkZjpyZXNvdXJjZT0iaHR0cDovL3B1cmwub3JnL2RjL2RjbWl0eXBlL1N0aWxsSW1hZ2UiIC8+CiAgICAgICAgPGRjOnRpdGxlPjwvZGM6dGl0bGU+CiAgICAgIDwvY2M6V29yaz4KICAgIDwvcmRmOlJERj4KICA8L21ldGFkYXRhPgogIDxnCiAgICAgaW5rc2NhcGU6bGFiZWw9IkNhbHF1ZSAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzkuNzk3OTE2LC04My4yOTc5MTMpIj4KICAgIDxyZWN0CiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtvdmVyZmxvdzp2aXNpYmxlO2ZpbGw6IzUzNWQ2YztzdHJva2Utd2lkdGg6MC42MjEwMTQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3BhaW50LW9yZGVyOnN0cm9rZSBtYXJrZXJzIGZpbGw7c3RvcC1jb2xvcjojMDAwMDAwIgogICAgICAgaWQ9InJlY3Q0NiIKICAgICAgIHdpZHRoPSIxMzAuNDA0MTciCiAgICAgICBoZWlnaHQ9IjEzMC40MDQxNyIKICAgICAgIHg9IjM5Ljc5NzkxNiIKICAgICAgIHk9IjgzLjI5NzkxMyIKICAgICAgIHJ5PSIwIgogICAgICAgaW5rc2NhcGU6ZXhwb3J0LWZpbGVuYW1lPSIvdG1wL2NhcHR1cmUucG5nIgogICAgICAgaW5rc2NhcGU6ZXhwb3J0LXhkcGk9Ijc2IgogICAgICAgaW5rc2NhcGU6ZXhwb3J0LXlkcGk9Ijc2IiAvPgogICAgPGcKICAgICAgIHN0eWxlPSJjb2xvcjojOWE5OTk2O2ZpbGw6bm9uZTtzdHJva2U6I2I3YzRjODtzdHJva2Utd2lkdGg6MS4xMDc3OTtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZSIKICAgICAgIGlkPSJnNDIiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgzLjU4NjQyNTYsMCwwLDMuNTg2NDI1Niw2MS45NjI4OTMsMTAzLjY2OTY4KSIKICAgICAgIGlua3NjYXBlOmV4cG9ydC1maWxlbmFtZT0iL3RtcC9jYXB0dXJlLnBuZyIKICAgICAgIGlua3NjYXBlOmV4cG9ydC14ZHBpPSI3NiIKICAgICAgIGlua3NjYXBlOmV4cG9ydC15ZHBpPSI3NiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMTQsMjEgSCA0IEEgMiwyIDAgMCAxIDIsMTkgViA1IEEgMiwyIDAgMCAxIDQsMyBoIDE2IGEgMiwyIDAgMCAxIDIsMiB2IDkiCiAgICAgICAgIHN0cm9rZT0iIzlhOTk5NiIKICAgICAgICAgc3Ryb2tlLXdpZHRoPSIyIgogICAgICAgICBzdHJva2UtbGluZWNhcD0icm91bmQiCiAgICAgICAgIGlkPSJwYXRoMjgiCiAgICAgICAgIHN0eWxlPSJzdHJva2U6I2I3YzRjODtzdHJva2Utd2lkdGg6MS4xMDc3OTtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZSIgLz4KICAgICAgPHBhdGgKICAgICAgICAgZD0iTSAyLDcgSCAyMiBNIDUsNS4wMSA1LjAxLDQuOTk5IE0gOCw1LjAxIDguMDEsNC45OTkgTSAxMSw1LjAxIDExLjAxLDQuOTk5IE0gMTkuNSwxNiB2IDYgbSAwLDAgTCAxNywxOS41IE0gMTkuNSwyMiAyMiwxOS41IgogICAgICAgICBzdHJva2U9IiM5YTk5OTYiCiAgICAgICAgIHN0cm9rZS13aWR0aD0iMiIKICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgogICAgICAgICBzdHJva2UtbGluZWpvaW49InJvdW5kIgogICAgICAgICBpZD0icGF0aDMwIgogICAgICAgICBzdHlsZT0ic3Ryb2tlOiNiN2M0Yzg7c3Ryb2tlLXdpZHRoOjEuMTA3Nzk7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmUiIC8+CiAgICA8L2c+CiAgPC9nPgo8L3N2Zz4K"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script>
<link href="https://fonts.googleapis.com/css?family=Inconsolata:400,700" rel="stylesheet">
<title>Capture</title>
<style>
[v-cloak] {
display: none !important;
}
:root {
--bg: #282c34;
--list-item-bg: #2c313a;
--list-item-fg: #abb2bf;
--list-item-sel-bg: hsl(219, 22%, 25%);
--req-res-bg: #2c313a;
--req-res-fg: #abb2bf;
--links: #55b5c1;
--method-get: #98c379;
--method-post: #c678dd;
--method-put: #d19a66;
--method-patch: #a7afbc;
--method-delete: #e06c75;
--status-ok: #98c379;
--status-warn: #d19a66;
--status-error: #e06c75;
--btn-bg: var(--list-item-bg);
--btn-hover: var(--list-item-sel-bg);
--disabled: hsl(187, 5%, 50%);
}
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
div {
position: relative;
}
html,
body,
.dashboard {
height: 100%;
font-family: 'Inconsolata', monospace;
font-size: 1em;
font-weight: 400;
background: var(--bg);
}
.dashboard {
display: grid;
grid-template-columns: .6fr 1fr 1fr;
grid-template-rows: 1fr;
gap: .5rem;
}
.list,
.req,
.res {
display: grid;
grid-template-rows: auto 1fr;
gap: .5rem;
}
body {
padding: .5rem;
}
*::-webkit-scrollbar {
width: .25rem;
}
*::-webkit-scrollbar-thumb {
background-color: var(--list-item-fg);
}
.list,
.req,
.res {
overflow: auto;
}
.list-inner,
.req-inner,
.res-inner {
overflow-x: hidden;
overflow-y: auto;
}
.req-inner,
.res-inner {
background: var(--req-res-bg);
}
.req,
.res {
color: var(--req-res-fg);
}
.list-inner {
display: grid;
grid-template-rows: auto;
gap: .5rem;
align-content: start;
}
.list-item {
display: grid;
grid-template-columns: auto 1fr auto auto auto;
gap: .5rem;
font-size: 1.2em;
padding: 1rem;
background: var(--list-item-bg);
color: var(--list-item-fg);
cursor: pointer;
transition: background .15s linear;
}
.list-item,
.req,
.res {
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.1);
}
.list-item.selected {
background: var(--list-item-sel-bg);
}
.GET {
color: var(--method-get);
}
.POST {
color: var(--method-post);
}
.PUT {
color: var(--method-put);
}
.PATCH {
color: var(--method-patch);
}
.DELETE {
color: var(--method-delete);
}
.ok {
color: var(--status-ok);
}
.warn {
color: var(--status-warn);
}
.error {
color: var(--status-error);
}
.method {
font-size: 0.7em;
}
.status {
font-size: 0.8em;
}
.path {
font-size: 0.8em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
}
.time {
font-size: 0.7em;
color: var(--disabled);
}
.query {
padding: 1rem;
font-family: inherit;
font-weight: 400;
line-height: 1.2em;
color: #fff;
}
pre {
word-break: break-all;
white-space: pre-wrap;
padding: 1rem;
font-family: inherit;
font-weight: 400;
line-height: 1.2em;
}
.corner {
position: absolute;
top: 0;
right: 0;
width: 80px;
height: 50px;
background: var(--bg);
color: var(--disabled);
display: grid;
align-content: end;
justify-content: center;
transform: rotate(45deg) translate(10px, -40px);
padding-bottom: 4px;
font-size: .8em;
user-select: none;
}
.controls {
}
button {
background: var(--btn-bg);
border: 0;
padding: .5rem 1rem;
font-size: .75em;
font-family: inherit;
color: var(--links);
cursor: pointer;
outline: 0;
}
button:disabled {
color: var(--disabled);
cursor: default;
}
button:hover:enabled {
background: var(--btn-hover);
}
.button-svg {
padding: 0;
background: none;
}
.button-svg svg {
width: 16px;
height: 16px;
}
.button-svg[disabled] svg {
opacity: 0.3;
}
.button-svg svg {
stroke: #9a9996;
}
.button-svg:not([disabled]):hover svg * {
stroke: #fff;
}
.welcome {
display: grid;
position: absolute;
background: rgba(0, 0, 0, .5);
justify-content: center;
line-height: 1.5rem;
z-index: 9;
color: #fff;
font-size: 2em;
top: 50%;
right: 1rem;
left: 1rem;
transform: translate(0%, -50%);
padding: 3rem;
box-shadow: 0px 0px 20px 10px rgba(0, 0, 0, 0.1);
word-break: break-word;
}
.welcome span {
font-size: .5em;
color: #999;
}
@media only screen and (max-width: 1024px) {
.dashboard {
grid-template-columns: .7fr 1fr;
grid-template-rows: 1fr 1fr;
}
.list {
grid-row: 1 / 3;
}
.req {
grid-column: 2;
}
.res {
grid-column: 2;
grid-row: 2;
}
.welcome {
font-size: 1.5em;
}
}
@media only screen and (max-width: 484px) {
.dashboard {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr;
column-gap: 0;
}
.list {
grid-area: 1 / 2;
}
.req {
grid-row: 2;
}
.res {
grid-row: 3;
}
}
</style>
</head>
<body>
<div class="dashboard" id="app" v-cloak>
<div class="list">
<div class="controls">
<button class="button-svg" :disabled="items.length == 0" @click="clearDashboard">
<svg viewBox="0 0 24 24" stroke-width="3" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 9l-1.995 11.346A2 2 0 0116.035 22h-8.07a2 2 0 01-1.97-1.654L4 9M21 6h-5.625M3 6h5.625m0 0V4a2 2 0 012-2h2.75a2 2 0 012 2v2m-6.75 0h6.75" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
<div class="list-inner">
<div class="list-item" v-for="item in items" :key="item.id" @click="show(item)"
:class="{selected: selectedItem.id == item.id}">
<span class="method" :class="item.method">{{ item.method }}</span>
<span class="path">&lrm;{{ item.path }}&lrm;</span>
<span class="time">{{ item.elapsed }}ms</span>
<span class="status" :class="statusColor(item)">
{{ item.status == 999 ? 'failed' : item.status }}
</span>
<button class="button-svg" @click="retry(item.id)">
<svg stroke-width="3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21.888 13.5C21.164 18.311 17.013 22 12 22 6.477 22 2 17.523 2 12S6.477 2 12 2c4.1 0 7.625 2.468 9.168 6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17 8h4.4a.6.6 0 00.6-.6V3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
</div>
</div>
<div class="req">
<div class="controls">
<button :disabled="!canPrettifyBody('request')" @click="prettifyBody('request')">prettify</button>
<button :disabled="selectedItem.id == null" @click="copyCurl($event)" data-text="curl">curl</button>
</div>
<div class="req-inner">
<div class="corner">req</div>
<pre>{{ selectedItem.request }}</pre>
</div>
</div>
<div class="res">
<div class="controls">
<button :disabled="!canPrettifyBody('response')" @click="prettifyBody('response')">prettify</button>
</div>
<div class="res-inner">
<div class="corner">res</div>
<pre :class="{error: selectedItem.status == 999}">{{ selectedItem.response }}</pre>
</div>
</div>
<div class="welcome" v-show="items.length == 0">
<p>
Waiting for requests on http://localhost:{{proxyPort}}/<br>
<span>Proxying {{ targetURL }}</span>
</p>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
items: [],
selectedItem: {},
proxyPort: '',
targetURL: '',
},
created() {
this.setupStream();
},
methods: {
setupStream() {
let es = new EventSource('/conn/');
es.addEventListener('config', event => {
const cfg = JSON.parse(event.data);
this.proxyPort = cfg.ProxyPort;
this.targetURL = cfg.TargetURL;
});
es.addEventListener('captures', event => {
this.items = JSON.parse(event.data).reverse();
});
es.onerror = () => {
this.items = [];
this.selectedItem = {};
};
},
async show(item) {
this.selectedItem = { ...this.selectedItem, id: item.id, status: item.status };
let resp = await fetch('/info/' + item.id);
let data = await resp.json();
this.selectedItem = { ...this.selectedItem, ...data };
},
statusColor(item) {
if (item.status < 300) return 'ok';
if (item.status < 400) return 'warn';
return 'error';
},
async clearDashboard() {
this.selectedItem = {};
await fetch('/clear/');
},
canPrettifyBody(name) {
if (!this.selectedItem[name]) return false;
return this.selectedItem[name].indexOf('Content-Type: application/json') != -1;
},
prettifyBody(key) {
let regex = /\n([\{\[](.*\s*)*[\}\]])/;
let data = this.selectedItem[key];
let match = regex.exec(data);
let body = match[1];
let prettyBody = JSON.stringify(JSON.parse(body), null, ' ');
this.selectedItem[key] = data.replace(body, prettyBody);
},
copyCurl(event) {
this.changeText(event);
let e = document.createElement('textarea');
e.value = this.selectedItem.curl;
document.body.appendChild(e);
e.select();
document.execCommand('copy');
document.body.removeChild(e);
},
async retry(id) {
await fetch(`/retry/${id}`, {
headers: {
'Cache-Control': 'no-cache'
}
});
this.show(this.items[0]);
},
changeText(event) {
let elem = event.target;
let btnText = elem.getAttribute("data-text");
elem.innerText = "copied!";
setTimeout(() => elem.innerText = btnText, 400)
}
},
});
</script>
</body>
</html>

4
go.mod
View file

@ -1,3 +1,5 @@
module github.com/ofabricio/capture
require github.com/ofabricio/curl v0.1.0
go 1.16
require gopkg.in/ini.v1 v1.67.0 // indirect

4
go.sum
View file

@ -1,2 +1,2 @@
github.com/ofabricio/curl v0.1.0 h1:ntXuBULZLQmCdAMxZNXzse069DbAKb/Flxe/2uuZuNk=
github.com/ofabricio/curl v0.1.0/go.mod h1:RtLkZIOgxjm+l0jdj04lrETzu8u5SmPPdLyGAuC4ukg=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

376
main.go
View file

@ -3,68 +3,84 @@ package main
import (
"bytes"
"compress/gzip"
"crypto/tls"
_ "embed"
"encoding/json"
"fmt"
"html/template"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path"
"path/filepath"
"plugin"
"sort"
"strings"
"github.com/ofabricio/curl"
"time"
)
// StatusInternalProxyError is any unknown proxy error
// StatusInternalProxyError is any unknown proxy error.
const StatusInternalProxyError = 999
//go:embed dashboard.html
var dashboardHTML []byte
func main() {
config := ReadConfig()
startCapture(config)
cfg := ReadConfig()
fmt.Printf("Target is %s\n", cfg.TargetURL)
fmt.Printf("Listening on http://127.0.0.1:%s\n", cfg.ProxyPort)
fmt.Printf("Dashboard on http://127.0.0.1:%s\n", cfg.DashboardPort)
fmt.Println()
srv := NewCaptureService(cfg.MaxCaptures)
hdr := NewRecorderHandler(srv, NewPluginHandler(NewProxyHandler(cfg.TargetURL, cfg.TLSSkipVerify)))
go func() {
fmt.Println(http.ListenAndServe(":"+cfg.DashboardPort, NewDashboardHandler(hdr, srv, cfg)))
os.Exit(1)
}()
fmt.Println(http.ListenAndServe(":"+cfg.ProxyPort, hdr))
}
func startCapture(config Config) {
list := NewCaptureList(config.MaxCaptures)
handler := NewPlugin(NewRecorder(list, NewProxyHandler(config.TargetURL)))
http.HandleFunc("/", handler)
http.HandleFunc(config.DashboardPath, NewDashboardHTMLHandler(config))
http.HandleFunc(config.DashboardConnPath, NewDashboardConnHandler(list))
http.HandleFunc(config.DashboardClearPath, NewDashboardClearHandler(list))
http.HandleFunc(config.DashboardRetryPath, NewDashboardRetryHandler(list, handler))
http.HandleFunc(config.DashboardItemInfoPath, NewDashboardItemInfoHandler(list))
captureHost := fmt.Sprintf("http://localhost:%s", config.ProxyPort)
fmt.Printf("\nListening on %s", captureHost)
fmt.Printf("\n %s/%s\n\n", captureHost, config.Dashboard)
fmt.Println(http.ListenAndServe(":"+config.ProxyPort, nil))
func NewDashboardHandler(h http.HandlerFunc, srv *CaptureService, cfg Config) http.Handler {
router := http.NewServeMux()
router.HandleFunc("/", NewDashboardHTMLHandler())
router.HandleFunc("/conn/", NewDashboardConnHandler(srv, cfg))
router.HandleFunc("/info/", NewDashboardInfoHandler(srv))
router.HandleFunc("/clear/", NewDashboardClearHandler(srv))
router.HandleFunc("/retry/", NewDashboardRetryHandler(srv, h))
return router
}
// NewDashboardConnHandler opens an event stream connection with the dashboard
// so that it is notified everytime a new capture arrives
func NewDashboardConnHandler(list *CaptureList) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
if _, ok := rw.(http.Flusher); !ok {
fmt.Printf("streaming not supported at %s\n", req.URL)
http.Error(rw, "streaming not supported", http.StatusInternalServerError)
// so that it is notified everytime a new capture arrives.
func NewDashboardConnHandler(srv *CaptureService, cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if _, ok := w.(http.Flusher); !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sendEvent := func(event string, data interface{}) {
jsn, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsn)
w.(http.Flusher).Flush()
}
sendEvent("config", cfg)
for {
jsn, _ := json.Marshal(list.ItemsAsMetadata())
fmt.Fprintf(rw, "event: captures\ndata: %s\n\n", jsn)
rw.(http.Flusher).Flush()
sendEvent("captures", srv.DashboardItems())
select {
case <-list.Updated():
case <-srv.Updated():
case <-req.Context().Done():
return
}
@ -72,114 +88,159 @@ func NewDashboardConnHandler(list *CaptureList) http.HandlerFunc {
}
}
// NewDashboardClearHandler clears all the captures
func NewDashboardClearHandler(list *CaptureList) http.HandlerFunc {
// NewDashboardClearHandler clears all the captures.
func NewDashboardClearHandler(srv *CaptureService) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
list.RemoveAll()
srv.RemoveAll()
rw.WriteHeader(http.StatusOK)
}
}
// NewDashboardHTMLHandler returns the dashboard html page
func NewDashboardHTMLHandler(config Config) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Add("Content-Type", "text/html")
t, err := template.New("dashboard template").Delims("<<", ">>").Parse(dashboardHTML)
if err != nil {
msg := fmt.Sprintf("could not parse dashboard html template: %v", err)
fmt.Println(msg)
http.Error(rw, msg, http.StatusInternalServerError)
// NewDashboardHTMLHandler returns the dashboard html page.
func NewDashboardHTMLHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
// This redirect prevents accessing the dashboard page from paths other
// than the root path. This is important because the dashboard uses
// relative paths, so "/retry/" would become "/something/retry/".
if req.URL.Path != "/" {
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
return
}
t.Execute(rw, config)
w.Header().Add("Content-Type", "text/html")
w.Write(dashboardHTML)
}
}
// NewDashboardRetryHandler retries a request
func NewDashboardRetryHandler(list *CaptureList, next http.HandlerFunc) http.HandlerFunc {
// NewDashboardRetryHandler retries a request.
func NewDashboardRetryHandler(srv *CaptureService, next http.HandlerFunc) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
id := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
capture := list.Find(id)
if capture == nil {
http.Error(rw, "Item Not Found", http.StatusNotFound)
return
}
var reqBody []byte
capture.Req.Body, reqBody = drain(capture.Req.Body)
r, _ := http.NewRequest(capture.Req.Method, capture.Req.URL.String(), bytes.NewReader(reqBody))
id := path.Base(req.URL.Path)
capture := srv.Find(id)
// Create a new request based on the current one.
r, _ := http.NewRequest(capture.Req.Method, capture.Req.Url, bytes.NewReader(capture.Req.Body))
r.Header = capture.Req.Header
next.ServeHTTP(rw, r)
}
}
// NewDashboardItemInfoHandler returns the full capture info
func NewDashboardItemInfoHandler(list *CaptureList) http.HandlerFunc {
// NewDashboardInfoHandler returns the full capture info.
func NewDashboardInfoHandler(srv *CaptureService) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
id := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
capture := list.Find(id)
if capture == nil {
http.Error(rw, "Item Not Found", http.StatusNotFound)
return
}
id := path.Base(req.URL.Path)
capture := srv.Find(id)
rw.Header().Add("Content-Type", "application/json")
json.NewEncoder(rw).Encode(dump(capture))
}
}
// NewPlugin setups plugin handler for requests and resposes
func NewPlugin(next http.HandlerFunc) http.HandlerFunc {
p, err := plugin.Open("plugin.so")
// NewPluginHandler loads plugin files in the current directory.
// They are loaded sorted by filename.
func NewPluginHandler(next http.HandlerFunc) http.HandlerFunc {
ex, err := os.Executable()
if err != nil {
if strings.HasPrefix(err.Error(), "plugin.Open") {
fmt.Printf("error: could not open plugin file 'plugin.so': %v\n", err)
fmt.Println("error: could not get executable:", err)
return next
}
exPath := filepath.Dir(ex)
files, err := ioutil.ReadDir(exPath)
if err != nil {
fmt.Println("error: could not read directory:", err)
return next
}
for _, file := range files {
if file.IsDir() {
continue
}
if strings.HasSuffix(file.Name(), ".so") {
fmt.Printf("Loading plugin '%s'\n", file.Name())
p, err := plugin.Open(exPath + "/" + file.Name())
if err != nil {
fmt.Println("error: could not open plugin:", err)
os.Exit(1)
}
fn, err := p.Lookup("Handler")
if err != nil {
fmt.Println("error: could not find plugin Handler function:", err)
os.Exit(1)
}
pluginHandler, ok := fn.(func(http.HandlerFunc) http.HandlerFunc)
if !ok {
fmt.Println("error: plugin Handler function should be 'func(http.HandlerFunc) http.HandlerFunc'")
os.Exit(1)
}
next = pluginHandler(next)
}
return next
}
f, err := p.Lookup("Handler")
if err != nil {
fmt.Printf("error: could not find plugin Handler function %v\n", err)
return next
}
pluginFn, ok := f.(func(http.HandlerFunc) http.HandlerFunc)
if !ok {
fmt.Println("error: plugin Handler function should be 'func(http.HandlerFunc) http.HandlerFunc'")
return next
}
return pluginFn(next)
return next
}
// NewRecorder saves all the traffic data
func NewRecorder(list *CaptureList, next http.HandlerFunc) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
// NewRecorderHandler records all the traffic data.
func NewRecorderHandler(srv *CaptureService, next http.HandlerFunc) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
// save req body for later
var reqBody []byte
req.Body, reqBody = drain(req.Body)
// Save req body for later.
reqBody := &bytes.Buffer{}
r.Body = ioutil.NopCloser(io.TeeReader(r.Body, reqBody))
rec := httptest.NewRecorder()
next.ServeHTTP(rec, req)
// Record Roundtrip.
// respond
for k, v := range rec.HeaderMap {
start := time.Now()
next.ServeHTTP(rec, r)
elapsed := time.Since(start).Truncate(time.Millisecond) / time.Millisecond
resBody := rec.Body.Bytes()
// Respond to client with recorded response.
for k, v := range rec.Header() {
rw.Header()[k] = v
}
rw.WriteHeader(rec.Code)
rw.Write(rec.Body.Bytes())
rw.Write(resBody)
// record req and res
req.Body = ioutil.NopCloser(bytes.NewReader(reqBody))
res := rec.Result()
list.Insert(Capture{Req: req, Res: res})
// Save req and res data.
req := Req{
Proto: r.Proto,
Method: r.Method,
Url: r.URL.String(),
Path: r.URL.Path,
Query: extractQueryString(r.RequestURI),
Header: r.Header,
Body: reqBody.Bytes(),
}
res := Res{
Proto: rec.Result().Proto,
Status: rec.Result().Status,
Code: rec.Code,
Header: rec.Header(),
Body: resBody,
}
srv.Insert(Capture{Req: req, Res: res, Elapsed: elapsed})
}
}
// NewProxyHandler is the reverse proxy handler
func NewProxyHandler(URL string) http.HandlerFunc {
// NewProxyHandler is the reverse proxy handler.
func NewProxyHandler(URL string, TLSSkipVerify bool) http.HandlerFunc {
url, _ := url.Parse(URL)
proxy := httputil.NewSingleHostReverseProxy(url)
insecureTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: TLSSkipVerify},
}
proxy.Transport = insecureTransport
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
fmt.Printf("uh oh | %v | %s %s\n", err, req.Method, req.URL)
fmt.Printf("Uh oh | %v | %s %s\n", err, req.Method, req.URL)
rw.WriteHeader(StatusInternalProxyError)
fmt.Fprintf(rw, "%v", err)
}
@ -191,69 +252,72 @@ func NewProxyHandler(URL string) http.HandlerFunc {
}
}
func dump(c *Capture) CaptureDump {
reqDump, err := dumpRequest(c.Req)
if err != nil {
fmt.Printf("could not dump request: %v\n", err)
func dump(c *Capture) CaptureInfo {
req := c.Req
res := c.Res
query := ""
if len(req.Query) > 1 {
query = "?" + req.Query
}
resDump, err := dumpResponse(c.Res)
if err != nil {
fmt.Printf("could not dump response: %v\n", err)
return CaptureInfo{
Request: dumpContent(req.Header, req.Body, "%s %s%s %s\n\n", req.Method, req.Path, query, req.Proto),
Response: dumpContent(res.Header, res.Body, "%s %s\n\n", res.Proto, res.Status),
Curl: dumpCurl(req),
}
strcurl, err := curl.New(c.Req)
if err != nil {
fmt.Printf("could not convert request to curl: %v\n", err)
}
return CaptureDump{Request: string(reqDump), Response: string(resDump), Curl: strcurl}
}
func dumpRequest(req *http.Request) ([]byte, error) {
if req.Header.Get("Content-Encoding") == "gzip" {
return dumpGzipRequest(req)
func dumpContent(header http.Header, body []byte, format string, args ...interface{}) string {
b := strings.Builder{}
fmt.Fprintf(&b, format, args...)
dumpHeader(&b, header)
b.WriteString("\n")
dumpBody(&b, header, body)
return b.String()
}
func dumpHeader(dst *strings.Builder, header http.Header) {
var headers []string
for k, v := range header {
headers = append(headers, fmt.Sprintf("%s: %s\n", k, strings.Join(v, " ")))
}
return httputil.DumpRequest(req, true)
}
func dumpGzipRequest(req *http.Request) ([]byte, error) {
var reqBody []byte
req.Body, reqBody = drain(req.Body)
reader, _ := gzip.NewReader(bytes.NewReader(reqBody))
req.Body = ioutil.NopCloser(reader)
reqDump, err := httputil.DumpRequest(req, true)
req.Body = ioutil.NopCloser(bytes.NewReader(reqBody))
return reqDump, err
}
func dumpResponse(res *http.Response) ([]byte, error) {
if res.StatusCode == StatusInternalProxyError {
return dumpInternalProxyError(res)
sort.Strings(headers)
for _, v := range headers {
dst.WriteString(v)
}
if res.Header.Get("Content-Encoding") == "gzip" {
return dumpGzipResponse(res)
}
func dumpBody(dst *strings.Builder, header http.Header, body []byte) {
reqBody := body
if header.Get("Content-Encoding") == "gzip" {
reader, _ := gzip.NewReader(bytes.NewReader(body))
reqBody, _ = ioutil.ReadAll(reader)
}
return httputil.DumpResponse(res, true)
dst.Write(reqBody)
}
// Dumps only the body when we have an proxy error.
// This body is set in NewProxyHandler() in proxy.ErrorHandler
func dumpInternalProxyError(res *http.Response) ([]byte, error) {
var resBody []byte
res.Body, resBody = drain(res.Body)
return resBody, nil
func dumpCurl(req Req) string {
var b strings.Builder
// Build cmd.
fmt.Fprintf(&b, "curl -X %s %s", req.Method, req.Url)
// Build headers.
for k, v := range req.Header {
fmt.Fprintf(&b, " \\\n -H '%s: %s'", k, strings.Join(v, " "))
}
// Build body.
if len(req.Body) > 0 {
fmt.Fprintf(&b, " \\\n -d '%s'", req.Body)
}
return b.String()
}
func dumpGzipResponse(res *http.Response) ([]byte, error) {
var resBody []byte
res.Body, resBody = drain(res.Body)
reader, _ := gzip.NewReader(bytes.NewReader(resBody))
res.Body = ioutil.NopCloser(reader)
resDump, err := httputil.DumpResponse(res, true)
res.Body = ioutil.NopCloser(bytes.NewReader(resBody))
return resDump, err
}
func extractQueryString(uri string) string {
parts := strings.SplitN(uri, "?", 2)
func drain(b io.ReadCloser) (io.ReadCloser, []byte) {
all, _ := ioutil.ReadAll(b)
b.Close()
return ioutil.NopCloser(bytes.NewReader(all)), all
if len(parts) != 2 {
return ""
}
return parts[1]
}

View file

@ -22,7 +22,7 @@ func TestProxyHandler(t *testing.T) {
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
service := httptest.NewServer(http.HandlerFunc(tc.service))
capture := httptest.NewServer(NewProxyHandler(service.URL))
capture := httptest.NewServer(NewProxyHandler(service.URL, false))
// when
resp := tc.request(capture.URL)
@ -84,91 +84,67 @@ func PostRequest() TestCase {
}
}
func TestDumpRequest(t *testing.T) {
msg := "hello"
func TestDashboardRedirect(t *testing.T) {
// given
req, err := http.NewRequest(http.MethodPost, "http://localhost:9000/", strings.NewReader(msg))
if err != nil {
t.Errorf("Could not create request: %v", err)
// Given.
req, _ := http.NewRequest(http.MethodGet, "/something/", nil)
rec := httptest.NewRecorder()
// When.
NewDashboardHTMLHandler().ServeHTTP(rec, req)
// Then.
if rec.Code != http.StatusTemporaryRedirect {
t.Errorf("Wrong response code: got %d, want %d", rec.Code, http.StatusTemporaryRedirect)
}
// when
body, err := dumpRequest(req)
// then
if err != nil {
t.Errorf("Dump Request error: %v", err)
}
if !strings.Contains(string(body), msg) {
t.Errorf("Dump Request is not '%s'", msg)
if loc := rec.Header().Get("Location"); loc != "/" {
t.Errorf("Wrong redirect path: got '%s', want '/'", loc)
}
}
func TestDumpRequestGzip(t *testing.T) {
msg := "hello"
// given
req, err := http.NewRequest(http.MethodPost, "http://localhost:9000/", strings.NewReader(gzipStr(msg)))
req.Header.Set("Content-Encoding", "gzip")
if err != nil {
t.Errorf("Could not create request: %v", err)
func Example_dump() {
c := &Capture{
Req: Req{
Proto: "HTTP/1.1",
Url: "http://localhost/hello",
Path: "/hello",
Method: "GET",
Header: map[string][]string{"Content-Encoding": {"none"}},
Body: []byte(`hello`),
},
Res: Res{
Proto: "HTTP/1.1",
Header: map[string][]string{"Content-Encoding": {"gzip"}},
Body: gzipStr("gziped hello"),
Status: "200 OK",
},
}
got := dump(c)
// when
body, err := dumpRequest(req)
fmt.Println(got.Request)
fmt.Println(got.Response)
fmt.Println(got.Curl)
// then
if err != nil {
t.Errorf("Dump Request Gzip error: %v", err)
}
if !strings.Contains(string(body), msg) {
t.Errorf("Dump Request Gzip is not '%s'", msg)
}
// Output:
// GET /hello HTTP/1.1
//
// Content-Encoding: none
//
// hello
// HTTP/1.1 200 OK
//
// Content-Encoding: gzip
//
// gziped hello
// curl -X GET http://localhost/hello \
// -H 'Content-Encoding: none' \
// -d 'hello'
}
func TestDumpResponse(t *testing.T) {
msg := "hello"
// given
res := &http.Response{Body: ioutil.NopCloser(strings.NewReader(msg))}
// when
body, err := dumpResponse(res)
// then
if err != nil {
t.Errorf("Dump Response Error: %v", err)
}
if !strings.Contains(string(body), msg) {
t.Errorf("Dump Response is not '%s'", msg)
}
}
func TestDumpResponseGzip(t *testing.T) {
msg := "hello"
// given
h := make(http.Header)
h.Set("Content-Encoding", "gzip")
res := &http.Response{Header: h, Body: ioutil.NopCloser(strings.NewReader(gzipStr(msg)))}
// when
body, err := dumpResponse(res)
// then
if err != nil {
t.Errorf("Dump Response error: %v", err)
}
if !strings.Contains(string(body), msg) {
t.Error("Not hello")
}
}
func gzipStr(str string) string {
func gzipStr(str string) []byte {
var buff bytes.Buffer
g := gzip.NewWriter(&buff)
io.WriteString(g, str)
g.Close()
return buff.String()
return buff.Bytes()
}