TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// selfSigned writes a self-signed certificate for names into dir.
|
||||
func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||
return
|
||||
}
|
||||
|
||||
func withCerts(env *apiEnv) *certs.Manager {
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||
env.srv.cfg.Certs = m
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCertificatesAPI(t *testing.T) {
|
||||
env := newAPIEnv(t)
|
||||
m := withCerts(env)
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||
|
||||
// A user who isn't an administrator sees nothing.
|
||||
hash, _ := auth.HashPassword("pw-pw-pw-pw")
|
||||
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
|
||||
env.st.CreateUser(context.Background(), u)
|
||||
tok, pid, sh := auth.NewToken()
|
||||
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||
env.do("GET", "/certificates", tok, nil, 403)
|
||||
|
||||
out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201)
|
||||
c := out["certificate"].(map[string]any)
|
||||
if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" {
|
||||
t.Errorf("created: %v", c)
|
||||
}
|
||||
if _, has := c["key_pem"]; has {
|
||||
t.Error("the key left the server")
|
||||
}
|
||||
if m.WebCertificate() == nil {
|
||||
t.Error("not serving")
|
||||
}
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409)
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400)
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400)
|
||||
list := env.do("GET", "/certificates", env.token, nil, 200)
|
||||
if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" {
|
||||
t.Errorf("list: %v", list)
|
||||
}
|
||||
env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200)
|
||||
if m.WebCertificate() != nil {
|
||||
t.Error("still serving after the web use was taken off")
|
||||
}
|
||||
got := env.do("GET", "/certificates/own", env.token, nil, 200)
|
||||
if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") {
|
||||
t.Errorf("files: %v", got["files"])
|
||||
}
|
||||
env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200)
|
||||
env.do("DELETE", "/certificates/own", env.token, nil, 200)
|
||||
env.do("GET", "/certificates/own", env.token, nil, 404)
|
||||
}
|
||||
|
||||
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||
env := newAPIEnv(t)
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||
s := env.srv
|
||||
cr, err := newCertReloader(certFile, keyFile, s.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.certs = cr
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||
hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil {
|
||||
io.WriteString(w, "tls "+r.Proto)
|
||||
} else {
|
||||
io.WriteString(w, "plain")
|
||||
}
|
||||
}))}
|
||||
go hs.Serve(newSniffListener(ln, cfg))
|
||||
t.Cleanup(func() { hs.Close() })
|
||||
addr := ln.Addr().String()
|
||||
|
||||
get := func(c *http.Client, url string) string {
|
||||
t.Helper()
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}}
|
||||
if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" {
|
||||
t.Errorf("HTTPS: %q", got)
|
||||
}
|
||||
// Loopback may still use plain HTTP (tools on the server itself).
|
||||
if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" {
|
||||
t.Errorf("plain from loopback: %q", got)
|
||||
}
|
||||
|
||||
// From elsewhere: browsers are redirected, API calls refused.
|
||||
h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") }))
|
||||
req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil)
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" {
|
||||
t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}"))
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
req.Header.Set("Authorization", "Bearer x")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") {
|
||||
t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Without any certificate, plain HTTP is served as before.
|
||||
s.certs = nil
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil)
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Body.String() != "served" {
|
||||
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// TLS certificates (docs/certificates.md). Administrators only: a
|
||||
// certificate's key can impersonate the server.
|
||||
|
||||
type certView struct {
|
||||
*model.Certificate
|
||||
Status certs.Status `json:"status"`
|
||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (s *Server) presentCert(c *model.Certificate) certView {
|
||||
m := s.cfg.Certs
|
||||
v := certView{Certificate: c, Status: m.StatusOf(c), Serving: m.WebName() == c.Name && c.HasUse(model.CertUseWeb)}
|
||||
if c.Source == model.CertSourceACME {
|
||||
t := m.Due(c)
|
||||
v.NextTry = &t
|
||||
}
|
||||
if t := m.Issuing(c.Name); !t.IsZero() {
|
||||
v.IssuingSince = &t
|
||||
}
|
||||
if c.HasUse(model.CertUseDNS) && c.NotAfter != nil {
|
||||
dir := m.FilesDir(c.Name)
|
||||
v.Files = &certFiles{Chain: dir + "/" + certs.ChainFile, Key: dir + "/" + certs.KeyFile}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) certsReady(p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
case errors.As(err, &ie):
|
||||
return badRequest("%s", ie.Msg)
|
||||
case errors.Is(err, certs.ErrNotFound):
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
out = append(out, s.presentCert(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCreate: POST /certificates. With "issue": true (the default for
|
||||
// certificates Jarl obtains) it is obtained before the answer.
|
||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
certs.Input
|
||||
Issue *bool `json:"issue"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Create(r.Context(), in.Input, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && (in.Issue == nil || *in.Issue) {
|
||||
return s.writeIssue(w, r, c.Name, p, http.StatusCreated)
|
||||
}
|
||||
if c.Source == model.CertSourceACME {
|
||||
s.cfg.Certs.Kick()
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certUpdate: PATCH /certificates/{name}.
|
||||
func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Update(r.Context(), r.PathValue("name"), in, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certIssue: POST /certificates/{name}/issue starts obtaining it now
|
||||
// (202), or rereads the files of a file-source certificate (200).
|
||||
func (s *Server) certIssue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeIssue(w, r, r.PathValue("name"), p, http.StatusOK)
|
||||
}
|
||||
|
||||
// writeIssue starts obtaining the certificate (it takes a while: poll
|
||||
// GET /certificates/{name} until issuing_since is gone).
|
||||
func (s *Server) writeIssue(w http.ResponseWriter, r *http.Request, name string, p *auth.Principal, status int) error {
|
||||
c, err := s.cfg.Certs.IssueAsync(r.Context(), name, p)
|
||||
if c == nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && status == http.StatusOK {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
out := map[string]any{"certificate": s.presentCert(c)}
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCheck: POST /certificates/{name}/check (or POST /certificates/check
|
||||
// with a certificate's fields) says where each challenge would go, without
|
||||
// asking the CA for anything.
|
||||
func (s *Server) certCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var c *model.Certificate
|
||||
if name := r.PathValue("name"); name != "" {
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Get(r.Context(), name); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
} else {
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Name == nil {
|
||||
in.Name = model.Ptr("check")
|
||||
}
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Preview(in); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
}
|
||||
routes, err := s.cfg.Certs.Check(r.Context(), c)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
ok := true
|
||||
for _, rt := range routes {
|
||||
ok = ok && rt.Error == ""
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "routes": routes, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certSummary sums up the certificates for the dashboard (nil without any).
|
||||
func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
if s.cfg.Certs == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := s.cfg.Certs.List(ctx)
|
||||
if err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := &api.CertSummary{Total: len(all), Problems: []api.CertProblem{}, Serving: s.cfg.Certs.WebName()}
|
||||
for _, c := range all {
|
||||
if st := s.cfg.Certs.StatusOf(c); st.State != "ok" && st.State != "pending" {
|
||||
out.Problems = append(out.Problems, api.CertProblem{Name: c.Name, State: st.State, Message: st.Message})
|
||||
}
|
||||
if c.NotAfter != nil && (out.NextAfter == nil || c.NotAfter.Before(*out.NextAfter)) {
|
||||
out.Next, out.NextAfter = c.Name, c.NotAfter
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -283,6 +283,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
if p.IsAdmin() {
|
||||
resp.Backup = s.backupStatus(r.Context(), now)
|
||||
resp.TOTP = s.totpOverview(r.Context())
|
||||
resp.Certificates = s.certSummary(r.Context())
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||
|
||||
@@ -274,6 +274,21 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
||||
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
||||
}
|
||||
|
||||
if s.cfg.Certs != nil {
|
||||
if list, err := s.cfg.Certs.List(ctx); err == nil {
|
||||
for _, c := range list {
|
||||
if c.NotAfter != nil {
|
||||
pw.gauge("jarl_certificate_expiry_timestamp_seconds", "When each certificate Jarl manages expires (Unix time; docs/certificates.md).", unix(*c.NotAfter), "certificate", c.Name, "source", c.Source)
|
||||
}
|
||||
st := s.cfg.Certs.StatusOf(c)
|
||||
pw.gauge("jarl_certificate_failing", "1 when obtaining or reading a certificate failed the last time.", b2f(st.State == "failing" || st.State == "expired"), "certificate", c.Name, "source", c.Source)
|
||||
if c.Source == model.CertSourceACME {
|
||||
pw.gauge("jarl_certificate_renewal_failures", "Failed attempts to obtain the certificate since the last success.", float64(c.Failures), "certificate", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||
for _, t := range tokens {
|
||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||
|
||||
@@ -162,6 +162,14 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
||||
// Query statistics
|
||||
m.HandleFunc("GET "+p+"/certificates", s.authed(s.certList))
|
||||
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||
|
||||
+23
-13
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -60,6 +61,9 @@ type Config struct {
|
||||
TOTPIssuer string
|
||||
// Metrics configures /metrics (metrics.go).
|
||||
Metrics Metrics
|
||||
// Certs manages TLS certificates (nil in tests that don't need it),
|
||||
// including the one jarld serves (h_certs.go, sniff.go).
|
||||
Certs *certs.Manager
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -143,14 +147,19 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
if len(s.cfg.Listen) == 0 {
|
||||
return errors.New("no listen addresses configured")
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if s.cfg.TLSCertFile != "" {
|
||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.certs = cr
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
||||
}
|
||||
// With a certificate (tls.cert_file, or one Jarl manages, which may
|
||||
// turn up later), the listeners serve HTTPS and plain HTTP side by
|
||||
// side (sniff.go).
|
||||
var tlsCfg *tls.Config
|
||||
if s.certs != nil || s.cfg.Certs != nil {
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||
}
|
||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||
var listeners []net.Listener
|
||||
@@ -168,8 +177,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
for i, ln := range listeners {
|
||||
handler := s.Handler()
|
||||
if tlsCfg != nil {
|
||||
handler = s.requireHTTPS(handler)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
@@ -181,23 +194,20 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
scheme := "http"
|
||||
if tlsCfg != nil {
|
||||
if s.httpsAvailable() {
|
||||
scheme = "https"
|
||||
}
|
||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||
if tlsCfg != nil {
|
||||
ln = newSniffListener(ln, tlsCfg)
|
||||
}
|
||||
go func(srv *http.Server, ln net.Listener) {
|
||||
var err error
|
||||
if tlsCfg != nil {
|
||||
err = srv.ServeTLS(ln, "", "")
|
||||
} else {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}(srv, ln)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
if !s.httpsAvailable() && (s.cfg.Certs == nil || !s.cfg.Certs.HasWeb(ctx)) {
|
||||
for _, a := range s.cfg.Listen {
|
||||
if host, _, err := net.SplitHostPort(a); err == nil {
|
||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||
@@ -211,7 +221,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
return err
|
||||
}
|
||||
s.serveSocket(errc)
|
||||
s.logHTTPReq(tlsCfg != nil)
|
||||
s.logHTTPReq(s.httpsAvailable())
|
||||
if ready != nil {
|
||||
ready()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||
// TLS handshake (0x16), the plain connection otherwise. That way jarld
|
||||
// can start serving HTTPS when a certificate turns up (one Jarl obtains,
|
||||
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||
// a redirect instead of an error.
|
||||
type sniffListener struct {
|
||||
net.Listener
|
||||
cfg *tls.Config
|
||||
ch chan net.Conn
|
||||
errc chan error
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||
go l.loop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *sniffListener) loop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case l.errc <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
go l.classify(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) classify(c net.Conn) {
|
||||
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
b, err := br.Peek(1)
|
||||
c.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||
if b[0] == 0x16 {
|
||||
out = tls.Server(out, l.cfg)
|
||||
}
|
||||
select {
|
||||
case l.ch <- out:
|
||||
case <-l.done:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.ch:
|
||||
return c, nil
|
||||
case err := <-l.errc:
|
||||
return nil, err
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Close() error {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
// peekedConn reads through the buffer that holds the peeked byte.
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
|
||||
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||
|
||||
// webCertificate is the certificate jarld serves: one Jarl manages with
|
||||
// the web use, else tls.cert_file.
|
||||
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if s.cfg.Certs != nil {
|
||||
if c := s.cfg.Certs.WebCertificate(); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
if s.certs != nil {
|
||||
return s.certs.get(hello)
|
||||
}
|
||||
return nil, errNoCertificate
|
||||
}
|
||||
|
||||
// httpsAvailable reports whether jarld has a certificate to serve.
|
||||
func (s *Server) httpsAvailable() bool {
|
||||
return s.certs != nil || s.cfg.Certs != nil && s.cfg.Certs.WebCertificate() != nil
|
||||
}
|
||||
|
||||
// requireHTTPS sends plain-HTTP requests to HTTPS once jarld has a
|
||||
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||
// error, so tokens and passwords aren't sent in the clear again. Requests
|
||||
// from loopback (tools on this server) and from trusted proxies (which
|
||||
// terminate TLS themselves) are served as they are.
|
||||
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil || !s.httpsAvailable() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
target := "https://" + r.Host + r.URL.RequestURI()
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
msg := "jarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user