From c71958384e8a15c6149b1634c7565fdc9667e693 Mon Sep 17 00:00:00 2001 From: Victor Vrantchan Date: Fri, 17 Jul 2020 12:41:33 -0400 Subject: [PATCH] build a simple web framework. (#691) Added the basics for HTML templates, serving assets and so on. --- cmd/micromdm/micromdm.go | 31 ++++++ cmd/micromdm/server.go | 27 +++++ pkg/frontend/frontend.go | 230 +++++++++++++++++++++++++++++++++++++++ ui/includes/404.tmpl | 11 ++ ui/includes/500.tmpl | 13 +++ ui/includes/home.tmpl | 6 + ui/layouts/base.tmpl | 22 ++++ ui/layouts/nav.tmpl | 2 + ui/static/style.css | 0 9 files changed, 342 insertions(+) create mode 100644 cmd/micromdm/server.go create mode 100644 pkg/frontend/frontend.go create mode 100644 ui/includes/404.tmpl create mode 100644 ui/includes/500.tmpl create mode 100644 ui/includes/home.tmpl create mode 100644 ui/layouts/base.tmpl create mode 100644 ui/layouts/nav.tmpl create mode 100644 ui/static/style.css diff --git a/cmd/micromdm/micromdm.go b/cmd/micromdm/micromdm.go index 29bfe08c..d50d4406 100644 --- a/cmd/micromdm/micromdm.go +++ b/cmd/micromdm/micromdm.go @@ -7,10 +7,12 @@ import ( "fmt" "io" "io/ioutil" + "net/http" "os" "os/signal" "strconv" "syscall" + "time" "github.com/oklog/run" "github.com/peterbourgon/ff/v3" @@ -28,15 +30,24 @@ func writePID(path string) error { return nil } +type cliFlags struct { + siteName string + http string +} + func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ( ctx = context.Background() logger = log.New(log.Output(stderr)) + cli = &cliFlags{} rootfs = flag.NewFlagSet("micromdm", flag.ContinueOnError) pidfile = rootfs.String("pidfile", "/tmp/micromdm.pid", "Path to server pidfile") _ = rootfs.String("config", "", "Path to config file (optional)") ) + rootfs.StringVar(&cli.siteName, "site_name", "Acme", "Name of the site as it would appear in the top left of the HTML UI") + rootfs.StringVar(&cli.http, "http", "localhost:9000", "HTTP service address") + // default output is os.Stderr. // setting the output and flag.ContinueOnError overrides allows testing usage. rootfs.SetOutput(stderr) @@ -54,6 +65,7 @@ func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int { // add a help subcommand to make usage more discoverable. helpCmd := &ffcli.Command{ Name: "help", + ShortHelp: "Print this help text.", UsageFunc: func(c *ffcli.Command) string { return "" }, Exec: func(_ context.Context, args []string) error { rootfs.Usage() @@ -71,11 +83,30 @@ func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return err } + srv, err := setup(cli, logger) + if err != nil { + return err + } + // run.Group manages lifecycles of various long running goroutines: // - signal handlers for SIGTERM/SIGHUP etc. // - http.Server listeners. var g run.Group + { + server := &http.Server{ + Handler: srv.ui.Handler(), + Addr: cli.http, + } + g.Add(func() error { + log.Info(logger).Log("component", "frontend", "msg", "started") + return server.ListenAndServe() + }, func(error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + server.Shutdown(ctx) + }) + } { // when the binary receives SIGINT or SIGTERM, execution is cancelled ctx, cancel := context.WithCancel(ctx) diff --git a/cmd/micromdm/server.go b/cmd/micromdm/server.go new file mode 100644 index 00000000..84f412a2 --- /dev/null +++ b/cmd/micromdm/server.go @@ -0,0 +1,27 @@ +package main + +import ( + "micromdm.io/v2/pkg/frontend" + "micromdm.io/v2/pkg/log" +) + +type server struct { + ui *frontend.Server +} + +func ui(f *cliFlags, logger log.Logger) (*frontend.Server, error) { + return frontend.New(frontend.Config{ + Logger: logger, + SiteName: f.siteName, + }) +} + +func setup(f *cliFlags, logger log.Logger) (*server, error) { + uisrv, err := ui(f, logger) + if err != nil { + return nil, err + } + + srv := &server{ui: uisrv} + return srv, nil +} diff --git a/pkg/frontend/frontend.go b/pkg/frontend/frontend.go new file mode 100644 index 00000000..b7983dd5 --- /dev/null +++ b/pkg/frontend/frontend.go @@ -0,0 +1,230 @@ +// Package frontend provides a lightweight framework for building the MicroMDM HTML UI. +package frontend + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "runtime/debug" + "sync" + "text/template" + + "github.com/gorilla/mux" + + "micromdm.io/v2/pkg/log" +) + +// Data keys. Private, set via helper methods. +const ( + dHTTPCode = "http-code" + dLogErr = "log-error" + dLogKV = "log-keyvals" + dFormErrs = "errors" + dFormAlert = "alert" +) + +// Data provides request parameters when calling RenderTemplate. +type Data map[string]interface{} + +// WithLog adds keyvals to log when rendering a template. +func (d Data) WithLog(err error, keyvals ...interface{}) Data { + d[dLogErr] = err + d[dLogKV] = keyvals + return d +} + +// WithCode sets an HTTP status code. The default value when not set is 200 OK. +func (d Data) WithCode(code int) Data { + d[dHTTPCode] = code + return d +} + +// FormErrors adds an "errors" key with a mapping of form fields names to error messages. +// FormErrors sets the HTTP status code to 400 StatusBadRequest. +func (d Data) FormErrors(errs map[string]string) Data { + d[dFormErrs] = errs + return d.WithCode(http.StatusBadRequest) +} + +// Framework specifies methods frontend sub-packages depend on. +// Framework is mainly exported to give sub-packages a common interface to depend on. +// Server is the only used Framework implementation. +type Framework interface { + Fail(ctx context.Context, w http.ResponseWriter, err error, keyvals ...interface{}) + RenderTemplate(ctx context.Context, w http.ResponseWriter, name string, data Data) + HandleFunc(path string, f func(http.ResponseWriter, *http.Request), methods ...string) +} + +// Server implements Framework. +type Server struct { + r *mux.Router + + mu sync.Mutex + templates map[string]*template.Template + + siteName string +} + +// Config parameters to create a new Server. +type Config struct { + Logger log.Logger + SiteName string +} + +// New creates a Server. +func New(config Config) (*Server, error) { + srv := &Server{ + r: mux.NewRouter(), + templates: make(map[string]*template.Template), + siteName: config.SiteName, + } + + srv.r.Use( + log.HTTP(config.Logger), // HTTP logging middleware. + srv.recoverPanic, // convert any panic into 500 errors. + ) + + // have to set middleware for NotFoundHandler separate from matched routes. + srv.r.NotFoundHandler = log.HTTP(config.Logger)(http.HandlerFunc(srv.notFound)) + + srv.r.HandleFunc("/", srv.indexPage).Methods(http.MethodGet) + + // Serve all static content. + // This is another place that will need to be improved to serve from a CDN or object store instead. + srv.r.PathPrefix("/assets/").Methods(http.MethodGet). + Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + http.StripPrefix("/assets/", http.FileServer(http.Dir("ui/static"))).ServeHTTP(w, r) + })) + + if err := srv.loadTemplates(); err != nil { + return nil, fmt.Errorf("loading ui templates: %s", err) + } + + return srv, nil +} + +// Handler returns the mux router used by the Server. +func (srv *Server) Handler() http.Handler { return srv.r } + +// HandleFunc wraps *mux.Router, allowing other packages to register with the router. +func (srv *Server) HandleFunc(path string, f func(http.ResponseWriter, *http.Request), methods ...string) { + if len(methods) == 0 { + methods = []string{http.MethodGet} + } + + srv.r.HandleFunc(path, f).Methods(methods...) +} + +// Fail renders the 500 InternalServerError template and logs accordingly. +func (srv *Server) Fail(ctx context.Context, w http.ResponseWriter, err error, keyvals ...interface{}) { + srv.RenderTemplate(ctx, w, "500.tmpl", Data{}. + WithLog(err, keyvals...). + WithCode(http.StatusInternalServerError), + ) +} + +// RenderTemplate renders HTML templates. +func (srv *Server) RenderTemplate(ctx context.Context, w http.ResponseWriter, name string, data Data) { + logger := log.FromContext(ctx) + + data["trace_id"] = log.TraceID(ctx) + data["siteName"] = srv.siteName + + if logErr, ok := data[dLogErr]; ok { + var kv []interface{} + kv = append(kv, "err", logErr) + extras, ok := data[dLogKV] + if ok { + kv = append(kv, extras.([]interface{})...) + } + + // log the template name, avoiding loops to srv.Fail + if name != "500.tmpl" { + kv = append(kv, "template", name) + } + + log.Info(logger).Log(kv...) + } + + tmpl, ok := srv.templates[name] + if !ok { + srv.Fail(ctx, w, errors.New("no such template"), "template", name) + return + } + + // create a buffer to call ExecuteTemplate with, allowing for extra error handling + // TODO: benchmark for allocations here + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "base.tmpl", data); err != nil && name != "500.tmpl" { + srv.Fail(ctx, w, err, "msg", "executing template", "template", name) + return + } else if err != nil { + log.Info(logger).Log("msg", "500 template failed to render", "err", err) + return + } + + if code, ok := data[dHTTPCode]; ok { + w.WriteHeader(code.(int)) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + buf.WriteTo(w) + log.Debug(logger).Log("msg", "rendered template", "template", name) +} + +// loadTemplates loads the UI from disk and caches it in a map. +// A lot of the inspiration came from an article I came across: +// https://blog.questionable.services/article/approximating-html-template-inheritance/ +// Changes/imporvements to consider: +// - make the layouts/includes locations configurable. +// - allow loading from object storage (gcs/s3) instead of a local disk. +// - support reloading with SIGHUP/other listeners. +// Today, SIGHUP reloads the entire process, which works okay... +func (srv *Server) loadTemplates() error { + layouts, err := filepath.Glob("ui/layouts/*.tmpl") + if err != nil { + return fmt.Errorf("load layouts: %s", err) + } + + includes, err := filepath.Glob("ui/includes/*.tmpl") + if err != nil { + return fmt.Errorf("load includes: %s", err) + } + + srv.mu.Lock() + defer srv.mu.Unlock() + + for _, tpl := range includes { + files := append(layouts, tpl) + srv.templates[filepath.Base(tpl)] = template.Must( + template.New(filepath.Base(tpl)).ParseFiles(files...), + ) + } + + return nil +} + +func (srv *Server) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + srv.Fail(r.Context(), w, fmt.Errorf("panic: %v", err), "msg", "recover panic") + debug.PrintStack() + } + }() + next.ServeHTTP(w, r) + }) +} + +func (srv *Server) notFound(w http.ResponseWriter, r *http.Request) { + srv.RenderTemplate(r.Context(), w, "404.tmpl", Data{}.WithCode(http.StatusNotFound)) +} + +func (srv *Server) indexPage(w http.ResponseWriter, r *http.Request) { + srv.RenderTemplate(r.Context(), w, "home.tmpl", Data{}) +} diff --git a/ui/includes/404.tmpl b/ui/includes/404.tmpl new file mode 100644 index 00000000..6cac850f --- /dev/null +++ b/ui/includes/404.tmpl @@ -0,0 +1,11 @@ +{{ define "title"}} + Not Found +{{ end }} +{{ define "content" }} +
+

404 Not Found

+

+ The page you're looking for is not here. Return +

+
+{{ end }} diff --git a/ui/includes/500.tmpl b/ui/includes/500.tmpl new file mode 100644 index 00000000..aeb6dbd0 --- /dev/null +++ b/ui/includes/500.tmpl @@ -0,0 +1,13 @@ +{{ define "title"}} + Internal Server Error +{{ end }} +{{ define "content" }} +
+ {{ with .siteName }}

{{.}}

{{ end }} +

500 Internal Server Error

+

+ You encountered an unknown error. We are looking into it. Return + {{ with .trace_id }}

Error ID: {{.}}

{{ end }} +

+
+{{ end }} diff --git a/ui/includes/home.tmpl b/ui/includes/home.tmpl new file mode 100644 index 00000000..b78ea198 --- /dev/null +++ b/ui/includes/home.tmpl @@ -0,0 +1,6 @@ +{{ define "title" -}} + Home - {{.siteName}} +{{- end }} +{{ define "content" -}} +

Do it!

+{{- end }} diff --git a/ui/layouts/base.tmpl b/ui/layouts/base.tmpl new file mode 100644 index 00000000..60b4c267 --- /dev/null +++ b/ui/layouts/base.tmpl @@ -0,0 +1,22 @@ +{{- define "base.tmpl" -}} + + + + + + + {{ template "title" . }} + + + {{ template "nav.tmpl" . }} + {{ template "scripts" . }} + {{- template "sidebar" . }} + {{- template "content" . }} + + + +{{ end }} +{{ define "scripts" }} +{{- end }} +{{ define "sidebar" }} +{{- end }} diff --git a/ui/layouts/nav.tmpl b/ui/layouts/nav.tmpl new file mode 100644 index 00000000..41042c6f --- /dev/null +++ b/ui/layouts/nav.tmpl @@ -0,0 +1,2 @@ + diff --git a/ui/static/style.css b/ui/static/style.css new file mode 100644 index 00000000..e69de29b