diff --git a/README.md b/README.md index 5f4d36e..4dc3c35 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ environment variables that you can set. | Variable Name | Description | Default Value | |-----------------------------|---------------------------------------------------------|---------------| | `TLS_DOMAIN` | Comma-separated list of domain names to use for TLS provisioning. If not set, TLS will be disabled. | None | +| `TLS_LOCAL` | Whether to use a self-signed certificate authority for TLS certificate provisioning. | Disabled | | `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this value when starting your server. | 3000 | | `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB | | `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB | diff --git a/internal/config.go b/internal/config.go index 655f9eb..a6d1f8a 100644 --- a/internal/config.go +++ b/internal/config.go @@ -48,6 +48,7 @@ type Config struct { MaxRequestBody int TLSDomains []string + TLSLocal bool ACMEDirectoryURL string EAB_KID string EAB_HMACKey string @@ -87,6 +88,7 @@ func NewConfig() (*Config, error) { MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody), TLSDomains: getEnvStrings("TLS_DOMAIN", []string{}), + TLSLocal: getEnvBool("TLS_LOCAL", false), ACMEDirectoryURL: getEnvString("ACME_DIRECTORY", defaultACMEDirectoryURL), EAB_KID: getEnvString("EAB_KID", ""), EAB_HMACKey: getEnvString("EAB_HMAC_KEY", ""), @@ -108,7 +110,7 @@ func NewConfig() (*Config, error) { } func (c *Config) HasTLS() bool { - return len(c.TLSDomains) > 0 + return len(c.TLSDomains) > 0 || c.TLSLocal } func findEnv(key string) (string, bool) { diff --git a/internal/server.go b/internal/server.go index b168402..6b30d96 100644 --- a/internal/server.go +++ b/internal/server.go @@ -7,10 +7,21 @@ import ( "log/slog" "net" "net/http" + "os" "time" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" + "golang.org/x/net/idna" ) type Server struct { @@ -38,7 +49,13 @@ func (s *Server) Start() { s.httpServer.Handler = manager.HTTPHandler(http.HandlerFunc(httpRedirectHandler)) s.httpsServer = s.defaultHttpServer(httpsAddress) - s.httpsServer.TLSConfig = manager.TLSConfig() + + if s.config.TLSLocal { + s.httpsServer.TLSConfig = s.localTLSConfig() + } else { + s.httpsServer.TLSConfig = manager.TLSConfig() + } + s.httpsServer.Handler = s.handler go s.httpServer.ListenAndServe() @@ -84,6 +101,161 @@ func (s *Server) certManager() *autocert.Manager { } } +func (s *Server) localTLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: s.getLocalCertificate, + NextProtos: []string{ + "h2", "http/1.1", // enable HTTP/2 + }, + } +} + +func (s *Server) getLocalCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := hello.ServerName + if name == "" { + return nil, errors.New("thruster_local_tls: missing server name") + } + + name, err := idna.Lookup.ToASCII(name) + if err != nil { + return nil, errors.New("thruster/local_tls: server name contains invalid character") + } + + keyUsage := x509.KeyUsageDigitalSignature + keyUsage |= x509.KeyUsageKeyEncipherment + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Thruster Local"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 10 * 24 * time.Hour), + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(name); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, name) + } + + authority, err := s.getLocalAuthority() + if err != nil { + return nil, err + } + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + authcert, err := x509.ParseCertificate(authority.Certificate[0]) + if err != nil { + return nil, err + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, authcert, &priv.PublicKey, authority.PrivateKey) + if err != nil { + return nil, err + } + + cert := &tls.Certificate{ + Certificate: [][]byte{authority.Certificate[0], derBytes}, + PrivateKey: authority.PrivateKey, + } + + return cert, nil +} + +func (s *Server) getLocalAuthority() (*tls.Certificate, error) { + + cert, err := tls.LoadX509KeyPair(fmt.Sprintf("%s/authority.crt", s.config.StoragePath), fmt.Sprintf("%s/authority.pem", s.config.StoragePath)) + if err == nil { + return &cert, nil + } + + err = os.MkdirAll(s.config.StoragePath, 0750) + + keyUsage := x509.KeyUsageDigitalSignature + keyUsage |= x509.KeyUsageKeyEncipherment + keyUsage |= x509.KeyUsageCertSign + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Thruster Local CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 10 * 24 * time.Hour), + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, err + } + + certOut, err := os.Create(fmt.Sprintf("%s/authority.crt", s.config.StoragePath)) + if err != nil { + return nil, err + } + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, err + } + + if err := certOut.Close(); err != nil { + return nil, err + } + + keyOut, err := os.Create(fmt.Sprintf("%s/authority.pem", s.config.StoragePath)) + if err != nil { + return nil, err + } + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + + if err != nil { + return nil, err + } + + if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + return nil, err + } + + if err := keyOut.Close(); err != nil { + return nil, err + } + + cer := tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + } + + return &cer, nil +} + func (s *Server) externalAccountBinding() *acme.ExternalAccountBinding { if s.config.EAB_KID == "" || s.config.EAB_HMACKey == "" { return nil