The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .github
    └── workflows
    │   └── push.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── go.mod
├── go.sum
├── main.go
├── main_test.go
└── screenshot.png


/.github/workflows/push.yml:
--------------------------------------------------------------------------------
 1 | on:
 2 |   push:
 3 |     branches:
 4 |       - master
 5 |   pull_request:
 6 |     branches:
 7 |       - master
 8 | name: Push
 9 | jobs:
10 |   test:
11 |     runs-on: ubuntu-latest
12 |     steps:
13 |     - name: Checkout code
14 |       uses: actions/checkout@v4
15 |     - name: Install Go
16 |       uses: actions/setup-go@v5
17 |       with:
18 |         go-version-file: 'go.mod'
19 |     - name: Test
20 |       run: go test -race ./...
21 |     - name: Vet
22 |       run: go vet ./...
23 |     - name: Mod verify
24 |       run: go mod verify
25 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
 2 | *.o
 3 | *.a
 4 | *.so
 5 | 
 6 | # Folders
 7 | _obj
 8 | _test
 9 | 
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 | 
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 | 
20 | _testmain.go
21 | 
22 | *.exe
23 | *.test
24 | *.prof
25 | 
26 | httpstat
27 | 


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


--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
 1 | TARGETS = linux-386 linux-amd64 linux-arm linux-arm64 darwin-amd64 windows-386 windows-amd64
 2 | COMMAND_NAME = httpstat
 3 | PACKAGE_NAME = github.com/davecheney/$(COMMAND_NAME)
 4 | LDFLAGS = -ldflags=-X=main.version=$(VERSION)
 5 | OBJECTS = $(patsubst $(COMMAND_NAME)-windows-amd64%,$(COMMAND_NAME)-windows-amd64%.exe, $(patsubst $(COMMAND_NAME)-windows-386%,$(COMMAND_NAME)-windows-386%.exe, $(patsubst %,$(COMMAND_NAME)-%-v$(VERSION), $(TARGETS)))) 
 6 | 
 7 | release: check-env $(OBJECTS) ## Build release binaries (requires VERSION)
 8 | 
 9 | clean: check-env ## Remove release binaries
10 | 	rm $(OBJECTS)
11 | 
12 | $(OBJECTS): $(wildcard *.go)
13 | 	env GOOS=`echo $@ | cut -d'-' -f2` GOARCH=`echo $@ | cut -d'-' -f3 | cut -d'.' -f 1` go build -o $@ $(LDFLAGS) $(PACKAGE_NAME)
14 | 
15 | .PHONY: help check-env
16 | 
17 | check-env:
18 | ifndef VERSION
19 | 	$(error VERSION is undefined)
20 | endif
21 | 
22 | help:
23 | 	@grep -E '^[a-zA-Z_-]+:.*?## .*$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $1, $2}'
24 | 
25 | .DEFAULT_GOAL := help
26 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # httpstat [![Build Status](https://github.com/davecheney/httpstat/actions/workflows/push.yml/badge.svg)](https://github.com/davecheney/httpstat/actions/workflows/push.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/davecheney/httpstat)](https://goreportcard.com/report/github.com/davecheney/httpstat)
 2 | 
 3 | ![Shameless](./screenshot.png)
 4 | 
 5 | Imitation is the sincerest form of flattery.
 6 | 
 7 | But seriously, https://github.com/reorx/httpstat is the new hotness, and this is a shameless rip off.
 8 | 
 9 | ## Installation
