├── .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 --------------------------------------------------------------------------------