Skip to content
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,42 @@ That CA cert is used to dynamically create certificates for your apps when acces

When `-install` is used (and let's be honest, that's how you want to use puma-dev), then it listens on port 443 by default (configurable with `-install-https-port`) so you can just do `https://blah.test` to access your app via https.

#### Backdating leaf certificates (e.g. for `libfaketime`)

Each app's certificate has its `NotBefore` set to the current time when it's generated. If a service's clock is moved into the past (for example with [`libfaketime`](https://github.com/wolfcw/libfaketime) to test time-dependent behavior), TLS connections to it can fail because the cert appears "not yet valid" relative to that faked clock.

To avoid this, set `PUMADEV_LEAF_CERT_NOT_BEFORE` before starting puma-dev to backdate the `NotBefore` of newly generated app certificates. It accepts either:

- A duration, e.g. `PUMADEV_LEAF_CERT_NOT_BEFORE=48h` — backdates by that much relative to the current time whenever a cert is generated (a positive value is treated the same as its negative equivalent).
- A fixed timestamp in [RFC3339](https://www.rfc-editor.org/rfc/rfc3339) format, e.g. `PUMADEV_LEAF_CERT_NOT_BEFORE=2026-01-01T00:00:00Z`. If it isn't in the past, it's ignored and the current time is used instead.

This only affects certs generated after the variable is set — it won't retroactively re-sign certs already cached for apps you've already visited, so restart puma-dev (or `-stop` it) after changing it.

**Note:** if puma-dev is running as a background service (the normal way to run it via `-install` on macOS, or via `systemd` on Linux), a plain shell `export` won't reach it — you have to set the variable where that service definition lives:

- macOS (`launchd`): add an `EnvironmentVariables` entry to `~/Library/LaunchAgents/io.puma.dev.plist`, then reload it:

```sh
/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables dict" ~/Library/LaunchAgents/io.puma.dev.plist
/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:PUMADEV_LEAF_CERT_NOT_BEFORE string 48h" ~/Library/LaunchAgents/io.puma.dev.plist
launchctl unload ~/Library/LaunchAgents/io.puma.dev.plist
launchctl load ~/Library/LaunchAgents/io.puma.dev.plist
```

- Linux (`systemd`): add an `Environment=` line to your `puma-dev.service` unit, then reload:

```ini
[Service]
Environment="PUMADEV_LEAF_CERT_NOT_BEFORE=48h"
```

```sh
sudo systemctl daemon-reload
sudo systemctl restart puma-dev
```

If you're running puma-dev directly in the foreground (e.g. `PUMADEV_LEAF_CERT_NOT_BEFORE=48h puma-dev`), a plain shell env var is enough.

### Webpack Dev Server

If your app uses HTTPS then the Webpack Dev Server (WDS) should be run via SSL too to avoid browser "Mixed content" errors. While the WDS can generate its own certificates, these expire regularly and often need re-trusting in a new tab to avoid repeating console errors about `/sockjs-node/info?t=123` that break the auto-reloading of assets via WDS.
Expand Down
34 changes: 32 additions & 2 deletions dev/ssl.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,36 @@ func (c *certCache) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certi
return cert, nil
}

// leafCertNotBefore determines the NotBefore time for leaf certificates,
// allowing it to be backdated via PUMADEV_LEAF_CERT_NOT_BEFORE so that
// tools like libfaketime don't see certs as "not yet valid" when a
// service's clock has been moved into the past.
func leafCertNotBefore() time.Time {
now := time.Now()

value := os.Getenv("PUMADEV_LEAF_CERT_NOT_BEFORE")
if value == "" {
return now
}

if d, err := time.ParseDuration(value); err == nil {
if d > 0 {
d = -d
}
return now.Add(d)
}

if t, err := time.Parse(time.RFC3339, value); err == nil {
if t.Before(now) {
return t
}
return now
}

log.Printf("puma-dev: invalid PUMADEV_LEAF_CERT_NOT_BEFORE value %q, using current time", value)
return now
}

