checker-tls/checker/fetch.go

108 lines
3.5 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"strconv"
"strings"
"time"
)
// FetchChain dials host:port, optionally upgrades the connection via STARTTLS,
// completes a TLS handshake (InsecureSkipVerify so callers receive the chain
// even when PKIX would reject it), and returns the peer certificates leaf
// first.
//
// starttls is the protocol name as registered (smtp, submission, imap, pop3,
// ldap, xmpp-client, ...); pass "" for direct TLS. AutoSTARTTLS provides
// well-known port defaults.
func FetchChain(ctx context.Context, host string, port uint16, starttls string, timeout time.Duration) ([]*x509.Certificate, error) {
host = strings.TrimSuffix(host, ".")
addr := net.JoinHostPort(host, strconv.Itoa(int(port)))
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
defer conn.Close()
if dl, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
if starttls != "" {
up, ok := starttlsUpgraders[starttls]
if !ok {
return nil, fmt.Errorf("unsupported starttls protocol %q", starttls)
}
if err := up(conn, host); err != nil {
return nil, fmt.Errorf("starttls-%s: %w", starttls, err)
}
}
tlsConn := tls.Client(conn, &tls.Config{
ServerName: host,
InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it
})
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
return nil, fmt.Errorf("tls handshake: %w", err)
}
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return nil, fmt.Errorf("server presented no certificate")
}
return state.PeerCertificates, nil
}
// BuildChain produces a CertInfo per peer certificate (leaf first), with the
// four (selector, matching_type) DANE hash pairs precomputed. This is the
// same projection probe() applies internally; exported so HTTP handlers can
// reuse it without re-deriving the format.
func BuildChain(certs []*x509.Certificate) []CertInfo {
return buildChain(certs)
}
// AutoSTARTTLS maps a well-known port to the STARTTLS dialect FetchChain
// should drive. Returns "" when the port has no auto-mapping (caller should
// then use direct TLS or pass an explicit dialect).
func AutoSTARTTLS(port uint16) string {
switch port {
case 25, 587:
return "smtp"
case 143:
return "imap"
case 110:
return "pop3"
case 389:
return "ldap"
case 5222:
return "xmpp-client"
}
return ""
}