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/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)
|
||||||
|
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/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)
|
||||||
|
Reference in New Issue
Block a user