Compare commits

...

175 commits
v0.1.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
Fabricio cd153c35fb remove weird test 2018-12-03 22:00:26 -02:00
Fabricio 9c760e0926 less race conditions 2018-12-03 21:47:39 -02:00
Fabricio 888ea8935c select last item on retry 2018-12-02 14:57:57 -02:00
Fabricio 8529ec7c74 remove unnecessary line 2018-12-02 14:57:32 -02:00
Fabricio 6577238fca no need to clear on connection anymore 2018-12-02 14:36:30 -02:00
Fabricio a1d03ff264 signals items change with closed channel 2018-12-02 12:17:23 -02:00
Fabricio b336d33dd3 comment code 2018-12-01 20:59:33 -02:00
Fabricio e48bc52d12 change Handler to HandlerFunc 2018-12-01 19:45:41 -02:00
Fabricio 4d4dc19510 clear dashboard on disconnect 2018-11-29 19:10:35 -02:00
Fabricio 7bb6796cc8 add better feedback when a request fails 2018-11-29 17:52:14 -02:00
Fabricio a265ab136c add release tag 2018-11-28 07:05:20 -02:00
Fabricio b795509c2a show current proxy target in dashboard 2018-11-27 06:47:40 -02:00
Fabricio 3085cb3429 add retry button 2018-11-25 19:31:56 -02:00
Fabricio 1706954423 exchange socketio for SSE 2018-11-25 16:23:43 -02:00
Fabricio f7de340a06 add curl button 2018-11-25 13:01:53 -02:00
Fabricio bdaa96ef56 move dump to get info 2018-11-24 12:24:16 -02:00
Fabricio e6f222c497 add plugin support 2018-11-24 08:03:16 -02:00
Fabricio 55dbc31c18 fix readme 2018-11-24 08:02:53 -02:00
Fabricio 43376c3a55 rename stuff 2018-11-22 19:52:02 -02:00
Fabricio ecbfb4f67a better encapsulation 2018-11-22 19:45:20 -02:00
Fabricio ff6587ce2b add travis 2018-11-17 09:11:23 -02:00
Fabricio 2e2fbc0da0 remove Dockerfile 2018-11-17 08:55:04 -02:00
Fabricio 495604319a add go modules 2018-11-17 08:47:48 -02:00
Fabricio 5d369f8971 fix concurrency problem with captureID 2018-11-17 07:49:18 -02:00
Fabricio b92002c15d rename stuff 2018-11-16 19:39:53 -02:00
Fabricio 0d73abcefd change reverse proxy core 2018-11-15 20:42:18 -02:00
Fabricio 56a16fe526 change pre tag font 2018-10-11 20:15:51 -03:00
Fabricio b476e72225 change to json encode 2018-09-25 20:00:02 -03:00
Fabricio d57ebe7d59 ignore binary 2018-09-17 12:51:43 -03:00
Fabricio cd51fc5f45 fix layout width 2018-09-17 12:51:05 -03:00
Fabricio 2fdd919063 rename Args to Config 2018-09-16 11:21:36 -03:00
Fabricio cb16e22b8b remove the need of ItemPath 2018-09-16 11:18:39 -03:00
Fabricio aa70e1b21c smaller height for list item 2018-09-16 11:02:04 -03:00
Fabricio a5f77f7913 convert url to string 2018-09-07 13:41:24 -03:00
Fabricio 927cc5c348 remove listeners 2018-09-07 13:36:08 -03:00
Fabricio 0add702364 update dashboard image 2018-09-07 12:06:35 -03:00
Fabricio fc01ff23f1 add clear button and fix layout 2018-09-07 11:45:02 -03:00
Fabricio 9e0ae17760 smaller container 2018-08-24 22:14:27 -03:00
Fabricio 665b4579cb better responsibilities 2018-08-05 14:52:23 -03:00
Fabricio 1dd6e592e0 convert string concatenation to sprintf 2018-08-02 06:09:00 -03:00
Fabricio a197f0d95f update dashboard img 2018-08-01 19:47:55 -03:00
Fabricio ce77c1b497 adjusts 2018-08-01 19:36:23 -03:00
Fabricio 629484b60d fix format 2018-07-26 19:59:38 -03:00
Fabricio b32dd867f5 fix selection bug after max items 2018-07-26 19:51:34 -03:00
Fabricio 6f8d8ae8ac fix regex 2018-07-26 12:39:27 -03:00
Fabricio 76cbe2c7ca add fancy arrows and utf-8 2018-07-26 07:47:28 -03:00
Fabricio 48c44c3b99 update README 2018-07-25 20:03:16 -03:00
Fabricio 119fb120f3 update dashboard image 2018-07-25 19:36:21 -03:00
Fabricio 77e13ca1f9 change links color 2018-07-25 19:31:34 -03:00
Fabricio 51a1fe16ea fix typo 2018-07-25 19:27:38 -03:00
Fabricio 50449eb338 add css transition 2018-07-25 19:25:23 -03:00
Fabricio a3cd7b2fdf change regex because of firefox 2018-07-25 18:49:57 -03:00
Fabricio ffbae9c2f6 adds dashboard atom theme 2018-07-24 20:36:56 -03:00
Fabricio 8036e80267 update dashboard image 2018-07-24 19:25:15 -03:00
Fabricio 8e7304b300 change font to OpenSans 2018-07-24 19:07:05 -03:00
Fabricio 6a661ebec3 merge extended colors 2018-07-24 18:37:46 -03:00
Fabricio 3cf7eb461f roboto font and adjusts in the dashboard 2018-07-24 10:40:24 -03:00
Fabricio 3fffcb029c update README 2018-07-23 18:49:59 -03:00
Fabricio f9293738c6 adds dockerfile 2018-07-23 18:49:48 -03:00
Fabricio 98a6ff0269 adds prettify body 2018-07-23 18:49:23 -03:00
Fabricio 77821a1247 two step item fetch 2018-07-21 20:48:51 -03:00
17 changed files with 1316 additions and 273 deletions