10 | `httpstat` requires Go 1.20 or later.
11 | ```
12 | go install github.com/davecheney/httpstat@latest
13 | ```
14 | 
15 | ## Usage
16 | ```
17 | httpstat https://example.com/
18 | ```
19 | ## Features
20 | 
21 | - Windows/BSD/Linux supported.
22 | - HTTP and HTTPS are supported, for self signed certificates use `-k`.
23 | - Skip timing the body of a response with `-I`.
24 | - Follow 30x redirects with `-L`.
25 | - Change HTTP method with `-X METHOD`.
26 | - Provide a `PUT` or `POST` request body with `-d string`. To supply the `PUT` or `POST` body as a file, use `-d @filename`.
27 | - Add extra request headers with `-H 'Name: value'`.
28 | - The response body is usually discarded, you can use `-o filename` to save it to a file, or `-O` to save it to the file name suggested by the server.
29 | - HTTP/HTTPS proxies supported via the usual `HTTP_PROXY`/`HTTPS_PROXY` env vars (as well as lower case variants).
30 | - Supply your own client side certificate with `-E cert.pem`.
31 | 
32 | ## Contributing
33 | 
34 | Bug reports are most welcome, but with the exception of #5, this project is closed.
35 | 
36 | Pull requests must include a `fixes #NNN` or `updates #NNN` comment. 
37 | 
38 | Please discuss your design on the accompanying issue before submitting a pull request. If there is no suitable issue, please open one to discuss the feature before slinging code. Thank you.
39 | 


--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
 1 | module github.com/davecheney/httpstat
 2 | 
 3 | go 1.23
 4 | 
 5 | require github.com/fatih/color v1.18.0
 6 | 
 7 | require (
 8 | 	github.com/mattn/go-colorable v0.1.13 // indirect
 9 | 	github.com/mattn/go-isatty v0.0.20 // indirect
10 | 	golang.org/x/sys v0.25.0 // indirect
11 | )
12 | 


--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
 1 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 2 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 3 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 4 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 5 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 6 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 7 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 8 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 9 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
10 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
11 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
12 | 


--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"crypto/tls"
  6 | 	"encoding/pem"
  7 | 	"flag"
  8 | 	"fmt"
  9 | 	"io"
 10 | 	"log"
 11 | 	"mime"
 12 | 	"net"
 13 | 	"net/http"
 14 | 	"net/http/httptrace"
 15 | 	"net/url"
 16 | 	"os"
 17 | 	"path"
 18 | 	"runtime"
 19 | 	"sort"
 20 | 	"strconv"
 21 | 	"strings"
 22 | 	"time"
 23 | 
 24 | 	"github.com/fatih/color"
 25 | )
 26 | 
 27 | const (
 28 | 	httpsTemplate = `` +
 29 | 		`  DNS Lookup   TCP Connection   TLS Handshake   Server Processing   Content Transfer` + "\n" +
 30 | 		`[%s  |     %s  |    %s  |        %s  |       %s  ]` + "\n" +
 31 | 		`            |                |               |                   |                  |` + "\n" +
 32 | 		`   namelookup:%s      |               |                   |                  |` + "\n" +
 33 | 		`                       connect:%s     |                   |                  |` + "\n" +
 34 | 		`                                   pretransfer:%s         |                  |` + "\n" +
 35 | 		`                                                     starttransfer:%s        |` + "\n" +
 36 | 		`                                                                                total:%s` + "\n"
 37 | 
 38 | 	httpTemplate = `` +
 39 | 		`   DNS Lookup   TCP Connection   Server Processing   Content Transfer` + "\n" +
 40 | 		`[ %s  |     %s  |        %s  |       %s  ]` + "\n" +
 41 | 		`             |                |                   |                  |` + "\n" +
 42 | 		`    namelookup:%s      |                   |                  |` + "\n" +
 43 | 		`                        connect:%s         |                  |` + "\n" +
 44 | 		`                                      starttransfer:%s        |` + "\n" +
 45 | 		`                                                                 total:%s` + "\n"
 46 | )
 47 | 
 48 | var (
 49 | 	// Command line flags.
 50 | 	httpMethod      string
 51 | 	postBody        string
 52 | 	followRedirects bool
 53 | 	onlyHeader      bool
 54 | 	insecure        bool
 55 | 	httpHeaders     headers
 56 | 	saveOutput      bool
 57 | 	outputFile      string
 58 | 	showVersion     bool
 59 | 	clientCertFile  string
 60 | 	fourOnly        bool
 61 | 	sixOnly         bool
 62 | 
 63 | 	// number of redirects followed
 64 | 	redirectsFollowed int
 65 | 
 66 | 	version = "devel" // for -v flag, updated during the release process with -ldflags=-X=main.version=...
 67 | )
 68 | 
 69 | const maxRedirects = 10
 70 | 
 71 | func init() {
 72 | 	flag.StringVar(&httpMethod, "X", "GET", "HTTP method to use")
 73 | 	flag.StringVar(&postBody, "d", "", "the body of a POST or PUT request; from file use @filename")
 74 | 	flag.BoolVar(&followRedirects, "L", false, "follow 30x redirects")
 75 | 	flag.BoolVar(&onlyHeader, "I", false, "don't read body of request")
 76 | 	flag.BoolVar(&insecure, "k", false, "allow insecure SSL connections")
 77 | 	flag.Var(&httpHeaders, "H", "set HTTP header; repeatable: -H 'Accept: ...' -H 'Range: ...'")
 78 | 	flag.BoolVar(&saveOutput, "O", false, "save body as remote filename")
 79 | 	flag.StringVar(&outputFile, "o", "", "output file for body")
 80 | 	flag.BoolVar(&showVersion, "v", false, "print version number")
 81 | 	flag.StringVar(&clientCertFile, "E", "", "client cert file for tls config")
 82 | 	flag.BoolVar(&fourOnly, "4", false, "resolve IPv4 addresses only")
 83 | 	flag.BoolVar(&sixOnly, "6", false, "resolve IPv6 addresses only")
 84 | 
 85 | 	flag.Usage = usage
 86 | }
 87 | 
 88 | func usage() {
 89 | 	fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] URL\n\n", os.Args[0])
 90 | 	fmt.Fprintln(os.Stderr, "OPTIONS:")
 91 | 	flag.PrintDefaults()
 92 | 	fmt.Fprintln(os.Stderr, "")
 93 | 	fmt.Fprintln(os.Stderr, "ENVIRONMENT:")
 94 | 	fmt.Fprintln(os.Stderr, "  HTTP_PROXY    proxy for HTTP requests; complete URL or HOST[:PORT]")
 95 | 	fmt.Fprintln(os.Stderr, "                used for HTTPS requests if HTTPS_PROXY undefined")
 96 | 	fmt.Fprintln(os.Stderr, "  HTTPS_PROXY   proxy for HTTPS requests; complete URL or HOST[:PORT]")
 97 | 	fmt.Fprintln(os.Stderr, "  NO_PROXY      comma-separated list of hosts to exclude from proxy")
 98 | }
 99 | 
