Repository: davecheney/httpstat Branch: master Commit: c64e6dee9402 Files: 9 Total size: 20.5 KB Directory structure: gitextract__z7b9of5/ ├── .github/ │ └── workflows/ │ └── push.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── main.go └── main_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/push.yml ================================================ on: push: branches: - master pull_request: branches: - master name: Push jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Test run: go test -race ./... - name: Vet run: go vet ./... - name: Mod verify run: go mod verify ================================================ FILE: .gitignore ================================================ # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so # Folders _obj _test # Architecture specific extensions/prefixes *.[568vq] [568vq].out *.cgo1.go *.cgo2.c _cgo_defun.c _cgo_gotypes.go _cgo_export.* _testmain.go *.exe *.test *.prof httpstat ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2016 Dave Cheney 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. ================================================ FILE: Makefile ================================================ TARGETS = linux-386 linux-amd64 linux-arm linux-arm64 darwin-amd64 windows-386 windows-amd64 COMMAND_NAME = httpstat PACKAGE_NAME = github.com/davecheney/$(COMMAND_NAME) LDFLAGS = -ldflags=-X=main.version=$(VERSION) 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)))) release: check-env $(OBJECTS) ## Build release binaries (requires VERSION) clean: check-env ## Remove release binaries rm $(OBJECTS) $(OBJECTS): $(wildcard *.go) env GOOS=`echo $@ | cut -d'-' -f2` GOARCH=`echo $@ | cut -d'-' -f3 | cut -d'.' -f 1` go build -o $@ $(LDFLAGS) $(PACKAGE_NAME) .PHONY: help check-env check-env: ifndef VERSION $(error VERSION is undefined) endif help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .DEFAULT_GOAL := help ================================================ FILE: README.md ================================================ # 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) ![Shameless](./screenshot.png) Imitation is the sincerest form of flattery. But seriously, https://github.com/reorx/httpstat is the new hotness, and this is a shameless rip off. ## Installation `httpstat` requires Go 1.20 or later. ``` go install github.com/davecheney/httpstat@latest ``` ## Usage ``` httpstat https://example.com/ ``` ## Features - Windows/BSD/Linux supported. - HTTP and HTTPS are supported, for self signed certificates use `-k`. - Skip timing the body of a response with `-I`. - Follow 30x redirects with `-L`. - Change HTTP method with `-X METHOD`. - Provide a `PUT` or `POST` request body with `-d string`. To supply the `PUT` or `POST` body as a file, use `-d @filename`. - Add extra request headers with `-H 'Name: value'`. - 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. - HTTP/HTTPS proxies supported via the usual `HTTP_PROXY`/`HTTPS_PROXY` env vars (as well as lower case variants). - Supply your own client side certificate with `-E cert.pem`. ## Contributing Bug reports are most welcome, but with the exception of #5, this project is closed. Pull requests must include a `fixes #NNN` or `updates #NNN` comment. 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. ================================================ FILE: go.mod ================================================ module github.com/davecheney/httpstat go 1.23 require github.com/fatih/color v1.18.0 require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/sys v0.25.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= ================================================ FILE: main.go ================================================ package main import ( "context" "crypto/tls" "encoding/pem" "flag" "fmt" "io" "log" "mime" "net" "net/http" "net/http/httptrace" "net/url" "os" "path" "runtime" "sort" "strconv" "strings" "time" "github.com/fatih/color" ) const ( httpsTemplate = `` + ` DNS Lookup TCP Connection TLS Handshake Server Processing Content Transfer` + "\n" + `[%s | %s | %s | %s | %s ]` + "\n" + ` | | | | |` + "\n" + ` namelookup:%s | | | |` + "\n" + ` connect:%s | | |` + "\n" + ` pretransfer:%s | |` + "\n" + ` starttransfer:%s |` + "\n" + ` total:%s` + "\n" httpTemplate = `` + ` DNS Lookup TCP Connection Server Processing Content Transfer` + "\n" + `[ %s | %s | %s | %s ]` + "\n" + ` | | | |` + "\n" + ` namelookup:%s | | |` + "\n" + ` connect:%s | |` + "\n" + ` starttransfer:%s |` + "\n" + ` total:%s` + "\n" ) var ( // Command line flags. httpMethod string postBody string followRedirects bool onlyHeader bool insecure bool httpHeaders headers saveOutput bool outputFile string showVersion bool clientCertFile string fourOnly bool sixOnly bool // number of redirects followed redirectsFollowed int version = "devel" // for -v flag, updated during the release process with -ldflags=-X=main.version=... ) const maxRedirects = 10 func init() { flag.StringVar(&httpMethod, "X", "GET", "HTTP method to use") flag.StringVar(&postBody, "d", "", "the body of a POST or PUT request; from file use @filename") flag.BoolVar(&followRedirects, "L", false, "follow 30x redirects") flag.BoolVar(&onlyHeader, "I", false, "don't read body of request") flag.BoolVar(&insecure, "k", false, "allow insecure SSL connections") flag.Var(&httpHeaders, "H", "set HTTP header; repeatable: -H 'Accept: ...' -H 'Range: ...'") flag.BoolVar(&saveOutput, "O", false, "save body as remote filename") flag.StringVar(&outputFile, "o", "", "output file for body") flag.BoolVar(&showVersion, "v", false, "print version number") flag.StringVar(&clientCertFile, "E", "", "client cert file for tls config") flag.BoolVar(&fourOnly, "4", false, "resolve IPv4 addresses only") flag.BoolVar(&sixOnly, "6", false, "resolve IPv6 addresses only") flag.Usage = usage } func usage() { fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] URL\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "OPTIONS:") flag.PrintDefaults() fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "ENVIRONMENT:") fmt.Fprintln(os.Stderr, " HTTP_PROXY proxy for HTTP requests; complete URL or HOST[:PORT]") fmt.Fprintln(os.Stderr, " used for HTTPS requests if HTTPS_PROXY undefined") fmt.Fprintln(os.Stderr, " HTTPS_PROXY proxy for HTTPS requests; complete URL or HOST[:PORT]") fmt.Fprintln(os.Stderr, " NO_PROXY comma-separated list of hosts to exclude from proxy") } func printf(format string, a ...interface{}) (n int, err error) { return fmt.Fprintf(color.Output, format, a...) } func grayscale(code color.Attribute) func(string, ...interface{}) string { return color.New(code + 232).SprintfFunc() } func main() { flag.Parse() if showVersion { fmt.Printf("%s %s (runtime: %s)\n", os.Args[0], version, runtime.Version()) os.Exit(0) } if fourOnly && sixOnly { fmt.Fprintf(os.Stderr, "%s: Only one of -4 and -6 may be specified\n", os.Args[0]) os.Exit(-1) } args := flag.Args() if len(args) != 1 { flag.Usage() os.Exit(2) } if (httpMethod == "POST" || httpMethod == "PUT") && postBody == "" { log.Fatal("must supply post body using -d when POST or PUT is used") } if onlyHeader { httpMethod = "HEAD" } url := parseURL(args[0]) visit(url) } // readClientCert - helper function to read client certificate // from pem formatted file func readClientCert(filename string) []tls.Certificate { if filename == "" { return nil } var ( pkeyPem []byte certPem []byte ) // read client certificate file (must include client private key and certificate) certFileBytes, err := os.ReadFile(filename) if err != nil { log.Fatalf("failed to read client certificate file: %v", err) } for { block, rest := pem.Decode(certFileBytes) if block == nil { break } certFileBytes = rest if strings.HasSuffix(block.Type, "PRIVATE KEY") { pkeyPem = pem.EncodeToMemory(block) } if strings.HasSuffix(block.Type, "CERTIFICATE") { certPem = pem.EncodeToMemory(block) } } cert, err := tls.X509KeyPair(certPem, pkeyPem) if err != nil { log.Fatalf("unable to load client cert and key pair: %v", err) } return []tls.Certificate{cert} } func parseURL(uri string) *url.URL { if !strings.Contains(uri, "://") && !strings.HasPrefix(uri, "//") { uri = "//" + uri } url, err := url.Parse(uri) if err != nil { log.Fatalf("could not parse url %q: %v", uri, err) } if url.Scheme == "" { url.Scheme = "http" if !strings.HasSuffix(url.Host, ":80") { url.Scheme += "s" } } return url } func headerKeyValue(h string) (string, string) { i := strings.Index(h, ":") if i == -1 { log.Fatalf("Header '%s' has invalid format, missing ':'", h) } return strings.TrimRight(h[:i], " "), strings.TrimLeft(h[i:], " :") } func dialContext(network string) func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, _, addr string) (net.Conn, error) { return (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: false, }).DialContext(ctx, network, addr) } } // visit visits a url and times the interaction. // If the response is a 30x, visit follows the redirect. func visit(url *url.URL) { req := newRequest(httpMethod, url, postBody) var t0, t1, t2, t3, t4, t5, t6 time.Time trace := &httptrace.ClientTrace{ DNSStart: func(_ httptrace.DNSStartInfo) { t0 = time.Now() }, DNSDone: func(_ httptrace.DNSDoneInfo) { t1 = time.Now() }, ConnectStart: func(_, _ string) { if t1.IsZero() { // connecting to IP t1 = time.Now() } }, ConnectDone: func(net, addr string, err error) { if err != nil { log.Fatalf("unable to connect to host %v: %v", addr, err) } t2 = time.Now() printf("\n%s%s\n", color.GreenString("Connected to "), color.CyanString(addr)) }, GotConn: func(_ httptrace.GotConnInfo) { t3 = time.Now() }, GotFirstResponseByte: func() { t4 = time.Now() }, TLSHandshakeStart: func() { t5 = time.Now() }, TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { t6 = time.Now() }, } req = req.WithContext(httptrace.WithClientTrace(context.Background(), trace)) tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: true, } switch { case fourOnly: tr.DialContext = dialContext("tcp4") case sixOnly: tr.DialContext = dialContext("tcp6") } switch url.Scheme { case "https": host, _, err := net.SplitHostPort(req.Host) if err != nil { host = req.Host } tr.TLSClientConfig = &tls.Config{ ServerName: host, InsecureSkipVerify: insecure, Certificates: readClientCert(clientCertFile), MinVersion: tls.VersionTLS12, } } client := &http.Client{ Transport: tr, CheckRedirect: func(req *http.Request, via []*http.Request) error { // always refuse to follow redirects, visit does that // manually if required. return http.ErrUseLastResponse }, } resp, err := client.Do(req) if err != nil { log.Fatalf("failed to read response: %v", err) } // Print SSL/TLS version which is used for connection connectedVia := "plaintext" if resp.TLS != nil { switch resp.TLS.Version { case tls.VersionTLS12: connectedVia = "TLSv1.2" case tls.VersionTLS13: connectedVia = "TLSv1.3" } } printf("\n%s %s\n", color.GreenString("Connected via"), color.CyanString("%s", connectedVia)) bodyMsg := readResponseBody(req, resp) resp.Body.Close() t7 := time.Now() // after read body if t0.IsZero() { // we skipped DNS t0 = t1 } // print status line and headers printf("\n%s%s%s\n", color.GreenString("HTTP"), grayscale(14)("/"), color.CyanString("%d.%d %s", resp.ProtoMajor, resp.ProtoMinor, resp.Status)) names := make([]string, 0, len(resp.Header)) for k := range resp.Header { names = append(names, k) } sort.Sort(headers(names)) for _, k := range names { printf("%s %s\n", grayscale(14)(k+":"), color.CyanString(strings.Join(resp.Header[k], ","))) } if bodyMsg != "" { printf("\n%s\n", bodyMsg) } fmta := func(d time.Duration) string { return color.CyanString("%7dms", int(d/time.Millisecond)) } fmtb := func(d time.Duration) string { return color.CyanString("%-9s", strconv.Itoa(int(d/time.Millisecond))+"ms") } colorize := func(s string) string { v := strings.Split(s, "\n") v[0] = grayscale(16)(v[0]) return strings.Join(v, "\n") } fmt.Println() switch url.Scheme { case "https": printf(colorize(httpsTemplate), fmta(t1.Sub(t0)), // dns lookup fmta(t2.Sub(t1)), // tcp connection fmta(t6.Sub(t5)), // tls handshake fmta(t4.Sub(t3)), // server processing fmta(t7.Sub(t4)), // content transfer fmtb(t1.Sub(t0)), // namelookup fmtb(t2.Sub(t0)), // connect fmtb(t3.Sub(t0)), // pretransfer fmtb(t4.Sub(t0)), // starttransfer fmtb(t7.Sub(t0)), // total ) case "http": printf(colorize(httpTemplate), fmta(t1.Sub(t0)), // dns lookup fmta(t3.Sub(t1)), // tcp connection fmta(t4.Sub(t3)), // server processing fmta(t7.Sub(t4)), // content transfer fmtb(t1.Sub(t0)), // namelookup fmtb(t3.Sub(t0)), // connect fmtb(t4.Sub(t0)), // starttransfer fmtb(t7.Sub(t0)), // total ) } if followRedirects && isRedirect(resp) { loc, err := resp.Location() if err != nil { if err == http.ErrNoLocation { // 30x but no Location to follow, give up. return } log.Fatalf("unable to follow redirect: %v", err) } redirectsFollowed++ if redirectsFollowed > maxRedirects { log.Fatalf("maximum number of redirects (%d) followed", maxRedirects) } visit(loc) } } func isRedirect(resp *http.Response) bool { return resp.StatusCode > 299 && resp.StatusCode < 400 } func newRequest(method string, url *url.URL, body string) *http.Request { req, err := http.NewRequest(method, url.String(), createBody(body)) if err != nil { log.Fatalf("unable to create request: %v", err) } for _, h := range httpHeaders { k, v := headerKeyValue(h) if strings.EqualFold(k, "host") { req.Host = v continue } req.Header.Add(k, v) } return req } func createBody(body string) io.Reader { if strings.HasPrefix(body, "@") { filename := body[1:] f, err := os.Open(filename) if err != nil { log.Fatalf("failed to open data file %s: %v", filename, err) } return f } return strings.NewReader(body) } // getFilenameFromHeaders tries to automatically determine the output filename, // when saving to disk, based on the Content-Disposition header. // If the header is not present, or it does not contain enough information to // determine which filename to use, this function returns "". func getFilenameFromHeaders(headers http.Header) string { // if the Content-Disposition header is set parse it if hdr := headers.Get("Content-Disposition"); hdr != "" { // pull the media type, and subsequent params, from // the body of the header field mt, params, err := mime.ParseMediaType(hdr) // if there was no error and the media type is attachment if err == nil && mt == "attachment" { if filename := params["filename"]; filename != "" { return filename } } } // return an empty string if we were unable to determine the filename return "" } // readResponseBody consumes the body of the response. // readResponseBody returns an informational message about the // disposition of the response body's contents. func readResponseBody(req *http.Request, resp *http.Response) string { if isRedirect(resp) || req.Method == http.MethodHead { return "" } w := io.Discard msg := color.CyanString("Body discarded") if saveOutput || outputFile != "" { filename := outputFile if saveOutput { // try to get the filename from the Content-Disposition header // otherwise fall back to the RequestURI if filename = getFilenameFromHeaders(resp.Header); filename == "" { filename = path.Base(req.URL.RequestURI()) } if filename == "/" { log.Fatalf("No remote filename; specify output filename with -o to save response body") } } f, err := os.Create(filename) if err != nil { log.Fatalf("unable to create file %s: %v", filename, err) } defer f.Close() w = f msg = color.CyanString("Body read") } if _, err := io.Copy(w, resp.Body); err != nil && w != io.Discard { log.Fatalf("failed to read response body: %v", err) } return msg } type headers []string func (h headers) String() string { var o []string for _, v := range h { o = append(o, "-H "+v) } return strings.Join(o, " ") } func (h *headers) Set(v string) error { *h = append(*h, v) return nil } func (h headers) Len() int { return len(h) } func (h headers) Swap(i, j int) { h[i], h[j] = h[j], h[i] } func (h headers) Less(i, j int) bool { a, b := h[i], h[j] // server always sorts at the top if a == "Server" { return true } if b == "Server" { return false } endtoend := func(n string) bool { // https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.1 switch n { case "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "TE", "Trailers", "Transfer-Encoding", "Upgrade": return false default: return true } } x, y := endtoend(a), endtoend(b) if x == y { // both are of the same class return a < b } return x } ================================================ FILE: main_test.go ================================================ package main import "testing" func TestParseURL(t *testing.T) { tests := []struct { in string want string }{ {"https://golang.org", "https://golang.org"}, {"https://golang.org:443/test", "https://golang.org:443/test"}, {"localhost:8080/test", "https://localhost:8080/test"}, {"localhost:80/test", "http://localhost:80/test"}, {"//localhost:8080/test", "https://localhost:8080/test"}, {"//localhost:80/test", "http://localhost:80/test"}, } for _, test := range tests { u := parseURL(test.in) if u.String() != test.want { t.Errorf("Given: %s\nwant: %s\ngot: %s", test.in, test.want, u.String()) } } }