Adds authentication as a feature with simple username and password configuration (#1127)

* Starting work for auth

* Adds flags

* Does redirect

* Refactors code

* Adds vue templates

* Completes logic

* Cleans up some of the error messages

* Refactors

* Updates readme

* Updates titles

* Adds logout

* Cleans up imports
This commit is contained in:
Amir Raminfar
2021-04-08 08:29:58 -07:00
committed by GitHub
parent 7b7fd11bc7
commit b4e0afdb2c
23 changed files with 641 additions and 319 deletions

118
web/auth.go Normal file
View File

@@ -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)
}

12
web/csp.go Normal file
View File

@@ -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)
})
}

110
web/events.go Normal file
View File

@@ -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
}

138
web/logs.go Normal file
View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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.")