100 | func printf(format string, a ...interface{}) (n int, err error) {
101 | 	return fmt.Fprintf(color.Output, format, a...)
102 | }
103 | 
104 | func grayscale(code color.Attribute) func(string, ...interface{}) string {
105 | 	return color.New(code + 232).SprintfFunc()
106 | }
107 | 
108 | func main() {
109 | 	flag.Parse()
110 | 
111 | 	if showVersion {
112 | 		fmt.Printf("%s %s (runtime: %s)\n", os.Args[0], version, runtime.Version())
113 | 		os.Exit(0)
114 | 	}
115 | 
116 | 	if fourOnly && sixOnly {
117 | 		fmt.Fprintf(os.Stderr, "%s: Only one of -4 and -6 may be specified\n", os.Args[0])
118 | 		os.Exit(-1)
119 | 	}
120 | 
121 | 	args := flag.Args()
122 | 	if len(args) != 1 {
123 | 		flag.Usage()
124 | 		os.Exit(2)
125 | 	}
126 | 
127 | 	if (httpMethod == "POST" || httpMethod == "PUT") && postBody == "" {
128 | 		log.Fatal("must supply post body using -d when POST or PUT is used")
129 | 	}
130 | 
131 | 	if onlyHeader {
132 | 		httpMethod = "HEAD"
133 | 	}
134 | 
135 | 	url := parseURL(args[0])
136 | 
137 | 	visit(url)
138 | }
139 | 
140 | // readClientCert - helper function to read client certificate
141 | // from pem formatted file
142 | func readClientCert(filename string) []tls.Certificate {
143 | 	if filename == "" {
144 | 		return nil
145 | 	}
146 | 	var (
147 | 		pkeyPem []byte
148 | 		certPem []byte
149 | 	)
150 | 
151 | 	// read client certificate file (must include client private key and certificate)
152 | 	certFileBytes, err := os.ReadFile(filename)
153 | 	if err != nil {
154 | 		log.Fatalf("failed to read client certificate file: %v", err)
155 | 	}
156 | 
157 | 	for {
158 | 		block, rest := pem.Decode(certFileBytes)
159 | 		if block == nil {
160 | 			break
161 | 		}
162 | 		certFileBytes = rest
163 | 
164 | 		if strings.HasSuffix(block.Type, "PRIVATE KEY") {
165 | 			pkeyPem = pem.EncodeToMemory(block)
166 | 		}
167 | 		if strings.HasSuffix(block.Type, "CERTIFICATE") {
168 | 			certPem = pem.EncodeToMemory(block)
169 | 		}
170 | 	}
171 | 
172 | 	cert, err := tls.X509KeyPair(certPem, pkeyPem)
173 | 	if err != nil {
174 | 		log.Fatalf("unable to load client cert and key pair: %v", err)
175 | 	}
176 | 	return []tls.Certificate{cert}
177 | }
178 | 
179 | func parseURL(uri string) *url.URL {
180 | 	if !strings.Contains(uri, "://") && !strings.HasPrefix(uri, "//") {
181 | 		uri = "//" + uri
182 | 	}
183 | 
184 | 	url, err := url.Parse(uri)
185 | 	if err != nil {
186 | 		log.Fatalf("could not parse url %q: %v", uri, err)
187 | 	}
188 | 
189 | 	if url.Scheme == "" {
190 | 		url.Scheme = "http"
191 | 		if !strings.HasSuffix(url.Host, ":80") {
192 | 			url.Scheme += "s"
193 | 		}
194 | 	}
195 | 	return url
196 | }
197 | 
198 | func headerKeyValue(h string) (string, string) {
199 | 	i := strings.Index(h, ":")
200 | 	if i == -1 {
201 | 		log.Fatalf("Header '%s' has invalid format, missing ':'", h)
202 | 	}
203 | 	return strings.TrimRight(h[:i], " "), strings.TrimLeft(h[i:], " :")
204 | }
205 | 
206 | func dialContext(network string) func(ctx context.Context, network, addr string) (net.Conn, error) {
207 | 	return func(ctx context.Context, _, addr string) (net.Conn, error) {
208 | 		return (&net.Dialer{
209 | 			Timeout:   30 * time.Second,
210 | 			KeepAlive: 30 * time.Second,
211 | 			DualStack: false,
212 | 		}).DialContext(ctx, network, addr)
213 | 	}
214 | }
215 | 
216 | // visit visits a url and times the interaction.
217 | // If the response is a 30x, visit follows the redirect.
218 | func visit(url *url.URL) {
219 | 	req := newRequest(httpMethod, url, postBody)
220 | 
221 | 	var t0, t1, t2, t3, t4, t5, t6 time.Time
222 | 
223 | 	trace := &httptrace.ClientTrace{
224 | 		DNSStart: func(_ httptrace.DNSStartInfo) { t0 = time.Now() },
225 | 		DNSDone:  func(_ httptrace.DNSDoneInfo) { t1 = time.Now() },
226 | 		ConnectStart: func(_, _ string) {
227 | 			if t1.IsZero() {
228 | 				// connecting to IP
229 | 				t1 = time.Now()
230 | 			}
231 | 		},
232 | 		ConnectDone: func(net, addr string, err error) {
233 | 			if err != nil {
234 | 				log.Fatalf("unable to connect to host %v: %v", addr, err)
235 | 			}
236 | 			t2 = time.Now()
237 | 
238 | 			printf("\n%s%s\n", color.GreenString("Connected to "), color.CyanString(addr))
239 | 		},
240 | 		GotConn:              func(_ httptrace.GotConnInfo) { t3 = time.Now() },
241 | 		GotFirstResponseByte: func() { t4 = time.Now() },
242 | 		TLSHandshakeStart:    func() { t5 = time.Now() },
243 | 		TLSHandshakeDone:     func(_ tls.ConnectionState, _ error) { t6 = time.Now() },
244 | 	}
245 | 	req = req.WithContext(httptrace.WithClientTrace(context.Background(), trace))
246 | 
247 | 	tr := &http.Transport{
248 | 		Proxy:                 http.ProxyFromEnvironment,
249 | 		MaxIdleConns:          100,
250 | 		IdleConnTimeout:       90 * time.Second,
251 | 		TLSHandshakeTimeout:   10 * time.Second,
252 | 		ExpectContinueTimeout: 1 * time.Second,
253 | 		ForceAttemptHTTP2:     true,
254 | 	}
255 | 
256 | 	switch {
257 | 	case fourOnly:
258 | 		tr.DialContext = dialContext("tcp4")
259 | 	case sixOnly:
260 | 		tr.DialContext = dialContext("tcp6")
261 | 	}
262 | 
263 | 	switch url.Scheme {
264 | 	case "https":
265 | 		host, _, err := net.SplitHostPort(req.Host)
266 | 		if err != nil {
267 | 			host = req.Host
268 | 		}
269 | 
270 | 		tr.TLSClientConfig = &tls.Config{
271 | 			ServerName:         host,
272 | 			InsecureSkipVerify: insecure,
273 | 			Certificates:       readClientCert(clientCertFile),
274 | 			MinVersion:         tls.VersionTLS12,
275 | 		}
276 | 	}
277 | 
278 | 	client := &http.Client{
279 | 		Transport: tr,
280 | 		CheckRedirect: func(req *http.Request, via []*http.Request) error {
281 | 			// always refuse to follow redirects, visit does that
282 | 			// manually if required.
283 | 			return http.ErrUseLastResponse
284 | 		},
285 | 	}
286 | 
287 | 	resp, err := client.Do(req)
288 | 	if err != nil {
289 | 		log.Fatalf("failed to read response: %v", err)
290 | 	}
291 | 
292 | 	// Print SSL/TLS version which is used for connection
293 | 	connectedVia := "plaintext"
294 | 	if resp.TLS != nil {
295 | 		switch resp.TLS.Version {
296 | 		case tls.VersionTLS12:
297 | 			connectedVia = "TLSv1.2"
298 | 		case tls.VersionTLS13:
299 | 			connectedVia = "TLSv1.3"
300 | 		}
301 | 	}
302 | 	printf("\n%s %s\n", color.GreenString("Connected via"), color.CyanString("%s", connectedVia))
303 | 
304 | 	bodyMsg := readResponseBody(req, resp)
305 | 	resp.Body.Close()
306 | 
307 | 	t7 := time.Now() // after read body
308 | 	if t0.IsZero() {
309 | 		// we skipped DNS
310 | 		t0 = t1
311 | 	}
312 | 
313 | 	// print status line and headers
314 | 	printf("\n%s%s%s\n", color.GreenString("HTTP"), grayscale(14)("/"), color.CyanString("%d.%d %s", resp.ProtoMajor, resp.ProtoMinor, resp.Status))
315 | 
316 | 	names := make([]string, 0, len(resp.Header))
317 | 	for k := range resp.Header {
318 | 		names = append(names, k)
319 | 	}
320 | 	sort.Sort(headers(names))
321 | 	for _, k := range names {
322 | 		printf("%s %s\n", grayscale(14)(k+":"), color.CyanString(strings.Join(resp.Header[k], ",")))
323 | 	}
324 | 
325 | 	if bodyMsg != "" {
326 | 		printf("\n%s\n", bodyMsg)
327 | 	}
328 | 
329 | 	fmta := func(d time.Duration) string {
330 | 		return color.CyanString("%7dms", int(d/time.Millisecond))
331 | 	}
332 | 
333 | 	fmtb := func(d time.Duration) string {
334 | 		return color.CyanString("%-9s", strconv.Itoa(int(d/time.Millisecond))+"ms")
335 | 	}
336 | 
337 | 	colorize := func(s string) string {
338 | 		v := strings.Split(s, "\n")
339 | 		v[0] = grayscale(16)(v[0])
340 | 		return strings.Join(v, "\n")
341 | 	}
342 | 
343 | 	fmt.Println()
344 | 
345 | 	switch url.Scheme {
346 | 	case "https":
347 | 		printf(colorize(httpsTemplate),
348 | 			fmta(t1.Sub(t0)), // dns lookup
349 | 			fmta(t2.Sub(t1)), // tcp connection
350 | 			fmta(t6.Sub(t5)), // tls handshake
351 | 			fmta(t4.Sub(t3)), // server processing
352 | 			fmta(t7.Sub(t4)), // content transfer
353 | 			fmtb(t1.Sub(t0)), // namelookup
354 | 			fmtb(t2.Sub(t0)), // connect
355 | 			fmtb(t3.Sub(t0)), // pretransfer
356 | 			fmtb(t4.Sub(t0)), // starttransfer
357 | 			fmtb(t7.Sub(t0)), // total
358 | 		)
359 | 	case "http":
360 | 		printf(colorize(httpTemplate),
361 | 			fmta(t1.Sub(t0)), // dns lookup
362 | 			fmta(t3.Sub(t1)), // tcp connection
363 | 			fmta(t4.Sub(t3)), // server processing
364 | 			fmta(t7.Sub(t4)), // content transfer
365 | 			fmtb(t1.Sub(t0)), // namelookup
366 | 			fmtb(t3.Sub(t0)), // connect
367 | 			fmtb(t4.Sub(t0)), // starttransfer
368 | 			fmtb(t7.Sub(t0)), // total
369 | 		)
370 | 	}
371 | 
372 | 	if followRedirects && isRedirect(resp) {
373 | 		loc, err := resp.Location()
374 | 		if err != nil {
375 | 			if err == http.ErrNoLocation {
376 | 				// 30x but no Location to follow, give up.
377 | 				return
378 | 			}
379 | 			log.Fatalf("unable to follow redirect: %v", err)
380 | 		}
381 | 
382 | 		redirectsFollowed++
383 | 		if redirectsFollowed > maxRedirects {
384 | 			log.Fatalf("maximum number of redirects (%d) followed", maxRedirects)
385 | 		}
386 | 
387 | 		visit(loc)
388 | 	}
389 | }
390 | 
391 | func isRedirect(resp *http.Response) bool {
392 | 	return resp.StatusCode > 299 && resp.StatusCode < 400
393 | }
394 | 
395 | func newRequest(method string, url *url.URL, body string) *http.Request {
396 | 	req, err := http.NewRequest(method, url.String(), createBody(body))
397 | 	if err != nil {
398 | 		log.Fatalf("unable to create request: %v", err)
399 | 	}
400 | 	for _, h := range httpHeaders {
401 | 		k, v := headerKeyValue(h)
402 | 		if strings.EqualFold(k, "host") {
403 | 			req.Host = v
404 | 			continue
405 | 		}
406 | 		req.Header.Add(k, v)
407 | 	}
408 | 	return req
409 | }
410 | 
411 | func createBody(body string) io.Reader {
412 | 	if strings.HasPrefix(body, "@") {
413 | 		filename := body[1:]
414 | 		f, err := os.Open(filename)
415 | 		if err != nil {
416 | 			log.Fatalf("failed to open data file %s: %v", filename, err)
417 | 		}
418 | 		return f
419 | 	}
420 | 	return strings.NewReader(body)
421 | }
422 | 
423 | // getFilenameFromHeaders tries to automatically determine the output filename,
424 | // when saving to disk, based on the Content-Disposition header.
425 | // If the header is not present, or it does not contain enough information to
426 | // determine which filename to use, this function returns "".
427 | func getFilenameFromHeaders(headers http.Header) string {
428 | 	// if the Content-Disposition header is set parse it
429 | 	if hdr := headers.Get("Content-Disposition"); hdr != "" {
430 | 		// pull the media type, and subsequent params, from
431 | 		// the body of the header field
432 | 		mt, params, err := mime.ParseMediaType(hdr)
433 | 
434 | 		// if there was no error and the media type is attachment
435 | 		if err == nil && mt == "attachment" {
436 | 			if filename := params["filename"]; filename != "" {
437 | 				return filename
438 | 			}
439 | 		}
440 | 	}
441 | 
442 | 	// return an empty string if we were unable to determine the filename
443 | 	return ""
444 | }
445 | 
446 | // readResponseBody consumes the body of the response.
447 | // readResponseBody returns an informational message about the
448 | // disposition of the response body's contents.
449 | func readResponseBody(req *http.Request, resp *http.Response) string {
450 | 	if isRedirect(resp) || req.Method == http.MethodHead {
451 | 		return ""
452 | 	}
453 | 
454 | 	w := io.Discard
455 | 	msg := color.CyanString("Body discarded")
456 | 
457 | 	if saveOutput || outputFile != "" {
458 | 		filename := outputFile
459 | 
460 | 		if saveOutput {
461 | 			// try to get the filename from the Content-Disposition header
462 | 			// otherwise fall back to the RequestURI
463 | 			if filename = getFilenameFromHeaders(resp.Header); filename == "" {
464 | 				filename = path.Base(req.URL.RequestURI())
465 | 			}
466 | 
467 | 			if filename == "/" {
468 | 				log.Fatalf("No remote filename; specify output filename with -o to save response body")
469 | 			}
470 | 		}
471 | 
472 | 		f, err := os.Create(filename)
473 | 		if err != nil {
474 | 			log.Fatalf("unable to create file %s: %v", filename, err)
475 | 		}
476 | 		defer f.Close()
477 | 		w = f
478 | 		msg = color.CyanString("Body read")
479 | 	}
480 | 
481 | 	if _, err := io.Copy(w, resp.Body); err != nil && w != io.Discard {
482 | 		log.Fatalf("failed to read response body: %v", err)
483 | 	}
484 | 
485 | 	return msg
486 | }
487 | 
488 | type headers []string
489 | 
490 | func (h headers) String() string {
491 | 	var o []string
492 | 	for _, v := range h {
493 | 		o = append(o, "-H "+v)
494 | 	}
495 | 	return strings.Join(o, " ")
496 | }
497 | 
498 | func (h *headers) Set(v string) error {
499 | 	*h = append(*h, v)
500 | 	return nil
501 | }
502 | 
503 | func (h headers) Len() int      { return len(h) }
504 | func (h headers) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
505 | func (h headers) Less(i, j int) bool {
506 | 	a, b := h[i], h[j]
507 | 
508 | 	// server always sorts at the top
509 | 	if a == "Server" {
510 | 		return true
511 | 	}
512 | 	if b == "Server" {
513 | 		return false
514 | 	}
515 | 
516 | 	endtoend := func(n string) bool {
517 | 		// https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.1
518 | 		switch n {
519 | 		case "Connection",
520 | 			"Keep-Alive",
521 | 			"Proxy-Authenticate",
522 | 			"Proxy-Authorization",
523 | 			"TE",
524 | 			"Trailers",
525 | 			"Transfer-Encoding",
526 | 			"Upgrade":
527 | 			return false
528 | 		default:
529 | 			return true
530 | 		}
531 | 	}
532 | 
533 | 	x, y := endtoend(a), endtoend(b)
534 | 	if x == y {
535 | 		// both are of the same class
536 | 		return a < b
537 | 	}
538 | 	return x
539 | }
540 | 


--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
 1 | package main
 2 | 
 3 | import "testing"
 4 | 
 5 | func TestParseURL(t *testing.T) {
 6 | 	tests := []struct {
 7 | 		in   string
 8 | 		want string
 9 | 	}{
10 | 		{"https://golang.org", "https://golang.org"},
11 | 		{"https://golang.org:443/test", "https://golang.org:443/test"},
12 | 		{"localhost:8080/test", "https://localhost:8080/test"},
13 | 		{"localhost:80/test", "http://localhost:80/test"},
14 | 		{"//localhost:8080/test", "https://localhost:8080/test"},
15 | 		{"//localhost:80/test", "http://localhost:80/test"},
16 | 	}
17 | 
18 | 	for _, test := range tests {
19 | 		u := parseURL(test.in)
20 | 		if u.String() != test.want {
21 | 			t.Errorf("Given: %s\nwant: %s\ngot: %s", test.in, test.want, u.String())
22 | 		}
23 | 	}
24 | }
25 | 


--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davecheney/httpstat/c64e6dee9402449a92375ec361f6c9ebaddc9a9e/screenshot.png


--------------------------------------------------------------------------------