func makeCert(
parent *tls.Certificate,
name string,
Expand All @@ -163,8 +193,8 @@ func makeCert(
}

// create certificate structure with proper values
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
notBefore := leafCertNotBefore()
notAfter := time.Now().Add(365 * 24 * time.Hour)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
Expand Down
98 changes: 98 additions & 0 deletions dev/ssl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package dev

import (
"crypto/tls"
"crypto/x509"
"path/filepath"
"testing"
"time"

. "github.com/puma/puma-dev/dev/devtest"
"github.com/stretchr/testify/assert"
Expand All @@ -25,3 +27,99 @@ func TestGeneratePumaDevCertificateAuthority(t *testing.T) {

assert.NoError(t, err)
}

const notBeforeEnvVar = "PUMADEV_LEAF_CERT_NOT_BEFORE"

func TestLeafCertNotBefore_unset(t *testing.T) {
t.Setenv(notBeforeEnvVar, "")

assert.WithinDuration(t, time.Now(), leafCertNotBefore(), time.Second)
}

func TestLeafCertNotBefore_negativeDuration(t *testing.T) {
t.Setenv(notBeforeEnvVar, "-48h")

assert.WithinDuration(t, time.Now().Add(-48*time.Hour), leafCertNotBefore(), time.Second)
}

func TestLeafCertNotBefore_positiveDurationIsNegated(t *testing.T) {
t.Setenv(notBeforeEnvVar, "48h")

assert.WithinDuration(t, time.Now().Add(-48*time.Hour), leafCertNotBefore(), time.Second)
}

func TestLeafCertNotBefore_pastTimestamp(t *testing.T) {
past := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
t.Setenv(notBeforeEnvVar, past.Format(time.RFC3339))

assert.WithinDuration(t, past, leafCertNotBefore(), time.Second)
}

func TestLeafCertNotBefore_futureTimestampIgnored(t *testing.T) {
future := time.Now().Add(72 * time.Hour)
t.Setenv(notBeforeEnvVar, future.Format(time.RFC3339))

assert.WithinDuration(t, time.Now(), leafCertNotBefore(), time.Second)
}

func TestLeafCertNotBefore_invalidValueIgnored(t *testing.T) {
t.Setenv(notBeforeEnvVar, "not-a-real-value")

assert.WithinDuration(t, time.Now(), leafCertNotBefore(), time.Second)
}

func testCA(t *testing.T) *tls.Certificate {
tmpPath := "tmp"
defer MakeDirectoryOrFail(t, tmpPath)()

certPath := filepath.Join(tmpPath, "ca-cert.pem")
keyPath := filepath.Join(tmpPath, "ca-key.pem")

if err := GeneratePumaDevCertificateAuthority(certPath, keyPath); err != nil {
t.Fatalf("generating test CA: %s", err)
}

ca, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
t.Fatalf("loading test CA: %s", err)
}

return &ca
}

func TestMakeCert_notBeforeStillProducesAValidCert(t *testing.T) {
ca := testCA(t)

caCert, err := x509.ParseCertificate(ca.Certificate[0])
assert.NoError(t, err)

roots := x509.NewCertPool()
roots.AddCert(caCert)

cases := map[string]string{
"unset": "",
"negative duration": "-48h",
"positive duration": "48h",
"past timestamp": time.Now().Add(-72 * time.Hour).Format(time.RFC3339),
"offset large enough to have previously pushed notAfter into the past": "9000h",
}

for name, value := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv(notBeforeEnvVar, value)

tlsCert, err := makeCert(ca, "leaf.test")
assert.NoError(t, err)

leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
assert.NoError(t, err)

_, err = leaf.Verify(x509.VerifyOptions{
Roots: roots,
CurrentTime: time.Now(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
assert.NoError(t, err, "leaf cert should be currently valid when verified against the CA")
})
}
}