├── screenshot.png ├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE ├── README.md └── main.go /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ble/httpstat/master/screenshot.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/github.com/fatih/color"] 2 | path = vendor/github.com/fatih/color 3 | url = https://github.com/fatih/color 4 | [submodule "vendor/github.com/mattn/go-colorable"] 5 | path = vendor/github.com/mattn/go-colorable 6 | url = https://github.com/mattn/go-colorable 7 | [submodule "vendor/github.com/mattn/go-isatty"] 8 | path = vendor/github.com/mattn/go-isatty 9 | url = https://github.com/mattn/go-isatty 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go_import_path: github.com/davecheney/httpstat 3 | go: 4 | - 1.7.1 5 | - tip 6 | 7 | sudo: false 8 | 9 | install: 10 | - echo "skipping travis' default go get -t -v to ensure we're only using vendor/ dependencies" 11 | 12 | script: 13 | - go vet $(go list ./... | grep -v "vendor") 14 | - go build github.com/davecheney/httpstat 15 | - ./httpstat http://dave.cheney.net/ 16 | - ./httpstat http://dave.cheney.net:80/ 17 | - ./httpstat -X POST -d 'post' http://httpbin.org/post 18 | - ./httpstat -X DELETE http://httpbin.org/delete 19 | - ./httpstat https://www.google.com/ 20 | - ./httpstat https://www.google.com:443/ 21 | - ./httpstat -L http://httpbin.org/redirect-to?url=https://httpbin.org/relative-redirect/1 22 | - ./httpstat -I http://example.com/ 23 | - ./httpstat -I -L http://httpbin.org/redirect/1 24 | - ./httpstat https://www.apple.com/ 25 | - ./httpstat -H Accept:\ application/vnd.heroku+json\;\ version=3 https://api.heroku.com/schema 26 | - ./httpstat -O http://example.com/file && stat file 27 | - ./httpstat -o custom http://example.com/file && stat custom 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpstat 2 | 3 | [![Build Status](https://travis-ci.org/davecheney/httpstat.svg?branch=master)](https://travis-ci.org/davecheney/httpstat) 4 | 5 | ![Shameless](./screenshot.png) 6 | 7 | Imitation is the sincerest form of flattery. 8 | 9 | But seriously, https://github.com/reorx/httpstat is the new hotness, and this is a shameless rip off. 10 | 11 | ## Installation 12 | ``` 13 | $ go get -u github.com/davecheney/httpstat 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 | 30 | ## We don't need no stinking curl 31 | 32 | `httpstat.py` is a wrapper around `curl(1)`, which is all fine and good, but what if you don't have `curl(1)` or `python(1)` installed? 33 | 34 | ## TODO 35 | 36 | This project is aiming for a 1.0 release on the 3rd of October. Open issues for this release are tagged with [this milestone](https://github.com/davecheney/httpstat/milestone/1). 37 | 38 | Any open issue not tagged for the [stable release milestone](https://github.com/davecheney/httpstat/milestone/1) will be addressed after the 1.0 release. 39 | 40 | ## Contributing 41 | 42 | Bug reports and feature requests are welcome. 43 | 44 | Pull requests are most welcome but must include a `fixes #NNN` or `updates #NNN` comment. 45 | 46 | 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. 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "path" 16 | "sort" 17 | "strconv" 18 | "strings" 19 | "time" 20 | 21 | "github.com/fatih/color" 22 | ) 23 | 24 | const ( 25 | HTTPS_TEMPLATE = `` + 26 | ` DNS Lookup TCP Connection TLS Handshake Server Processing Content Transfer` + "\n" + 27 | `[%s | %s | %s | %s | %s ]` + "\n" + 28 | ` | | | | |` + "\n" + 29 | ` namelookup:%s | | | |` + "\n" + 30 | ` connect:%s | | |` + "\n" + 31 | ` pretransfer:%s | |` + "\n" + 32 | ` starttransfer:%s |` + "\n" + 33 | ` total:%s` + "\n" 34 | 35 | HTTP_TEMPLATE = `` + 36 | ` DNS Lookup TCP Connection Server Processing Content Transfer` + "\n" + 37 | `[ %s | %s | %s | %s ]` + "\n" + 38 | ` | | | |` + "\n" + 39 | ` namelookup:%s | | |` + "\n" + 40 | ` connect:%s | |` + "\n" + 41 | ` starttransfer:%s |` + "\n" + 42 | ` total:%s` + "\n" 43 | ) 44 | 45 | var ( 46 | requestBody io.Reader 47 | 48 | grayscale = func(code int) func(string) string { 49 | if color.NoColor { 50 | return func(s string) string { return s } 51 | } 52 | return func(s string) string { 53 | return fmt.Sprintf("\x1b[;38;5;%dm%s\x1b[0m", code+232, s) 54 | } 55 | } 56 | 57 | // Command line flags. 58 | httpMethod string 59 | postBody string 60 | followRedirects bool 61 | onlyHeader bool 62 | insecure bool 63 | httpHeaders headers 64 | saveOutput bool 65 | outputFile string 66 | 67 | // number of redirects followed 68 | redirectsFollowed int 69 | 70 | usage = fmt.Sprintf("usage: %s URL", os.Args[0]) 71 | ) 72 | 73 | const maxRedirects = 10 74 | 75 | func init() { 76 | flag.StringVar(&httpMethod, "X", "GET", "HTTP method to use") 77 | flag.StringVar(&postBody, "d", "", "the body of a POST or PUT request") 78 | flag.BoolVar(&followRedirects, "L", false, "follow 30x redirects") 79 | flag.BoolVar(&onlyHeader, "I", false, "don't read body of request") 80 | flag.BoolVar(&insecure, "k", false, "allow insecure SSL connections") 81 | flag.Var(&httpHeaders, "H", "HTTP Header(s) to set. Can be used multiple times. -H 'Accept:...' -H 'Range:....'") 82 | flag.BoolVar(&saveOutput, "O", false, "Save body as remote filename") 83 | flag.StringVar(&outputFile, "o", "", "output file for body") 84 | 85 | flag.Usage = func() { 86 | os.Stderr.WriteString(usage + "\n") 87 | flag.PrintDefaults() 88 | os.Exit(2) 89 | } 90 | } 91 | 92 | func main() { 93 | flag.Parse() 94 | 95 | args := flag.Args() 96 | if len(args) != 1 { 97 | log.Fatalf(usage) 98 | } 99 | 100 | url, err := url.Parse(args[0]) 101 | if err != nil { 102 | log.Fatalf("could not parse url %q: %v", args[0], err) 103 | } 104 | visit(url) 105 | } 106 | 107 | func headerKeyValue(h string) (string, string) { 108 | i := strings.Index(h, ":") 109 | if i == -1 { 110 | log.Fatalf("Header '%s' has invalid format, missing ':'", h) 111 | } 112 | return strings.TrimRight(h[:i], " "), strings.TrimLeft(h[i:], " :") 113 | } 114 | 115 | // visit visits a url and times the interaction. 116 | // If the response is a 30x, visit follows the redirect. 117 | func visit(url *url.URL) { 118 | scheme := url.Scheme 119 | hostport := url.Host 120 | host, port := func() (string, string) { 121 | host, port, err := net.SplitHostPort(hostport) 122 | if err != nil { 123 | host = hostport 124 | } 125 | switch scheme { 126 | case "https": 127 | if port == "" { 128 | port = "443" 129 | } 130 | case "http": 131 | if port == "" { 132 | port = "80" 133 | } 134 | default: 135 | log.Fatalf("unsupported url scheme %q", scheme) 136 | } 137 | return host, port 138 | }() 139 | 140 | t0 := time.Now() // before dns resolution 141 | raddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%s", host, port)) 142 | if err != nil { 143 | log.Fatalf("unable to resolve host: %v", err) 144 | } 145 | 146 | var conn net.Conn 147 | t1 := time.Now() // after dns resolution, before connect 148 | conn, err = net.DialTCP("tcp", nil, raddr) 149 | if err != nil { 150 | log.Fatalf("unable to connect to host %vv %v", raddr, err) 151 | } 152 | fmt.Printf("\n%s%s\n", color.GreenString("Connected to "), color.CyanString(raddr.String())) 153 | 154 | var t2 time.Time // after connect, before TLS handshake 155 | if scheme == "https" { 156 | t2 = time.Now() 157 | c := tls.Client(conn, &tls.Config{ 158 | ServerName: host, 159 | InsecureSkipVerify: insecure, 160 | }) 161 | if err := c.Handshake(); err != nil { 162 | log.Fatalf("unable to negotiate TLS handshake: %v", err) 163 | } 164 | conn = c 165 | } 166 | 167 | t3 := time.Now() // after connect, before request 168 | if onlyHeader { 169 | httpMethod = "HEAD" 170 | } 171 | if (httpMethod == "POST" || httpMethod == "PUT") && postBody == "" { 172 | log.Fatal("must supply post body using -d when POST or PUT is used") 173 | } 174 | req, err := http.NewRequest(httpMethod, url.String(), createBody(postBody)) 175 | if err != nil { 176 | log.Fatalf("unable to create request: %v", err) 177 | } 178 | for _, h := range httpHeaders { 179 | req.Header.Add(headerKeyValue(h)) 180 | } 181 | 182 | if err := req.Write(conn); err != nil { 183 | log.Fatalf("failed to write request: %v", err) 184 | } 185 | 186 | t4 := time.Now() // after request, before read response 187 | resp, err := http.ReadResponse(bufio.NewReader(conn), req) 188 | if err != nil { 189 | log.Fatalf("failed to read response: %v", err) 190 | } 191 | 192 | t5 := time.Now() // after read response 193 | bodyMsg := readResponseBody(req, resp) 194 | resp.Body.Close() 195 | 196 | t6 := time.Now() // after read body 197 | 198 | // print status line and headers 199 | fmt.Printf("\n%s%s%s\n", color.GreenString("HTTP"), grayscale(14)("/"), color.CyanString("%d.%d %s", resp.ProtoMajor, resp.ProtoMinor, resp.Status)) 200 | 201 | names := make([]string, 0, len(resp.Header)) 202 | for k := range resp.Header { 203 | names = append(names, k) 204 | } 205 | sort.Sort(headers(names)) 206 | for _, k := range names { 207 | fmt.Println(grayscale(14)(k+":"), color.CyanString(strings.Join(resp.Header[k], ","))) 208 | } 209 | 210 | if bodyMsg != "" { 211 | fmt.Printf("\n%s\n", bodyMsg) 212 | } 213 | 214 | fmta := func(d time.Duration) string { 215 | return color.CyanString("%7dms", int(d/time.Millisecond)) 216 | } 217 | 218 | fmtb := func(d time.Duration) string { 219 | return color.CyanString("%-9s", strconv.Itoa(int(d/time.Millisecond))+"ms") 220 | } 221 | 222 | colorize := func(s string) string { 223 | v := strings.Split(s, "\n") 224 | v[0] = grayscale(16)(v[0]) 225 | return strings.Join(v, "\n") 226 | } 227 | 228 | fmt.Println() 229 | 230 | switch scheme { 231 | case "https": 232 | fmt.Printf(colorize(HTTPS_TEMPLATE), 233 | fmta(t1.Sub(t0)), // dns lookup 234 | fmta(t2.Sub(t1)), // tcp connection 235 | fmta(t3.Sub(t2)), // tls handshake 236 | fmta(t5.Sub(t4)), // server processing 237 | fmta(t6.Sub(t5)), // content transfer 238 | fmtb(t1.Sub(t0)), // namelookup 239 | fmtb(t2.Sub(t0)), // connect 240 | fmtb(t3.Sub(t0)), // pretransfer 241 | fmtb(t5.Sub(t0)), // starttransfer 242 | fmtb(t6.Sub(t0)), // total 243 | ) 244 | case "http": 245 | fmt.Printf(colorize(HTTP_TEMPLATE), 246 | fmta(t1.Sub(t0)), // dns lookup 247 | fmta(t3.Sub(t1)), // tcp connection 248 | fmta(t5.Sub(t3)), // server processing 249 | fmta(t6.Sub(t5)), // content transfer 250 | fmtb(t1.Sub(t0)), // namelookup 251 | fmtb(t3.Sub(t0)), // connect 252 | fmtb(t5.Sub(t0)), // starttransfer 253 | fmtb(t6.Sub(t0)), // total 254 | ) 255 | } 256 | 257 | if followRedirects && resp.StatusCode > 299 && resp.StatusCode < 400 { 258 | loc, err := resp.Location() 259 | if err != nil { 260 | if err == http.ErrNoLocation { 261 | // 30x but no Location to follow, give up. 262 | return 263 | } 264 | log.Fatalf("unable to follow redirect: %v", err) 265 | } 266 | 267 | redirectsFollowed++ 268 | if redirectsFollowed > maxRedirects { 269 | log.Fatalf("maximum number of redirects (%d) followed\n", maxRedirects) 270 | } 271 | 272 | visit(loc) 273 | } 274 | } 275 | 276 | func createBody(body string) io.Reader { 277 | if strings.HasPrefix(body, "@") { 278 | filename := body[1:] 279 | f, err := os.Open(filename) 280 | if err != nil { 281 | log.Fatalf("failed to open data file %s: %v", filename, err) 282 | } 283 | return f 284 | } 285 | return strings.NewReader(body) 286 | } 287 | 288 | // readResponseBody consumes the body of the response. 289 | // readResponseBody returns an informational message about the 290 | // disposition of the response body's contents. 291 | func readResponseBody(req *http.Request, resp *http.Response) string { 292 | // TODO(dfc) do not process body if status code is in the 30x range 293 | 294 | // TODO(dfc) if we issued a HEAD request, there is no body to process. 295 | 296 | w := ioutil.Discard 297 | msg := color.CyanString("Body discarded") 298 | 299 | if saveOutput == true || outputFile != "" { 300 | filename := outputFile 301 | 302 | if saveOutput == true { 303 | // TODO(dfc) handle Content-Disposition: attachment 304 | // TODO(dfc) handle the case where someone calls 305 | // httpstat -O http://example.com/ 306 | filename = path.Base(req.URL.RequestURI()) 307 | } 308 | 309 | var err error 310 | w, err = os.Create(filename) 311 | if err != nil { 312 | log.Fatalf("unable to create file %s", outputFile) 313 | } 314 | msg = color.CyanString("Body read") 315 | } 316 | 317 | if _, err := io.Copy(w, resp.Body); err != nil { 318 | log.Fatalf("failed to read response body: %v", err) 319 | } 320 | 321 | return msg 322 | } 323 | 324 | type headers []string 325 | 326 | func (h headers) String() string { 327 | var o []string 328 | for _, v := range h { 329 | o = append(o, "-H "+v) 330 | } 331 | return strings.Join(o, " ") 332 | } 333 | 334 | func (h *headers) Set(v string) error { 335 | *h = append(*h, v) 336 | return nil 337 | } 338 | 339 | func (h headers) Len() int { return len(h) } 340 | func (h headers) Swap(i, j int) { h[i], h[j] = h[j], h[i] } 341 | func (h headers) Less(i, j int) bool { 342 | a, b := h[i], h[j] 343 | 344 | // server always sorts at the top 345 | if a == "Server" { 346 | return true 347 | } 348 | if b == "Server" { 349 | return false 350 | } 351 | 352 | endtoend := func(n string) bool { 353 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.1 354 | switch n { 355 | case "Connection", 356 | "Keep-Alive", 357 | "Proxy-Authenticate", 358 | "Proxy-Authorization", 359 | "TE", 360 | "Trailers", 361 | "Transfer-Encoding", 362 | "Upgrade": 363 | return false 364 | default: 365 | return true 366 | } 367 | } 368 | 369 | x, y := endtoend(a), endtoend(b) 370 | if x == y { 371 | // both are of the same class 372 | return a < b 373 | } 374 | return x 375 | } 376 | --------------------------------------------------------------------------------