servercert

package module
v0.5.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: BSD-3-Clause Imports: 23 Imported by: 3

README

servercert

Go Reference License CI

Where a daemon's TLS server certificate comes from: a certificate and key on disk, re-read when they change, or an ACME CA through autocert, External Account Binding included. Pure Go, CGO_ENABLED=0.

src, err := servercert.New(servercert.Config{
    CertFile: "/etc/fileshare/tls/fullchain.pem",
    KeyFile:  "/etc/fileshare/tls/privkey.pem",
    OnError:  func(err error) { log.Print(err) },
})
srv := &http.Server{Addr: ":443", Handler: h, TLSConfig: src.TLSConfig()}
srv.ListenAndServeTLS("", "")

Files

New reads the pair and fails now on a pair that is unreadable, does not parse, or whose key is not the certificate's. After that, at most once every 10 seconds and during a handshake, the files are read again; a changed pair is parsed — without holding a lock — and swapped in.

A changed pair that does not load (the certificate written before its key, a truncated file) is reported once to OnError, and the last good pair stays in service. A renewal job that writes the two files one after the other never takes the server down.

ACME

autocert answers two challenge types, and no other:

challenge spec the CA connects to
tls-alpn-01 RFC 8737 port 443, ALPN acme-tls/1
http-01 RFC 8555 §8.3 port 80; set HTTPChallenge and serve HTTPHandler there

No dns-01, hence no wildcard and no IP-address certificate: Check refuses both rather than letting them fail at the first handshake.

Certificates are obtained at the first TLS handshake whose SNI names one of Domains — or before any client, by Source.Prefetch (since v0.5.0), which a server calls once its listeners are up, so the first client does not wait for an issuance. They are renewed before they expire: autocert renews at the lesser of 30 days and a third of the lifetime before expiry, which suits the shorter certificates CAs are moving to (Let's Encrypt's 45-day and 6-day profiles). A client that connects by IP address sends no SNI and is served the first domain's certificate (before v0.5.0 its handshake failed).

TLSConfig().NextProtos holds acme-tls/1. Append your protocols, never replace the slice: without it the CA's tls-alpn-01 validation fails. Append them you must, too — a Go TLS server that advertises ALPN protocols refuses a client whose offer shares none, so an NFS-over-TLS server adds sunrpc (RFC 9289). net/http adds http/1.1 and h2 by itself.

An internal server, no port open: GÉANT TCS via HARICA

autocert returns at once when a new order is already ready — every authorization valid before any challenge. That is what a CA with pre-validated domains answers, and it is the case of GÉANT TCS, served by HARICA since 2025-01-10 (it replaced Sectigo): an Enterprise Admin ACME account gets OV certificates for the organisation's validated domains with no ACME challenge at all (DFN documentation: „Keine ACME Challenge"; RENATER). An Enterprise User account is not that: its DV certificates always go through an http or dns challenge, so it needs a port the CA can reach.

Two conditions, both from RENATER's documentation: the domain's CAA record, if it has one, must allow harica.gr (IN CAA 0 issue "harica.gr"), or the request stays Pending; and the server must reach acme-v02.harica.gr and ocsp-tls.harica.gr outbound.

So a server that the Internet reaches on neither port 80 nor 443 — a file server inside a lab network — still gets a publicly trusted certificate:

src, err := servercert.New(servercert.Config{
    ACME: &servercert.ACME{
        DirectoryURL:   "https://acme-v02.harica.gr/acme/<uuid>/directory", // the Server URL cm.harica.gr shows
        Email:          "it@example.org",
        Domains:        []string{"files.lab.example.org"},
        CacheDir:       "/var/lib/fileshare/acme",
        EABKeyID:       "<key ID from https://cm.harica.gr>",
        EABHMACKeyFile: "/etc/fileshare/harica-hmac", // base64url, as HARICA gives it
    },
    OnError: func(err error) { log.Print(err) },
})

