During the 1.3.1 update, a change was made to stop using http.Handle (and ServeMux underneath), in order to have manual control over the http.Server object. Unfortunately, it was overlooked that nothing was doing routing / multiplexing, so when using multiple packfiles only one handler (picked arbitrarily due to map) would actually be active on any given invocation. Correct this by adding an explicit handler. We don't use ServeMux so as to avoid bringing in any of its more complex behaviours like path cleaning etc.
363 lines
9.1 KiB
Go
363 lines
9.1 KiB
Go
/*
|
||
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)
|
||
}
|