Add the Hammerhead control plane
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>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user