The HMAC key is a secret: the configuration names a file holding it, never the key itself.

Let's Encrypt
servercert.Config{ACME: &servercert.ACME{
    Domains:  []string{"files.example.org"}, // DirectoryURL empty: Let's Encrypt production
    CacheDir: "/var/lib/fileshare/acme",
}}

What Check refuses

Config.Check touches nothing — no file read, no directory created — and refuses: neither or both sources; half a file pair; ACME without domains or cache directory; a relative path; half an EAB; a domain that is an IP address, a wildcard, a single label, ends with a dot, or is not a valid host name; a directory URL that is not https (RFC 8555 §6.1).

New also refuses a cache directory that is a symbolic link, one accessible by group or others, one owned by a user other than the process's effective user (it holds private keys; mode and owner are not checked on Windows, where ACLs decide both -- restrict the directory's ACL to the service account yourself), and one it cannot write (a file is created and removed there); it creates a missing one 0700, and checks what is there after creating it. Every directory above the cache must be one only root or the effective user can change, as sshd's StrictModes requires of the path to authorized_keys: owned by one of them, and not writable by group or others unless sticky (/tmp). A symbolic link above the cache is refused unless root owns it (macOS's /var and /tmp): whoever owns a link can re-point it, and the private keys with it. On Windows, a symbolic link anywhere in the path is refused. A security audit found that only the last component was checked (TestCacheDirParentSymlinkIsRefused).

A defect worked around