4
.dockerignore Normal file
View file

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

4
.gitignore vendored
View file

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

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"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Fabricio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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,44 +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
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)
## Building / Running
## Running
git clone https://github.com/ofabricio/capture.git
cd capture
go build
./capture -url=https://example.com/api -port=9000 -dashboard=apple -max-captures=16
```
./capture -url=https://example.com/ -port 9000 -dashboard 9001 -captures 16
```
### Binaries / Executables
Via docker:
For ready-to-use executables (no need to build it yourself) for *Windows* and *Linux*, see [Releases](https://github.com/ofabricio/capture/releases) page
```
docker run -p 9000:9000 -p 9001:9001 deblan/capture -url=https://example.com/ -port 9000 -dashboard 9001 -captures 16
```
### Configurations
#### Settings
| param | description |
|-----------------|-------------|
| `-url` | **Required.** Set the base url you want to capture |
| `-port` | Set the proxy port. Default: *9000* |
| `-dashboard` | Set the dashboard name. Default: *dashboard* |
| `-max-captures` | Set the max number of captures to show in the dashboard. Default: *16* |
| `-h` | Show help |
| 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/13nzb48.png)
![dashboard](https://upload.deblan.org/u/2023-05/64746afd.png)
## Building
Manually:
```
git clone --depth 1 https://gitnet.fr/deblan/capture.git
cd capture
go build
```
Via docker:
```
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 directory.
**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)
}
}
```

17
args.go
View file

@ -1,17 +0,0 @@
package main
import (
"flag"
"net/url"
"strconv"
)
func parseFlags() (*url.URL, string, string, int) {
target := flag.String("url", "https://jsonplaceholder.typicode.com", "Required. Set the base url you want to capture")
proxyPort := flag.Int("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")
flag.Parse()
targetURL, _ := url.Parse(*target)
return targetURL, strconv.Itoa(*proxyPort), *dashboard, *maxCaptures
}

View file

@ -1,21 +1,142 @@
package main
import (
"net/http"
"strconv"
"sync"
"time"
)
var captureID int
// CaptureService handles captures.
type CaptureService struct {
items []Capture
mu sync.RWMutex
maxItems int
updated chan struct{} // signals any change in "items".
}
// Capture is our traffic data.
type Capture struct {
Url string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
ID int
Req Req
Res Res
// Elapsed time of the request, in milliseconds.
Elapsed time.Duration
}
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"`
}
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"`
}
type Captures struct {
items []Capture
max int
// 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"`
}
func (c *Captures) Add(capture Capture) {
c.items = append([]Capture{capture}, c.items...)
if len(c.items) > c.max {
c.items = c.items[:len(c.items)-1]
// NewCaptureService creates a new service of captures.
func NewCaptureService(maxItems int) *CaptureService {
return &CaptureService{
maxItems: maxItems,
updated: make(chan struct{}),
}
}
// Insert inserts a new capture.
func (s *CaptureService) Insert(capture Capture) {
s.mu.Lock()
defer s.mu.Unlock()
captureID++
capture.ID = captureID
s.items = append(s.items, capture)
if len(s.items) > s.maxItems {
s.items = s.items[1:]
}
s.signalsUpdate()
}
// 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 s.items {
if c.ID == idInt {
return &c
}
}
return nil
}
// RemoveAll removes all the captures.
func (s *CaptureService) RemoveAll() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = nil
s.signalsUpdate()
}
// DashboardItems returns the dashboard's list of items.
func (s *CaptureService) DashboardItems() []DashboardItem {
s.mu.RLock()
defer s.mu.RUnlock()
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
}
// signalsUpdate fires an update signal.
func (s *CaptureService) signalsUpdate() {
close(s.updated)
s.updated = make(chan struct{})
}
// 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
}

