diff --git a/.reflex b/.reflex index 2ea61568..244590bb 100644 --- a/.reflex +++ b/.reflex @@ -1 +1 @@ --r '\.go$' -R 'node_modules' -R '^static/' -R '^.cache/' -G '*_test.go' -s -- go run main.go --level debug +-r '\.(go|vue|html|js|css)$' -R 'node_modules' -G '*_test.go' -s -- go run main.go --level debug --username amir --password pass --key test diff --git a/Makefile b/Makefile index 0eadcc4e..9247cc39 100644 --- a/Makefile +++ b/Makefile @@ -28,5 +28,8 @@ test: fake_static build: static CGO_ENABLED=0 go build -ldflags "-s -w" +dev: + yarn dev + int: docker-compose -f integration/docker-compose.test.yml up --build --force-recreate --exit-code-from integration diff --git a/README.md b/README.md index e32b0548..96932509 100644 --- a/README.md +++ b/README.md @@ -10,33 +10,33 @@ Dozzle is a simple, lightweight application that provides you with a web based i [![Docker Version](https://images.microbadger.com/badges/version/amir20/dozzle.svg)](https://hub.docker.com/r/amir20/dozzle/) ![Test](https://github.com/amir20/dozzle/workflows/Test/badge.svg) - ## Features -* Intelligent fuzzy search for container names 🤖 -* Search logs using regex 🔦 -* Small memory footprint 🏎 -* Split screen for viewing multiple logs -* Download logs easy -* Live stats with memory and CPU usage +- Intelligent fuzzy search for container names 🤖 +- Search logs using regex 🔦 +- Small memory footprint 🏎 +- Split screen for viewing multiple logs +- Download logs easy +- Live stats with memory and CPU usage +- Authentication with username and password 🚨 -While dozzle should work for most, it is not meant to be a full logging solution. For enterprise applications, products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited. +While Dozzle should work for most, it is not meant to be a full logging solution. For enterprise applications, products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited. Dozzle won't cost any money and aims to focus only on real-time logs. -## Getting dozzle +## Getting Dozzle Dozzle is a very small Docker container (4 MB compressed). Pull the latest release from the index: $ docker pull amir20/dozzle:latest -## Using dozzle +## Using Dozzle The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`: $ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest -dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`. +Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`. ### With Docker swarm @@ -61,7 +61,7 @@ dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo #### Security -dozzle doesn't support authentication out of the box. You can control the device dozzle binds to by passing `--addr` parameter. For example, +You can control the device Dozzle binds to by passing `--addr` parameter. For example, $ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:1224 amir20/dozzle:latest --addr localhost:1224 @@ -73,14 +73,16 @@ If you wish to restrict the containers shown you can pass the `--filter` paramet this would then only allow you to view containers with a name starting with "foo". You can use other filters like `status` as well, please check the official docker [command line docs](https://docs.docker.com/engine/reference/commandline/ps/#filtering) for available filters. +Dozzle supports very simple authentication out of the box with username and password. You should deploy using SSL to keep the credentials safe. + #### Changing base URL -dozzle by default mounts to "/". If you want to control the base path you can use the `--base` option. For example, if you want to mount at "/foobar", +Dozzle by default mounts to "/". If you want to control the base path you can use the `--base` option. For example, if you want to mount at "/foobar", then you can override by using `--base /foobar`. See env variables below for using `DOZZLE_BASE` to change this. $ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest --base /foobar -dozzle will be available at [http://localhost:8080/foobar/](http://localhost:8080/foobar/). +Dozzle will be available at [http://localhost:8080/foobar/](http://localhost:8080/foobar/). #### Environment variables and configuration @@ -94,15 +96,20 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can | n/a | `DOCKER_API_VERSION` | not set | | `--tailSize` | `DOZZLE_TAILSIZE` | `300` | | `--filter` | `DOZZLE_FILTER` | `""` | +| `--username` | `DOZZLE_USERNAME` | `""` | +| `--password` | `DOZZLE_PASSWORD` | `""` | +| `--key` | `DOZZLE_KEY` | `""` | + +Note: When using username and password `DOZZLE_KEY` is required for session management. ## Troubleshooting and FAQs
I installed Dozzle, but logs are slow or they never load. Help! - Dozzle uses Server Sent Events (SSE) which connects to a server using a HTTP stream without closing the connection. If any proxy tries to buffer this connection, then Dozzle never receives the data and hangs forever waiting for the reverse proxy to flush the buffer. Since version `1.23.0`, Dozzle sends the `X-Accel-Buffering: no` header which should stop reverse proxies buffering. However, some proxies may ignore this header. In those cases, you need to explicitly disable any buffering. +Dozzle uses Server Sent Events (SSE) which connects to a server using a HTTP stream without closing the connection. If any proxy tries to buffer this connection, then Dozzle never receives the data and hangs forever waiting for the reverse proxy to flush the buffer. Since version `1.23.0`, Dozzle sends the `X-Accel-Buffering: no` header which should stop reverse proxies buffering. However, some proxies may ignore this header. In those cases, you need to explicitly disable any buffering. - Below is an example with nginx and using `proxy_pass` to disable buffering. +Below is an example with nginx and using `proxy_pass` to disable buffering. ``` server { @@ -121,20 +128,21 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can } ``` +
What data does Dozzle collect? - Dozzle does not collect any metrics or analytics. Dozzle has a [strict](https://github.com/amir20/dozzle/blob/master/routes.go#L33-L38) Content Security Policy which only allows the following policies: +Dozzle does not collect any metrics or analytics. Dozzle has a [strict](https://github.com/amir20/dozzle/blob/master/routes.go#L33-L38) Content Security Policy which only allows the following policies: - - Allow connect to `api.github.com` to fetch most recent version. - - Only allow ` diff --git a/assets/main.js b/assets/main.js index 719fbcc1..d355cef6 100644 --- a/assets/main.js +++ b/assets/main.js @@ -10,7 +10,7 @@ import Autocomplete from "buefy/dist/esm/autocomplete"; import store from "./store"; import config from "./store/config"; import App from "./App.vue"; -import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound } from "./pages"; +import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages"; Vue.use(VueRouter); Vue.use(Meta); @@ -47,6 +47,11 @@ const routes = [ component: Show, name: "show", }, + { + path: "/login", + component: Login, + name: "login", + }, { path: "/*", component: PageNotFound, diff --git a/assets/pages/Container.vue b/assets/pages/Container.vue index 206df5c8..5fac6f31 100644 --- a/assets/pages/Container.vue +++ b/assets/pages/Container.vue @@ -6,7 +6,7 @@ diff --git a/assets/pages/Index.vue b/assets/pages/Index.vue index f5f330d2..783686bd 100644 --- a/assets/pages/Index.vue +++ b/assets/pages/Index.vue @@ -3,7 +3,14 @@
-

Hello, there!

+
+
+

Hello, there!

+
+
+ Logout +
+
@@ -84,6 +91,8 @@ export default { version: config.version, search: null, sort: "running", + secured: config.secured, + base: config.base, }; }, methods: { diff --git a/assets/pages/Login.vue b/assets/pages/Login.vue new file mode 100644 index 00000000..8692acdd --- /dev/null +++ b/assets/pages/Login.vue @@ -0,0 +1,83 @@ + + + diff --git a/assets/pages/PageNotFound.vue b/assets/pages/PageNotFound.vue index 60da90a1..12877c52 100644 --- a/assets/pages/PageNotFound.vue +++ b/assets/pages/PageNotFound.vue @@ -14,5 +14,10 @@ diff --git a/assets/pages/index.js b/assets/pages/index.js index ea1519e7..fc66a8ca 100644 --- a/assets/pages/index.js +++ b/assets/pages/index.js @@ -4,3 +4,4 @@ export { default as Show } from "./Show.vue"; export { default as Container } from "./Container.vue"; export { default as Settings } from "./Settings.vue"; export { default as PageNotFound } from "./PageNotFound.vue"; +export { default as Login } from "./Login.vue"; diff --git a/assets/store/config.js b/assets/store/config.js index 726cf430..de2b3bd5 100644 --- a/assets/store/config.js +++ b/assets/store/config.js @@ -2,8 +2,12 @@ const config = JSON.parse(document.querySelector("script#config__json").textCont if (config.version == "{{ .Version }}") { config.version = "dev"; config.base = ""; + config.authorizationNeeded = false; + config.secured = false; } else { config.version = config.version.replace(/^v/, ""); + config.authorizationNeeded = config.authorizationNeeded === "true"; + config.secured = config.secured === "true"; } export default config; diff --git a/assets/store/index.js b/assets/store/index.js index 0e104679..b36fa657 100644 --- a/assets/store/index.js +++ b/assets/store/index.js @@ -16,6 +16,7 @@ const state = { searchFilter: null, isMobile: mql.matches, settings: storage.get(DOZZLE_SETTINGS_KEY), + authorizationNeeded: config.authorizationNeeded, }; const mutations = { @@ -101,10 +102,12 @@ const getters = { }, }; -const es = new EventSource(`${config.base}/api/events/stream`); -es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false); -es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false); -es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false); +if (!config.authorizationNeeded) { + const es = new EventSource(`${config.base}/api/events/stream`); + es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false); + es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false); + es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false); +} mql.addEventListener("change", (e) => store.commit("SET_MOBILE_WIDTH", e.matches)); diff --git a/go.mod b/go.mod index df85c972..9e06890b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.1 // indirect github.com/gorilla/mux v1.8.0 + github.com/gorilla/sessions v1.2.1 github.com/magiconair/properties v1.8.5 github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect diff --git a/go.sum b/go.sum index 7f291835..3707fb79 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= diff --git a/main.go b/main.go index 9d146521..f1f4045e 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "embed" "io/fs" + _ "net/http/pprof" "net/url" "os" "os/signal" @@ -25,6 +26,9 @@ var ( tailSize = 300 filters map[string]string version = "dev" + key string + username string + password string ) //go:embed static @@ -40,6 +44,9 @@ func init() { pflag.String("level", "info", "logging level") pflag.Int("tailSize", 300, "Tail size to use for initial container logs") pflag.StringToStringVar(&filters, "filter", map[string]string{}, "Container filters to use for showing logs") + pflag.String("key", "", "Dozzle secure key used for session encryption. Should be a random generated string. Use openssl rand -base64 32 to create one.") + pflag.String("username", "", "Dozzle username to use for authentication. Requires key and password.") + pflag.String("password", "", "Dozzle password for authentication. Requires username and key.") pflag.Parse() viper.AutomaticEnv() @@ -50,6 +57,9 @@ func init() { base = viper.GetString("base") level = viper.GetString("level") tailSize = viper.GetInt("tailSize") + key = viper.GetString("key") + username = viper.GetString("username") + password = viper.GetString("password") // Until https://github.com/spf13/viper/issues/911 is fixed. We have to use this hacky way. // filters = viper.GetStringMapString("filter") @@ -83,11 +93,24 @@ func main() { log.Fatalf("Could not connect to Docker Engine: %v", err) } + if username != "" || password != "" { + if username == "" || password == "" { + log.Fatalf("Username AND password are required for authentication") + } + + if key == "" { + log.Fatalf("Key is required for authentication") + } + } + config := web.Config{ Addr: addr, Base: base, Version: version, TailSize: tailSize, + Key: key, + Username: username, + Password: password, } static, err := fs.Sub(content, "static") diff --git a/web/auth.go b/web/auth.go new file mode 100644 index 00000000..4d8e4c6d --- /dev/null +++ b/web/auth.go @@ -0,0 +1,118 @@ +package web + +import ( + "net/http" + "time" + + "github.com/gorilla/sessions" + log "github.com/sirupsen/logrus" +) + +var secured = false +var store *sessions.CookieStore + +const authorityKey = "AUTH_TIMESTAMP" +const sessionName = "session" + +func initializeAuth(h *handler) { + if h.config.Username != "" && h.config.Password != "" { + store = sessions.NewCookieStore([]byte(h.config.Key)) + store.Options.HttpOnly = true + store.Options.SameSite = http.SameSiteLaxMode + store.Options.MaxAge = 0 + secured = true + } +} + +func authorizationRequired(f http.HandlerFunc) http.Handler { + if secured { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := store.Get(r, sessionName) + if session.IsNew { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } else { + f(w, r) + } + }) + } else { + return http.HandlerFunc(f) + } +} + +func (h *handler) isAuthorized(r *http.Request) bool { + if !secured { + return true + } + + session, _ := store.Get(r, sessionName) + if session.IsNew { + return false + } + + if _, ok := session.Values[authorityKey]; ok { + // TODO check for timestamp + return true + } + + return false +} + +func (h *handler) isAuthorizationNeeded(r *http.Request) bool { + return secured && !h.isAuthorized(r) +} + +func (h *handler) validateCredentials(w http.ResponseWriter, r *http.Request) { + if !secured { + log.Panic("Validating credentials with secured=false should not happen") + } + + if r.Method != "POST" { + log.Fatal("Expecting method to be POST") + http.Error(w, http.StatusText(http.StatusNotAcceptable), http.StatusNotAcceptable) + return + } + + if err := r.ParseMultipartForm(4 * 1024); err != nil { + log.Fatalf("Error while parsing form data: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + user := r.PostFormValue("username") + pass := r.PostFormValue("password") + session, _ := store.Get(r, sessionName) + + if user == h.config.Username && pass == h.config.Password { + session.Values[authorityKey] = time.Now().Unix() + + if err := session.Save(r, w); err != nil { + log.Fatalf("Error while parsing saving session: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(http.StatusText(http.StatusOK))) + return + } + + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +func (h *handler) clearSession(w http.ResponseWriter, r *http.Request) { + if !secured { + log.Panic("Validating credentials with secured=false should not happen") + } + + session, _ := store.Get(r, sessionName) + delete(session.Values, authorityKey) + + if err := session.Save(r, w); err != nil { + log.Fatalf("Error while parsing saving session: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, h.config.Base, http.StatusTemporaryRedirect) +} diff --git a/web/csp.go b/web/csp.go new file mode 100644 index 00000000..7bf2eefb --- /dev/null +++ b/web/csp.go @@ -0,0 +1,12 @@ +package web + +import ( + "net/http" +) + +func cspHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script'") + next.ServeHTTP(w, r) + }) +} diff --git a/web/events.go b/web/events.go new file mode 100644 index 00000000..ae6a93b3 --- /dev/null +++ b/web/events.go @@ -0,0 +1,110 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/amir20/dozzle/docker" + + log "github.com/sirupsen/logrus" +) + +func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { + f, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ctx := r.Context() + + events, err := h.client.Events(ctx) + stats := make(chan docker.ContainerStat) + + if containers, err := h.client.ListContainers(); err == nil { + for _, c := range containers { + if c.State == "running" { + if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil { + log.Errorf("error while streaming container stats: %v", err) + } + } + } + } + + if err := sendContainersJSON(h.client, w); err != nil { + log.Errorf("error while encoding containers to stream: %v", err) + } + + f.Flush() + + for { + select { + case stat := <-stats: + bytes, _ := json.Marshal(stat) + if _, err := fmt.Fprintf(w, "event: container-stat\ndata: %s\n\n", string(bytes)); err != nil { + log.Errorf("error writing stat to event stream: %v", err) + return + } + f.Flush() + case event, ok := <-events: + if !ok { + return + } + switch event.Name { + case "start", "die": + log.Debugf("triggering docker event: %v", event.Name) + if event.Name == "start" { + log.Debugf("found new container with id: %v", event.ActorID) + if err := h.client.ContainerStats(ctx, event.ActorID, stats); err != nil { + log.Errorf("error when streaming new container stats: %v", err) + } + if err := sendContainersJSON(h.client, w); err != nil { + log.Errorf("error encoding containers to stream: %v", err) + return + } + } + + bytes, _ := json.Marshal(event) + if _, err := fmt.Fprintf(w, "event: container-%s\ndata: %s\n\n", event.Name, string(bytes)); err != nil { + log.Errorf("error writing event to event stream: %v", err) + return + } + + f.Flush() + default: + // do nothing + } + case <-ctx.Done(): + return + case <-err: + return + } + } +} + +func sendContainersJSON(client docker.Client, w http.ResponseWriter) error { + containers, err := client.ListContainers() + if err != nil { + return err + } + + if _, err := fmt.Fprint(w, "event: containers-changed\ndata: "); err != nil { + return err + } + + if err := json.NewEncoder(w).Encode(containers); err != nil { + return err + } + + if _, err := fmt.Fprint(w, "\n\n"); err != nil { + return err + } + + return nil +} diff --git a/web/logs.go b/web/logs.go new file mode 100644 index 00000000..9bd6aee4 --- /dev/null +++ b/web/logs.go @@ -0,0 +1,138 @@ +package web + +import ( + "bufio" + "compress/gzip" + "context" + + "fmt" + "io" + "net/http" + "runtime" + "strings" + + "time" + + "github.com/dustin/go-humanize" + + log "github.com/sirupsen/logrus" +) + +func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + + from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from")) + to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to")) + id := r.URL.Query().Get("id") + + reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to) + defer reader.Close() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + io.Copy(w, reader) +} + +func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + container, err := h.client.FindContainer(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + now := time.Now() + from := time.Unix(container.Created, 0) + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.log.gz", container.ID)) + w.Header().Set("Content-Type", "application/gzip") + zw := gzip.NewWriter(w) + defer zw.Close() + zw.Name = fmt.Sprintf("%v.log", container.ID) + zw.Comment = "Logs generated by Dozzle" + zw.ModTime = now + + reader, err := h.client.ContainerLogsBetweenDates(r.Context(), container.ID, from, now) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + io.Copy(zw, reader) +} + +func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + + f, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + container, err := h.client.FindContainer(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + reader, err := h.client.ContainerLogs(r.Context(), container.ID, h.config.TailSize, r.Header.Get("Last-Event-ID")) + if err != nil { + if err == io.EOF { + fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") + f.Flush() + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + defer reader.Close() + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + message := scanner.Text() + fmt.Fprintf(w, "data: %s\n", message) + if index := strings.IndexAny(message, " "); index != -1 { + id := message[:index] + if _, err := time.Parse(time.RFC3339Nano, id); err == nil { + fmt.Fprintf(w, "id: %s\n", id) + } + } + fmt.Fprintf(w, "\n") + f.Flush() + } + + log.Debugf("streaming stopped: %v", container.ID) + + if scanner.Err() == nil { + log.Debugf("container stopped: %v", container.ID) + fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") + f.Flush() + } else if scanner.Err() != context.Canceled { + log.Errorf("unknown error while streaming %v", scanner.Err()) + } + + log.WithField("routines", runtime.NumGoroutine()).Debug("runtime goroutine stats") + + if log.IsLevelEnabled(log.DebugLevel) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + // For info on each, see: https://golang.org/pkg/runtime/#MemStats + log.WithFields(log.Fields{ + "allocated": humanize.Bytes(m.Alloc), + "totalAllocated": humanize.Bytes(m.TotalAlloc), + "system": humanize.Bytes(m.Sys), + }).Debug("runtime mem stats") + } +} diff --git a/web/routes.go b/web/routes.go index 8e8beb26..21e32212 100644 --- a/web/routes.go +++ b/web/routes.go @@ -1,25 +1,13 @@ package web import ( - "bufio" - "compress/gzip" - "context" - - "encoding/json" "fmt" "html/template" - "io" "io/fs" "io/ioutil" "net/http" - _ "net/http/pprof" - "runtime" - "strings" - - "time" "github.com/amir20/dozzle/docker" - "github.com/dustin/go-humanize" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" @@ -31,13 +19,15 @@ type Config struct { Addr string Version string TailSize int + Key string + Username string + Password string } type handler struct { - client docker.Client - content fs.FS - config *Config - fileServer http.Handler + client docker.Client + content fs.FS + config *Config } // CreateServer creates a service for http handler @@ -50,21 +40,27 @@ func CreateServer(c docker.Client, content fs.FS, config Config) *http.Server { return &http.Server{Addr: config.Addr, Handler: createRouter(handler)} } +var fileServer http.Handler + func createRouter(h *handler) *mux.Router { + initializeAuth(h) + base := h.config.Base r := mux.NewRouter() - r.Use(setCSPHeaders) + r.Use(cspHeaders) if base != "/" { r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, base+"/", http.StatusMovedPermanently) })) } s := r.PathPrefix(base).Subrouter() - s.HandleFunc("/api/logs/stream", h.streamLogs) - s.HandleFunc("/api/logs/download", h.downloadLogs) - s.HandleFunc("/api/logs", h.fetchLogsBetweenDates) - s.HandleFunc("/api/events/stream", h.streamEvents) - s.HandleFunc("/version", h.version) + s.Handle("/api/logs/stream", authorizationRequired(h.streamLogs)) + s.Handle("/api/logs/download", authorizationRequired(h.downloadLogs)) + s.Handle("/api/logs", authorizationRequired(h.fetchLogsBetweenDates)) + s.Handle("/api/events/stream", authorizationRequired(h.streamEvents)) + s.HandleFunc("/api/validateCredentials", h.validateCredentials) + s.Handle("/logout", authorizationRequired(h.clearSession)) + s.Handle("/version", authorizationRequired(h.version)) if log.IsLevelEnabled(log.DebugLevel) { s.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux) @@ -76,270 +72,61 @@ func createRouter(h *handler) *mux.Router { s.PathPrefix("/").Handler(http.StripPrefix(base, http.HandlerFunc(h.index))) } - h.fileServer = http.FileServer(http.FS(h.content)) + fileServer = http.FileServer(http.FS(h.content)) return r } -func setCSPHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script'") - next.ServeHTTP(w, r) - }) -} - func (h *handler) index(w http.ResponseWriter, req *http.Request) { _, err := h.content.Open(req.URL.Path) if err == nil && req.URL.Path != "" && req.URL.Path != "/" { - h.fileServer.ServeHTTP(w, req) + fileServer.ServeHTTP(w, req) } else { - file, err := h.content.Open("index.html") - if err != nil { - panic(err) - } - bytes, err := ioutil.ReadAll(file) - if err != nil { - panic(err) - } - tmpl, err := template.New("index.html").Parse(string(bytes)) - if err != nil { - panic(err) - } - - path := "" - if h.config.Base != "/" { - path = h.config.Base - } - - data := struct { - Base string - Version string - }{path, h.config.Version} - err = tmpl.Execute(w, data) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } -} - -func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=UTF-8") - - from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from")) - to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to")) - id := r.URL.Query().Get("id") - - reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to) - defer reader.Close() - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - io.Copy(w, reader) -} - -func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - container, err := h.client.FindContainer(id) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - now := time.Now() - from := time.Unix(container.Created, 0) - - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.log.gz", container.ID)) - w.Header().Set("Content-Type", "application/gzip") - zw := gzip.NewWriter(w) - defer zw.Close() - zw.Name = fmt.Sprintf("%v.log", container.ID) - zw.Comment = "Logs generated by Dozzle" - zw.ModTime = now - - reader, err := h.client.ContainerLogsBetweenDates(r.Context(), container.ID, from, now) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - io.Copy(zw, reader) -} - -func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "id is required", http.StatusBadRequest) - return - } - - f, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) - return - } - - container, err := h.client.FindContainer(id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - reader, err := h.client.ContainerLogs(r.Context(), container.ID, h.config.TailSize, r.Header.Get("Last-Event-ID")) - if err != nil { - if err == io.EOF { - fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") - f.Flush() - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - defer reader.Close() - - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - message := scanner.Text() - fmt.Fprintf(w, "data: %s\n", message) - if index := strings.IndexAny(message, " "); index != -1 { - id := message[:index] - if _, err := time.Parse(time.RFC3339Nano, id); err == nil { - fmt.Fprintf(w, "id: %s\n", id) - } - } - fmt.Fprintf(w, "\n") - f.Flush() - } - - log.Debugf("streaming stopped: %v", container.ID) - - if scanner.Err() == nil { - log.Debugf("container stopped: %v", container.ID) - fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") - f.Flush() - } else if scanner.Err() != context.Canceled { - log.Errorf("unknown error while streaming %v", scanner.Err()) - } - - log.WithField("routines", runtime.NumGoroutine()).Debug("runtime goroutine stats") - - if log.IsLevelEnabled(log.DebugLevel) { - var m runtime.MemStats - runtime.ReadMemStats(&m) - // For info on each, see: https://golang.org/pkg/runtime/#MemStats - log.WithFields(log.Fields{ - "allocated": humanize.Bytes(m.Alloc), - "totalAllocated": humanize.Bytes(m.TotalAlloc), - "system": humanize.Bytes(m.Sys), - }).Debug("runtime mem stats") - } -} - -func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { - f, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - ctx := r.Context() - - events, err := h.client.Events(ctx) - stats := make(chan docker.ContainerStat) - - if containers, err := h.client.ListContainers(); err == nil { - for _, c := range containers { - if c.State == "running" { - if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil { - log.Errorf("error while streaming container stats: %v", err) - } - } - } - } - - if err := sendContainersJSON(h.client, w); err != nil { - log.Errorf("error while encoding containers to stream: %v", err) - } - - f.Flush() - - for { - select { - case stat := <-stats: - bytes, _ := json.Marshal(stat) - if _, err := fmt.Fprintf(w, "event: container-stat\ndata: %s\n\n", string(bytes)); err != nil { - log.Errorf("error writing stat to event stream: %v", err) - return - } - f.Flush() - case event, ok := <-events: - if !ok { - return - } - switch event.Name { - case "start", "die": - log.Debugf("triggering docker event: %v", event.Name) - if event.Name == "start" { - log.Debugf("found new container with id: %v", event.ActorID) - if err := h.client.ContainerStats(ctx, event.ActorID, stats); err != nil { - log.Errorf("error when streaming new container stats: %v", err) - } - if err := sendContainersJSON(h.client, w); err != nil { - log.Errorf("error encoding containers to stream: %v", err) - return - } - } - - bytes, _ := json.Marshal(event) - if _, err := fmt.Fprintf(w, "event: container-%s\ndata: %s\n\n", event.Name, string(bytes)); err != nil { - log.Errorf("error writing event to event stream: %v", err) - return - } - - f.Flush() - default: - // do nothing - } - case <-ctx.Done(): - return - case <-err: + if !h.isAuthorized(req) && req.URL.Path != "login" { + http.Redirect(w, req, h.config.Base+"login", http.StatusTemporaryRedirect) return } + h.executeTemplate(w, req) + } +} + +func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) { + file, err := h.content.Open("index.html") + if err != nil { + log.Panic(err) + } + bytes, err := ioutil.ReadAll(file) + if err != nil { + log.Panic(err) + } + tmpl, err := template.New("index.html").Parse(string(bytes)) + if err != nil { + log.Panic(err) + } + + path := "" + if h.config.Base != "/" { + path = h.config.Base + } + + data := struct { + Base string + Version string + AuthorizationNeeded bool + Secured bool + }{ + path, + h.config.Version, + h.isAuthorizationNeeded(req), + secured, + } + err = tmpl.Execute(w, data) + if err != nil { + log.Panic(err) + http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h *handler) version(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "%v", h.config.Version) } - -func sendContainersJSON(client docker.Client, w http.ResponseWriter) error { - containers, err := client.ListContainers() - if err != nil { - return err - } - - if _, err := fmt.Fprint(w, "event: containers-changed\ndata: "); err != nil { - return err - } - - if err := json.NewEncoder(w).Encode(containers); err != nil { - return err - } - - if _, err := fmt.Fprint(w, "\n\n"); err != nil { - return err - } - - return nil -} diff --git a/web/routes_test.go b/web/routes_test.go index 84e78a4b..17fe44c4 100644 --- a/web/routes_test.go +++ b/web/routes_test.go @@ -297,10 +297,9 @@ func Test_createRoutes_foobar_file(t *testing.T) { require.NoError(t, afero.WriteFile(fs, "test", []byte("test page"), 0644), "WriteFile should have no error.") handler := createRouter(&handler{ - client: mockedClient, - content: afero.NewIOFS(fs), - config: &Config{Base: "/foobar"}, - fileServer: nil, + client: mockedClient, + content: afero.NewIOFS(fs), + config: &Config{Base: "/foobar"}, }) req, err := http.NewRequest("GET", "/foobar/test", nil) require.NoError(t, err, "NewRequest should not return an error.")