package blacktip import ( "context" "crypto/tls" "errors" "path/filepath" "strings" "time" "git.veillax.com/Veillax/reef/model" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" ) // Certificates for https ports come from Let's Encrypt via autocert, using the // TLS-ALPN-01 challenge on the same port 443 listener, so nothing extra has to // be opened. A wildcard hostname such as *.example.com gets a separate // certificate for each subdomain on its first visit. type acmeState struct { mgr *autocert.Manager email string staging bool tried map[string]time.Time certs map[string]string } // configureACME builds the manager. Caller holds b.mu. func (b *Blacktip) configureACME(email string, staging bool) { a := &b.acme if a.tried == nil { a.tried, a.certs = map[string]time.Time{}, map[string]string{} } if a.mgr != nil && a.email == email && a.staging == staging { return } m := &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(filepath.Join(b.cfg.DataDir, "certs")), HostPolicy: b.hostPolicy, Email: email, } if staging { m.Client = &acme.Client{DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory"} } a.mgr, a.email, a.staging = m, email, staging a.tried = map[string]time.Time{} } // hostPolicy only lets us request certificates for hostnames we actually serve over https. func (b *Blacktip) hostPolicy(_ context.Context, host string) error { b.mu.Lock() defer b.mu.Unlock() for _, ms := range b.maps { if ms.m.Proto == model.ProtoHTTPS && model.HostMatches(ms.m.Hostname, strings.ToLower(host)) { return nil } } return errors.New("not a hostname served here") } func (b *Blacktip) getCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { if b.cfg.GetCertificate != nil { return b.cfg.GetCertificate(hello) } b.mu.Lock() mgr := b.acme.mgr b.mu.Unlock() if mgr == nil { return nil, errors.New("certificates are not configured yet") } return mgr.GetCertificate(hello) } // warmCerts requests certificates ahead of the first visitor so problems show up in the UI. // Caller holds b.mu. func (b *Blacktip) warmCerts(ms []model.Mapping) { for _, m := range ms { if m.Proto != model.ProtoHTTPS || strings.HasPrefix(m.Hostname, "*") { continue } host := m.Hostname if b.cfg.GetCertificate != nil { b.acme.certs[host] = "custom certificate" continue } wait := 12 * time.Hour if strings.HasPrefix(b.acme.certs[host], "error") { wait = 3 * time.Minute } if t, ok := b.acme.tried[host]; ok && time.Since(t) < wait { continue } b.acme.tried[host] = time.Now() b.acme.certs[host] = "requesting…" mgr := b.acme.mgr go func() { ctx, cancel := context.WithTimeout(b.ctx, 2*time.Minute) defer cancel() done := make(chan error, 1) go func() { _, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: host}) done <- err }() var err error select { case err = <-done: case <-ctx.Done(): err = ctx.Err() } b.mu.Lock() if err != nil { b.acme.certs[host] = "error: " + err.Error() } else { b.acme.certs[host] = "valid" } b.mu.Unlock() go b.report() }() } } // certStatus describes the certificate for m. Caller holds b.mu. func (b *Blacktip) certStatus(m model.Mapping) string { if m.Proto != model.ProtoHTTPS { return "" } if strings.HasPrefix(m.Hostname, "*") { if b.tr != nil { if b.cfg.Traefik.NoACME { return "self-signed (Traefik default certificate)" } if b.cfg.Traefik.ACMEDNS == "" { return "error: wildcard certificates need -acme-dns " } return "wildcard certificate via DNS challenge" } return "issued per subdomain on first visit" } if s := b.acme.certs[m.Hostname]; s != "" { return s } return "pending" } // acmeFailing reports whether any certificate request failed. Caller holds b.mu. func (b *Blacktip) acmeFailing() bool { for _, s := range b.acme.certs { if strings.HasPrefix(s, "error") { return true } } return false }