From 3d98d2a2bbbf5e85fe24e8641fceb4994853e6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 21 Jun 2022 11:05:25 +0200 Subject: [PATCH 1/3] chore: add nix/treefmt/just config --- .envrc | 1 + .github/workflows/nix.yml | 46 +++++++++++++ .gitignore | 2 + default.nix | 44 +++++++++++++ flake.lock | 43 ++++++++++++ flake.lock.nix | 134 ++++++++++++++++++++++++++++++++++++++ flake.nix | 38 +++++++++++ justfile | 14 ++++ shell.nix | 2 + treefmt.toml | 9 +++ 10 files changed, 333 insertions(+) create mode 100644 .envrc create mode 100644 .github/workflows/nix.yml create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.lock.nix create mode 100644 flake.nix create mode 100644 justfile create mode 100644 shell.nix create mode 100644 treefmt.toml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..9e9de14 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,46 @@ +name: Nix +on: + push: + branches: + - master + pull_request: + workflow_dispatch: +jobs: + build: + strategy: + matrix: + os: [ ubuntu-20.04 ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - uses: cachix/install-nix-action@v17 + - uses: cachix/cachix-action@v10 + with: + name: numtide + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + - run: | + export PRJ_ROOT=$PWD + nix-shell --pure --run "just lint" + - run: nix-build + flakes: + strategy: + matrix: + os: [ ubuntu-20.04 ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + with: + # Nix Flakes doesn't work on shallow clones + fetch-depth: 0 + - uses: cachix/install-nix-action@v17 + with: + extra_nix_config: | + experimental-features = nix-command flakes + - uses: cachix/cachix-action@v10 + with: + name: numtide + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + - run: nix flake check + - run: nix develop -c echo OK + - name: Run nix flake archive + run: nix flake archive diff --git a/.gitignore b/.gitignore index 1b53422..f6d4dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /serve-go +/.direnv +/result* diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..799c07f --- /dev/null +++ b/default.nix @@ -0,0 +1,44 @@ +{ + system ? builtins.currentSystem, + inputs ? import ./flake.lock.nix {}, + nixpkgs ? + import inputs.nixpkgs { + inherit system; + # Makes the config pure as well. See /top-level/impure.nix: + config = {}; + overlays = []; + }, + buildGoModule ? nixpkgs.buildGoModule, +}: let + serve-go = + buildGoModule + { + name = "serve-go"; + src = ./.; + vendorSha256 = null; + meta = with nixpkgs.lib; { + description = "HTTP web server for SPA"; + homepage = "https://github.com/numtide/serve-go"; + license = licenses.mit; + maintainers = with maintainers; [zimbatm jfroche]; + platforms = platforms.linux; + }; + }; + devShell = + nixpkgs.mkShellNoCC + { + buildInputs = with nixpkgs; [ + gofumpt + golangci-lint + alejandra + go + golint + treefmt + just + gcc + ]; + }; +in { + inherit serve-go devShell; + default = serve-go; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1134145 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1655567057, + "narHash": "sha256-Cc5hQSMsTzOHmZnYm8OSJ5RNUp22bd5NADWLHorULWQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e0a42267f73ea52adc061a64650fddc59906fc99", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.lock.nix b/flake.lock.nix new file mode 100644 index 0000000..0fa103f --- /dev/null +++ b/flake.lock.nix @@ -0,0 +1,134 @@ +# Adapted from https://github.com/edolstra/flake-compat/blob/master/default.nix +# +# This version only gives back the inputs. In that mode, flake becomes little +# more than a niv replacement. +{src ? ./.}: let + lockFilePath = src + "/flake.lock"; + + lockFile = builtins.fromJSON (builtins.readFile lockFilePath); + + # Emulate builtins.fetchTree + # + # TODO: only implement polyfill if the builtin doesn't exist? + fetchTree = info: + if info.type == "github" + then { + outPath = fetchTarball { + url = "https://api.${info.host or "github.com"}/repos/${info.owner}/${info.repo}/tarball/${info.rev}"; + sha256 = info.narHash; + }; + rev = info.rev; + shortRev = builtins.substring 0 7 info.rev; + lastModified = info.lastModified; + narHash = info.narHash; + } + else if info.type == "git" + then + { + outPath = + builtins.fetchGit + ( + { + url = info.url; + sha256 = info.narHash; + } + // ( + if info ? rev + then {inherit (info) rev;} + else {} + ) + // ( + if info ? ref + then {inherit (info) ref;} + else {} + ) + ); + lastModified = info.lastModified; + narHash = info.narHash; + } + // ( + if info ? rev + then { + rev = info.rev; + shortRev = builtins.substring 0 7 info.rev; + } + else {} + ) + else if info.type == "path" + then { + outPath = builtins.path {path = info.path;}; + narHash = info.narHash; + } + else if info.type == "tarball" + then { + outPath = fetchTarball { + url = info.url; + sha256 = info.narHash; + }; + narHash = info.narHash; + } + else if info.type == "gitlab" + then { + inherit (info) rev narHash lastModified; + outPath = fetchTarball { + url = "https://${info.host or "gitlab.com"}/api/v4/projects/${info.owner}%2F${info.repo}/repository/archive.tar.gz?sha=${info.rev}"; + sha256 = info.narHash; + }; + shortRev = builtins.substring 0 7 info.rev; + } + else + # FIXME: add Mercurial, tarball inputs. + throw "flake input has unsupported input type '${info.type}'"; + + allNodes = + builtins.mapAttrs + ( + key: node: let + sourceInfo = + if key == lockFile.root + then {} + else fetchTree (node.info or {} // removeAttrs node.locked ["dir"]); + + inputs = + builtins.mapAttrs + (inputName: inputSpec: allNodes.${resolveInput inputSpec}) + (node.inputs or {}); + + # Resolve a input spec into a node name. An input spec is + # either a node name, or a 'follows' path from the root + # node. + resolveInput = inputSpec: + if builtins.isList inputSpec + then getInputByPath lockFile.root inputSpec + else inputSpec; + + # Follow an input path (e.g. ["dwarffs" "nixpkgs"]) from the + # root node, returning the final node. + getInputByPath = nodeName: path: + if path == [] + then nodeName + else + getInputByPath + # Since this could be a 'follows' input, call resolveInput. + (resolveInput lockFile.nodes.${nodeName}.inputs.${builtins.head path}) + (builtins.tail path); + + result = + sourceInfo + // { + inherit inputs; + inherit sourceInfo; + }; + in + if node.flake or true + then result + else sourceInfo + ) + lockFile.nodes; + + result = + if lockFile.version >= 5 && lockFile.version <= 7 + then allNodes.${lockFile.root}.inputs + else throw "lock file '${lockFilePath}' has unsupported version ${toString lockFile.version}"; +in + result diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d0108a1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,38 @@ +{ + description = "HTTP web server for SPA"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + flake-utils.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachSystem ["x86_64-linux"] ( + system: let + nixpkgs' = nixpkgs.legacyPackages.${system}; + pkgs = import self { + inherit system; + inputs = null; + nixpkgs = nixpkgs'; + }; + in { + defaultPackage = pkgs.default; + packages = pkgs; + devShells.default = pkgs.devShell; + checks = { + fmt = with nixpkgs'; + runCommandLocal "fmt" {} '' + export HOME=$(mktemp -d) + cd ${./.} + ${treefmt}/bin/treefmt --fail-on-change > $out + ''; + }; + } + ); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..ad92f9a --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +default: + @just --list + +# Format and lint project +fmt: + treefmt + +# Build the project +build: + go build . + +# Run linters not covered by treefmt +lint: + golangci-lint run diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..5738f0e --- /dev/null +++ b/shell.nix @@ -0,0 +1,2 @@ +{system ? builtins.currentSystem}: +(import ./. {inherit system;}).devShell diff --git a/treefmt.toml b/treefmt.toml new file mode 100644 index 0000000..1e468aa --- /dev/null +++ b/treefmt.toml @@ -0,0 +1,9 @@ +[formatter.nix] +command = "alejandra" +includes = ["*.nix"] + +[formatter.go] +command = "gofumpt" +options = ["-w"] +includes = ["*.go"] +excludes = [] From 8c7a182c9532582c31c6c4a14d46bf4600a12ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 21 Jun 2022 11:06:45 +0200 Subject: [PATCH 2/3] feat: add optional HTTP Strict Transport Security (HSTS) headers Available for https traffic only. references: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security spec: https://www.rfc-editor.org/rfc/rfc6797#section-6.1 --- main.go | 41 +++++++++++++++++++++++++++++++++++++--- spa/fs.go | 6 ++++-- spa/hsts_middleware.go | 33 ++++++++++++++++++++++++++++++++ spa/oembed_middleware.go | 11 +---------- spa/utils.go | 15 +++++++++++++++ 5 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 spa/hsts_middleware.go create mode 100644 spa/utils.go diff --git a/main.go b/main.go index 224defc..f05c937 100644 --- a/main.go +++ b/main.go @@ -5,14 +5,18 @@ import ( "fmt" "net/http" "os" + "strconv" "github.com/numtide/serve-go/spa" ) var ( - workDir string - port int = 3000 - oEmbedUrl string = os.Getenv("SERVEGO_OEMBED_URL") + workDir string + port int = 3000 + oEmbedUrl string = os.Getenv("SERVEGO_OEMBED_URL") + hstsSeconds uint64 = 0 + hstsIncludeSubDomains bool = false + hstsPreload bool = false ) func init() { @@ -27,12 +31,18 @@ func usage() { fmt.Fprintf(out, "Options:\n") fmt.Fprintf(out, " -listen: Port to listen to (default %d)\n", port) fmt.Fprintf(out, " -oembed-url: Sets the oEmbed Link header if set (env: $SERVEGO_OEMBED_URL) (default %s)\n", oEmbedUrl) + fmt.Fprintf(out, " -hstsSeconds: Sets the HTTP Strict Transport Security max-age header if set (env: $SERVEGO_HSTS_SECONDS) (default %s)\n", hstsSeconds) + fmt.Fprintf(out, " -hstsIncludeSubDomains: Sets the HTTP Strict Transport Security includeSubDmains header if set (env: $SERVEGO_HSTS_INCLUDE_SUBDOMAINS) (default %s)\n", hstsIncludeSubDomains) + fmt.Fprintf(out, " -hstsPreload: Sets the HTTP Strict Transport Security preload header if set (env: $SERVEGO_HSTS_PRELOAD) (default %s)\n", hstsPreload) fmt.Fprintf(out, " : Folder to serve (default to current directory)\n") } func run() error { flag.IntVar(&port, "listen", port, "Port to listen to") flag.StringVar(&oEmbedUrl, "oembed-url", oEmbedUrl, "Sets the oEmbed Link header if set") + flag.Uint64Var(&hstsSeconds, "hstsSeconds", hstsSeconds, "Sets the HTTP Strict Transport Security max-age if set") + flag.Bool("hstsIncludeSubDomains", hstsIncludeSubDomains, "Sets the HTTP Strict Transport Security includeSubDomains if set") + flag.Bool("hstsPreload", hstsPreload, "Sets the HTTP Strict Transport Security includeSubDomains if set") flag.Parse() if flag.NArg() > 0 { @@ -41,6 +51,23 @@ func run() error { workDir = "." } + val, ok := os.LookupEnv("SERVEGO_HSTS_SECONDS") + if ok { + var err error + hstsSeconds, err = strconv.ParseUint(val, 10, 64) + if err != nil { + panic(err) + } + } + _, ok = os.LookupEnv("SERVEGO_HSTS_INCLUDE_SUBDOMAINS") + if ok { + hstsIncludeSubDomains = true + } + _, ok = os.LookupEnv("SERVEGO_HSTS_PRELOAD") + if ok { + hstsPreload = true + } + fs := http.Dir(workDir) h := spa.FileServer(fs) @@ -53,6 +80,14 @@ func run() error { } } + if hstsSeconds > 0 { + var err error + h, err = spa.NewHSTSMiddleware(h, hstsSeconds, hstsIncludeSubDomains, hstsPreload) + if err != nil { + panic(err) + } + } + addr := fmt.Sprintf(":%d", port) fmt.Printf("Serving %s on %s\n", workDir, addr) diff --git a/spa/fs.go b/spa/fs.go index cc2ce22..179e3b8 100644 --- a/spa/fs.go +++ b/spa/fs.go @@ -564,8 +564,10 @@ type fileHandler struct { root http.FileSystem } -var errMissingSeek = errors.New("io.File missing Seek method") -var errMissingReadDir = errors.New("io.File directory missing ReadDir method") +var ( + errMissingSeek = errors.New("io.File missing Seek method") + errMissingReadDir = errors.New("io.File directory missing ReadDir method") +) // FileServer returns a handler that serves HTTP requests // with the contents of the file system rooted at root. diff --git a/spa/hsts_middleware.go b/spa/hsts_middleware.go new file mode 100644 index 0000000..b6dbc3b --- /dev/null +++ b/spa/hsts_middleware.go @@ -0,0 +1,33 @@ +package spa + +import ( + "fmt" + "net/http" + "strings" +) + +type HSTSMiddleware struct { + h http.Handler + seconds uint64 + includeSubdomains bool + preload bool +} + +func NewHSTSMiddleware(next http.Handler, hstsSeconds uint64, includeSubdomains bool, preload bool) (*HSTSMiddleware, error) { + return &HSTSMiddleware{next, hstsSeconds, includeSubdomains, preload}, nil +} + +func (o *HSTSMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if GetRequestScheme(r) == "https" { + stsParameters := []string{fmt.Sprintf("max-age=%d", o.seconds)} + if o.includeSubdomains { + stsParameters = append(stsParameters, "includeSubDomains") + } + if o.preload { + stsParameters = append(stsParameters, "preload") + } + stsString := strings.Join(stsParameters[:], "; ") + w.Header().Set("Strict-Transport-Security", stsString) + } + o.h.ServeHTTP(w, r) +} diff --git a/spa/oembed_middleware.go b/spa/oembed_middleware.go index 89cab0c..bae2eba 100644 --- a/spa/oembed_middleware.go +++ b/spa/oembed_middleware.go @@ -23,16 +23,7 @@ func (o *OembedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Prepare the target url targetUrl := cloneUrl(r.URL) targetUrl.Host = r.Host - - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { - if r.TLS == nil { - scheme = "http" - } else { - scheme = "https" - } - } - targetUrl.Scheme = scheme + targetUrl.Scheme = GetRequestScheme(r) // Build the oEmbed URL u := cloneUrl(o.URL) diff --git a/spa/utils.go b/spa/utils.go new file mode 100644 index 0000000..37f5e7f --- /dev/null +++ b/spa/utils.go @@ -0,0 +1,15 @@ +package spa + +import "net/http" + +func GetRequestScheme(r *http.Request) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if r.TLS == nil { + scheme = "http" + } else { + scheme = "https" + } + } + return scheme +} From b745e147581010f3f2fb37c55c54288f0d2047fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 21 Jun 2022 12:01:30 +0200 Subject: [PATCH 3/3] fix: lint --- main.go | 6 +++--- spa/fs.go | 36 ++++++++++-------------------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/main.go b/main.go index f05c937..893ad8a 100644 --- a/main.go +++ b/main.go @@ -31,9 +31,9 @@ func usage() { fmt.Fprintf(out, "Options:\n") fmt.Fprintf(out, " -listen: Port to listen to (default %d)\n", port) fmt.Fprintf(out, " -oembed-url: Sets the oEmbed Link header if set (env: $SERVEGO_OEMBED_URL) (default %s)\n", oEmbedUrl) - fmt.Fprintf(out, " -hstsSeconds: Sets the HTTP Strict Transport Security max-age header if set (env: $SERVEGO_HSTS_SECONDS) (default %s)\n", hstsSeconds) - fmt.Fprintf(out, " -hstsIncludeSubDomains: Sets the HTTP Strict Transport Security includeSubDmains header if set (env: $SERVEGO_HSTS_INCLUDE_SUBDOMAINS) (default %s)\n", hstsIncludeSubDomains) - fmt.Fprintf(out, " -hstsPreload: Sets the HTTP Strict Transport Security preload header if set (env: $SERVEGO_HSTS_PRELOAD) (default %s)\n", hstsPreload) + fmt.Fprintf(out, " -hstsSeconds: Sets the HTTP Strict Transport Security max-age header if larger than 0 (env: $SERVEGO_HSTS_SECONDS) (default %d)\n", hstsSeconds) + fmt.Fprintf(out, " -hstsIncludeSubDomains: Sets the HTTP Strict Transport Security includeSubDomains header if set (env: $SERVEGO_HSTS_INCLUDE_SUBDOMAINS) (default %v)\n", hstsIncludeSubDomains) + fmt.Fprintf(out, " -hstsPreload: Sets the HTTP Strict Transport Security preload header if set (env: $SERVEGO_HSTS_PRELOAD) (default %v)\n", hstsPreload) fmt.Fprintf(out, " : Folder to serve (default to current directory)\n") } diff --git a/spa/fs.go b/spa/fs.go index 179e3b8..1df78b2 100644 --- a/spa/fs.go +++ b/spa/fs.go @@ -145,15 +145,15 @@ func serveContent(w http.ResponseWriter, r *http.Request, name string, modtime t for _, ra := range ranges { part, err := mw.CreatePart(ra.mimeHeader(ctype, size)) if err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) return } if _, err := content.Seek(ra.start, io.SeekStart); err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) return } if _, err := io.CopyN(part, content, ra.length); err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) return } } @@ -174,6 +174,7 @@ func serveContent(w http.ResponseWriter, r *http.Request, name string, modtime t fmt.Println(name, "code=", code, "size=", sendSize, "ct=", w.Header().Get("Content-Type"), "ce=", w.Header().Get("Content-Encoding")) if r.Method != "HEAD" { + // nolint:errcheck io.CopyN(w, sendContent, sendSize) } } @@ -512,6 +513,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, hfs http.FileSystem, name return } setLastModified(w, d.ModTime()) + // nolint:errcheck w.Write([]byte("directory listing disabled")) return } @@ -546,29 +548,10 @@ func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { w.WriteHeader(http.StatusMovedPermanently) } -func containsDotDot(v string) bool { - if !strings.Contains(v, "..") { - return false - } - for _, ent := range strings.FieldsFunc(v, isSlashRune) { - if ent == ".." { - return true - } - } - return false -} - -func isSlashRune(r rune) bool { return r == '/' || r == '\\' } - type fileHandler struct { root http.FileSystem } -var ( - errMissingSeek = errors.New("io.File missing Seek method") - errMissingReadDir = errors.New("io.File directory missing ReadDir method") -) - // FileServer returns a handler that serves HTTP requests // with the contents of the file system rooted at root. // @@ -608,10 +591,10 @@ func (r httpRange) contentRange(size int64) string { } func (r httpRange) mimeHeader(contentType string, size int64) textproto.MIMEHeader { - return textproto.MIMEHeader{ - "Content-Range": {r.contentRange(size)}, - "Content-Type": {contentType}, - } + h := make(textproto.MIMEHeader) + h.Set("Content-Range", r.contentRange(size)) + h.Set("Content-Type", contentType) + return h } // parseRange parses a Range header string as per RFC 7233. @@ -704,6 +687,7 @@ func rangesMIMESize(ranges []httpRange, contentType string, contentSize int64) ( var w countingWriter mw := multipart.NewWriter(&w) for _, ra := range ranges { + // nolint:errcheck mw.CreatePart(ra.mimeHeader(contentType, contentSize)) encSize += ra.length }