Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
105 lines
2.6 KiB
Go
105 lines
2.6 KiB
Go
package certificates
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// storage manages certificate/key material on disk. Private keys are
|
|
// written with 0600 permissions and their directories with 0700; nothing
|
|
// under here is ever written to a log.
|
|
type storage struct {
|
|
root string
|
|
}
|
|
|
|
func newStorage(root string) (*storage, error) {
|
|
if err := os.MkdirAll(filepath.Join(root, "accounts"), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "certs"), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
return &storage{root: root}, nil
|
|
}
|
|
|
|
func (s *storage) certDir(name string) string {
|
|
return filepath.Join(s.root, "certs", safeName(name))
|
|
}
|
|
|
|
func safeName(name string) string {
|
|
out := make([]rune, 0, len(name))
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
|
|
out = append(out, r)
|
|
default:
|
|
out = append(out, '_')
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return "cert"
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// SavePair writes fullchain.pem and privkey.pem for the given logical
|
|
// certificate name and returns their paths.
|
|
func (s *storage) SavePair(name string, certPEM, keyPEM []byte) (certPath, keyPath string, err error) {
|
|
dir := s.certDir(name)
|
|
if err = os.MkdirAll(dir, 0o700); err != nil {
|
|
return
|
|
}
|
|
certPath = filepath.Join(dir, "fullchain.pem")
|
|
keyPath = filepath.Join(dir, "privkey.pem")
|
|
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
|
return
|
|
}
|
|
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s *storage) accountKeyPath() string {
|
|
return filepath.Join(s.root, "accounts", "account.key")
|
|
}
|
|
|
|
func loadTLSCertificate(certPath, keyPath string) (*tls.Certificate, error) {
|
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(cert.Certificate) == 0 {
|
|
return nil, errors.New("empty certificate chain")
|
|
}
|
|
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert.Leaf = leaf
|
|
return &cert, nil
|
|
}
|
|
|
|
// ValidateKeyPair checks that certPEM/keyPEM parse and form a matching
|
|
// pair before anything is written to disk.
|
|
func ValidateKeyPair(certPEM, keyPEM []byte) (*x509.Certificate, error) {
|
|
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("certificate/key do not match: %w", err)
|
|
}
|
|
block, _ := pem.Decode(certPEM)
|
|
if block == nil {
|
|
return nil, errors.New("no PEM data found in certificate")
|
|
}
|
|
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return leaf, nil
|
|
}
|