From 644924b3ecc9718099f6b6f1d051086284e75c5e Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:00:28 -0400 Subject: [PATCH 1/4] Add BYO TLS certificate support (#145, #146) Serve content over HTTPS using your own certificate and key pair. When TLS is active, the server runs dual listeners: HTTPS for content and HTTP for automatic redirects. Certificates can be reloaded at runtime via SIGHUP or the /_/tls/reload API endpoint. New flags: --tls-cert, --tls-key, --hostname, --https-port, --http-port. All CLI flags are now organized into logical groups in --help output. --- README.md | 76 +++-- app.go | 98 +++++++ docs/README.md | 1 + docs/authentication.md | 2 +- docs/static-file-server.md | 6 +- docs/tls.md | 128 +++++++++ go.mod | 8 +- go.sum | 8 +- internal/server/filter.go | 11 + internal/server/filter_test.go | 45 +++ internal/server/handlers.go | 11 +- internal/server/listener.go | 124 ++++++++- internal/server/router.go | 11 +- internal/server/server.go | 17 ++ internal/server/signal_other.go | 9 + internal/server/signal_unix.go | 46 +++ internal/server/startup.go | 21 +- internal/server/tls.go | 145 ++++++++++ internal/server/tls_test.go | 433 +++++++++++++++++++++++++++++ internal/server/validation.go | 85 ++++++ internal/server/validation_test.go | 333 ++++++++++++++++++++++ 21 files changed, 1575 insertions(+), 43 deletions(-) create mode 100644 docs/tls.md create mode 100644 internal/server/signal_other.go create mode 100644 internal/server/signal_unix.go create mode 100644 internal/server/tls.go create mode 100644 internal/server/tls_test.go create mode 100644 internal/server/validation_test.go diff --git a/README.md b/README.md index d3d0676..2fd49b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ * **Markdown support:** if a `README.md` or `readme.md` file is present in the directory during directory listing, it will be rendered as HTML. Additional support for GitHub-flavored markdown is also available. * **Fully air-gapped:** the directory listing feature is fully air-gapped, meaning that it does not require any external resources to be loaded. This is useful for environments where internet access is not available. * **Redirections support:** if a `_redirections` file exists in the target directory, it will be used to redirect requests to other locations. Learn about the syntax [in the docs](docs/redirections.md). +* **TLS / HTTPS support:** serve content over HTTPS using your own certificate and key. Includes automatic HTTP-to-HTTPS redirects, certificate reload via SIGHUP or API, and a `/_/tls` metadata endpoint. Learn more [in the docs](docs/tls.md). The app is available both as a standalone binary and as a Docker container image. @@ -80,34 +81,53 @@ A simple HTTP server and a directory listing tool. Usage: http-server [flags] -Flags: - --banner string markdown text to be rendered at the top of the directory listing page - --cors enable CORS support by setting the "Access-Control-Allow-Origin" header to "*" - --custom-404 string custom "page not found" to serve - --custom-404-code int custom status code for pages not found - --custom-css-file string path within the served files to a custom CSS file - --disable-cache-buster disable the cache buster for assets from the directory listing feature - --disable-directory-listing disable the directory listing feature and return 404s for directories without index - --disable-etag disable etag header generation - --disable-markdown disable the markdown rendering feature - --disable-redirects disable redirection file handling - --ensure-unexpired-jwt enable time validation for JWT claims "exp" and "nbf" - --etag-max-size string maximum size for etag header generation, where bigger size = more memory usage (default "5M") - --force-download-extensions strings file extensions that should be downloaded instead of displayed in browser - --gzip enable gzip compression for supported content-types - -h, --help help for http-server - --hide-files-in-markdown hide file and directory listing in markdown rendering - --hide-links hide the links to this project's source code visible in the header and footer - --jwt-key string signing key for JWT authentication - --markdown-before-dir render markdown content before the directory listing - --password string password for basic authentication - -d, --path string path to the directory you want to serve (default "./") - --pathprefix string path prefix for the URL where the server will listen on (default "/") - -p, --port int port to configure the server to listen on (default 5000) - --render-all-markdown if enabled, all Markdown files will be rendered using the same rendering as the directory listing READMEs - --title string title of the directory listing page - --username string username for basic authentication - -v, --version version for http-server +Server: + --banner string markdown text to be rendered at the top of the directory listing page + --cors enable CORS support by setting the "Access-Control-Allow-Origin" header to "*" + --gzip enable gzip compression for supported content-types + --hide-links hide the links to this project's source code visible in the header and footer + -d, --path string path to the directory you want to serve (default "./") + --pathprefix string path prefix for the URL where the server will listen on (default "/") + -p, --port int port to configure the server to listen on (default 5000) + --title string title of the directory listing page + +TLS: + --hostname string hostname for HTTP-to-HTTPS redirects, required when TLS is active + --http-port int HTTP listener port when TLS is active, use 0 to disable HTTP redirect (default 80) + --https-port int HTTPS listener port when TLS is active (default 443) + --tls-cert string path to TLS certificate file in PEM format + --tls-key string path to TLS private key file in PEM format + +Authentication: + --ensure-unexpired-jwt enable time validation for JWT claims "exp" and "nbf" + --jwt-key string signing key for JWT authentication + --password string password for basic authentication + --username string username for basic authentication + +Directory Listing: + --custom-css-file string path within the served files to a custom CSS file + --disable-cache-buster disable the cache buster for assets from the directory listing feature + --disable-directory-listing disable the directory listing feature and return 404s for directories without index + --disable-markdown disable the markdown rendering feature + --hide-files-in-markdown hide file and directory listing in markdown rendering + --markdown-before-dir render markdown content before the directory listing + --render-all-markdown if enabled, all Markdown files will be rendered using the same rendering as the directory listing READMEs + +Error Pages: + --custom-404 string custom "page not found" to serve + --custom-404-code int custom status code for pages not found + +Performance: + --disable-etag disable etag header generation + --etag-max-size string maximum size for etag header generation, where bigger size = more memory usage (default "5M") + --force-download-extensions strings file extensions that should be downloaded instead of displayed in browser + +Other: + --disable-redirects disable redirection file handling + +Global: + -h, --help help for http-server + -v, --version version for http-server ``` ### Detailed configuration diff --git a/app.go b/app.go index a374ec5..21d6100 100644 --- a/app.go +++ b/app.go @@ -65,6 +65,11 @@ func run() error { srv.LogOutput = cmd.OutOrStdout() srv.SetVersion(version) + // Track whether port flags were explicitly set for TLS validation + srv.PortExplicitlySet = cmd.Flags().Changed("port") + srv.HTTPPortExplicitlySet = cmd.Flags().Changed("http-port") + srv.HTTPSPortExplicitlySet = cmd.Flags().Changed("https-port") + // Validate fields to make sure they're correct if err := srv.Validate(); err != nil { return fmt.Errorf("unable to validate configuration: %w", err) @@ -133,10 +138,103 @@ func run() error { flags.BoolVar(&srv.FullMarkdownRender, "render-all-markdown", false, "if enabled, all Markdown files will be rendered using the same rendering as the directory listing READMEs") flags.StringSliceVar(&srv.ForceDownloadExtensions, "force-download-extensions", nil, "file extensions that should be downloaded instead of displayed in browser") + // TLS flags + flags.StringVar(&srv.TLSCert, "tls-cert", "", "path to TLS certificate file in PEM format") + flags.StringVar(&srv.TLSKey, "tls-key", "", "path to TLS private key file in PEM format") + flags.IntVar(&srv.HTTPPort, "http-port", 80, "HTTP listener port when TLS is active, use 0 to disable HTTP redirect") + flags.IntVar(&srv.HTTPSPort, "https-port", 443, "HTTPS listener port when TLS is active") + flags.StringVar(&srv.Hostname, "hostname", "", "hostname for HTTP-to-HTTPS redirects, required when TLS is active") + + // Annotate flags with groups for the custom help template + flagGroups := map[string][]string{ + "Server": {"port", "path", "pathprefix", "title", "banner", "gzip", "cors", "hide-links"}, + "TLS": {"tls-cert", "tls-key", "http-port", "https-port", "hostname"}, + "Authentication": {"username", "password", "jwt-key", "ensure-unexpired-jwt"}, + "Directory Listing": { + "disable-directory-listing", "disable-markdown", "markdown-before-dir", "render-all-markdown", + "hide-files-in-markdown", "custom-css-file", "disable-cache-buster", + }, + "Error Pages": {"custom-404", "custom-404-code"}, + "Performance": {"disable-etag", "etag-max-size", "force-download-extensions"}, + "Other": {"disable-redirects"}, + } + + for group, names := range flagGroups { + for _, name := range names { + rootCmd.Flags().SetAnnotation(name, "group", []string{group}) //nolint:errcheck // best-effort grouping + } + } + + rootCmd.SetUsageFunc(groupedUsageFunc(flagGroups)) + //nolint:wrapcheck // no need to wrap this error return rootCmd.Execute() } +// groupedUsageFunc returns a cobra UsageFunc that renders flags organized +// by group annotations instead of a flat alphabetical list. +func groupedUsageFunc(groups map[string][]string) func(*cobra.Command) error { + // Build a reverse map: flag name -> group name + flagToGroup := make(map[string]string) + for group, names := range groups { + for _, name := range names { + flagToGroup[name] = group + } + } + + // Ordered group names for consistent output + groupOrder := []string{"Server", "TLS", "Authentication", "Directory Listing", "Error Pages", "Performance", "Other"} + + return func(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Usage:\n %s\n\n", cmd.UseLine()) + + // Collect flags by group + grouped := make(map[string][]*pflag.Flag) + var ungrouped []*pflag.Flag + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + if group, ok := flagToGroup[f.Name]; ok { + grouped[group] = append(grouped[group], f) + } else { + ungrouped = append(ungrouped, f) + } + }) + + // Print each group + for _, group := range groupOrder { + flags, ok := grouped[group] + if !ok || len(flags) == 0 { + continue + } + + fmt.Fprintf(out, "%s:\n", group) + fs := pflag.NewFlagSet(group, pflag.ContinueOnError) + for _, f := range flags { + fs.AddFlag(f) + } + fmt.Fprint(out, fs.FlagUsages()) + fmt.Fprintln(out) + } + + // Print ungrouped flags (help, version) + if len(ungrouped) > 0 { + fmt.Fprintf(out, "Global:\n") + fs := pflag.NewFlagSet("global", pflag.ContinueOnError) + for _, f := range ungrouped { + fs.AddFlag(f) + } + fmt.Fprint(out, fs.FlagUsages()) + fmt.Fprintln(out) + } + + return nil + } +} + // sendPipeToLogger reads from the pipe and sends the output to the logger func sendPipeToLogger(logger *log.Logger, pipe io.Reader) { // Scan the log messages per line diff --git a/docs/README.md b/docs/README.md index 157977e..7d078c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,3 +8,4 @@ This is the documentation for `http-server`, a simple, no-dependencies command-l * [Authentication](authentication.md) * [Redirections](redirections.md) * [Force Download Extensions](force-download.md) +* [TLS / HTTPS support](tls.md) diff --git a/docs/authentication.md b/docs/authentication.md index 7ccc6c9..474c470 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -6,7 +6,7 @@ You can enable basic authentication by using the `--username` and `--password` flags. If both flags are provided, the server will require the provided username and password to access its contents. -This is the simplest form of authentication. The username and password are sent in plain text over the network if you are not serving `http-server` via HTTPS. As such, it's not recommended for production use. If you still decide to use it, use a strong password. +This is the simplest form of authentication. The username and password are sent in plain text over the network if you are not using HTTPS. For production use, enable [TLS support](tls.md) with `--tls-cert` and `--tls-key` to encrypt the connection. If you still decide to use it without HTTPS, use a strong password. ### JWT authentication diff --git a/docs/static-file-server.md b/docs/static-file-server.md index 39f64fb..94786bf 100644 --- a/docs/static-file-server.md +++ b/docs/static-file-server.md @@ -1,5 +1,9 @@ # Static file server -The core nature of `http-server` is to be a static file server. You can serve any folder in the node where `http-server` is running. **None of the files are hidden**, which means if the user that's executing `http-server` can see them, then they will be listed. The only exception is the `.http-server.yaml` configuration file, which is removed from view and direct access, since it may contain sensitive information. +The core nature of `http-server` is to be a static file server. You can serve any folder in the node where `http-server` is running. **None of the files are hidden**, which means if the user that's executing `http-server` can see them, then they will be listed. The exceptions are: + +* The `.http-server.yaml` configuration file, which is removed from view and direct access since it may contain sensitive information. +* The `_redirects` file used for redirection rules. +* TLS certificate and key files, if they reside inside the served directory and TLS is active. These are hidden from listings and blocked from direct download. See the [TLS documentation](tls.md) for details. The files served are type-hinted and their `Content-Type` header set through this method. The server also supports `Accept-Ranges` header, meaning you can perform partial requests for bigger files and ensure it's possible to download them in chunks if needed. diff --git a/docs/tls.md b/docs/tls.md new file mode 100644 index 0000000..b95cf24 --- /dev/null +++ b/docs/tls.md @@ -0,0 +1,128 @@ +# TLS / HTTPS support + +`http-server` supports serving content over HTTPS using your own TLS certificate and key. + +## Quick start + +```bash +http-server --tls-cert cert.pem --tls-key key.pem --hostname example.com -d ./site +``` + +This starts two listeners: + +- **HTTPS** on port 443 serving your content +- **HTTP** on port 80 redirecting all requests to HTTPS + +## Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--tls-cert` | *(none)* | Path to the TLS certificate file (PEM format) | +| `--tls-key` | *(none)* | Path to the TLS private key file (PEM format) | +| `--hostname` | *(none, required with TLS)* | Hostname used in HTTP-to-HTTPS redirect URLs | +| `--https-port` | `443` | Port for the HTTPS listener | +| `--http-port` | `80` | Port for the HTTP redirect listener (use `0` to disable) | + +Both `--tls-cert` and `--tls-key` must be provided together. When TLS is active, the `--port` flag cannot be used. Use `--http-port` and `--https-port` instead. + +## Custom ports + +To use non-privileged ports (no root required): + +```bash +http-server --tls-cert cert.pem --tls-key key.pem --hostname localhost \ + --https-port 8443 --http-port 8080 -d ./site +``` + +## Disabling the HTTP redirect + +If you only want HTTPS with no HTTP listener at all: + +```bash +http-server --tls-cert cert.pem --tls-key key.pem --hostname example.com \ + --http-port 0 -d ./site +``` + +## Certificate file hiding + +If your certificate and key files are inside the directory being served, they are automatically hidden from directory listings and blocked from direct download. A warning is printed at startup recommending you move them outside the served directory. + +## Certificate reload + +You can reload the TLS certificate without restarting the server: + +- **Unix:** send `SIGHUP` to the process: `kill -HUP $(pgrep http-server)` +- **Any platform:** send a POST request to the `/_/tls/reload` endpoint + +The reload endpoint returns JSON: + +```json +{"reloaded": true} +``` + +If the new certificate is invalid, the old certificate is preserved and an error is returned. + +## Certificate metadata endpoint + +When TLS is active, a `GET /_/tls` endpoint returns JSON metadata about the currently loaded certificate: + +```json +{ + "tls_mode": "byo", + "tls_cert_subject": "example.com", + "tls_cert_sans": ["example.com", "www.example.com"], + "tls_cert_issuer": "Let's Encrypt", + "tls_cert_not_after": "2026-06-15T00:00:00Z", + "tls_cert_not_before": "2026-03-15T00:00:00Z" +} +``` + +When TLS is not active, this endpoint is not available. + +Both `/_/tls` and `/_/tls/reload` are protected by the same authentication (basic auth or JWT) as the rest of the server, if configured. + +## Validation + +At startup, `http-server` validates: + +- Both `--tls-cert` and `--tls-key` are provided together +- The certificate and key files exist and form a valid pair +- The certificate has not expired (hard error) +- The certificate is not future-dated (hard error) +- The certificate expiry is within 30 days (warning, server still starts) +- `--port` is not explicitly set alongside TLS flags +- `--http-port` and `--https-port` differ (unless `--http-port` is `0`) +- `--hostname` is provided + +## Docker usage + +```yaml +services: + http-server: + image: ghcr.io/patrickdappollonio/docker-http-server:v2 + restart: unless-stopped + ports: + - "443:443" + - "80:80" + volumes: + - ./site:/html:ro + - ./certs:/certs:ro + environment: + - TLS_CERT=/certs/cert.pem + - TLS_KEY=/certs/key.pem + - HOSTNAME=example.com +``` + +Or with custom ports: + +```yaml + ports: + - "8443:8443" + - "8080:8080" + environment: + - TLS_CERT=/certs/cert.pem + - TLS_KEY=/certs/key.pem + - HOSTNAME=example.com + - HTTPS_PORT=8443 + - HTTP_PORT=8080 +``` diff --git a/go.mod b/go.mod index 48bb0f3..d3beced 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/patrickdappollonio/http-server -go 1.24.0 - -toolchain go1.24.3 +go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.5 @@ -16,6 +14,7 @@ require ( github.com/yuin/goldmark v1.7.17 go.abhg.dev/goldmark/mermaid v0.6.0 go.uber.org/automaxprocs v1.6.0 + golang.org/x/sync v0.19.0 ) require ( @@ -27,7 +26,8 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect diff --git a/go.sum b/go.sum index 4c7ef7a..5b90892 100644 --- a/go.sum +++ b/go.sum @@ -59,10 +59,12 @@ github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -88,6 +90,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= diff --git a/internal/server/filter.go b/internal/server/filter.go index b07eb02..4eeae59 100644 --- a/internal/server/filter.go +++ b/internal/server/filter.go @@ -61,3 +61,14 @@ func (s *Server) isFiltered(filename string) bool { return false } + +// isAbsolutePathForbidden returns true if the given absolute path matches +// a forbidden absolute path (used for TLS cert/key file hiding). +func (s *Server) isAbsolutePathForbidden(absPath string) bool { + for _, forbidden := range s.forbiddenAbsPaths { + if absPath == forbidden { + return true + } + } + return false +} diff --git a/internal/server/filter_test.go b/internal/server/filter_test.go index 1495975..5bc3feb 100644 --- a/internal/server/filter_test.go +++ b/internal/server/filter_test.go @@ -51,3 +51,48 @@ func Test_isFiltered(t *testing.T) { }) } } + +func TestIsAbsolutePathForbidden(t *testing.T) { + tests := []struct { + name string + forbiddenPaths []string + checkPath string + want bool + }{ + { + name: "cert in served dir is hidden", + forbiddenPaths: []string{"/srv/www/cert.pem"}, + checkPath: "/srv/www/cert.pem", + want: true, + }, + { + name: "cert outside served dir is not hidden", + forbiddenPaths: []string{"/etc/tls/cert.pem"}, + checkPath: "/srv/www/cert.pem", + want: false, + }, + { + name: "same name in different directory is not hidden", + forbiddenPaths: []string{"/srv/www/cert.pem"}, + checkPath: "/srv/www/subdir/cert.pem", + want: false, + }, + { + name: "empty forbidden paths", + forbiddenPaths: nil, + checkPath: "/srv/www/cert.pem", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Server{ + forbiddenAbsPaths: tt.forbiddenPaths, + } + if got := s.isAbsolutePathForbidden(tt.checkPath); got != tt.want { + t.Errorf("isAbsolutePathForbidden() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index cbc1ec6..71c3b34 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -39,6 +39,12 @@ func (s *Server) showOrRender(w http.ResponseWriter, r *http.Request) { return } + // Block access to forbidden absolute paths (e.g., TLS cert/key files) + if s.isAbsolutePathForbidden(currentPath) { + httpErrorf(http.StatusNotFound, w, "404 not found") + return + } + // Stat the current path info, err := os.Stat(currentPath) //nolint:gosec // path is sanitized via filepath.Abs and constrained to the serving root if err != nil { @@ -209,10 +215,13 @@ func (s *Server) walk(requestedPath string, w http.ResponseWriter, r *http.Reque return } - // Check if file starts with config prefix + // Check if file is filtered by name or absolute path if s.isFiltered(fi.Name()) { continue } + if fullPath := filepath.Join(requestedPath, fi.Name()); s.isAbsolutePathForbidden(fullPath) { + continue + } files = append(files, fi) } diff --git a/internal/server/listener.go b/internal/server/listener.go index 51bc272..b2a1738 100644 --- a/internal/server/listener.go +++ b/internal/server/listener.go @@ -2,12 +2,17 @@ package server import ( "context" + "crypto/tls" "fmt" "net/http" "os" "os/signal" "syscall" "time" + + "golang.org/x/sync/errgroup" + + "github.com/patrickdappollonio/http-server/internal/middlewares" ) func (s *Server) ListenAndServe() error { @@ -23,16 +28,22 @@ func (s *Server) ListenAndServe() error { s.cacheBuster = s.version } - // Set up an initial server + if s.IsTLSEnabled() { + return s.listenTLS() + } + + return s.listenHTTPOnly() +} + +// listenHTTPOnly starts a single HTTP listener (existing behavior). +func (s *Server) listenHTTPOnly() error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", s.Port), Handler: s.router(), } - // Create a signal to wait for an error done := make(chan error, 1) - // Start the server asynchronously go func() { fmt.Fprintln(s.LogOutput, "Starting server...") if err := srv.ListenAndServe(); err != nil { @@ -44,7 +55,6 @@ func (s *Server) ListenAndServe() error { } }() - // Wait for a closing signal go func() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer cancel() @@ -59,3 +69,109 @@ func (s *Server) ListenAndServe() error { return <-done } + +// listenTLS starts dual HTTP+HTTPS listeners with errgroup coordination. +func (s *Server) listenTLS() error { + router := s.router() + + // HTTPS server with GetCertificate callback + tlsConfig := &tls.Config{ + GetCertificate: s.getCertificate, + } + httpsServer := &http.Server{ + Addr: fmt.Sprintf(":%d", s.HTTPSPort), + Handler: router, + TLSConfig: tlsConfig, + } + + // HTTP redirect server (disabled when --http-port 0) + var httpServer *http.Server + if s.HTTPPort != 0 { + httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.HTTPPort), + Handler: s.httpRedirectHandler(), + } + } + + g, ctx := errgroup.WithContext(context.Background()) + + // HTTPS listener + g.Go(func() error { + fmt.Fprintf(s.LogOutput, "Starting HTTPS server on :%d...\n", s.HTTPSPort) + // Empty cert/key paths: GetCertificate provides the cert + if err := httpsServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + return fmt.Errorf("HTTPS server error: %w", err) + } + return nil + }) + + // HTTP redirect listener + if httpServer != nil { + g.Go(func() error { + fmt.Fprintf(s.LogOutput, "Starting HTTP server on :%d (redirecting to HTTPS)...\n", s.HTTPPort) + if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { + return fmt.Errorf("HTTP server error: %w", err) + } + return nil + }) + } + + // Context watcher: if the errgroup context is cancelled (from any goroutine + // failing), shut down all servers to prevent g.Wait() from hanging. + g.Go(func() error { + <-ctx.Done() + shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutCancel() + httpsServer.Shutdown(shutCtx) //nolint:errcheck,contextcheck // best-effort shutdown with fresh deadline + if httpServer != nil { + httpServer.Shutdown(shutCtx) //nolint:errcheck,contextcheck // best-effort shutdown with fresh deadline + } + return nil + }) + + // Signal handler: SIGINT/SIGTERM trigger graceful shutdown + g.Go(func() error { + sigCtx, sigCancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer sigCancel() + + <-sigCtx.Done() + fmt.Fprintln(s.LogOutput, "Requesting server to stop. Please wait...") + + //nolint:contextcheck // intentionally creating a new context for shutdown deadline, independent of the cancelled signal context + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer shutCancel() + + if err := httpsServer.Shutdown(shutCtx); err != nil { //nolint:contextcheck // shutdown uses a fresh deadline context, not the cancelled signal context + fmt.Fprintf(s.LogOutput, "HTTPS shutdown error: %v\n", err) + } + if httpServer != nil { + if err := httpServer.Shutdown(shutCtx); err != nil { //nolint:contextcheck // same as above + fmt.Fprintf(s.LogOutput, "HTTP shutdown error: %v\n", err) + } + } + + fmt.Fprintln(s.LogOutput, "Server closed. Bye!") + return nil + }) + + // SIGHUP handler for cert reload (Unix only, no-op on Windows) + s.startSIGHUPHandler(ctx) + + //nolint:wrapcheck // errors from errgroup already wrapped by listener goroutines + return g.Wait() +} + +// httpRedirectHandler returns an HTTP handler that redirects all requests +// to the HTTPS equivalent using the configured hostname. +func (s *Server) httpRedirectHandler() http.Handler { + return middlewares.LogRequest(s.LogOutput, logFormat, "token")( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := "https://" + s.Hostname + if s.HTTPSPort != 443 { + target += fmt.Sprintf(":%d", s.HTTPSPort) + } + target += r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + }), + ) +} diff --git a/internal/server/router.go b/internal/server/router.go index 6c05750..66706e1 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -23,7 +23,8 @@ func (s *Server) router() http.Handler { r.Use(middleware.Recoverer) // Only allow specific methods in all our requests - r.Use(middlewares.VerbsAllowed("GET", "HEAD")) + // POST is included for the /_/tls/reload endpoint + r.Use(middlewares.VerbsAllowed("GET", "HEAD", "POST")) // Disable access to specific files r.Use(middlewares.DisableAccessToFile(s.isFiltered, http.StatusNotFound)) @@ -95,6 +96,14 @@ func (s *Server) router() http.Handler { // Create a health check endpoint r.HandleFunc(path.Join(s.PathPrefix, specialPath, "health"), s.healthCheck) + // TLS info and reload endpoints (only when TLS is active) + // Protected by the same auth as file serving + if s.IsTLSEnabled() { + tlsPath := path.Join(s.PathPrefix, specialPath, "tls") + r.With(basicAuth, jwtAuth).Get(tlsPath, s.tlsInfoHandler) + r.With(basicAuth, jwtAuth).Post(path.Join(tlsPath, "reload"), s.tlsReloadHandler) + } + // Handle special path prefix cases if s.PathPrefix != "/" { // If the path prefix is not the root of the server, then we diff --git a/internal/server/server.go b/internal/server/server.go index 44647b2..df01c4e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,10 +1,12 @@ package server import ( + "crypto/tls" "html/template" "io" "path" "strings" + "sync/atomic" "github.com/patrickdappollonio/http-server/internal/redirects" ) @@ -69,6 +71,21 @@ type Server struct { // Force download settings ForceDownloadExtensions []string SkipForceDownloadFiles []string + + // TLS settings + TLSCert string `flagName:"tls-cert"` + TLSKey string `flagName:"tls-key"` + HTTPPort int `flagName:"http-port"` + HTTPSPort int `flagName:"https-port"` + Hostname string `flagName:"hostname"` + + // Internal TLS fields + activeTLSMode TLSMode + certPointer atomic.Pointer[tls.Certificate] + PortExplicitlySet bool + HTTPPortExplicitlySet bool + HTTPSPortExplicitlySet bool + forbiddenAbsPaths []string } // IsBasicAuthEnabled returns true if the server has been configured with diff --git a/internal/server/signal_other.go b/internal/server/signal_other.go new file mode 100644 index 0000000..9bbe592 --- /dev/null +++ b/internal/server/signal_other.go @@ -0,0 +1,9 @@ +//go:build windows + +package server + +import "context" + +// startSIGHUPHandler is a no-op on Windows. Use the /_/tls/reload +// endpoint for certificate reloading instead. +func (s *Server) startSIGHUPHandler(_ context.Context) {} diff --git a/internal/server/signal_unix.go b/internal/server/signal_unix.go new file mode 100644 index 0000000..92a48eb --- /dev/null +++ b/internal/server/signal_unix.go @@ -0,0 +1,46 @@ +//go:build !windows + +package server + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +// startSIGHUPHandler starts a goroutine that reloads TLS certificates +// on SIGHUP with a 1-second debounce to prevent rapid reload storms. +func (s *Server) startSIGHUPHandler(ctx context.Context) { + if !s.IsTLSEnabled() { + return + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + defer signal.Stop(sigCh) + + var lastReload time.Time + + for { + select { + case <-ctx.Done(): + return + case <-sigCh: + if time.Since(lastReload) < time.Second { + fmt.Fprintln(s.LogOutput, "SIGHUP received but debounced (less than 1s since last reload)") + continue + } + lastReload = time.Now() + if err := s.reloadCert(); err != nil { + fmt.Fprintf(s.LogOutput, "TLS certificate reload failed: %v\n", err) + } else { + fmt.Fprintln(s.LogOutput, "TLS certificate reloaded successfully") + } + } + } + }() +} diff --git a/internal/server/startup.go b/internal/server/startup.go index e5e1d7d..70da73f 100644 --- a/internal/server/startup.go +++ b/internal/server/startup.go @@ -13,7 +13,26 @@ const startupPrefix = " >" func (s *Server) PrintStartup() { fmt.Fprintln(s.LogOutput, "SETUP:") - fmt.Fprintln(s.LogOutput, startupPrefix, "Configured to use port:", s.Port) + if s.IsTLSEnabled() { + fmt.Fprintln(s.LogOutput, startupPrefix, "TLS mode:", s.ActiveTLSMode()) + fmt.Fprintln(s.LogOutput, startupPrefix, "TLS certificate:", s.TLSCert) + fmt.Fprintln(s.LogOutput, startupPrefix, "TLS key:", s.TLSKey) + fmt.Fprintln(s.LogOutput, startupPrefix, "Hostname:", s.Hostname) + fmt.Fprintln(s.LogOutput, startupPrefix, "HTTPS port:", s.HTTPSPort) + if s.HTTPPort != 0 { + fmt.Fprintf(s.LogOutput, "%s HTTP port: %d (redirecting to HTTPS)\n", startupPrefix, s.HTTPPort) + } else { + fmt.Fprintln(s.LogOutput, startupPrefix, "HTTP redirect disabled (--http-port 0)") + } + + // Print cert expiry info + if meta := s.certMetadata(); meta != nil { + fmt.Fprintf(s.LogOutput, "%s Certificate subject: %s\n", startupPrefix, meta["tls_cert_subject"]) + fmt.Fprintf(s.LogOutput, "%s Certificate expires: %s\n", startupPrefix, meta["tls_cert_not_after"]) + } + } else { + fmt.Fprintln(s.LogOutput, startupPrefix, "Configured to use port:", s.Port) + } fmt.Fprintln(s.LogOutput, startupPrefix, "Serving path:", s.Path) if s.PathPrefix != "" && s.PathPrefix != "/" { diff --git a/internal/server/tls.go b/internal/server/tls.go new file mode 100644 index 0000000..1b8f8bd --- /dev/null +++ b/internal/server/tls.go @@ -0,0 +1,145 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +// TLSMode represents the server's TLS operating mode. +type TLSMode string + +const ( + TLSModeOff TLSMode = "off" + TLSModeBYO TLSMode = "byo" + TLSModeAuto TLSMode = "auto" // Phase 2: certmagic +) + +// IsTLSEnabled returns true if TLS is active in any mode. +func (s *Server) IsTLSEnabled() bool { + return s.activeTLSMode != TLSModeOff && s.activeTLSMode != "" +} + +// ActiveTLSMode returns the current TLS mode. +func (s *Server) ActiveTLSMode() TLSMode { + return s.activeTLSMode +} + +// loadAndStoreCert loads the certificate and key from disk, validates +// expiry, and stores the result in the atomic pointer. +func (s *Server) loadAndStoreCert() error { + cert, err := tls.LoadX509KeyPair(s.TLSCert, s.TLSKey) + if err != nil { + return fmt.Errorf("unable to load TLS certificate and key: %w", err) + } + + // Parse the leaf certificate for metadata and expiry checking + if cert.Leaf == nil { + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("unable to parse TLS certificate: %w", err) + } + cert.Leaf = parsed + } + + // Check if the certificate is not yet valid or has expired + now := time.Now() + if now.Before(cert.Leaf.NotBefore) { + return fmt.Errorf("TLS certificate is not yet valid (valid from %s)", cert.Leaf.NotBefore.Format(time.RFC3339)) + } + if now.After(cert.Leaf.NotAfter) { + return fmt.Errorf("TLS certificate expired on %s", cert.Leaf.NotAfter.Format(time.RFC3339)) + } + + // Warn if the certificate expires within 30 days + if time.Until(cert.Leaf.NotAfter) < 30*24*time.Hour { + s.printWarningf("TLS certificate expires in less than 30 days (expires: %s)", cert.Leaf.NotAfter.Format(time.RFC3339)) + } + + s.certPointer.Store(&cert) + return nil +} + +// reloadCert reloads the certificate and key from disk. +func (s *Server) reloadCert() error { + return s.loadAndStoreCert() +} + +// getCertificate is the tls.Config.GetCertificate callback that returns +// the current certificate from the atomic pointer. +func (s *Server) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert := s.certPointer.Load() + if cert == nil { + return nil, errors.New("no TLS certificate loaded") + } + return cert, nil +} + +// certMetadata returns metadata about the currently loaded certificate. +func (s *Server) certMetadata() map[string]any { + cert := s.certPointer.Load() + if cert == nil || cert.Leaf == nil { + return nil + } + + leaf := cert.Leaf + + sans := make([]string, 0, len(leaf.DNSNames)+len(leaf.IPAddresses)) + sans = append(sans, leaf.DNSNames...) + for _, ip := range leaf.IPAddresses { + sans = append(sans, ip.String()) + } + + return map[string]any{ + "tls_mode": string(s.activeTLSMode), + "tls_cert_subject": leaf.Subject.CommonName, + "tls_cert_sans": sans, + "tls_cert_issuer": leaf.Issuer.CommonName, + "tls_cert_not_after": leaf.NotAfter.Format(time.RFC3339), + "tls_cert_not_before": leaf.NotBefore.Format(time.RFC3339), + } +} + +// tlsInfoHandler handles GET requests to /_/tls, returning certificate +// metadata as JSON. +func (s *Server) tlsInfoHandler(w http.ResponseWriter, _ *http.Request) { + meta := s.certMetadata() + if meta == nil { + http.Error(w, "no certificate loaded", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + //nolint:errchkjson // best-effort JSON response to HTTP client + json.NewEncoder(w).Encode(meta) +} + +// tlsReloadHandler handles POST requests to /_/tls/reload, triggering +// a certificate reload from disk. +func (s *Server) tlsReloadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.reloadCert(); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + //nolint:errchkjson // best-effort JSON error response + json.NewEncoder(w).Encode(map[string]any{ + "reloaded": false, + "error": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + //nolint:errchkjson // best-effort JSON response + json.NewEncoder(w).Encode(map[string]any{ + "reloaded": true, + }) +} diff --git a/internal/server/tls_test.go b/internal/server/tls_test.go new file mode 100644 index 0000000..d4d1374 --- /dev/null +++ b/internal/server/tls_test.go @@ -0,0 +1,433 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// generateTestCert creates a self-signed certificate and key in PEM format, +// writing them to the specified paths. The certificate is valid for the +// given duration from now. +func generateTestCert(t *testing.T, certPath, keyPath string, validFor time.Duration) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + notBefore := time.Now() + notAfter := notBefore.Add(validFor) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: notBefore, + NotAfter: notAfter, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + + certFile, err := os.Create(certPath) + if err != nil { + t.Fatalf("create cert file: %v", err) + } + pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + certFile.Close() + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + + keyFile, err := os.Create(keyPath) + if err != nil { + t.Fatalf("create key file: %v", err) + } + pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + keyFile.Close() +} + +// generateExpiredTestCert creates a certificate that expired in the past. +func generateExpiredTestCert(t *testing.T, certPath, keyPath string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "expired"}, + NotBefore: time.Now().Add(-48 * time.Hour), + NotAfter: time.Now().Add(-24 * time.Hour), + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + + certFile, err := os.Create(certPath) + if err != nil { + t.Fatalf("create cert file: %v", err) + } + pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + certFile.Close() + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + + keyFile, err := os.Create(keyPath) + if err != nil { + t.Fatalf("create key file: %v", err) + } + pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + keyFile.Close() +} + +func TestTLSMode(t *testing.T) { + t.Run("default is off", func(t *testing.T) { + s := &Server{} + if s.IsTLSEnabled() { + t.Error("expected TLS to be disabled by default") + } + // Zero value of TLSMode is "", which is treated as off + if mode := s.ActiveTLSMode(); mode != "" && mode != TLSModeOff { + t.Errorf("expected empty or TLSModeOff, got %s", mode) + } + }) + + t.Run("byo mode", func(t *testing.T) { + s := &Server{activeTLSMode: TLSModeBYO} + if !s.IsTLSEnabled() { + t.Error("expected TLS to be enabled in BYO mode") + } + if s.ActiveTLSMode() != TLSModeBYO { + t.Errorf("expected TLSModeBYO, got %s", s.ActiveTLSMode()) + } + }) +} + +func TestLoadAndStoreCert(t *testing.T) { + t.Run("valid cert", func(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("loadAndStoreCert() error: %v", err) + } + + cert := s.certPointer.Load() + if cert == nil { + t.Fatal("cert pointer is nil after loading") + } + if cert.Leaf == nil { + t.Fatal("cert leaf is nil after loading") + } + if cert.Leaf.Subject.CommonName != "localhost" { + t.Errorf("expected CN=localhost, got %s", cert.Leaf.Subject.CommonName) + } + }) + + t.Run("expired cert", func(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateExpiredTestCert(t, certPath, keyPath) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + } + + err := s.loadAndStoreCert() + if err == nil { + t.Fatal("expected error for expired cert") + } + }) + + t.Run("nonexistent cert", func(t *testing.T) { + s := &Server{ + TLSCert: "/nonexistent/cert.pem", + TLSKey: "/nonexistent/key.pem", + } + + err := s.loadAndStoreCert() + if err == nil { + t.Fatal("expected error for nonexistent cert files") + } + }) + + t.Run("mismatched cert and key", func(t *testing.T) { + dir := t.TempDir() + certPath1 := filepath.Join(dir, "cert1.pem") + keyPath1 := filepath.Join(dir, "key1.pem") + generateTestCert(t, certPath1, keyPath1, 365*24*time.Hour) + + certPath2 := filepath.Join(dir, "cert2.pem") + keyPath2 := filepath.Join(dir, "key2.pem") + generateTestCert(t, certPath2, keyPath2, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath1, + TLSKey: keyPath2, // mismatched key + } + + err := s.loadAndStoreCert() + if err == nil { + t.Fatal("expected error for mismatched cert and key") + } + }) +} + +func TestGetCertificate(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("loadAndStoreCert() error: %v", err) + } + + cert, err := s.getCertificate(&tls.ClientHelloInfo{}) + if err != nil { + t.Fatalf("getCertificate() error: %v", err) + } + if cert == nil { + t.Fatal("getCertificate() returned nil") + } +} + +func TestGetCertificate_NilPointer(t *testing.T) { + s := &Server{} + + _, err := s.getCertificate(&tls.ClientHelloInfo{}) + if err == nil { + t.Fatal("expected error when no cert is loaded") + } +} + +func TestReloadCert(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + } + + // Initial load + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("initial load: %v", err) + } + + oldCert := s.certPointer.Load() + + // Generate a new cert at the same paths + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + // Reload + if err := s.reloadCert(); err != nil { + t.Fatalf("reloadCert() error: %v", err) + } + + newCert := s.certPointer.Load() + if newCert == oldCert { + t.Error("cert pointer should have changed after reload") + } +} + +func TestReloadCert_PreservesOldOnFailure(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("initial load: %v", err) + } + + oldCert := s.certPointer.Load() + + // Delete the cert file to cause reload failure + os.Remove(certPath) + + err := s.reloadCert() + if err == nil { + t.Fatal("expected error when cert file is missing") + } + + // Old cert should still be loaded + currentCert := s.certPointer.Load() + if currentCert != oldCert { + t.Error("cert should be preserved after failed reload") + } +} + +func TestCertMetadata(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + activeTLSMode: TLSModeBYO, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("loadAndStoreCert() error: %v", err) + } + + meta := s.certMetadata() + if meta == nil { + t.Fatal("certMetadata() returned nil") + } + + if meta["tls_mode"] != "byo" { + t.Errorf("expected tls_mode=byo, got %v", meta["tls_mode"]) + } + if meta["tls_cert_subject"] != "localhost" { + t.Errorf("expected subject=localhost, got %v", meta["tls_cert_subject"]) + } + sans, ok := meta["tls_cert_sans"].([]string) + if !ok || len(sans) == 0 { + t.Error("expected non-empty SANs") + } +} + +func TestTLSInfoHandler(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + activeTLSMode: TLSModeBYO, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("loadAndStoreCert() error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/_/tls", nil) + rec := httptest.NewRecorder() + s.tlsInfoHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + var result map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if result["tls_mode"] != "byo" { + t.Errorf("expected tls_mode=byo, got %v", result["tls_mode"]) + } + if result["tls_cert_subject"] != "localhost" { + t.Errorf("expected subject=localhost, got %v", result["tls_cert_subject"]) + } +} + +func TestTLSInfoHandler_NoCert(t *testing.T) { + s := &Server{} + req := httptest.NewRequest(http.MethodGet, "/_/tls", nil) + rec := httptest.NewRecorder() + s.tlsInfoHandler(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rec.Code) + } +} + +func TestTLSReloadHandler(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + TLSCert: certPath, + TLSKey: keyPath, + activeTLSMode: TLSModeBYO, + } + + if err := s.loadAndStoreCert(); err != nil { + t.Fatalf("loadAndStoreCert() error: %v", err) + } + + t.Run("POST reloads cert", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/_/tls/reload", nil) + rec := httptest.NewRecorder() + s.tlsReloadHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + var result map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result["reloaded"] != true { + t.Error("expected reloaded=true") + } + }) + + t.Run("GET returns 405", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/_/tls/reload", nil) + rec := httptest.NewRecorder() + s.tlsReloadHandler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rec.Code) + } + }) +} diff --git a/internal/server/validation.go b/internal/server/validation.go index eba6316..89d0447 100644 --- a/internal/server/validation.go +++ b/internal/server/validation.go @@ -81,6 +81,11 @@ func (s *Server) Validate() error { s.etagMaxSizeBytes = size + // Validate TLS configuration + if err := s.validateTLS(); err != nil { + return err + } + // Attempt to validate the structure, and grab the errors if err := getValidator().Struct(s); err != nil { // If the error isn't empty, and its type is of ValidationError @@ -132,6 +137,86 @@ func validateIsFileInPath(basepath, file string) bool { return strings.HasPrefix(absfile, absbasepath) } +func (s *Server) validateTLS() error { + hasCert := s.TLSCert != "" + hasKey := s.TLSKey != "" + + // Both must be set or both unset + if hasCert != hasKey { + return errors.New("both --tls-cert and --tls-key must be provided together") + } + + // Determine TLS mode + if hasCert && hasKey { + s.activeTLSMode = TLSModeBYO + } else { + s.activeTLSMode = TLSModeOff + } + + if s.IsTLSEnabled() { + // Cannot use --port alongside TLS flags + if s.PortExplicitlySet { + return errors.New("cannot use --port with TLS flags; use --http-port and --https-port instead") + } + + // Hostname is required for redirect URL construction + if s.Hostname == "" { + return errors.New("--hostname is required when TLS is active") + } + + // Ports must differ (unless HTTP is disabled) + if s.HTTPPort != 0 && s.HTTPPort == s.HTTPSPort { + return fmt.Errorf("--http-port (%d) and --https-port (%d) must differ", s.HTTPPort, s.HTTPSPort) + } + + // Validate cert and key files exist + if _, err := os.Stat(s.TLSCert); err != nil { + return fmt.Errorf("TLS certificate file %q: %w", s.TLSCert, err) + } + if _, err := os.Stat(s.TLSKey); err != nil { + return fmt.Errorf("TLS key file %q: %w", s.TLSKey, err) + } + + // Load cert, check expiry, populate atomic pointer + if err := s.loadAndStoreCert(); err != nil { + return err + } + + // Check if cert/key files are inside the served directory and hide them + s.setupTLSFileHiding() + } + + // If TLS is not active, port flags should not be set + if !s.IsTLSEnabled() { + if s.HTTPPortExplicitlySet { + return errors.New("--http-port requires TLS to be active (set --tls-cert and --tls-key)") + } + if s.HTTPSPortExplicitlySet { + return errors.New("--https-port requires TLS to be active (set --tls-cert and --tls-key)") + } + } + + return nil +} + +func (s *Server) setupTLSFileHiding() { + absPath, err := filepath.Abs(s.Path) + if err != nil { + return + } + + for _, f := range []string{s.TLSCert, s.TLSKey} { + absFile, err := filepath.Abs(f) + if err != nil { + continue + } + if strings.HasPrefix(absFile, absPath+string(filepath.Separator)) { + s.forbiddenAbsPaths = append(s.forbiddenAbsPaths, absFile) + s.printWarningf("TLS file %q is inside the served directory and will be hidden from listings. Consider moving it outside.", f) + } + } +} + func (s *Server) printWarningf(format string, args ...interface{}) { if s.LogOutput != nil { fmt.Fprintf(s.LogOutput, warnPrefix+format+"\n", args...) diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go new file mode 100644 index 0000000..78c17f5 --- /dev/null +++ b/internal/server/validation_test.go @@ -0,0 +1,333 @@ +package server + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func TestValidateTLS_BothCertAndKey(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if s.ActiveTLSMode() != TLSModeBYO { + t.Errorf("expected TLSModeBYO, got %s", s.ActiveTLSMode()) + } +} + +func TestValidateTLS_OnlyCert(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when only cert is provided") + } +} + +func TestValidateTLS_OnlyKey(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSKey: keyPath, + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when only key is provided") + } +} + +func TestValidateTLS_PortConflict(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + PortExplicitlySet: true, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when --port is set with TLS") + } +} + +func TestValidateTLS_HostnameRequired(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when hostname is missing with TLS") + } +} + +func TestValidateTLS_SameHTTPAndHTTPSPort(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8443, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when HTTP and HTTPS ports are the same") + } +} + +func TestValidateTLS_ExpiredCert(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateExpiredTestCert(t, certPath, keyPath) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error for expired certificate") + } +} + +func TestValidateTLS_ExpiringSoonCert(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 15*24*time.Hour) // expires in 15 days + + buf := &bytes.Buffer{} + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: buf, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error for expiring-soon cert, got: %v", err) + } + + // Should contain a warning + if !bytes.Contains(buf.Bytes(), []byte("expires in less than 30 days")) { + t.Error("expected expiry warning in output") + } +} + +func TestValidateTLS_MismatchedCertKey(t *testing.T) { + dir := t.TempDir() + certPath1 := filepath.Join(dir, "cert1.pem") + keyPath1 := filepath.Join(dir, "key1.pem") + generateTestCert(t, certPath1, keyPath1, 365*24*time.Hour) + + certPath2 := filepath.Join(dir, "cert2.pem") + keyPath2 := filepath.Join(dir, "key2.pem") + generateTestCert(t, certPath2, keyPath2, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath1, + TLSKey: keyPath2, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error for mismatched cert and key") + } +} + +func TestValidateTLS_CertFileHiding(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + buf := &bytes.Buffer{} + s := &Server{ + Port: 5000, + Path: dir, // cert is inside the served directory + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: buf, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(s.forbiddenAbsPaths) != 2 { + t.Errorf("expected 2 forbidden paths, got %d", len(s.forbiddenAbsPaths)) + } + + // Should contain a warning about files being inside served dir + if !bytes.Contains(buf.Bytes(), []byte("inside the served directory")) { + t.Error("expected warning about cert files inside served directory") + } +} + +func TestValidateTLS_CertOutsideServedDir(t *testing.T) { + servedDir := t.TempDir() + certDir := t.TempDir() + certPath := filepath.Join(certDir, "cert.pem") + keyPath := filepath.Join(certDir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + // Create a file in servedDir so it's a valid directory + os.WriteFile(filepath.Join(servedDir, "index.html"), []byte("hello"), 0644) + + s := &Server{ + Port: 5000, + Path: servedDir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 8080, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(s.forbiddenAbsPaths) != 0 { + t.Errorf("expected 0 forbidden paths when cert is outside served dir, got %d", len(s.forbiddenAbsPaths)) + } +} + +func TestValidateTLS_NoTLSFlags(t *testing.T) { + dir := t.TempDir() + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error without TLS flags, got: %v", err) + } + + if s.IsTLSEnabled() { + t.Error("expected TLS to be disabled when no flags set") + } +} + +func TestValidateTLS_HTTPPortZero(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + HTTPPort: 0, + HTTPSPort: 8443, + Hostname: "localhost", + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error with --http-port 0, got: %v", err) + } +} From be1d9525c2befcc87d64d45d6f32990b98c24341 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:18:40 -0400 Subject: [PATCH 2/4] Add automatic Let's Encrypt certificate support (#145) Pass --hostname without --tls-cert/--tls-key and http-server provisions a free TLS certificate from Let's Encrypt automatically. Handles HTTP-01 challenges on port 80, automatic renewal, and local certificate storage via certmagic. New flag: --tls-email for Let's Encrypt account notifications. --- README.md | 13 ++-- app.go | 5 +- docs/tls.md | 55 ++++++++++++--- go.mod | 19 ++++- go.sum | 52 ++++++++++++-- internal/server/listener.go | 31 +++++++-- internal/server/server.go | 3 + internal/server/signal_unix.go | 2 +- internal/server/startup.go | 17 ++++- internal/server/tls.go | 64 ++++++++++++++--- internal/server/validation.go | 47 +++++++++---- internal/server/validation_test.go | 107 +++++++++++++++++++++++++++++ 12 files changed, 355 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 2fd49b9..fd49bb4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ * **Markdown support:** if a `README.md` or `readme.md` file is present in the directory during directory listing, it will be rendered as HTML. Additional support for GitHub-flavored markdown is also available. * **Fully air-gapped:** the directory listing feature is fully air-gapped, meaning that it does not require any external resources to be loaded. This is useful for environments where internet access is not available. * **Redirections support:** if a `_redirections` file exists in the target directory, it will be used to redirect requests to other locations. Learn about the syntax [in the docs](docs/redirections.md). -* **TLS / HTTPS support:** serve content over HTTPS using your own certificate and key. Includes automatic HTTP-to-HTTPS redirects, certificate reload via SIGHUP or API, and a `/_/tls` metadata endpoint. Learn more [in the docs](docs/tls.md). +* **TLS / HTTPS support:** serve content over HTTPS with automatic Let's Encrypt certificates or your own cert+key. Includes HTTP-to-HTTPS redirects, certificate reload via SIGHUP or API, and a `/_/tls` metadata endpoint. Learn more [in the docs](docs/tls.md). The app is available both as a standalone binary and as a Docker container image. @@ -92,11 +92,12 @@ Server: --title string title of the directory listing page TLS: - --hostname string hostname for HTTP-to-HTTPS redirects, required when TLS is active - --http-port int HTTP listener port when TLS is active, use 0 to disable HTTP redirect (default 80) - --https-port int HTTPS listener port when TLS is active (default 443) - --tls-cert string path to TLS certificate file in PEM format - --tls-key string path to TLS private key file in PEM format + --hostname string hostname for HTTP-to-HTTPS redirects and automatic certificate provisioning + --http-port int HTTP listener port when TLS is active, use 0 to disable HTTP redirect (default 80) + --https-port int HTTPS listener port when TLS is active (default 443) + --tls-cert string path to TLS certificate file in PEM format + --tls-email string email address for Let's Encrypt account notifications + --tls-key string path to TLS private key file in PEM format Authentication: --ensure-unexpired-jwt enable time validation for JWT claims "exp" and "nbf" diff --git a/app.go b/app.go index 21d6100..3cfd64e 100644 --- a/app.go +++ b/app.go @@ -143,12 +143,13 @@ func run() error { flags.StringVar(&srv.TLSKey, "tls-key", "", "path to TLS private key file in PEM format") flags.IntVar(&srv.HTTPPort, "http-port", 80, "HTTP listener port when TLS is active, use 0 to disable HTTP redirect") flags.IntVar(&srv.HTTPSPort, "https-port", 443, "HTTPS listener port when TLS is active") - flags.StringVar(&srv.Hostname, "hostname", "", "hostname for HTTP-to-HTTPS redirects, required when TLS is active") + flags.StringVar(&srv.Hostname, "hostname", "", "hostname for HTTP-to-HTTPS redirects and automatic certificate provisioning") + flags.StringVar(&srv.TLSEmail, "tls-email", "", "email address for Let's Encrypt account notifications") // Annotate flags with groups for the custom help template flagGroups := map[string][]string{ "Server": {"port", "path", "pathprefix", "title", "banner", "gzip", "cors", "hide-links"}, - "TLS": {"tls-cert", "tls-key", "http-port", "https-port", "hostname"}, + "TLS": {"tls-cert", "tls-key", "http-port", "https-port", "hostname", "tls-email"}, "Authentication": {"username", "password", "jwt-key", "ensure-unexpired-jwt"}, "Directory Listing": { "disable-directory-listing", "disable-markdown", "markdown-before-dir", "render-all-markdown", diff --git a/docs/tls.md b/docs/tls.md index b95cf24..8482ada 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -1,29 +1,64 @@ # TLS / HTTPS support -`http-server` supports serving content over HTTPS using your own TLS certificate and key. +`http-server` supports serving content over HTTPS in two modes: -## Quick start +- **Automatic certificates:** pass `--hostname` and `http-server` provisions a free TLS certificate from Let's Encrypt automatically. No cert files needed. +- **Bring your own certificate:** pass `--tls-cert` and `--tls-key` with your own certificate and key files. + +## Automatic certificates (Let's Encrypt) + +The simplest way to serve over HTTPS. Just pass `--hostname` with your domain: ```bash -http-server --tls-cert cert.pem --tls-key key.pem --hostname example.com -d ./site +http-server --hostname example.com -d ./site ``` This starts two listeners: -- **HTTPS** on port 443 serving your content -- **HTTP** on port 80 redirecting all requests to HTTPS +- **HTTPS** on port 443 serving your content with a certificate from Let's Encrypt +- **HTTP** on port 80 handling ACME HTTP-01 challenges and redirecting all other requests to HTTPS + +Certificate provisioning, renewal, and storage are fully automatic. Certificates are stored locally in `~/.local/share/certmagic/`. + +### How it works + +When `http-server` starts with `--hostname`, it contacts Let's Encrypt's ACME servers to prove you control the domain. It does this by responding to an HTTP-01 challenge: Let's Encrypt makes an HTTP request to `http://yourdomain.com/.well-known/acme-challenge/` on port 80, and `http-server` responds with the expected token. Once verified, a certificate is issued. + +This means: +- **Port 80 must be reachable** from the public internet +- **DNS must point to your server** (A or AAAA record for the hostname) +- The certificate is provisioned on first startup and renewed automatically before expiry + +### ACME email (optional) + +Let's Encrypt can send expiry notifications to an email address: + +```bash +http-server --hostname example.com --tls-email you@example.com -d ./site +``` + +## Bring your own certificate + +If you already have a certificate (from any CA, self-signed, or internal PKI), pass the cert and key files: + +```bash +http-server --tls-cert cert.pem --tls-key key.pem --hostname example.com -d ./site +``` + +Both `--tls-cert` and `--tls-key` must be provided together. `--hostname` is required for HTTP-to-HTTPS redirect URL construction. ## Flags | Flag | Default | Description | |------|---------|-------------| -| `--tls-cert` | *(none)* | Path to the TLS certificate file (PEM format) | -| `--tls-key` | *(none)* | Path to the TLS private key file (PEM format) | -| `--hostname` | *(none, required with TLS)* | Hostname used in HTTP-to-HTTPS redirect URLs | +| `--hostname` | *(none)* | Domain name for TLS. Alone = auto-cert. With `--tls-cert`/`--tls-key` = BYO cert. | +| `--tls-cert` | *(none)* | Path to TLS certificate file (PEM format, BYO mode) | +| `--tls-key` | *(none)* | Path to TLS private key file (PEM format, BYO mode) | +| `--tls-email` | *(none)* | Email for Let's Encrypt notifications (auto mode) | | `--https-port` | `443` | Port for the HTTPS listener | -| `--http-port` | `80` | Port for the HTTP redirect listener (use `0` to disable) | +| `--http-port` | `80` | Port for the HTTP listener (use `0` to disable) | -Both `--tls-cert` and `--tls-key` must be provided together. When TLS is active, the `--port` flag cannot be used. Use `--http-port` and `--https-port` instead. +When TLS is active, the `--port` flag cannot be used. Use `--http-port` and `--https-port` instead. ## Custom ports diff --git a/go.mod b/go.mod index d3beced..6ba0018 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/patrickdappollonio/http-server go 1.25.0 require ( + github.com/caddyserver/certmagic v0.25.2 github.com/go-chi/chi/v5 v5.2.5 github.com/go-playground/validator/v10 v10.30.1 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -18,22 +19,34 @@ require ( ) require ( + github.com/caddyserver/zerossl v0.1.5 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 5b90892..152903f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= @@ -15,6 +21,8 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -41,6 +49,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -50,6 +60,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -82,20 +102,40 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY= go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/server/listener.go b/internal/server/listener.go index b2a1738..a8569f0 100644 --- a/internal/server/listener.go +++ b/internal/server/listener.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "github.com/caddyserver/certmagic" "golang.org/x/sync/errgroup" "github.com/patrickdappollonio/http-server/internal/middlewares" @@ -72,24 +73,46 @@ func (s *Server) listenHTTPOnly() error { // listenTLS starts dual HTTP+HTTPS listeners with errgroup coordination. func (s *Server) listenTLS() error { + // In auto mode, provision certificates via certmagic before starting listeners + if s.activeTLSMode == TLSModeAuto { + if err := s.setupAutoTLS(context.Background()); err != nil { + return err + } + } + router := s.router() - // HTTPS server with GetCertificate callback - tlsConfig := &tls.Config{ - GetCertificate: s.getCertificate, + // Build TLS config based on mode + var tlsConfig *tls.Config + if s.activeTLSMode == TLSModeAuto && s.certmagicConfig != nil { + tlsConfig = s.certmagicConfig.TLSConfig() + tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...) + } else { + tlsConfig = &tls.Config{ + GetCertificate: s.getCertificate, + } } + httpsServer := &http.Server{ Addr: fmt.Sprintf(":%d", s.HTTPSPort), Handler: router, TLSConfig: tlsConfig, } + // HTTP handler: in auto mode, wrap with certmagic's ACME challenge handler + // so HTTP-01 challenges are solved on port 80 + httpHandler := s.httpRedirectHandler() + if s.activeTLSMode == TLSModeAuto && s.certmagicConfig != nil { + issuer := s.certmagicConfig.Issuers[0].(*certmagic.ACMEIssuer) + httpHandler = issuer.HTTPChallengeHandler(httpHandler) + } + // HTTP redirect server (disabled when --http-port 0) var httpServer *http.Server if s.HTTPPort != 0 { httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.HTTPPort), - Handler: s.httpRedirectHandler(), + Handler: httpHandler, } } diff --git a/internal/server/server.go b/internal/server/server.go index df01c4e..381df80 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "strings" "sync/atomic" + "github.com/caddyserver/certmagic" "github.com/patrickdappollonio/http-server/internal/redirects" ) @@ -78,6 +79,7 @@ type Server struct { HTTPPort int `flagName:"http-port"` HTTPSPort int `flagName:"https-port"` Hostname string `flagName:"hostname"` + TLSEmail string `flagName:"tls-email"` // Internal TLS fields activeTLSMode TLSMode @@ -86,6 +88,7 @@ type Server struct { HTTPPortExplicitlySet bool HTTPSPortExplicitlySet bool forbiddenAbsPaths []string + certmagicConfig *certmagic.Config } // IsBasicAuthEnabled returns true if the server has been configured with diff --git a/internal/server/signal_unix.go b/internal/server/signal_unix.go index 92a48eb..6ae1dbb 100644 --- a/internal/server/signal_unix.go +++ b/internal/server/signal_unix.go @@ -35,7 +35,7 @@ func (s *Server) startSIGHUPHandler(ctx context.Context) { continue } lastReload = time.Now() - if err := s.reloadCert(); err != nil { + if err := s.reloadCert(); err != nil { //nolint:contextcheck // signal handler has no meaningful request context fmt.Fprintf(s.LogOutput, "TLS certificate reload failed: %v\n", err) } else { fmt.Fprintln(s.LogOutput, "TLS certificate reloaded successfully") diff --git a/internal/server/startup.go b/internal/server/startup.go index 70da73f..5b0fb59 100644 --- a/internal/server/startup.go +++ b/internal/server/startup.go @@ -15,9 +15,20 @@ func (s *Server) PrintStartup() { if s.IsTLSEnabled() { fmt.Fprintln(s.LogOutput, startupPrefix, "TLS mode:", s.ActiveTLSMode()) - fmt.Fprintln(s.LogOutput, startupPrefix, "TLS certificate:", s.TLSCert) - fmt.Fprintln(s.LogOutput, startupPrefix, "TLS key:", s.TLSKey) fmt.Fprintln(s.LogOutput, startupPrefix, "Hostname:", s.Hostname) + + switch s.activeTLSMode { //nolint:exhaustive // TLSModeOff is handled by the outer if + case TLSModeBYO: + fmt.Fprintln(s.LogOutput, startupPrefix, "TLS certificate:", s.TLSCert) + fmt.Fprintln(s.LogOutput, startupPrefix, "TLS key:", s.TLSKey) + case TLSModeAuto: + fmt.Fprintln(s.LogOutput, startupPrefix, "Certificate provider: Let's Encrypt (automatic)") + if s.TLSEmail != "" { + fmt.Fprintln(s.LogOutput, startupPrefix, "ACME email:", s.TLSEmail) + } + default: + } + fmt.Fprintln(s.LogOutput, startupPrefix, "HTTPS port:", s.HTTPSPort) if s.HTTPPort != 0 { fmt.Fprintf(s.LogOutput, "%s HTTP port: %d (redirecting to HTTPS)\n", startupPrefix, s.HTTPPort) @@ -25,7 +36,7 @@ func (s *Server) PrintStartup() { fmt.Fprintln(s.LogOutput, startupPrefix, "HTTP redirect disabled (--http-port 0)") } - // Print cert expiry info + // Print cert expiry info (available after cert is loaded/provisioned) if meta := s.certMetadata(); meta != nil { fmt.Fprintf(s.LogOutput, "%s Certificate subject: %s\n", startupPrefix, meta["tls_cert_subject"]) fmt.Fprintf(s.LogOutput, "%s Certificate expires: %s\n", startupPrefix, meta["tls_cert_not_after"]) diff --git a/internal/server/tls.go b/internal/server/tls.go index 1b8f8bd..ebf081d 100644 --- a/internal/server/tls.go +++ b/internal/server/tls.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/tls" "crypto/x509" "encoding/json" @@ -8,6 +9,8 @@ import ( "fmt" "net/http" "time" + + "github.com/caddyserver/certmagic" ) // TLSMode represents the server's TLS operating mode. @@ -16,7 +19,7 @@ type TLSMode string const ( TLSModeOff TLSMode = "off" TLSModeBYO TLSMode = "byo" - TLSModeAuto TLSMode = "auto" // Phase 2: certmagic + TLSModeAuto TLSMode = "auto" ) // IsTLSEnabled returns true if TLS is active in any mode. @@ -64,14 +67,24 @@ func (s *Server) loadAndStoreCert() error { return nil } -// reloadCert reloads the certificate and key from disk. -func (s *Server) reloadCert() error { +// reloadCert reloads the certificate. In BYO mode, it reloads from disk. +// In auto mode, it triggers a certmagic renewal check. +func (s *Server) reloadCert() error { //nolint:contextcheck // reload is triggered by signals and HTTP handlers which don't have a meaningful context + if s.activeTLSMode == TLSModeAuto && s.certmagicConfig != nil { + //nolint:wrapcheck // certmagic errors are already descriptive + return s.certmagicConfig.ManageSync(context.Background(), []string{s.Hostname}) + } return s.loadAndStoreCert() } // getCertificate is the tls.Config.GetCertificate callback that returns -// the current certificate from the atomic pointer. -func (s *Server) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { +// the current certificate. In BYO mode, reads from the atomic pointer. +// In auto mode, delegates to certmagic. +func (s *Server) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if s.activeTLSMode == TLSModeAuto && s.certmagicConfig != nil { + //nolint:wrapcheck // certmagic errors are already descriptive + return s.certmagicConfig.GetCertificate(hello) + } cert := s.certPointer.Load() if cert == nil { return nil, errors.New("no TLS certificate loaded") @@ -79,14 +92,45 @@ func (s *Server) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error return cert, nil } +// setupAutoTLS configures certmagic for automatic certificate provisioning +// via Let's Encrypt. It synchronously obtains the certificate at startup. +func (s *Server) setupAutoTLS(ctx context.Context) error { + certmagic.DefaultACME.Email = s.TLSEmail + certmagic.DefaultACME.Agreed = true + + magic := certmagic.NewDefault() + + fmt.Fprintf(s.LogOutput, "Provisioning TLS certificate for %q via Let's Encrypt...\n", s.Hostname) + + if err := magic.ManageSync(ctx, []string{s.Hostname}); err != nil { + return fmt.Errorf("unable to provision TLS certificate for %q: %w", s.Hostname, err) + } + + s.certmagicConfig = magic + return nil +} + // certMetadata returns metadata about the currently loaded certificate. func (s *Server) certMetadata() map[string]any { - cert := s.certPointer.Load() - if cert == nil || cert.Leaf == nil { - return nil + var leaf *x509.Certificate + + if s.activeTLSMode == TLSModeAuto && s.certmagicConfig != nil { + // In auto mode, get cert from certmagic + cert, err := s.certmagicConfig.GetCertificate(&tls.ClientHelloInfo{ServerName: s.Hostname}) + if err == nil && cert != nil && cert.Leaf != nil { + leaf = cert.Leaf + } + } else { + // In BYO mode, get cert from atomic pointer + cert := s.certPointer.Load() + if cert != nil && cert.Leaf != nil { + leaf = cert.Leaf + } } - leaf := cert.Leaf + if leaf == nil { + return nil + } sans := make([]string, 0, len(leaf.DNSNames)+len(leaf.IPAddresses)) sans = append(sans, leaf.DNSNames...) @@ -126,7 +170,7 @@ func (s *Server) tlsReloadHandler(w http.ResponseWriter, r *http.Request) { return } - if err := s.reloadCert(); err != nil { + if err := s.reloadCert(); err != nil { //nolint:contextcheck // HTTP handler reload has no propagatable context w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) //nolint:errchkjson // best-effort JSON error response diff --git a/internal/server/validation.go b/internal/server/validation.go index 89d0447..909ed1b 100644 --- a/internal/server/validation.go +++ b/internal/server/validation.go @@ -140,16 +140,26 @@ func validateIsFileInPath(basepath, file string) bool { func (s *Server) validateTLS() error { hasCert := s.TLSCert != "" hasKey := s.TLSKey != "" + hasHostname := s.Hostname != "" - // Both must be set or both unset + // Both cert+key must be set together if hasCert != hasKey { return errors.New("both --tls-cert and --tls-key must be provided together") } - // Determine TLS mode - if hasCert && hasKey { + // Determine TLS mode: + // cert+key+hostname → BYO + // hostname alone → Auto (certmagic) + // neither → Off + switch { + case hasCert && hasKey: s.activeTLSMode = TLSModeBYO - } else { + if !hasHostname { + return errors.New("--hostname is required when TLS is active") + } + case hasHostname && !hasCert: + s.activeTLSMode = TLSModeAuto + default: s.activeTLSMode = TLSModeOff } @@ -159,17 +169,14 @@ func (s *Server) validateTLS() error { return errors.New("cannot use --port with TLS flags; use --http-port and --https-port instead") } - // Hostname is required for redirect URL construction - if s.Hostname == "" { - return errors.New("--hostname is required when TLS is active") - } - // Ports must differ (unless HTTP is disabled) if s.HTTPPort != 0 && s.HTTPPort == s.HTTPSPort { return fmt.Errorf("--http-port (%d) and --https-port (%d) must differ", s.HTTPPort, s.HTTPSPort) } + } - // Validate cert and key files exist + // BYO-specific validation + if s.activeTLSMode == TLSModeBYO { if _, err := os.Stat(s.TLSCert); err != nil { return fmt.Errorf("TLS certificate file %q: %w", s.TLSCert, err) } @@ -177,22 +184,32 @@ func (s *Server) validateTLS() error { return fmt.Errorf("TLS key file %q: %w", s.TLSKey, err) } - // Load cert, check expiry, populate atomic pointer if err := s.loadAndStoreCert(); err != nil { return err } - // Check if cert/key files are inside the served directory and hide them s.setupTLSFileHiding() } - // If TLS is not active, port flags should not be set + // Auto-specific validation + if s.activeTLSMode == TLSModeAuto { + if s.HTTPPort == 0 { + s.printWarningf("HTTP listener is disabled (--http-port 0). ACME HTTP-01 challenges require port 80 to be reachable. Certificate renewal may fail.") + } else if s.HTTPPort != 80 { + s.printWarningf("ACME HTTP-01 challenges require port 80 to be externally reachable. If --http-port %d is not mapped to port 80, certificate provisioning may fail.", s.HTTPPort) + } + } + + // If TLS is not active, TLS-specific flags should not be set if !s.IsTLSEnabled() { if s.HTTPPortExplicitlySet { - return errors.New("--http-port requires TLS to be active (set --tls-cert and --tls-key)") + return errors.New("--http-port requires TLS to be active (set --hostname for auto-TLS or --tls-cert/--tls-key for BYO)") } if s.HTTPSPortExplicitlySet { - return errors.New("--https-port requires TLS to be active (set --tls-cert and --tls-key)") + return errors.New("--https-port requires TLS to be active (set --hostname for auto-TLS or --tls-cert/--tls-key for BYO)") + } + if s.TLSEmail != "" { + return errors.New("--tls-email requires TLS to be active (set --hostname for auto-TLS)") } } diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go index 78c17f5..0edb62b 100644 --- a/internal/server/validation_test.go +++ b/internal/server/validation_test.go @@ -331,3 +331,110 @@ func TestValidateTLS_HTTPPortZero(t *testing.T) { t.Fatalf("expected no error with --http-port 0, got: %v", err) } } + +func TestValidateTLS_HostnameAloneTriggersAutoMode(t *testing.T) { + dir := t.TempDir() + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + Hostname: "example.com", + HTTPPort: 80, + HTTPSPort: 443, + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if s.ActiveTLSMode() != TLSModeAuto { + t.Errorf("expected TLSModeAuto, got %s", s.ActiveTLSMode()) + } +} + +func TestValidateTLS_HostnameWithCertKeyTriggersBYO(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + generateTestCert(t, certPath, keyPath, 365*24*time.Hour) + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + Hostname: "localhost", + HTTPPort: 8080, + HTTPSPort: 8443, + LogOutput: &bytes.Buffer{}, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if s.ActiveTLSMode() != TLSModeBYO { + t.Errorf("expected TLSModeBYO, got %s", s.ActiveTLSMode()) + } +} + +func TestValidateTLS_AutoModePortConflict(t *testing.T) { + dir := t.TempDir() + + s := &Server{ + Port: 5000, + PortExplicitlySet: true, + Path: dir, + ETagMaxSize: "5M", + Hostname: "example.com", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when --port is set with auto TLS") + } +} + +func TestValidateTLS_TLSEmailWithoutTLS(t *testing.T) { + dir := t.TempDir() + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + TLSEmail: "test@example.com", + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when --tls-email is set without TLS") + } +} + +func TestValidateTLS_AutoModeNonStandardHTTPPort(t *testing.T) { + dir := t.TempDir() + buf := &bytes.Buffer{} + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + Hostname: "example.com", + HTTPPort: 8080, + HTTPSPort: 443, + LogOutput: buf, + } + + if err := s.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !bytes.Contains(buf.Bytes(), []byte("ACME HTTP-01 challenges require port 80")) { + t.Error("expected warning about non-standard HTTP port for ACME challenges") + } +} From 6eb29fd31a93f3df95f940e71d1ea63324644b21 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:27:00 -0400 Subject: [PATCH 3/4] Add automatic Let's Encrypt certificates and cert storage configuration When --hostname is passed without --tls-cert/--tls-key, http-server provisions a free TLS certificate from Let's Encrypt automatically via certmagic. Handles HTTP-01 challenges, automatic renewal, and filesystem-based cert storage for multi-instance safety. New flags: --tls-email Let's Encrypt account notifications --tls-cache-dir certificate storage path (default: .certmagic/ in served dir) The cache directory is hidden from listings and blocked from direct access only when auto-TLS is active with that specific path. Users serving .certmagic directories without auto-TLS are not affected. The HTTP listener is mandatory in auto-TLS mode since it handles ACME HTTP-01 challenges (--http-port 0 is rejected). --- README.md | 13 +++++---- app.go | 3 +- docs/tls.md | 13 +++++++++ internal/server/filter.go | 10 ++++++- internal/server/filter_test.go | 45 ++++++++++++++++++++++++++++++ internal/server/server.go | 28 ++++++++++--------- internal/server/tls.go | 10 +++++++ internal/server/validation.go | 19 +++++++++++-- internal/server/validation_test.go | 19 +++++++++++++ 9 files changed, 136 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fd49bb4..223b123 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,13 @@ Server: --title string title of the directory listing page TLS: - --hostname string hostname for HTTP-to-HTTPS redirects and automatic certificate provisioning - --http-port int HTTP listener port when TLS is active, use 0 to disable HTTP redirect (default 80) - --https-port int HTTPS listener port when TLS is active (default 443) - --tls-cert string path to TLS certificate file in PEM format - --tls-email string email address for Let's Encrypt account notifications - --tls-key string path to TLS private key file in PEM format + --hostname string hostname for HTTP-to-HTTPS redirects and automatic certificate provisioning + --http-port int HTTP listener port when TLS is active, use 0 to disable HTTP redirect (default 80) + --https-port int HTTPS listener port when TLS is active (default 443) + --tls-cache-dir string directory for storing automatic TLS certificates (default: .certmagic/ in served directory) + --tls-cert string path to TLS certificate file in PEM format + --tls-email string email address for Let's Encrypt account notifications + --tls-key string path to TLS private key file in PEM format Authentication: --ensure-unexpired-jwt enable time validation for JWT claims "exp" and "nbf" diff --git a/app.go b/app.go index 3cfd64e..b8bcdbf 100644 --- a/app.go +++ b/app.go @@ -145,11 +145,12 @@ func run() error { flags.IntVar(&srv.HTTPSPort, "https-port", 443, "HTTPS listener port when TLS is active") flags.StringVar(&srv.Hostname, "hostname", "", "hostname for HTTP-to-HTTPS redirects and automatic certificate provisioning") flags.StringVar(&srv.TLSEmail, "tls-email", "", "email address for Let's Encrypt account notifications") + flags.StringVar(&srv.TLSCacheDir, "tls-cache-dir", "", "directory for storing automatic TLS certificates (default: .certmagic/ in served directory)") // Annotate flags with groups for the custom help template flagGroups := map[string][]string{ "Server": {"port", "path", "pathprefix", "title", "banner", "gzip", "cors", "hide-links"}, - "TLS": {"tls-cert", "tls-key", "http-port", "https-port", "hostname", "tls-email"}, + "TLS": {"tls-cert", "tls-key", "http-port", "https-port", "hostname", "tls-email", "tls-cache-dir"}, "Authentication": {"username", "password", "jwt-key", "ensure-unexpired-jwt"}, "Directory Listing": { "disable-directory-listing", "disable-markdown", "markdown-before-dir", "render-all-markdown", diff --git a/docs/tls.md b/docs/tls.md index 8482ada..4970dde 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -55,6 +55,7 @@ Both `--tls-cert` and `--tls-key` must be provided together. `--hostname` is req | `--tls-cert` | *(none)* | Path to TLS certificate file (PEM format, BYO mode) | | `--tls-key` | *(none)* | Path to TLS private key file (PEM format, BYO mode) | | `--tls-email` | *(none)* | Email for Let's Encrypt notifications (auto mode) | +| `--tls-cache-dir` | `.certmagic/` in served dir | Directory for storing automatic TLS certificates | | `--https-port` | `443` | Port for the HTTPS listener | | `--http-port` | `80` | Port for the HTTP listener (use `0` to disable) | @@ -78,6 +79,18 @@ http-server --tls-cert cert.pem --tls-key key.pem --hostname example.com \ --http-port 0 -d ./site ``` +## Certificate storage + +In auto mode, certificates and private keys are stored on the local filesystem at `.certmagic/` inside the served directory by default. This means certificates persist across restarts and multiple instances sharing the same volume will reuse the same certificates (certmagic uses file locking to prevent ACME races). + +To use a different storage location: + +```bash +http-server --hostname example.com --tls-cache-dir /etc/http-server/certs -d ./site +``` + +The `.certmagic/` directory (or custom cache dir) is automatically hidden from directory listings and blocked from direct URL access. Private keys stored inside it are never served to clients. + ## Certificate file hiding If your certificate and key files are inside the directory being served, they are automatically hidden from directory listings and blocked from direct download. A warning is printed at startup recommending you move them outside the served directory. diff --git a/internal/server/filter.go b/internal/server/filter.go index 4eeae59..8437ffc 100644 --- a/internal/server/filter.go +++ b/internal/server/filter.go @@ -63,12 +63,20 @@ func (s *Server) isFiltered(filename string) bool { } // isAbsolutePathForbidden returns true if the given absolute path matches -// a forbidden absolute path (used for TLS cert/key file hiding). +// a forbidden absolute path (exact match for cert/key files) or falls +// under a forbidden path prefix (for directories like .certmagic/). func (s *Server) isAbsolutePathForbidden(absPath string) bool { for _, forbidden := range s.forbiddenAbsPaths { if absPath == forbidden { return true } } + + for _, prefix := range s.forbiddenAbsPathPrefixes { + if strings.HasPrefix(absPath, prefix) { + return true + } + } + return false } diff --git a/internal/server/filter_test.go b/internal/server/filter_test.go index 5bc3feb..fdc4e8c 100644 --- a/internal/server/filter_test.go +++ b/internal/server/filter_test.go @@ -96,3 +96,48 @@ func TestIsAbsolutePathForbidden(t *testing.T) { }) } } + +func TestIsAbsolutePathForbidden_PrefixBlocking(t *testing.T) { + tests := []struct { + name string + forbiddenPrefixes []string + checkPath string + want bool + }{ + { + name: "certmagic dir itself is blocked", + forbiddenPrefixes: []string{"/srv/www/.certmagic/"}, + checkPath: "/srv/www/.certmagic", + want: false, // exact dir match uses forbiddenAbsPaths, not prefix + }, + { + name: "file inside certmagic dir is blocked", + forbiddenPrefixes: []string{"/srv/www/.certmagic/"}, + checkPath: "/srv/www/.certmagic/acme/cert.pem", + want: true, + }, + { + name: "nested deep file is blocked", + forbiddenPrefixes: []string{"/srv/www/.certmagic/"}, + checkPath: "/srv/www/.certmagic/acme-v02.api.letsencrypt.org-directory/sites/example.com/example.com.key", + want: true, + }, + { + name: "unrelated path is not blocked", + forbiddenPrefixes: []string{"/srv/www/.certmagic/"}, + checkPath: "/srv/www/index.html", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Server{ + forbiddenAbsPathPrefixes: tt.forbiddenPrefixes, + } + if got := s.isAbsolutePathForbidden(tt.checkPath); got != tt.want { + t.Errorf("isAbsolutePathForbidden() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 381df80..d54943d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -74,21 +74,23 @@ type Server struct { SkipForceDownloadFiles []string // TLS settings - TLSCert string `flagName:"tls-cert"` - TLSKey string `flagName:"tls-key"` - HTTPPort int `flagName:"http-port"` - HTTPSPort int `flagName:"https-port"` - Hostname string `flagName:"hostname"` - TLSEmail string `flagName:"tls-email"` + TLSCert string `flagName:"tls-cert"` + TLSKey string `flagName:"tls-key"` + HTTPPort int `flagName:"http-port"` + HTTPSPort int `flagName:"https-port"` + Hostname string `flagName:"hostname"` + TLSEmail string `flagName:"tls-email"` + TLSCacheDir string `flagName:"tls-cache-dir"` // Internal TLS fields - activeTLSMode TLSMode - certPointer atomic.Pointer[tls.Certificate] - PortExplicitlySet bool - HTTPPortExplicitlySet bool - HTTPSPortExplicitlySet bool - forbiddenAbsPaths []string - certmagicConfig *certmagic.Config + activeTLSMode TLSMode + certPointer atomic.Pointer[tls.Certificate] + PortExplicitlySet bool + HTTPPortExplicitlySet bool + HTTPSPortExplicitlySet bool + forbiddenAbsPaths []string + forbiddenAbsPathPrefixes []string + certmagicConfig *certmagic.Config } // IsBasicAuthEnabled returns true if the server has been configured with diff --git a/internal/server/tls.go b/internal/server/tls.go index ebf081d..bb7ee3e 100644 --- a/internal/server/tls.go +++ b/internal/server/tls.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "time" "github.com/caddyserver/certmagic" @@ -98,6 +99,15 @@ func (s *Server) setupAutoTLS(ctx context.Context) error { certmagic.DefaultACME.Email = s.TLSEmail certmagic.DefaultACME.Agreed = true + // Default storage to .certmagic/ inside the served directory, + // so certs persist alongside the content and work in containers + // with mounted volumes. Override with --tls-cache-dir. + cacheDir := s.TLSCacheDir + if cacheDir == "" { + cacheDir = filepath.Join(s.Path, ".certmagic") + } + certmagic.Default.Storage = &certmagic.FileStorage{Path: cacheDir} + magic := certmagic.NewDefault() fmt.Fprintf(s.LogOutput, "Provisioning TLS certificate for %q via Let's Encrypt...\n", s.Hostname) diff --git a/internal/server/validation.go b/internal/server/validation.go index 909ed1b..c059f7b 100644 --- a/internal/server/validation.go +++ b/internal/server/validation.go @@ -191,11 +191,24 @@ func (s *Server) validateTLS() error { s.setupTLSFileHiding() } - // Auto-specific validation + // Auto-specific validation and setup if s.activeTLSMode == TLSModeAuto { + // Hide the certmagic cache directory from listings and direct access. + // This directory contains private keys and must never be served. + cacheDir := s.TLSCacheDir + if cacheDir == "" { + cacheDir = filepath.Join(s.Path, ".certmagic") + } + if absCacheDir, err := filepath.Abs(cacheDir); err == nil { + // Block any path under the cache directory from direct access + s.forbiddenAbsPathPrefixes = append(s.forbiddenAbsPathPrefixes, absCacheDir+string(filepath.Separator)) + // Also block the directory itself + s.forbiddenAbsPaths = append(s.forbiddenAbsPaths, absCacheDir) + } if s.HTTPPort == 0 { - s.printWarningf("HTTP listener is disabled (--http-port 0). ACME HTTP-01 challenges require port 80 to be reachable. Certificate renewal may fail.") - } else if s.HTTPPort != 80 { + return errors.New("--http-port 0 is not allowed in auto-TLS mode: the HTTP listener is required for ACME HTTP-01 challenges") + } + if s.HTTPPort != 80 { s.printWarningf("ACME HTTP-01 challenges require port 80 to be externally reachable. If --http-port %d is not mapped to port 80, certificate provisioning may fail.", s.HTTPPort) } } diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go index 0edb62b..09ff18d 100644 --- a/internal/server/validation_test.go +++ b/internal/server/validation_test.go @@ -416,6 +416,25 @@ func TestValidateTLS_TLSEmailWithoutTLS(t *testing.T) { } } +func TestValidateTLS_AutoModeHTTPPortZeroRejected(t *testing.T) { + dir := t.TempDir() + + s := &Server{ + Port: 5000, + Path: dir, + ETagMaxSize: "5M", + Hostname: "example.com", + HTTPPort: 0, + HTTPSPort: 443, + LogOutput: &bytes.Buffer{}, + } + + err := s.Validate() + if err == nil { + t.Fatal("expected error when --http-port 0 in auto TLS mode") + } +} + func TestValidateTLS_AutoModeNonStandardHTTPPort(t *testing.T) { dir := t.TempDir() buf := &bytes.Buffer{} From 40931a6212a3083f01376e4f3078309d285875a2 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:32:33 -0400 Subject: [PATCH 4/4] Replace flag-change tracking with default-value comparison Config file and environment variable values were not detected by cmd.Flags().Changed(), so port conflicts were silently ignored when set via .http-server.yaml or env vars. Now compares against default values (5000 for --port, 80/443 for --http-port/--https-port) which works regardless of how the value was configured. --- app.go | 5 ---- internal/server/server.go | 3 --- internal/server/validation.go | 18 ++++++++++---- internal/server/validation_test.go | 40 +++++++++++++++++------------- 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/app.go b/app.go index b8bcdbf..fa85b6b 100644 --- a/app.go +++ b/app.go @@ -65,11 +65,6 @@ func run() error { srv.LogOutput = cmd.OutOrStdout() srv.SetVersion(version) - // Track whether port flags were explicitly set for TLS validation - srv.PortExplicitlySet = cmd.Flags().Changed("port") - srv.HTTPPortExplicitlySet = cmd.Flags().Changed("http-port") - srv.HTTPSPortExplicitlySet = cmd.Flags().Changed("https-port") - // Validate fields to make sure they're correct if err := srv.Validate(); err != nil { return fmt.Errorf("unable to validate configuration: %w", err) diff --git a/internal/server/server.go b/internal/server/server.go index d54943d..be4deeb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,9 +85,6 @@ type Server struct { // Internal TLS fields activeTLSMode TLSMode certPointer atomic.Pointer[tls.Certificate] - PortExplicitlySet bool - HTTPPortExplicitlySet bool - HTTPSPortExplicitlySet bool forbiddenAbsPaths []string forbiddenAbsPathPrefixes []string certmagicConfig *certmagic.Config diff --git a/internal/server/validation.go b/internal/server/validation.go index c059f7b..8207252 100644 --- a/internal/server/validation.go +++ b/internal/server/validation.go @@ -163,10 +163,15 @@ func (s *Server) validateTLS() error { s.activeTLSMode = TLSModeOff } + const defaultPort = 5000 + const defaultHTTPPort = 80 + const defaultHTTPSPort = 443 + if s.IsTLSEnabled() { - // Cannot use --port alongside TLS flags - if s.PortExplicitlySet { - return errors.New("cannot use --port with TLS flags; use --http-port and --https-port instead") + // If port was changed from its default, the user configured it + // expecting it to be used, but TLS uses http-port/https-port instead + if s.Port != defaultPort { + return fmt.Errorf("--port cannot be used with TLS; use --http-port and --https-port instead (port is set to %d)", s.Port) } // Ports must differ (unless HTTP is disabled) @@ -215,15 +220,18 @@ func (s *Server) validateTLS() error { // If TLS is not active, TLS-specific flags should not be set if !s.IsTLSEnabled() { - if s.HTTPPortExplicitlySet { + if s.HTTPPort != defaultHTTPPort { return errors.New("--http-port requires TLS to be active (set --hostname for auto-TLS or --tls-cert/--tls-key for BYO)") } - if s.HTTPSPortExplicitlySet { + if s.HTTPSPort != defaultHTTPSPort { return errors.New("--https-port requires TLS to be active (set --hostname for auto-TLS or --tls-cert/--tls-key for BYO)") } if s.TLSEmail != "" { return errors.New("--tls-email requires TLS to be active (set --hostname for auto-TLS)") } + if s.TLSCacheDir != "" { + return errors.New("--tls-cache-dir requires TLS to be active (set --hostname for auto-TLS)") + } } return nil diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go index 09ff18d..00ba323 100644 --- a/internal/server/validation_test.go +++ b/internal/server/validation_test.go @@ -82,14 +82,15 @@ func TestValidateTLS_PortConflict(t *testing.T) { generateTestCert(t, certPath, keyPath, 365*24*time.Hour) s := &Server{ - Port: 5000, - PortExplicitlySet: true, - Path: dir, - ETagMaxSize: "5M", - TLSCert: certPath, - TLSKey: keyPath, - Hostname: "localhost", - LogOutput: &bytes.Buffer{}, + Port: 8080, // non-default port conflicts with TLS + Path: dir, + ETagMaxSize: "5M", + TLSCert: certPath, + TLSKey: keyPath, + Hostname: "localhost", + HTTPPort: 80, + HTTPSPort: 443, + LogOutput: &bytes.Buffer{}, } err := s.Validate() @@ -294,10 +295,12 @@ func TestValidateTLS_NoTLSFlags(t *testing.T) { dir := t.TempDir() s := &Server{ - Port: 5000, - Path: dir, + Port: 5000, + Path: dir, ETagMaxSize: "5M", - LogOutput: &bytes.Buffer{}, + HTTPPort: 80, + HTTPSPort: 443, + LogOutput: &bytes.Buffer{}, } if err := s.Validate(); err != nil { @@ -385,12 +388,13 @@ func TestValidateTLS_AutoModePortConflict(t *testing.T) { dir := t.TempDir() s := &Server{ - Port: 5000, - PortExplicitlySet: true, - Path: dir, - ETagMaxSize: "5M", - Hostname: "example.com", - LogOutput: &bytes.Buffer{}, + Port: 8080, // non-default port conflicts with auto TLS + Path: dir, + ETagMaxSize: "5M", + Hostname: "example.com", + HTTPPort: 80, + HTTPSPort: 443, + LogOutput: &bytes.Buffer{}, } err := s.Validate() @@ -406,6 +410,8 @@ func TestValidateTLS_TLSEmailWithoutTLS(t *testing.T) { Port: 5000, Path: dir, ETagMaxSize: "5M", + HTTPPort: 80, + HTTPSPort: 443, TLSEmail: "test@example.com", LogOutput: &bytes.Buffer{}, }