diff --git a/.gitignore b/.gitignore index 9332b31..71c394e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /cmd/htpacker/htpacker +/cmd/packserver/packserver diff --git a/cmd/packserver/go.mod b/cmd/packserver/go.mod new file mode 100644 index 0000000..dc08b66 --- /dev/null +++ b/cmd/packserver/go.mod @@ -0,0 +1,10 @@ +module github.com/lwithers/htpack/cmd/packserver + +go 1.12 + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/lwithers/htpack v0.0.0-20190412081623-ea77f42dc393 + github.com/spf13/cobra v0.0.3 + github.com/spf13/pflag v1.0.3 // indirect +) diff --git a/cmd/packserver/go.sum b/cmd/packserver/go.sum new file mode 100644 index 0000000..bbe2ede --- /dev/null +++ b/cmd/packserver/go.sum @@ -0,0 +1,15 @@ +github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/lwithers/htpack v0.0.0-20190412081623-ea77f42dc393 h1:h++VdZ2eeJC9hf+W+LTVsYdYclJZcz6H5DYAMtGfzBA= +github.com/lwithers/htpack v0.0.0-20190412081623-ea77f42dc393/go.mod h1:+9noAoJ9IIiHkwn2Z2Po5upZOKItKKFgYr/cMESGYrc= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +golang.org/x/sys v0.0.0-20180924175946-90868a75fefd h1:ELJRxcWg6//yYBDjuf/SnMg1+X0jj5+BP5xXF31wl4w= +golang.org/x/sys v0.0.0-20180924175946-90868a75fefd/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cmd/packserver/main.go b/cmd/packserver/main.go new file mode 100644 index 0000000..fc5520c --- /dev/null +++ b/cmd/packserver/main.go @@ -0,0 +1,219 @@ +/* +Packserver is a standalone HTTP server that serves up one or more pack files. +*/ +package main + +import ( + "bufio" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/lwithers/htpack" + "github.com/spf13/cobra" +) + +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().Duration("expiry", 0, + "Tell client how long it can cache data for; 0 means no caching") + + 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 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-store") + } else { + extraHeaders.Set("Cache-Control", + fmt.Sprintf("public, max-age=%d", expiry/1e9)) + } + + // 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 + for prefix, packfile := range packPaths { + var handler http.Handler + handler, err = htpack.New(packfile) + if err != nil { + return err + } + + handler = &addHeaders{ + extraHeaders: extraHeaders, + handler: handler, + } + + if prefix != "/" { + http.Handle(prefix+"/", + http.StripPrefix(prefix, handler)) + } else { + http.Handle("/", handler) + } + } + + // main server loop + if keyFile == "" { + err = http.ListenAndServe(bindAddr, nil) + } else { + err = http.ListenAndServeTLS(bindAddr, certFile, keyFile, nil) + } + if err != nil { + 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) +}