Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/reef/model"
|
|
)
|
|
|
|
// DKIM: Hammerhead makes a signing key for a sending domain, gives the edge the
|
|
// private half, and shows you the public half as the DNS record to publish. The
|
|
// edge signs mail on its way out; receiving servers fetch the record to check it.
|
|
|
|
var selectorRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$`)
|
|
|
|
func (k *dkimView) record() string { return "v=DKIM1; k=rsa; p=" + k.PublicKey }
|
|
|
|
type dkimView struct {
|
|
ID string `json:"id"`
|
|
Domain string `json:"domain"`
|
|
Selector string `json:"selector"`
|
|
PublicKey string `json:"publicKey"`
|
|
Created time.Time `json:"created"`
|
|
RecordName string `json:"recordName"`
|
|
Record string `json:"record"`
|
|
Status string `json:"status"` // published, missing, mismatch or unknown
|
|
}
|
|
|
|
func (s *Server) listDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []dkimView{}
|
|
for _, k := range s.st.DKIM {
|
|
if k.OrgID != c.Org.ID {
|
|
continue
|
|
}
|
|
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
|
|
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
|
|
for _, x := range s.st.MailEgress {
|
|
if x.OrgID != c.Org.ID {
|
|
continue
|
|
}
|
|
switch st := s.mailChecks[x.ID].DKIM[k.ID]; {
|
|
case st == "published":
|
|
v.Status = "published"
|
|
case st != "" && v.Status != "published":
|
|
v.Status = st
|
|
}
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Domain+out[i].Selector < out[j].Domain+out[j].Selector })
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) createDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ Domain, Selector string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Domain = strings.ToLower(strings.TrimSpace(in.Domain))
|
|
in.Selector = strings.ToLower(strings.TrimSpace(in.Selector))
|
|
if in.Selector == "" {
|
|
in.Selector = "hh" + time.Now().Format("200601")
|
|
}
|
|
if !validHostname(in.Domain) || strings.HasPrefix(in.Domain, "*.") {
|
|
fail(w, 400, errors.New("enter the domain you send mail from, like example.com"))
|
|
return
|
|
}
|
|
if !selectorRE.MatchString(in.Selector) {
|
|
fail(w, 400, errors.New("a selector is letters, digits and dashes, like mail2026"))
|
|
return
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048) // RSA: every receiving server can verify it
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
priv, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, k := range s.st.DKIM {
|
|
if k.Domain == in.Domain && k.Selector == in.Selector {
|
|
fail(w, 400, errors.New("you already have a key with that selector for this domain; pick a new selector to rotate"))
|
|
return
|
|
}
|
|
}
|
|
k := &model.DKIMKey{ID: newID(), OrgID: c.Org.ID, Domain: in.Domain, Selector: in.Selector, Created: time.Now(),
|
|
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})), PublicKey: base64.StdEncoding.EncodeToString(pub)}
|
|
s.st.DKIM = append(s.st.DKIM, k)
|
|
s.bump() // edges need the new signer
|
|
s.audit(c.Org.ID, c.Actor, "dkim.create", in.Selector+"._domainkey."+in.Domain)
|
|
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
|
|
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
|
|
writeJSON(w, 200, v)
|
|
}
|
|
|
|
func (s *Server) deleteDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var gone *model.DKIMKey
|
|
for _, k := range s.st.DKIM {
|
|
if k.ID == id && k.OrgID == c.Org.ID {
|
|
gone = k
|
|
}
|
|
}
|
|
if gone == nil {
|
|
fail(w, 404, errors.New("no such key"))
|
|
return
|
|
}
|
|
s.st.DKIM = filter(s.st.DKIM, func(k *model.DKIMKey) bool { return k.ID != id })
|
|
s.bump()
|
|
s.audit(c.Org.ID, c.Actor, "dkim.delete", gone.Selector+"._domainkey."+gone.Domain)
|
|
ok(w)
|
|
}
|
|
|
|
// signersLocked lists the signing keys an org's mail egress may use. Caller holds s.mu.
|
|
func (s *Server) signersLocked(orgID string) []model.DKIMSigner {
|
|
out := []model.DKIMSigner{}
|
|
for _, k := range s.st.DKIM {
|
|
if k.OrgID == orgID {
|
|
out = append(out, model.DKIMSigner{KeyID: k.ID, Domain: k.Domain, Selector: k.Selector, PrivateKey: k.PrivateKey, PublicKey: k.PublicKey})
|
|
}
|
|
}
|
|
return out
|
|
}
|