63
config.go Normal file
View file

@ -0,0 +1,63 @@
package main
import (
"flag"
"fmt"
"gopkg.in/ini.v1"
"os"
)
// Config has all the configuration parsed from the command line.
type Config struct {
TargetURL string
ProxyPort string
DashboardPort string
TLSSkipVerify bool
MaxCaptures int
}
// ReadConfig reads the arguments from the command line.
func ReadConfig() Config {
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()
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,
DashboardPort: *dashboardPort,
TLSSkipVerify: *TLSSkipVerify,
}
}

View file

@ -1,148 +0,0 @@
package main
import (
"net/http"
)
func getDashboardHandler() http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Add("Content-Type", "text/html")
response.Write([]byte(dashboardHTML))
})
}
const dashboardHTML = `
<!DOCTYPE html>
<html ng-app="app">
<head>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.6/angular.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.4/socket.io.js"></script>
<title>Dashboard</title>
<style>
* { padding: 0; margin: 0; box-sizing: border-box }
html, body, .dashboard {
height: 100%;
font: 1em verdana, arial, helvetica, sans-serif;
}
div { display: flex; position: relative }
.dashboard { background: #eceff1 }
.list, .req, .res {
flex: 0 0 37%;
overflow: auto;
}
.list-inner, .req-inner, .res-inner{
margin: 1rem;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
}
.req-inner, .res-inner {
background: #fefefe;
padding: 1rem;
}
.req-inner { margin: 1rem 0 }
.list { flex: 0 0 26% }
.list-inner { flex-direction: column }
.list-item {
flex-shrink: 0;
padding: 1rem;
color: #767676;
background: #fefefe;
cursor: pointer;
margin-bottom: 0.5rem;
align-items: center;
}
.list-item:hover { background: #eceff1 }
.list-item, .req-inner, .res-inner {
box-shadow: 0px 1px 1px 0px rgba(0,0,0,0.25);
}
.selected { background: #ff4081 !important; color: #fff }
.list-item.selected .method,
.list-item.selected .status { color: #fff }
.ok,
.GET { color: #88d43f }
.POST { color: #ef9c26 }
.warn,
.PUT { color: #4c87dd }
.PATCH { color: #767676 }
.error,
.DELETE { color: #e53f42 }
.method { font-size: 0.7em; margin-right: 1rem; padding: .25rem .5rem }
.status { font-size: 0.8em; padding-left: 1rem }
.url { font-size: 0.8em; flex: 1; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl }
.req-inner, .res-inner { flex-direction: column }
.url-big { flex-shrink: 0; line-height: 1.5em; word-break: break-all; padding-bottom: 1rem; margin-bottom: 1rem; border-bottom: 1px solid #eee }
.url-big:empty { border: 0 }
pre { flex: 1; color: #555; word-break: normal; word-wrap: break-word; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="dashboard" ng-controller="controller">
<div class="list">
<div class="list-inner">
<div class="list-item" ng-repeat="item in items" ng-click="show(item)"
ng-class="{selected: selected == item}">
<span class="method" ng-class="item.method">{{item.method}}</span>
<span class="url">&lrm;{{item.url}}&lrm;</span>
<span class="status" ng-class="statusColor(item)">{{item.status}}</span>
</div>
</div>
</div>
<div class="req">
<div class="req-inner">
<div class="url-big">{{url}}</div>
<pre>{{request}}</pre>
</div>
</div>
<div class="res">
<div class="res-inner">
<pre>{{response}}</pre>
</div>
</div>
</div>
<script type="text/javascript">
angular.module('app', [])
.controller('controller', function($scope) {
$scope.show = item => {
$scope.request = item.request;
$scope.response = item.response;
$scope.url = item.url;
$scope.selected = item;
}
$scope.statusColor = item => {
let status = (item.status + '')[0] - 2;
return ['ok', 'warn', 'error', 'error'][status] || '';
}
let socket = io();
socket.on('connect', () => {
socket.on('captures', captures => {
$scope.items = captures;
$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>

5
go.mod Normal file
View file

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

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

361
main.go
View file

@ -3,104 +3,321 @@ package main
import (
"bytes"
"compress/gzip"
"errors"
"crypto/tls"
_ "embed"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"net/http/httputil"
"github.com/googollee/go-socket.io"
"net/url"
"os"
"path"
"path/filepath"
"plugin"
"sort"
"strings"
"time"
)
var captures Captures
var socket socketio.Socket
// StatusInternalProxyError is any unknown proxy error.
const StatusInternalProxyError = 999
type Transport struct {
http.RoundTripper
}
//go:embed dashboard.html
var dashboardHTML []byte
func main() {
targetURL, proxyPort, dashboard, maxCaptures := parseFlags()
captures.max = maxCaptures
cfg := ReadConfig()
proxy := httputil.NewSingleHostReverseProxy(targetURL)
proxy.Transport = Transport{http.DefaultTransport}
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()
http.Handle("/", getProxyHandler(proxy))
http.Handle("/socket.io/", getSocketHandler())
http.Handle("/"+dashboard+"/", getDashboardHandler())
srv := NewCaptureService(cfg.MaxCaptures)
hdr := NewRecorderHandler(srv, NewPluginHandler(NewProxyHandler(cfg.TargetURL, cfg.TLSSkipVerify)))
fmt.Printf("\nListening on http://localhost:%s", proxyPort)
fmt.Printf("\n http://localhost:%s/%s\n\n", proxyPort, dashboard)
http.ListenAndServe(":"+proxyPort, nil)
go func() {
fmt.Println(http.ListenAndServe(":"+cfg.DashboardPort, NewDashboardHandler(hdr, srv, cfg)))
os.Exit(1)
}()
fmt.Println(http.ListenAndServe(":"+cfg.ProxyPort, hdr))
}
func getProxyHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
request.Host = request.URL.Host
handler.ServeHTTP(response, request)
})
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
}
func getSocketHandler() http.Handler {
server, err := socketio.NewServer(nil)
// NewDashboardConnHandler opens an event stream connection with the dashboard
// 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
}
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 {
sendEvent("captures", srv.DashboardItems())
select {
case <-srv.Updated():
case <-req.Context().Done():
return
}
}
}
}
// NewDashboardClearHandler clears all the captures.
func NewDashboardClearHandler(srv *CaptureService) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
srv.RemoveAll()
rw.WriteHeader(http.StatusOK)
}
}
// 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
}
w.Header().Add("Content-Type", "text/html")
w.Write(dashboardHTML)
}
}
// NewDashboardRetryHandler retries a request.
func NewDashboardRetryHandler(srv *CaptureService, next http.HandlerFunc) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
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)
}
}
// NewDashboardInfoHandler returns the full capture info.
func NewDashboardInfoHandler(srv *CaptureService) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
id := path.Base(req.URL.Path)
capture := srv.Find(id)
rw.Header().Add("Content-Type", "application/json")
json.NewEncoder(rw).Encode(dump(capture))
}
}
// 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 {
log.Fatal(err)
fmt.Println("error: could not get executable:", err)
return next
}
server.On("connection", func(so socketio.Socket) {
socket = so
emit()
})
server.On("error", func(so socketio.Socket, err error) {
log.Println("socket error:", err)
})
return server
}
func (t Transport) RoundTrip(req *http.Request) (*http.Response, error) {
reqDump, err := httputil.DumpRequest(req, true)
exPath := filepath.Dir(ex)
files, err := ioutil.ReadDir(exPath)
if err != nil {
return nil, err
fmt.Println("error: could not read directory:", err)
return next
}
res, err := t.RoundTripper.RoundTrip(req)
if err != nil {
return nil, errors.New(err.Error() + ": " + req.URL.String())
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)
}
}
resDump, err := DumpResponse(res)
if err != nil {
return nil, err
}
capture := Capture{req.URL.Path, req.Method, res.StatusCode,
string(reqDump),
string(resDump),
}
captures.Add(capture)
emit()
return res, nil
return next
}
func DumpResponse(res *http.Response) ([]byte, error) {
var originalBody bytes.Buffer
res.Body = ioutil.NopCloser(io.TeeReader(res.Body, &originalBody))
if res.Header.Get("Content-Encoding") == "gzip" {
res.Body, _ = gzip.NewReader(res.Body)
// 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.
reqBody := &bytes.Buffer{}
r.Body = ioutil.NopCloser(io.TeeReader(r.Body, reqBody))
rec := httptest.NewRecorder()
// Record Roundtrip.
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(resBody)
// 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})
}
resDump, err := httputil.DumpResponse(res, true)
res.Body = ioutil.NopCloser(&originalBody)
return resDump, err
}
func emit() {
if socket == nil {
return
// 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)
rw.WriteHeader(StatusInternalProxyError)
fmt.Fprintf(rw, "%v", err)
}
return func(rw http.ResponseWriter, req *http.Request) {
req.Host = url.Host
req.URL.Host = url.Host
req.URL.Scheme = url.Scheme
proxy.ServeHTTP(rw, req)
}
socket.Emit("captures", captures.items)
}
func dump(c *Capture) CaptureInfo {
req := c.Req
res := c.Res
query := ""
if len(req.Query) > 1 {
query = "?" + req.Query
}
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),
}
}
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, " ")))
}
sort.Strings(headers)
for _, v := range headers {
dst.WriteString(v)
}
}
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)
}
dst.Write(reqBody)
}
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 extractQueryString(uri string) string {
parts := strings.SplitN(uri, "?", 2)
if len(parts) != 2 {
return ""
}
return parts[1]
}

150
main_test.go Normal file
View file

@ -0,0 +1,150 @@
package main
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Test the reverse proxy handler
func TestProxyHandler(t *testing.T) {
// given
tt := []TestCase{
GetRequest(),
PostRequest(),
}
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, false))
// when
resp := tc.request(capture.URL)
// then
tc.test(t, resp)
resp.Body.Close()
capture.Close()
service.Close()
})
}
}
type TestCase struct {
name string
request func(string) *http.Response
service func(http.ResponseWriter, *http.Request)
test func(*testing.T, *http.Response)
}
func GetRequest() TestCase {
msg := "hello"
return TestCase{
name: "GetRequest",
request: func(url string) *http.Response {
res, _ := http.Get(url)
return res
},
service: func(rw http.ResponseWriter, req *http.Request) {
fmt.Fprint(rw, string(msg))
},
test: func(t *testing.T, res *http.Response) {
body, _ := ioutil.ReadAll(res.Body)
if string(body) != msg {
t.Error("Wrong Body Response")
}
},
}
}
func PostRequest() TestCase {
msg := "hello"
return TestCase{
name: "PostRequest",
request: func(url string) *http.Response {
res, _ := http.Post(url, "text/plain", strings.NewReader(msg))
return res
},
service: func(rw http.ResponseWriter, req *http.Request) {
io.Copy(rw, req.Body)
},
test: func(t *testing.T, res *http.Response) {
body, _ := ioutil.ReadAll(res.Body)
if string(body) != msg {
t.Error("Wrong Body Response")
}
},
}
}
func TestDashboardRedirect(t *testing.T) {
// 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)
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Errorf("Wrong redirect path: got '%s', want '/'", loc)
}
}
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)
fmt.Println(got.Request)
fmt.Println(got.Response)
fmt.Println(got.Curl)
// 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 gzipStr(str string) []byte {
var buff bytes.Buffer
g := gzip.NewWriter(&buff)
io.WriteString(g, str)
g.Close()
return buff.Bytes()
}