htpack/cmd/packserver/main.go

364 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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)
}