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:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/snakeoil"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
@@ -333,8 +334,19 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if UITLSCert != "" {
|
||||
if strings.HasPrefix(UITLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
UITLSCert = snakeoil.Public(UITLSCert)
|
||||
} else {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(UITLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
UITLSKey = snakeoil.Private(UITLSKey)
|
||||
} else {
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
}
|
||||
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
|
||||
@@ -393,8 +405,19 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" {
|
||||
if strings.HasPrefix(SMTPTLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
SMTPTLSCert = snakeoil.Public(SMTPTLSCert)
|
||||
} else {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(SMTPTLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
SMTPTLSKey = snakeoil.Private(SMTPTLSKey)
|
||||
} else {
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
}
|
||||
|
||||
if !isFile(SMTPTLSCert) {
|
||||
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
|
||||
@@ -462,8 +485,18 @@ func VerifyConfig() error {
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
if strings.HasPrefix(POP3TLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
POP3TLSCert = snakeoil.Public(POP3TLSCert)
|
||||
} else {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
}
|
||||
if strings.HasPrefix(POP3TLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
POP3TLSKey = snakeoil.Private(POP3TLSKey)
|
||||
} else {
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
}
|
||||
|
||||
if !isFile(POP3TLSCert) {
|
||||
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
|
||||
|
196
internal/snakeoil/snakeoil.go
Normal file
196
internal/snakeoil/snakeoil.go
Normal 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
|
||||
}
|
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/snakeoil"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
@@ -101,6 +102,12 @@ func Listen() {
|
||||
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 != "" {
|
||||
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)
|
||||
|
Reference in New Issue
Block a user