Skip to content
Merged
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
9 changes: 9 additions & 0 deletions cmd/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/prvious/pv/internal/caddy"
"github.com/prvious/pv/internal/certs"
"github.com/prvious/pv/internal/config"
"github.com/prvious/pv/internal/detection"
"github.com/prvious/pv/internal/phpenv"
Expand Down Expand Up @@ -93,6 +94,14 @@ pv link --name=myapp ~/Code/myapp`,
return fmt.Errorf("cannot generate Caddyfile: %w", err)
}

// Generate TLS certificate for Vite dev server auto-detection.
hostname := name + "." + settings.TLD
if err := certs.EnsureValetConfig(settings.TLD); err != nil {
ui.Subtle(fmt.Sprintf("Skipped Vite TLS setup: %v", err))
} else if err := certs.GenerateSiteTLS(hostname); err != nil {
ui.Subtle(fmt.Sprintf("Vite TLS cert not generated (HTTPS HMR may need manual config): %v", err))
}

typeLabel := projectType
if typeLabel == "" {
typeLabel = "unknown"
Expand Down
6 changes: 6 additions & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

tea "charm.land/bubbletea/v2"
"github.com/prvious/pv/internal/binaries"
"github.com/prvious/pv/internal/certs"
"github.com/prvious/pv/internal/commands/composer"
"github.com/prvious/pv/internal/commands/mago"
"github.com/prvious/pv/internal/commands/service"
Expand Down Expand Up @@ -132,6 +133,11 @@ var setupCmd = &cobra.Command{
return fmt.Errorf("cannot save settings: %w", err)
}

// Write Valet-compatible config for Vite TLS auto-detection.
if err := certs.EnsureValetConfig(tld); err != nil {
ui.Subtle(fmt.Sprintf("Vite TLS config: %v", err))
}

// Install PHP versions.
for _, v := range selectedPHP {
if phpenv.IsInstalled(v) {
Expand Down
15 changes: 15 additions & 0 deletions cmd/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"charm.land/huh/v2"
"github.com/prvious/pv/internal/certs"
colimacmd "github.com/prvious/pv/internal/commands/colima"
"github.com/prvious/pv/internal/commands/composer"
"github.com/prvious/pv/internal/commands/mago"
Expand Down Expand Up @@ -202,6 +203,20 @@ var uninstallCmd = &cobra.Command{
}
}

// Remove Vite TLS certs for linked projects only.
if reg != nil {
var hostnames []string
for _, p := range reg.List() {
hostnames = append(hostnames, p.Name+"."+tld)
}
if err := certs.RemoveLinkedCerts(hostnames); err != nil {
ui.Subtle(fmt.Sprintf("Could not remove some Vite TLS certs: %v", err))
}
}
if err := certs.RemoveConfig(); err != nil {
ui.Subtle(fmt.Sprintf("Could not remove Valet config: %v", err))
}

// Remove ~/.pv directory.
if err := ui.Step("Removing ~/.pv...", func() (string, error) {
pvDir := config.PvDir()
Expand Down
7 changes: 7 additions & 0 deletions cmd/unlink.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/prvious/pv/internal/caddy"
"github.com/prvious/pv/internal/certs"
"github.com/prvious/pv/internal/config"
"github.com/prvious/pv/internal/registry"
"github.com/prvious/pv/internal/server"
Expand Down Expand Up @@ -70,6 +71,12 @@ pv unlink`,
if settings != nil {
tld = settings.TLD
}

// Remove TLS certificate for Vite dev server.
if err := certs.RemoveSiteTLS(name + "." + tld); err != nil {
ui.Subtle(fmt.Sprintf("Could not remove Vite TLS certs: %v", err))
}

domain := "https://" + name + "." + tld

fmt.Fprintln(os.Stderr)
Expand Down
112 changes: 112 additions & 0 deletions internal/certs/certs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package certs

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"time"
)

// GenerateSiteCert creates a TLS certificate for the given hostname, signed by
// the CA at caCertPath/caKeyPath, and writes the cert/key to certPath/keyPath.
func GenerateSiteCert(hostname, caCertPath, caKeyPath, certPath, keyPath string) error {
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
if err != nil {
return fmt.Errorf("cannot load CA: %w", err)
}

siteKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("cannot generate site key: %w", err)
}

serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("cannot generate serial number: %w", err)
}

template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(825 * 24 * time.Hour), // 825 days (macOS max for trusted TLS certs)
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}

certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &siteKey.PublicKey, caKey)
if err != nil {
return fmt.Errorf("cannot create certificate: %w", err)
}

certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return fmt.Errorf("cannot write certificate: %w", err)
}

keyDER, err := x509.MarshalECPrivateKey(siteKey)
if err != nil {
return fmt.Errorf("cannot marshal key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
os.Remove(certPath) // clean up orphaned cert
return fmt.Errorf("cannot write key: %w", err)
}

return nil
}

func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, err
}
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, nil, fmt.Errorf("no PEM block in CA certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, err
}

keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, err
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, nil, fmt.Errorf("no PEM block in CA key")
}

key, err := parsePrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}

return cert, key, nil
}

func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) {
if key, err := x509.ParseECPrivateKey(der); err == nil {
return key, nil
}
// Caddy's PKI (via smallstep) may store CA keys in PKCS#8 format.
parsed, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, fmt.Errorf("unsupported CA key format: %w", err)
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("CA key is %T, expected *ecdsa.PrivateKey", parsed)
}
return key, nil
}
142 changes: 142 additions & 0 deletions internal/certs/certs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package certs

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)

func createTestCA(t *testing.T, dir string) (certPath, keyPath string) {
t.Helper()

caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate CA key: %v", err)
}

template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}

certDER, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA cert: %v", err)
}

certPath = filepath.Join(dir, "root.crt")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
t.Fatalf("write CA cert: %v", err)
}

keyPath = filepath.Join(dir, "root.key")
keyDER, err := x509.MarshalECPrivateKey(caKey)
if err != nil {
t.Fatalf("marshal CA key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("write CA key: %v", err)
}

return certPath, keyPath
}

func TestGenerateSiteCert(t *testing.T) {
dir := t.TempDir()
caCertPath, caKeyPath := createTestCA(t, dir)

certPath := filepath.Join(dir, "myapp.test.crt")
keyPath := filepath.Join(dir, "myapp.test.key")

if err := GenerateSiteCert("myapp.test", caCertPath, caKeyPath, certPath, keyPath); err != nil {
t.Fatalf("GenerateSiteCert() error = %v", err)
}

// Verify cert file exists and is valid.
certPEM, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read cert: %v", err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("no PEM block in cert")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse cert: %v", err)
}

if cert.Subject.CommonName != "myapp.test" {
t.Errorf("CommonName = %q, want %q", cert.Subject.CommonName, "myapp.test")
}
if len(cert.DNSNames) != 1 || cert.DNSNames[0] != "myapp.test" {
t.Errorf("DNSNames = %v, want [myapp.test]", cert.DNSNames)
}
if cert.NotAfter.Before(time.Now().Add(800 * 24 * time.Hour)) {
t.Error("cert expires too soon")
}

// Verify key file exists and is valid.
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read key: %v", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
t.Fatal("no PEM block in key")
}

// Verify key permissions.
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat key: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("key permissions = %o, want 0600", info.Mode().Perm())
}

// Verify the cert is signed by the CA.
caCertPEM, _ := os.ReadFile(caCertPath)
caBlock, _ := pem.Decode(caCertPEM)
caCert, _ := x509.ParseCertificate(caBlock.Bytes)

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

if _, err := cert.Verify(x509.VerifyOptions{
Roots: pool,
DNSName: "myapp.test",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Errorf("cert verification failed: %v", err)
}
}

func TestGenerateSiteCert_MissingCA(t *testing.T) {
dir := t.TempDir()

err := GenerateSiteCert("myapp.test",
filepath.Join(dir, "nonexistent.crt"),
filepath.Join(dir, "nonexistent.key"),
filepath.Join(dir, "out.crt"),
filepath.Join(dir, "out.key"),
)
if err == nil {
t.Fatal("expected error for missing CA")
}
}
Loading