Skip to content

Add support for local TLS. #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
MaxRequestBody int

TLSDomains []string
TLSLocal bool
ACMEDirectoryURL string
EAB_KID string
EAB_HMACKey string
Expand Down Expand Up @@ -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", ""),
Expand All @@ -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) {
Expand Down
174 changes: 173 additions & 1 deletion internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When doing this, we won't need to create the manager above. It's likely harmless, but it would be clearer to separate the ACME TLS and local TLS support more fully.

Rather than branch on the specific differences, how about introducing an interface that both the local TLS provider, and the autocert Manager implement, maybe something like:

type TLSProvider interface {
	HTTPHandler(h http.Handler) http.Handler
	TLSConfig() *tls.Config
}

Then we can create either a local provider or an autocert manager (depending on the config), and wire it up the same way in either case.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry but I don't know enough about Go to implement this without significant help, so maybe it's better if you either add commits to this PR to do what you want or merge it as is and then refactor it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this sounds great @kevinmcconnell. Unfortunately I don't have the bandwidth to implement this at the moment but a +1 from me on the common interface as a nice abstraction.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries @reesericci, I'll try to wrap this up when I have a bit of spare time. Thanks for getting this work started.

} else {
s.httpsServer.TLSConfig = manager.TLSConfig()
}

s.httpsServer.Handler = s.handler

go s.httpServer.ListenAndServe()
Expand Down Expand Up @@ -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
},
Comment on lines +107 to +109
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to be specified, as HTTP/2 support should be enabled automatically.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, it needs to be specified for ALPN.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not necessary because the HTTP server includes h2 in its available protocols by default.

}
}

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, I think it's worth introducing a common interface for the two types of TLS provider. At which point, we can move the work of getLocalAuthority/getLocalCertificate into its own type that implements that interface.

That would also give us a handy place to add some test coverage for GetCertificate.


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
Expand Down