2019-04-15 14:10:26 +01:00
|
|
|
|
/*
|
|
|
|
|
Packserver is a standalone HTTP server that serves up one or more pack files.
|
|
|
|
|
*/
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
2022-11-26 10:39:47 +00:00
|
|
|
|
"context"
|
2019-04-15 14:10:26 +01:00
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
2022-11-26 10:39:47 +00:00
|
|
|
|
"os/signal"
|
2019-04-15 14:10:26 +01:00
|
|
|
|
"path/filepath"
|
2022-12-12 10:26:55 +00:00
|
|
|
|
"sort"
|
2019-04-15 14:10:26 +01:00
|
|
|
|
"strings"
|
2022-11-26 10:39:47 +00:00
|
|
|
|
"sync/atomic"
|
|
|
|
|
"syscall"
|
|
|
|
|
"time"
|
2019-04-15 14:10:26 +01:00
|
|
|
|
|
|
|
|
|
"github.com/spf13/cobra"
|
2020-01-15 18:36:05 +00:00
|
|
|
|
"src.lwithers.me.uk/go/htpack"
|
2019-04-15 14:10:26 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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")
|
2019-04-25 14:45:18 +01:00
|
|
|
|
rootCmd.Flags().String("index-file", "",
|
|
|
|
|
"Name of index file (index.html or similar)")
|
2019-04-15 14:10:26 +01:00
|
|
|
|
rootCmd.Flags().Duration("expiry", 0,
|
|
|
|
|
"Tell client how long it can cache data for; 0 means no caching")
|
2020-02-15 12:31:42 +00:00
|
|
|
|
rootCmd.Flags().String("fallback-404", "",
|
|
|
|
|
"Name of file to return if response would be 404 (spa.html or similar)")
|
2022-07-06 10:33:52 +01:00
|
|
|
|
rootCmd.Flags().String("frames", "sameorigin",
|
|
|
|
|
"Override X-Frame-Options header (can be sameorigin, deny, allow)")
|
2022-11-26 10:39:47 +00:00
|
|
|
|
rootCmd.Flags().Duration("graceful-shutdown-delay", 3*time.Second,
|
|
|
|
|
"Number of seconds to wait after receiving SIGTERM before initiating graceful shutdown")
|
2019-04-15 14:10:26 +01:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-06 10:33:52 +01:00
|
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2019-04-15 14:10:26 +01:00
|
|
|
|
// 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 {
|
2019-04-25 14:47:13 +01:00
|
|
|
|
extraHeaders.Set("Cache-Control", "no-cache")
|
2019-04-15 14:10:26 +01:00
|
|
|
|
} else {
|
|
|
|
|
extraHeaders.Set("Cache-Control",
|
|
|
|
|
fmt.Sprintf("public, max-age=%d", expiry/1e9))
|
|
|
|
|
}
|
|
|
|
|
|
2019-04-25 14:45:18 +01:00
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-15 12:31:42 +00:00
|
|
|
|
// optional 404 fallback file
|
|
|
|
|
fallback404File, err := c.Flags().GetString("fallback-404")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-26 10:39:47 +00:00
|
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2019-04-15 14:10:26 +01:00
|
|
|
|
// 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
|
2022-12-12 10:26:55 +00:00
|
|
|
|
router := &routerHandler{}
|
2019-04-15 14:10:26 +01:00
|
|
|
|
for prefix, packfile := range packPaths {
|
2019-04-25 14:45:18 +01:00
|
|
|
|
packHandler, err := htpack.New(packfile)
|
2019-04-15 14:10:26 +01:00
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2019-04-25 14:45:18 +01:00
|
|
|
|
if indexFile != "" {
|
|
|
|
|
packHandler.SetIndex(indexFile)
|
|
|
|
|
}
|
2020-02-15 12:31:42 +00:00
|
|
|
|
if err = packHandler.SetNotFound(fallback404File); err != nil {
|
|
|
|
|
return fmt.Errorf("%s: fallback-404 resource %q "+
|
|
|
|
|
"not found in packfile", prefix, fallback404File)
|
|
|
|
|
}
|
2022-07-06 10:33:52 +01:00
|
|
|
|
packHandler.SetHeader("X-Frame-Options", framesHeader)
|
2019-04-15 14:10:26 +01:00
|
|
|
|
|
2022-12-12 10:26:55 +00:00
|
|
|
|
var handler http.Handler = &addHeaders{
|
2019-04-15 14:10:26 +01:00
|
|
|
|
extraHeaders: extraHeaders,
|
2019-04-25 14:45:18 +01:00
|
|
|
|
handler: packHandler,
|
2019-04-15 14:10:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if prefix != "/" {
|
2022-11-26 10:39:47 +00:00
|
|
|
|
handler = http.StripPrefix(prefix, handler)
|
2019-04-15 14:10:26 +01:00
|
|
|
|
}
|
2022-12-12 10:26:55 +00:00
|
|
|
|
router.AddRoute(prefix, handler)
|
2019-04-15 14:10:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
2022-11-26 10:39:47 +00:00
|
|
|
|
// HTTP server object setup
|
|
|
|
|
sv := &http.Server{
|
|
|
|
|
Addr: bindAddr,
|
2022-12-12 10:26:55 +00:00
|
|
|
|
Handler: router,
|
2022-11-26 10:39:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}()
|
|
|
|
|
|
2019-04-15 14:10:26 +01:00
|
|
|
|
// main server loop
|
|
|
|
|
if keyFile == "" {
|
2022-11-26 10:39:47 +00:00
|
|
|
|
err = sv.ListenAndServe()
|
2019-04-15 14:10:26 +01:00
|
|
|
|
} else {
|
2022-11-26 10:39:47 +00:00
|
|
|
|
err = sv.ListenAndServeTLS(certFile, keyFile)
|
2019-04-15 14:10:26 +01:00
|
|
|
|
}
|
2022-11-26 10:39:47 +00:00
|
|
|
|
|
|
|
|
|
// 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:
|
2019-04-15 14:10:26 +01:00
|
|
|
|
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)
|
|
|
|
|
}
|
2022-12-12 10:26:55 +00:00
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|