1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-13 20:04:49 +02:00

Feature: Add ability to generate self-signed (snakeoil) certificates for UI, SMTP and POP3 (#539)

This commit is contained in:
Ralph Slooten
2025-07-24 17:02:50 +12:00
parent 38c343867e
commit f3e3536cdb
3 changed files with 242 additions and 6 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/snakeoil"
"github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/internal/tools"
) )
@@ -333,8 +334,19 @@ func VerifyConfig() error {
} }
if UITLSCert != "" { if UITLSCert != "" {
if strings.HasPrefix(UITLSCert, "sans:") {
// generate a self-signed certificate
UITLSCert = snakeoil.Public(UITLSCert)
} else {
UITLSCert = filepath.Clean(UITLSCert) UITLSCert = filepath.Clean(UITLSCert)
}
if strings.HasPrefix(UITLSKey, "sans:") {
// generate a self-signed key
UITLSKey = snakeoil.Private(UITLSKey)
} else {
UITLSKey = filepath.Clean(UITLSKey) UITLSKey = filepath.Clean(UITLSKey)
}
if !isFile(UITLSCert) { if !isFile(UITLSCert) {
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert) return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
@@ -393,8 +405,19 @@ func VerifyConfig() error {
} }
if SMTPTLSCert != "" { if SMTPTLSCert != "" {
if strings.HasPrefix(SMTPTLSCert, "sans:") {
// generate a self-signed certificate
SMTPTLSCert = snakeoil.Public(SMTPTLSCert)
} else {
SMTPTLSCert = filepath.Clean(SMTPTLSCert) SMTPTLSCert = filepath.Clean(SMTPTLSCert)
}
if strings.HasPrefix(SMTPTLSKey, "sans:") {
// generate a self-signed key
SMTPTLSKey = snakeoil.Private(SMTPTLSKey)
} else {
SMTPTLSKey = filepath.Clean(SMTPTLSKey) SMTPTLSKey = filepath.Clean(SMTPTLSKey)
}
if !isFile(SMTPTLSCert) { if !isFile(SMTPTLSCert) {
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert) return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
@@ -462,8 +485,18 @@ func VerifyConfig() error {
// POP3 server // POP3 server
if POP3TLSCert != "" { if POP3TLSCert != "" {
if strings.HasPrefix(POP3TLSCert, "sans:") {
// generate a self-signed certificate
POP3TLSCert = snakeoil.Public(POP3TLSCert)
} else {
POP3TLSCert = filepath.Clean(POP3TLSCert) POP3TLSCert = filepath.Clean(POP3TLSCert)
}
if strings.HasPrefix(POP3TLSKey, "sans:") {
// generate a self-signed key
POP3TLSKey = snakeoil.Private(POP3TLSKey)
} else {
POP3TLSKey = filepath.Clean(POP3TLSKey) POP3TLSKey = filepath.Clean(POP3TLSKey)
}
if !isFile(POP3TLSCert) { if !isFile(POP3TLSCert) {
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert) return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)

View File

@@ -0,0 +1,196 @@
// Package snakeoil provides functionality to generate a temporary self-signed certificates
// for testing purposes. It generates a public and private key pair, stores them in the
// OS's temporary directory, returning the paths to these files.
package snakeoil
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"os"
"strings"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
)
var keys = make(map[string]KeyPair)
// KeyPair holds the public and private key paths for a self-signed certificate.
type KeyPair struct {
Public string
Private string
}
// Certificates returns all configured self-signed certificates in use,
// used for file deletion on exit.
func Certificates() map[string]KeyPair {
return keys
}
// Public returns the path to a generated PEM-encoded RSA public key.
func Public(str string) string {
domains, key, err := parse(str)
if err != nil {
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
return ""
}
if pair, ok := keys[key]; ok {
return pair.Public
}
private, public, err := generate(domains)
if err != nil {
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
return ""
}
keys[key] = KeyPair{
Public: public,
Private: private,
}
return public
}
// Private returns the path to a generated PEM-encoded RSA private key.
func Private(str string) string {
domains, key, err := parse(str)
if err != nil {
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
return ""
}
if pair, ok := keys[key]; ok {
return pair.Private
}
private, public, err := generate(domains)
if err != nil {
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
return ""
}
keys[key] = KeyPair{
Public: public,
Private: private,
}
return private
}
// Parse takes the original string input, removes the "sans:" prefix,
// splits the result into individual domains, and returns a slice of unique domains,
// along with a unique key that is a comma-separated list of these domains.
func parse(str string) ([]string, string, error) {
// remove "sans:" prefix
str = str[5:]
var domains []string
// split the string by commas and trim whitespace
for domain := range strings.SplitSeq(str, ",") {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain != "" && !tools.InArray(domain, domains) {
domains = append(domains, domain)
}
}
if len(domains) == 0 {
return domains, "", errors.New("no valid domains provided")
}
// generate sha256 hash of the domains to create a unique key
hasher := sha256.New()
hasher.Write([]byte(strings.Join(domains, ",")))
key := base64.URLEncoding.EncodeToString(hasher.Sum(nil))
return domains, key, nil
}
// Generate a new self-signed certificate and return a public & private key paths.
func generate(domains []string) (string, string, error) {
logger.Log().Infof("[tls] generating temp self-signed certificate for: %s", strings.Join(domains, ","))
key, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return "", "", err
}
keyBytes := x509.MarshalPKCS1PrivateKey(key)
// PEM encoding of private key
keyPEM := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
},
)
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
// create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(0),
Subject: pkix.Name{
CommonName: domains[0],
Organization: []string{"Mailpit self-signed certificate"},
},
DNSNames: domains,
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
// create certificate using template
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return "", "", err
}
// PEM encoding of certificate
certPem := pem.EncodeToMemory(
&pem.Block{
Type: "CERTIFICATE",
Bytes: derBytes,
},
)
// Store the paths to the generated keys
priv, err := os.CreateTemp("", ".mailpit-*-private.pem")
if err != nil {
return "", "", err
}
if _, err := priv.Write(keyPEM); err != nil {
return "", "", err
}
if err := priv.Close(); err != nil {
return "", "", err
}
pub, err := os.CreateTemp("", ".mailpit-*-public.pem")
if err != nil {
return "", "", err
}
if _, err := pub.Write(certPem); err != nil {
return "", "", err
}
if err := pub.Close(); err != nil {
return "", "", err
}
return priv.Name(), pub.Name(), nil
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3" "github.com/axllent/mailpit/internal/pop3"
"github.com/axllent/mailpit/internal/prometheus" "github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/snakeoil"
"github.com/axllent/mailpit/internal/stats" "github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/internal/tools"
@@ -101,6 +102,12 @@ func Listen() {
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
} }
// add temporary self-signed certificates to get deleted afterwards
for _, keyPair := range snakeoil.Certificates() {
storage.AddTempFile(keyPair.Public)
storage.AddTempFile(keyPair.Private)
}
if config.UITLSCert != "" && config.UITLSKey != "" { if config.UITLSCert != "" && config.UITLSKey != "" {
logger.Log().Infof("[http] starting on %s (TLS)", config.HTTPListen) logger.Log().Infof("[http] starting on %s (TLS)", config.HTTPListen)
logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot) logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)