/* Packserver is a standalone HTTP server that serves up one or more pack files. */ package main import ( "bufio" "context" "errors" "fmt" "net/http" "os" "os/signal" "path/filepath" "sort" "strings" "sync/atomic" "syscall" "time" "github.com/spf13/cobra" "src.lwithers.me.uk/go/htpack" ) var rootCmd = &cobra.Command{ Use: "packserver", Short: "packserver is an HTTP server which serves .htpack files", Long: `packserver can efficiently serve a pre-packed file tree over HTTP(S). The files must first have been prepared using the ‘htpacker’ tool. In order to use HTTPS, specify the --key (or -k) flag. This should name a PEM-encoded key file. This file may also contain the certificate; if not, then pass the --cert (or -c) flag in addition. Pack files may be specified as "/prefix=file", or just as "file" (which implies "/=file"). Any /prefix present in the request URL will be stripped off before searching the .htpack for the named file. Only one .htpack file can be served at a particular prefix, and serving matches the longest (most specific) prefixes first.`, RunE: run, } func main() { rootCmd.Flags().StringP("bind", "b", ":8080", "Address to listen on / bind to") rootCmd.Flags().StringP("key", "k", "", "Path to PEM-encoded HTTPS key") rootCmd.Flags().StringP("cert", "c", "", "Path to PEM-encoded HTTPS cert") rootCmd.Flags().StringSliceP("header", "H", nil, "Extra headers; use flag once for each, in form -H header=value") rootCmd.Flags().String("header-file", "", "Path to text file containing one line for each header=value to add") rootCmd.Flags().String("index-file", "", "Name of index file (index.html or similar)") rootCmd.Flags().Duration("expiry", 0, "Tell client how long it can cache data for; 0 means no caching") rootCmd.Flags().String("fallback-404", "", "Name of file to return if response would be 404 (spa.html or similar)") rootCmd.Flags().String("frames", "sameorigin", "Override X-Frame-Options header (can be sameorigin, deny, allow)") rootCmd.Flags().Duration("graceful-shutdown-delay", 3*time.Second, "Number of seconds to wait after receiving SIGTERM before initiating graceful shutdown") if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func run(c *cobra.Command, args []string) error { bindAddr, err := c.Flags().GetString("bind") if err != nil { return err } // parse TLS arguments keyFile, err := c.Flags().GetString("key") if err != nil { return err } certFile, err := c.Flags().GetString("cert") if err != nil { return err } switch { case keyFile == "" && certFile == "": // nothing to do case keyFile == "": return errors.New("cannot specify --cert without --key") case certFile == "": certFile = keyFile } // parse frames header framesHeader := "SAMEORIGIN" frames, err := c.Flags().GetString("frames") if err != nil { return err } switch frames { case "sameorigin": framesHeader = "SAMEORIGIN" case "allow": framesHeader = "" case "deny": framesHeader = "DENY" default: return errors.New("--frames must be one of sameorigin, deny, allow") } // parse extra headers extraHeaders := make(http.Header) hdrs, err := c.Flags().GetStringSlice("header") if err != nil { return err } for _, hdr := range hdrs { pos := strings.IndexRune(hdr, '=') if pos == -1 { return fmt.Errorf("header %q must be in form "+ "name=value", hdr) } extraHeaders.Add(hdr[:pos], hdr[pos+1:]) } hdrfile, err := c.Flags().GetString("header-file") if err != nil { return err } if err := loadHeaderFile(hdrfile, extraHeaders); err != nil { fmt.Fprintln(os.Stderr, "--header-file:", err) os.Exit(1) } // parse expiry time // NB: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control expiry, err := c.Flags().GetDuration("expiry") if err != nil { return err } if expiry <= 0 { extraHeaders.Set("Cache-Control", "no-cache") } else { extraHeaders.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", expiry/1e9)) } // optional index file // NB: this is set below, as the handlers are instantiated indexFile, err := c.Flags().GetString("index-file") if err != nil { return err } // optional 404 fallback file fallback404File, err := c.Flags().GetString("fallback-404") if err != nil { return err } // graceful shutdown delay must be > 0 gracefulShutdownDelay, err := c.Flags().GetDuration("graceful-shutdown-delay") if err != nil { return err } if gracefulShutdownDelay <= 0 { return errors.New("graceful shutdown delay must be > 0s") } // verify .htpack specifications if len(args) == 0 { return errors.New("must specify one or more .htpack files") } packPaths := make(map[string]string) for _, arg := range args { prefix, packfile := "/", arg if pos := strings.IndexRune(arg, '='); pos != -1 { prefix, packfile = arg[:pos], arg[pos+1:] } prefix = filepath.Clean(prefix) if prefix[0] != '/' { return fmt.Errorf("%s: prefix must start with '/'", arg) } if other, used := packPaths[prefix]; used { return fmt.Errorf("%s: prefix %q already used by %s", arg, prefix, other) } packPaths[prefix] = packfile } // load packfiles, registering handlers as we go router := &routerHandler{} for prefix, packfile := range packPaths { packHandler, err := htpack.New(packfile) if err != nil { return err } if indexFile != "" { packHandler.SetIndex(indexFile) } if err = packHandler.SetNotFound(fallback404File); err != nil { return fmt.Errorf("%s: fallback-404 resource %q "+ "not found in packfile", prefix, fallback404File) } packHandler.SetHeader("X-Frame-Options", framesHeader) var handler http.Handler = &addHeaders{ extraHeaders: extraHeaders, handler: packHandler, } if prefix != "/" { handler = http.StripPrefix(prefix, handler) } router.AddRoute(prefix, handler) } // HTTP server object setup sv := &http.Server{ Addr: bindAddr, Handler: router, } // register SIGINT, SIGTERM handler sigch := make(chan os.Signal, 1) signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM) var ( // if we are shut down by a signal, then http.ListenAndServe() // returns straight away, but we actually need to wait for // Shutdown() to complete prior to returning / exiting isSignalled atomic.Bool signalDone = make(chan struct{}) ) go func() { <-sigch time.Sleep(gracefulShutdownDelay) isSignalled.Store(true) shutctx, shutcancel := context.WithTimeout(context.Background(), gracefulShutdownDelay) sv.Shutdown(shutctx) shutcancel() close(signalDone) }() // main server loop if keyFile == "" { err = sv.ListenAndServe() } else { err = sv.ListenAndServeTLS(certFile, keyFile) } // if we were shut down by a signal, wait for Shutdown() to return if isSignalled.Load() { <-signalDone } switch err { case nil, http.ErrServerClosed: // OK default: fmt.Fprintln(os.Stderr, err) os.Exit(1) } return nil } func loadHeaderFile(hdrfile string, extraHeaders http.Header) error { if hdrfile == "" { return nil } f, err := os.Open(hdrfile) if err != nil { return err } defer f.Close() scanner := bufio.NewScanner(f) var lineNum int for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) lineNum++ if line == "" { continue } pos := strings.IndexRune(line, '=') if pos == -1 { return fmt.Errorf("%s: line %d: not in form "+ "header=value", hdrfile, lineNum) } extraHeaders.Add(line[:pos], line[pos+1:]) } if err := scanner.Err(); err != nil { return fmt.Errorf("%s: %v", hdrfile, err) } return nil } type addHeaders struct { extraHeaders http.Header handler http.Handler } func (ah *addHeaders) ServeHTTP(w http.ResponseWriter, r *http.Request) { for name, values := range ah.extraHeaders { w.Header()[name] = append(w.Header()[name], values...) } ah.handler.ServeHTTP(w, r) } // routeEntry is used within routerHandler to map a specific prefix to a // specific handler. type routeEntry struct { // prefix is a path prefix with trailing "/" such as "/foo/". prefix string // handler for the request if prefix matches. handler http.Handler } // routerHandler holds a list of routes sorted by longest-prefix-first. type routerHandler struct { // entries are the list of prefixes, with longest prefix strings first. // The sorting ensures we can iterate through from the start and match // "/dir/subdir/" in preference to just "/dir/". entries []routeEntry } // AddRoute adds a new entry into the handler. It is not concurrency safe; the // handler should not be in use. func (rh *routerHandler) AddRoute(prefix string, handler http.Handler) { if !strings.HasSuffix(prefix, "/") { prefix += "/" } rh.entries = append(rh.entries, routeEntry{ prefix: prefix, handler: handler, }) sort.Slice(rh.entries, func(i, j int) bool { l1, l2 := len(rh.entries[i].prefix), len(rh.entries[j].prefix) if l1 > l2 { return true } if l1 == l2 { return rh.entries[i].prefix < rh.entries[j].prefix } return false }) } func (rh *routerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { for _, entry := range rh.entries { if strings.HasPrefix(r.URL.Path, entry.prefix) { entry.handler.ServeHTTP(w, r) return } } http.NotFound(w, r) }