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
+171
@@ -0,0 +1,171 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
const defaultMailListen = "127.0.0.1:2525"
|
||||
|
||||
func (s *Server) findMailEgress(id string) *model.MailEgress {
|
||||
for _, e := range s.st.MailEgress {
|
||||
if e.ID == id {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackListen(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// validateMailEgress checks e against org c and normalises it. Caller holds s.mu.
|
||||
func (s *Server) validateMailEgress(c *orgCtx, e *model.MailEgress) error {
|
||||
e.OrgID = c.Org.ID
|
||||
e.Name = strings.TrimSpace(e.Name)
|
||||
site := s.findSite(e.SiteID)
|
||||
if site == nil || site.Local || site.OrgID != c.Org.ID {
|
||||
return errors.New("choose one of your sites: the mail server's network (the built-in local site already sends from the edge itself)")
|
||||
}
|
||||
edge := s.findEdge(e.EdgeID)
|
||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||
return errors.New("choose an edge")
|
||||
}
|
||||
e.Listen = strings.TrimSpace(e.Listen)
|
||||
if e.Listen == "" {
|
||||
e.Listen = defaultMailListen
|
||||
}
|
||||
if !validAddr(e.Listen) {
|
||||
return errors.New("listen address must look like 127.0.0.1:2525 (an address on the site's machine)")
|
||||
}
|
||||
for _, o := range s.st.MailEgress {
|
||||
if o.ID != e.ID && o.SiteID == e.SiteID && o.Listen == e.Listen {
|
||||
return errors.New("another mail egress on that site already listens on " + e.Listen)
|
||||
}
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(e.SourceIP))
|
||||
if ip == nil || ip.IsUnspecified() {
|
||||
return errors.New("choose the edge IP address your mail should leave from")
|
||||
}
|
||||
e.SourceIP = ip.String()
|
||||
e.HeloName = strings.ToLower(strings.TrimSpace(e.HeloName))
|
||||
if !validHostname(e.HeloName) || strings.HasPrefix(e.HeloName, "*.") {
|
||||
return errors.New("enter the hostname your mail server announces, like mail.example.com (its reverse DNS should match)")
|
||||
}
|
||||
e.Smarthost = strings.TrimSpace(e.Smarthost)
|
||||
if e.Smarthost != "" && !validAddr(e.Smarthost) {
|
||||
return errors.New("smarthost must look like smtp.example.com:587")
|
||||
}
|
||||
if e.RatePerMinute < 0 || e.RatePerMinute > 6000 {
|
||||
return errors.New("messages per minute must be between 1 and 6000")
|
||||
}
|
||||
if e.Username != "" && e.Password == "" {
|
||||
return errors.New("set a password too, or clear the username")
|
||||
}
|
||||
if e.Username == "" && !isLoopbackListen(e.Listen) {
|
||||
return errors.New("listening beyond this machine without a password would let anything on your network send mail from your IP; set a username and password, or listen on 127.0.0.1")
|
||||
}
|
||||
if e.Name == "" {
|
||||
e.Name = "mail via " + e.HeloName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) saveMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in model.MailEgress
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
idx := -1
|
||||
if id != "" {
|
||||
for i, e := range s.st.MailEgress {
|
||||
if e.ID == id && e.OrgID == c.Org.ID {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
fail(w, 404, errors.New("no such mail egress"))
|
||||
return
|
||||
}
|
||||
in.ID = id
|
||||
if in.Password == "" && in.Username != "" && in.Username == s.st.MailEgress[idx].Username {
|
||||
in.Password = s.st.MailEgress[idx].Password // blank keeps the saved password
|
||||
}
|
||||
}
|
||||
if err := s.validateMailEgress(c, &in); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
if idx >= 0 {
|
||||
s.st.MailEgress[idx] = &in
|
||||
delete(s.mailStatus, id)
|
||||
} else {
|
||||
in.ID = newID()
|
||||
s.st.MailEgress = append(s.st.MailEgress, &in)
|
||||
}
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mailegress.save", in.Name)
|
||||
resp := in // the stored record keeps its password; the response never carries it
|
||||
resp.Password = ""
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
|
||||
func (s *Server) deleteMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e := s.findMailEgress(id)
|
||||
if e == nil || e.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such mail egress"))
|
||||
return
|
||||
}
|
||||
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.ID != id })
|
||||
delete(s.mailStatus, id)
|
||||
delete(s.mailChecks, id)
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mailegress.delete", e.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
type mailEgressView struct {
|
||||
model.MailEgress
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
Status string `json:"status"`
|
||||
StatusKnown bool `json:"statusKnown"`
|
||||
Check *model.MailCheck `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
||||
func (s *Server) mailEgressViewsLocked(orgID string) []mailEgressView {
|
||||
out := []mailEgressView{}
|
||||
for _, e := range s.st.MailEgress {
|
||||
if e.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
v := mailEgressView{MailEgress: *e, PasswordSet: e.Password != ""}
|
||||
v.Password = ""
|
||||
v.Status, v.StatusKnown = s.mailStatus[e.ID]
|
||||
if chk, ok := s.mailChecks[e.ID]; ok {
|
||||
c := chk
|
||||
v.Check = &c
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user