x/crypto/acme v0.57.0 (golang/go#77704): after finalizing an order the CA is still processing, the client polls the Location header of the finalize response, which RFC 8555 does not put there. Pebble and Buypass send none, and the certificate is never fetched. The ACME client's transport supplies the header from the order URL it saw earlier; a CA that sends it sees no difference. A test runs the same issuance without the workaround and requires it to fail, so its removal is signalled the day x/crypto is fixed.

Tests

The ACME tests obtain real certificates from pebble, Let's Encrypt's test CA, run in-process from its ca, db, va and wfe packages, with a TCP DNS stub sending the test name to 127.0.0.1. Certificates are verified by crypto/tls against pebble's root. Covered: tls-alpn-01; http-01 through HTTPHandler (and its control with HTTPChallenge off); EAB required and absent, wrong, unknown, then right in the same test; a pre-validated domain issued with no challenge port open (and its control: no prior authorization, no certificate).

Documentation

Overview

Package servercert says where a daemon's TLS server certificate comes from: a certificate and key on disk, re-read when they change, or an ACME CA through golang.org/x/crypto/acme/autocert, External Account Binding included.

src, err := servercert.New(servercert.Config{
    ACME: &servercert.ACME{
        DirectoryURL:   "https://acme-v02.harica.gr/acme/<uuid>/directory", // as cm.harica.gr shows it
        Domains:        []string{"files.example.org"},
        CacheDir:       "/var/lib/fileshare/acme",
        EABKeyID:       "<key ID from cm.harica.gr>",
        EABHMACKeyFile: "/etc/fileshare/harica-hmac",
    },
    OnError: func(err error) { log.Print(err) },
})
tc := src.TLSConfig()
tc.NextProtos = append(tc.NextProtos, "sunrpc") // APPEND: keep acme-tls/1

Files

CertFile and KeyFile are read by New, which fails at once on a pair that is unreadable, does not parse, or whose key is not the certificate's. At most once every 10 seconds, during a handshake, the files are read again; a pair whose contents changed is parsed (without holding any lock) and swapped in. A changed pair that does not load -- the certificate written before its key, a truncated file -- is reported once to Config.OnError and the last good pair stays in service.

ACME

autocert answers two challenge types and no other:

  • tls-alpn-01 (RFC 8737): the CA connects to port 443 of the domain and negotiates the "acme-tls/1" ALPN protocol. Source.TLSConfig carries that protocol; the caller must APPEND its own to NextProtos, never replace them.
  • http-01 (RFC 8555 §8.3): the CA fetches a token on port 80. With ACME.HTTPChallenge, Source.HTTPHandler serves it and must be on :80.

There is no dns-01, hence no wildcard, and no IP address certificates; Config.Check refuses both.

A CA may need no challenge at all. autocert's order flow returns as soon as a new order is already "ready" (RFC 8555 §7.1.6): every authorization is valid before any challenge is attempted. That is the case of GÉANT TCS, served by HARICA since 2025-01-10 (it replaced Sectigo): an Enterprise ADMIN account (OV certificates) with pre-validated domains gets its certificates with no ACME challenge; an Enterprise User account (DV) is challenged like any other, and either way the domain's CAA must allow harica.gr (DFN: https://doku.tid.dfn.de/de:dfnpki:tcs:2025:acme). The directory URL, the EAB key ID and the HMAC key are all shown by https://cm.harica.gr for the ACME account -- of the form https://acme-v02.harica.gr/acme/<uuid>/directory in SURF's instructions (https://servicedesk.surf.nl/wiki/spaces/WIKI/pages/147098524/ACME, June 2025); older guides show another form, so copy it rather than build it. So an INTERNAL server reachable on neither port 80 nor 443 from the Internet can still get a publicly trusted certificate.

Certificates are obtained at the first TLS handshake whose SNI names one of ACME.Domains, or before any client with Source.Prefetch, then renewed by autocert before they expire. A client that connects by IP address sends no SNI and is served the FIRST domain's certificate, which it still checks against the address it dialled.

External Account Binding (RFC 8555 §7.3.4) ties the ACME account to an account at the CA. The HMAC key is a secret, so the configuration names a FILE holding it (base64url, as CAs hand it out), never the key itself.

One defect of x/crypto/acme is worked around here (golang/go#77704): after finalizing an order the CA is still processing, the client polls the Location header of the finalize response, which RFC 8555 does not put there; pebble and Buypass do not send it. The ACME client's transport supplies it from the order URL it saw earlier.

Tests

The ACME tests obtain real certificates from pebble, Let's Encrypt's test CA, run in-process, and verify them with crypto/tls against pebble's root: tls-alpn-01, http-01, EAB required (absent, wrong and right keys), and a pre-validated domain issued with no challenge port open.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ACME

type ACME struct {
	// DirectoryURL is the CA's ACME directory, https only (RFC 8555 §6.1).
	// Empty means Let's Encrypt production, [acme.LetsEncryptURL].
	// For GÉANT TCS it is the Server URL cm.harica.gr shows for the ACME
	// account, such as https://acme-v02.harica.gr/acme/<uuid>/directory.
	DirectoryURL string

	// Email is the account's contact address; optional.
	Email string

	// Domains are the names a certificate may be requested for: the host
	// whitelist. Required. No IP address and no wildcard: autocert can do
	// neither (it validates with tls-alpn-01 or http-01, and a wildcard needs
	// dns-01).
	Domains []string

	// CacheDir holds the account key and the certificates, which are
	// private keys. Required and absolute. Created 0700 if missing; one that
	// exists and is accessible by group or others is refused (not checked on
	// Windows, where mode bits do not describe access).
	CacheDir string

	// EABKeyID and EABHMACKeyFile are the External Account Binding (RFC
	// 8555 §7.3.4) a CA such as HARICA, ZeroSSL or Sectigo requires to tie
	// the ACME account to a customer account. Both or neither. The file
	// holds the HMAC key as the CA hands it out, base64url; the key is a
	// secret, which is why it is a file and never a value in the config.
	EABKeyID       string
	EABHMACKeyFile string

	// HTTPChallenge also answers http-01 (RFC 8555 §8.3) when tls-alpn-01
	// fails. The caller must then serve [Source.HTTPHandler] on port 80.
	HTTPChallenge bool

	// HTTPClient talks to the CA. Nil means http.DefaultClient. Set it to
	// trust a private CA's directory certificate, or to go through a proxy.
	// It is copied, not modified: the copy's transport is wrapped to work
	// around golang/go#77704 (see the package documentation).
	HTTPClient *http.Client
}

ACME obtains the certificate from an ACME CA (RFC 8555) through golang.org/x/crypto/acme/autocert.

type Config

type Config struct {
	// CertFile and KeyFile are PEM files: the certificate chain, leaf first,
	// and its private key. Both absolute. They are re-read when they change
	// (see [Source.TLSConfig]).
	CertFile, KeyFile string

	// ACME obtains and renews the certificate from a CA instead.
	ACME *ACME

	// OnError, if set, is told about what the source could not do but
	// survived: a changed certificate pair that does not parse or whose key
	// does not match (the previous pair keeps being served), or an ACME
	// issuance that failed for one of the configured domains. It is called
	// from inside a TLS handshake, so it must not block.
	OnError func(error)
}

Config says where the certificate comes from: a pair of files, or ACME. Exactly one of the two is given.

func (Config) Check

func (c Config) Check() error

Check refuses a Config that cannot work, touching nothing: no file is read and no directory created.

type Source

type Source struct {
	// contains filtered or unexported fields
}

Source serves the server certificate. It is safe for concurrent use.

func New

func New(c Config) (*Source, error)

New checks c and prepares the source. With files, the pair is read now and a pair that cannot be read, does not parse, or whose key does not match the certificate is an error now rather than at the first handshake. With ACME, the EAB key file is read and decoded and the cache directory created (0700) or checked; no request goes to the CA until a client connects.

func (*Source) Close

func (s *Source) Close() error

Close stops serving: every handshake after it fails. Certificates already in the cache directory stay there. It always returns nil.

func (*Source) HTTPHandler

func (s *Source) HTTPHandler(fallback http.Handler) http.Handler

HTTPHandler answers the http-01 challenge under /.well-known/acme-challenge/ and hands every other request to fallback; a nil fallback redirects to https. It is meaningful only with ACME and HTTPChallenge, and must then be served on port 80. Otherwise it is fallback itself, or a 404 handler when fallback is nil.

func (*Source) Prefetch added in v0.5.1

func (s *Source) Prefetch(ctx context.Context) error

Prefetch obtains the certificate of every domain now, instead of at the first handshake that names it -- so that the first client does not wait for an issuance, and a server that no client has yet reached by name still has its certificate in hand. Certificates already cached and valid cost nothing.

With tls-alpn-01 or http-01 the CA connects back to validate the name, so call it once the TLS listener on 443, or HTTPHandler on 80, is serving. A CA account whose domains are pre-validated (HARICA Enterprise Admin, for GÉANT TCS) asks for no challenge, and Prefetch may run at once.

A failure is returned, joined, and also told to Config.OnError. It is a no-op with files.

func (*Source) TLSConfig

func (s *Source) TLSConfig() *tls.Config

TLSConfig returns a new tls.Config serving this source's certificate, with MinVersion TLS 1.2.

With files, the pair is checked for change at most once every 10 seconds, during a handshake; a changed pair that fails to load leaves the previous one in service and goes to Config.OnError.

With ACME, the certificate is obtained at the first handshake whose SNI names one of the domains -- or earlier, by Prefetch -- and renewed by autocert before it expires. A client that connects by IP address sends no SNI and is served the first domain's certificate. NextProtos then holds "acme-tls/1", the protocol of the tls-alpn-01 challenge (RFC 8737): APPEND your own protocols to it, never replace it, or the CA's validation fails. Append them, too: a Go TLS server that advertises ALPN protocols refuses a client whose offer shares none of them, so an NFS server must add "sunrpc" (RFC 9289). net/http's ServeTLS adds "http/1.1" (and "h2") by itself.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL