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>
172 lines
4.9 KiB
Go
172 lines
4.9 KiB
Go
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
|
|
}
|