diff --git a/docs/slides/mnGo/Makefile b/docs/slides/mnGo/Makefile new file mode 100644 index 0000000000..a43b18e1cc --- /dev/null +++ b/docs/slides/mnGo/Makefile @@ -0,0 +1,110 @@ +.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags continuous + +VERSION ?= $(shell git describe --always --tags) +COMMIT ?= $(shell git rev-parse --short=8 HEAD) +GDM := $(shell command -v gdm 2> /dev/null) +GOBINDATA := $(shell go list -f {{.Root}} github.com/jteeuwen/go-bindata 2> /dev/null) +YARN := $(shell command -v yarn 2> /dev/null) + +SOURCES := $(shell find . -name '*.go' ! -name '*_gen.go') +UISOURCES := $(shell find ui -type f -not \( -path ui/build/\* -o -path ui/node_modules/\* -prune \) ) + +LDFLAGS=-ldflags "-s -X main.version=${VERSION} -X main.commit=${COMMIT}" +BINARY=chronograf + +.DEFAULT_GOAL := all + +all: dep build + +build: assets ${BINARY} + +dev: dep dev-assets ${BINARY} + +${BINARY}: $(SOURCES) .bindata .jsdep .godep + go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go + +docker-${BINARY}: $(SOURCES) + CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o ${BINARY} ${LDFLAGS} \ + ./cmd/chronograf/main.go + +docker: dep assets docker-${BINARY} + docker build -t chronograf . + +assets: .jssrc .bindata + +dev-assets: .dev-jssrc .bindata + +.bindata: server/swagger_gen.go canned/bin_gen.go dist/dist_gen.go + @touch .bindata + +dist/dist_gen.go: $(UISOURCES) + go generate -x ./dist + +server/swagger_gen.go: server/swagger.json + go generate -x ./server + +canned/bin_gen.go: canned/*.json + go generate -x ./canned + +.jssrc: $(UISOURCES) + cd ui && npm run build + @touch .jssrc + +.dev-jssrc: $(UISOURCES) + cd ui && npm run build:dev + @touch .dev-jssrc + +dep: .jsdep .godep + +.godep: Godeps +ifndef GDM + @echo "Installing GDM" + go get github.com/sparrc/gdm +endif +ifndef GOBINDATA + @echo "Installing go-bindata" + go get -u github.com/jteeuwen/go-bindata/... +endif + gdm restore + @touch .godep + +.jsdep: ui/yarn.lock +ifndef YARN + $(error Please install yarn 0.19.1+) +else + cd ui && yarn --no-progress --no-emoji + @touch .jsdep +endif + +gen: bolt/internal/internal.proto + go generate -x ./bolt/internal + +test: jstest gotest gotestrace + +gotest: + go test ./... + +gotestrace: + go test -race ./... + +jstest: + cd ui && npm test + +run: ${BINARY} + ./chronograf + +run-dev: ${BINARY} + ./chronograf -d --log-level=debug + +clean: + if [ -f ${BINARY} ] ; then rm ${BINARY} ; fi + cd ui && npm run clean + cd ui && rm -rf node_modules + rm -f dist/dist_gen.go canned/bin_gen.go server/swagger_gen.go + @rm -f .godep .jsdep .jssrc .dev-jssrc .bindata + +continuous: + while true; do if fswatch -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill chronograf; ./chronograf -d --log-level=debug & echo "#-> Build complete."; fi; sleep 0.5; done + +ctags: + ctags -R --languages="Go" --exclude=.git --exclude=ui . diff --git a/docs/slides/mnGo/auth/auth.go b/docs/slides/mnGo/auth/auth.go new file mode 100644 index 0000000000..49247d848b --- /dev/null +++ b/docs/slides/mnGo/auth/auth.go @@ -0,0 +1,28 @@ +// +build OMIT +package oauth2 + +import ( + "context" + "net/http" +) + +func AuthorizedToken(auth Authenticator, te TokenExtractor, next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := te.Extract(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + principal, err := auth.Authenticate(r.Context(), token) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Send the principal to the next handler for further authorization + ctx := context.WithValue(r.Context(), PrincipalKey, principal) + next.ServeHTTP(w, r.WithContext(ctx)) + return + }) +} diff --git a/docs/slides/mnGo/caching/dist.go b/docs/slides/mnGo/caching/dist.go new file mode 100644 index 0000000000..9d50750009 --- /dev/null +++ b/docs/slides/mnGo/caching/dist.go @@ -0,0 +1,15 @@ +// +build OMIT +package dist + +import ( + "fmt" + "net/http" +) + +func (b *BindataAssets) addCacheHeaders(filename string, w http.ResponseWriter) error { + w.Header().Add("Cache-Control", "public, max-age=3600") + fi, _ := AssetInfo(filename) + hour, minute, second := fi.ModTime().Clock() + etag := fmt.Sprintf(`"%d%d%d%d%d"`, fi.Size(), fi.ModTime().Day(), hour, minute, second) + w.Header().Set("ETag", etag) +} diff --git a/docs/slides/mnGo/go_and_react.slide b/docs/slides/mnGo/go_and_react.slide new file mode 100644 index 0000000000..4ee921da85 --- /dev/null +++ b/docs/slides/mnGo/go_and_react.slide @@ -0,0 +1,108 @@ +Serving React with light-weight Go +Lessons learned while building Chronograf +19:00 15 Mar 2017 +Tags: react, go, chronograf + +Chris Goller +Architect, InfluxData +chris@influxdb.com +@goller + +* Demo +- Source: [[https://github.com/influxdata/chronograf]] +- [[http://localhost:8888]] + +* Chronograf Goals + +- Single Page Application +- Install to Productivity in 2 mins +- Familiar dev environment for Javascript _and_ Go + +* Agenda + +- Walking through the middleware stack +- Satisfying both Go and Javascript developers + +* Simplest File Server + +.code simple/router.go + +- However, React routing needs "wildcard" route to return same HTML page. +- Client-Side Routing! + +* React routing +- simple routing in react applications use fragments +- but if the server supports wildcarding then we can get nice looking routes + +* Wildcard routing to default asset + +.code net/http/fs.go /func FileServer/,/^}/ + +Implement `FileSystem` with a default file + +.code simple/dir.go /type/,/OMIT END/ + +* Minimal React Host Server + +.code simple/react.go + +* Problems with SPA +- Slow loads +- Typically, not scrapable + +* Caching +- Cache-Control: [[https://tools.ietf.org/html/rfc7234#section-5.2]] +- ETag: [[https://tools.ietf.org/html/rfc7232#section-2.3]] + +.code caching/dist.go /w\./,/^}/ + +- Strong ETag validation + +* Compression +- gzip middleware by `github.com/NYTimes/gziphandler` +.code gzip/mux.go + +* Authentication and Authorization +- If authenticated able to see assets else redirected to /login +- If authorized able to read parts of REST API + +.code auth/auth.go /func AuthorizedToken/,/^}/ + +* Bonus middleware :) + +* HSTS +.code hsts/hsts.go /HSTS/,/^}/ + +- informs the client to cache that HTTPS should be used for a length of time. +- if client receives HTTP instead reject as a possible man-in-the-middle. +- HSTS doesn't redirect HTTP to HTTPS but rather is only used on HTTPS responses. + +* Version +.code version/version.go /func Version/,/^}/ +- Not necessarily directly useful for React +- Nice for debugging + +* Asset Rewriting +- index.html generally comes from / +- Operationally, nice to serve from different routes +- Better way? Help! +.code prefixer/url_prefixer.go /\*URLPrefixer/,/^}/ + +* Development Environment +- One repository +- "Native" tooling for all developers +- Demo +- One build system to rule them all... make! + +* Makefile +- Get all vendoring depedencies and build everything! + make + +- Test Go and Javascript + make test + +- Run Go server + make run + +- Continuous builds of the Go server + make continuous diff --git a/docs/slides/mnGo/gzip/mux.go b/docs/slides/mnGo/gzip/mux.go new file mode 100644 index 0000000000..5ca4e5bfc4 --- /dev/null +++ b/docs/slides/mnGo/gzip/mux.go @@ -0,0 +1,19 @@ +// +build OMIT +package server + +import ( + "net/http" + + "github.com/NYTimes/gziphandler" + "github.com/bouk/httprouter" +) + +func NewMux(opts MuxOpts, service Service) http.Handler { + router := httprouter.New() + ... + prefixedAssets := NewDefaultURLPrefixer(basepath, assets, opts.Logger) + // Compress the assets with gzip if an accepted encoding + compressed := gziphandler.GzipHandler(prefixedAssets) + ... + return handler +} diff --git a/docs/slides/mnGo/hsts/hsts.go b/docs/slides/mnGo/hsts/hsts.go new file mode 100644 index 0000000000..d35311663b --- /dev/null +++ b/docs/slides/mnGo/hsts/hsts.go @@ -0,0 +1,13 @@ +// +build OMIT +package server + +import "net/http" + +// HSTS add HTTP Strict Transport Security header with a max-age of two years +// Inspired from https://blog.bracebin.com/achieving-perfect-ssl-labs-score-with-go +func HSTS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + next.ServeHTTP(w, r) + }) +} diff --git a/docs/slides/mnGo/mux/mux.go b/docs/slides/mnGo/mux/mux.go new file mode 100644 index 0000000000..1b2333eef5 --- /dev/null +++ b/docs/slides/mnGo/mux/mux.go @@ -0,0 +1,248 @@ +// +build OMIT +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/NYTimes/gziphandler" + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch + "github.com/influxdata/chronograf/oauth2" +) + +const ( + // JSONType the mimetype for a json request + JSONType = "application/json" +) + +// MuxOpts are the options for the router. Mostly related to auth. +type MuxOpts struct { + Logger chronograf.Logger + Develop bool // Develop loads assets from filesystem instead of bindata + Basepath string // URL path prefix under which all chronograf routes will be mounted + UseAuth bool // UseAuth turns on Github OAuth and JWT + TokenSecret string + + ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux)) +} + +// NewMux attaches all the route handlers; handler returned servers chronograf. +func NewMux(opts MuxOpts, service Service) http.Handler { + router := httprouter.New() + + /* React Application */ + assets := Assets(AssetsOpts{ + Develop: opts.Develop, + Logger: opts.Logger, + }) + + // Prefix any URLs found in the React assets with any configured basepath + prefixedAssets := NewDefaultURLPrefixer(basepath, assets, opts.Logger) + + // Compress the assets with gzip if an accepted encoding + compressed := gziphandler.GzipHandler(prefixedAssets) + + // The react application handles all the routing if the server does not + // know about the route. This means that we never have unknown + // routes on the server. + router.NotFound = compressed + + /* Documentation */ + router.GET("/swagger.json", Spec()) + router.GET("/docs", Redoc("/swagger.json")) + + /* API */ + // Sources + // OMIT BEGIN + router.GET("/chronograf/v1/sources", service.Sources) + router.POST("/chronograf/v1/sources", service.NewSource) + + router.GET("/chronograf/v1/sources/:id", service.SourcesID) + router.PATCH("/chronograf/v1/sources/:id", service.UpdateSource) + router.DELETE("/chronograf/v1/sources/:id", service.RemoveSource) + // OMIT END + + // Source Proxy to Influx + router.POST("/chronograf/v1/sources/:id/proxy", service.Influx) + + // All possible permissions for users in this source + router.GET("/chronograf/v1/sources/:id/permissions", service.Permissions) + + // Users associated with the data source + router.GET("/chronograf/v1/sources/:id/users", service.SourceUsers) + router.POST("/chronograf/v1/sources/:id/users", service.NewSourceUser) + + router.GET("/chronograf/v1/sources/:id/users/:uid", service.SourceUserID) + router.DELETE("/chronograf/v1/sources/:id/users/:uid", service.RemoveSourceUser) + router.PATCH("/chronograf/v1/sources/:id/users/:uid", service.UpdateSourceUser) + + // Roles associated with the data source + router.GET("/chronograf/v1/sources/:id/roles", service.Roles) + router.POST("/chronograf/v1/sources/:id/roles", service.NewRole) + + router.GET("/chronograf/v1/sources/:id/roles/:rid", service.RoleID) + router.DELETE("/chronograf/v1/sources/:id/roles/:rid", service.RemoveRole) + router.PATCH("/chronograf/v1/sources/:id/roles/:rid", service.UpdateRole) + + // Kapacitor + router.GET("/chronograf/v1/sources/:id/kapacitors", service.Kapacitors) + router.POST("/chronograf/v1/sources/:id/kapacitors", service.NewKapacitor) + + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid", service.KapacitorsID) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid", service.UpdateKapacitor) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid", service.RemoveKapacitor) + + // Kapacitor rules + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules", service.KapacitorRulesGet) + router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/rules", service.KapacitorRulesPost) + + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesID) + router.PUT("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesPut) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesStatus) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesDelete) + + // Kapacitor Proxy + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyGet) + router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyPost) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyPatch) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyDelete) + + // Mappings + router.GET("/chronograf/v1/mappings", service.GetMappings) + + // Layouts + router.GET("/chronograf/v1/layouts", service.Layouts) + router.POST("/chronograf/v1/layouts", service.NewLayout) + + router.GET("/chronograf/v1/layouts/:id", service.LayoutsID) + router.PUT("/chronograf/v1/layouts/:id", service.UpdateLayout) + router.DELETE("/chronograf/v1/layouts/:id", service.RemoveLayout) + + // Users + router.GET("/chronograf/v1/me", service.Me) + + // Dashboards + router.GET("/chronograf/v1/dashboards", service.Dashboards) + router.POST("/chronograf/v1/dashboards", service.NewDashboard) + + router.GET("/chronograf/v1/dashboards/:id", service.DashboardID) + router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard) + router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard) + router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard) + + var authRoutes AuthRoutes + + var out http.Handler + /* Authentication */ + if opts.UseAuth { + // Encapsulate the router with OAuth2 + var auth http.Handler + auth, authRoutes = AuthAPI(opts, router) + + // Create middleware to redirect to the appropriate provider logout + targetURL := "/" + router.GET("/oauth/logout", Logout(targetURL, authRoutes)) + + out = Logger(opts.Logger, auth) + } else { + out = Logger(opts.Logger, router) + } + + router.GET("/chronograf/v1/", AllRoutes(authRoutes, opts.Logger)) + router.GET("/chronograf/v1", AllRoutes(authRoutes, opts.Logger)) + + return out +} + +// AuthAPI adds the OAuth routes if auth is enabled. +// TODO: this function is not great. Would be good if providers added their routes. +func AuthAPI(opts MuxOpts, router *httprouter.Router) (http.Handler, AuthRoutes) { + auth := oauth2.NewJWT(opts.TokenSecret) + routes := AuthRoutes{} + for _, pf := range opts.ProviderFuncs { + pf(func(p oauth2.Provider, m oauth2.Mux) { + loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, strings.ToLower(p.Name())) + logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, strings.ToLower(p.Name())) + callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, strings.ToLower(p.Name())) + router.Handler("GET", loginPath, m.Login()) + router.Handler("GET", logoutPath, m.Logout()) + router.Handler("GET", callbackPath, m.Callback()) + routes = append(routes, AuthRoute{ + Name: p.Name(), + Label: strings.Title(p.Name()), + Login: loginPath, + Logout: logoutPath, + Callback: callbackPath, + }) + }) + } + + tokenMiddleware := oauth2.AuthorizedToken(&auth, &oauth2.CookieExtractor{Name: "session"}, opts.Logger, router) + // Wrap the API with token validation middleware. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") || r.URL.Path == "/oauth/logout" { + tokenMiddleware.ServeHTTP(w, r) + return + } + router.ServeHTTP(w, r) + }), routes +} + +func encodeJSON(w http.ResponseWriter, status int, v interface{}, logger chronograf.Logger) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + unknownErrorWithMessage(w, err, logger) + } +} + +// Error writes an JSON message +func Error(w http.ResponseWriter, code int, msg string, logger chronograf.Logger) { + e := ErrorMessage{ + Code: code, + Message: msg, + } + b, err := json.Marshal(e) + if err != nil { + code = http.StatusInternalServerError + b = []byte(`{"code": 500, "message":"server_error"}`) + } + + logger. + WithField("component", "server"). + WithField("http_status ", code). + Error("Error message ", msg) + w.Header().Set("Content-Type", JSONType) + w.WriteHeader(code) + _, _ = w.Write(b) +} + +func invalidData(w http.ResponseWriter, err error, logger chronograf.Logger) { + Error(w, http.StatusUnprocessableEntity, fmt.Sprintf("%v", err), logger) +} + +func invalidJSON(w http.ResponseWriter, logger chronograf.Logger) { + Error(w, http.StatusBadRequest, "Unparsable JSON", logger) +} + +func unknownErrorWithMessage(w http.ResponseWriter, err error, logger chronograf.Logger) { + Error(w, http.StatusInternalServerError, fmt.Sprintf("Unknown error: %v", err), logger) +} + +func notFound(w http.ResponseWriter, id int, logger chronograf.Logger) { + Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), logger) +} + +func paramID(key string, r *http.Request) (int, error) { + ctx := r.Context() + param := httprouter.GetParamFromContext(ctx, key) + id, err := strconv.Atoi(param) + if err != nil { + return -1, fmt.Errorf("Error converting ID %s", param) + } + return id, nil +} diff --git a/docs/slides/mnGo/net/http/fs.go b/docs/slides/mnGo/net/http/fs.go new file mode 100644 index 0000000000..bbd687a778 --- /dev/null +++ b/docs/slides/mnGo/net/http/fs.go @@ -0,0 +1,25 @@ +// +build OMIT +package http + +import ( + "io" + "os" +) + +func FileServer(root FileSystem) Handler + +type FileSystem interface { + Open(name string) (File, error) +} + +// A File is returned by a FileSystem's Open method and can be +// served by the FileServer implementation. +// +// The methods should behave the same as those on an *os.File. +type File interface { + io.Closer + io.Reader + io.Seeker + Readdir(count int) ([]os.FileInfo, error) + Stat() (os.FileInfo, error) +} diff --git a/docs/slides/mnGo/prefixer/url_prefixer.go b/docs/slides/mnGo/prefixer/url_prefixer.go new file mode 100644 index 0000000000..6bfbf4328f --- /dev/null +++ b/docs/slides/mnGo/prefixer/url_prefixer.go @@ -0,0 +1,51 @@ +// +build OMIT +package server + +import ( + "bufio" + "bytes" + "io" + "net/http" + + "github.com/influxdata/chronograf" +) + +// URLPrefixer is a wrapper for an http.Handler that will prefix all occurrences of a relative URL with the configured Prefix +type URLPrefixer struct { + Prefix string // the prefix to be appended after any detected Attrs + Next http.Handler // the http.Handler which will generate the content to be modified by this handler + Attrs [][]byte // a list of attrs that should have their URLs prefixed. For example `src="` or `href="` would be valid +} + +func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + ... + // Read next handler's response byte by byte + src := bufio.NewScanner(nextRead) + src.Split(bufio.ScanBytes) + for { + window := buf.Bytes() + if matchlen, match := up.match(window, up.Attrs...); matchlen != 0 { + buf.Next(matchlen) // advance to the relative URL + for i := 0; i < matchlen; i++ { + src.Scan() + buf.Write(src.Bytes()) + } + rw.Write(match) // add the src attr to the output + io.WriteString(rw, up.Prefix) // write the prefix + } else {...} + } +} + +func NewDefaultURLPrefixer(prefix string, next http.Handler) *URLPrefixer { + return &URLPrefixer{ + Prefix: prefix, + Next: next, + Logger: lg, + Attrs: [][]byte{ + []byte(`src="`), + []byte(`href="`), + []byte(`url(`), + []byte(`data-basepath="`), // for forwarding basepath to frontend + }, + } +} diff --git a/docs/slides/mnGo/simple/dir.go b/docs/slides/mnGo/simple/dir.go new file mode 100644 index 0000000000..d12ff104cb --- /dev/null +++ b/docs/slides/mnGo/simple/dir.go @@ -0,0 +1,24 @@ +// +build OMIT +import ( + "net/http" + "os" +) + +type Dir struct { + Default string + dir http.Dir +} + +func (d Dir) Open(name string) (http.File, error) { + f, err := d.dir.Open(name) + if err != nil { + f, err = os.Open(d.Default) + if err != nil { + return nil, err + } + return f, nil + } + return f, err +} + +// OMIT END diff --git a/docs/slides/mnGo/simple/react.go b/docs/slides/mnGo/simple/react.go new file mode 100644 index 0000000000..a6d787e2da --- /dev/null +++ b/docs/slides/mnGo/simple/react.go @@ -0,0 +1,14 @@ +// +build OMIT +package main + +import ( + "log" + "net/http" +) + +func main() { + log.Fatal(http.ListenAndServe(":8888", http.FileServer(&Dir{ + Default: "src/ui/index.html", + dir: http.Dir("src/ui"), + }))) +} diff --git a/docs/slides/mnGo/simple/router.go b/docs/slides/mnGo/simple/router.go new file mode 100644 index 0000000000..32f4a90872 --- /dev/null +++ b/docs/slides/mnGo/simple/router.go @@ -0,0 +1,11 @@ +// +build OMIT +package main + +import ( + "log" + "net/http" +) + +func main() { + log.Fatal(http.ListenAndServe(":8888", http.FileServer(http.Dir("ui/build")))) +} diff --git a/docs/slides/mnGo/version/version.go b/docs/slides/mnGo/version/version.go new file mode 100644 index 0000000000..2f0e8b609e --- /dev/null +++ b/docs/slides/mnGo/version/version.go @@ -0,0 +1,15 @@ +// +build OMIT +package server + +import ( + "net/http" +) + +// Version handler adds X-Chronograf-Version header to responses +func Version(version string, h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Chronograf-Version", version) + h.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) +}