package hammerhead import ( "errors" "fmt" "net/http" "sort" "strconv" "strings" "time" "git.veillax.com/Veillax/reef/model" ) // ---------- organizations ---------- func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) { var in struct { Name string AllowLocal bool } if !decode(w, r, &in) { return } in.Name = strings.TrimSpace(in.Name) if in.Name == "" { fail(w, 400, errors.New("name is required")) return } s.mu.Lock() org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal) s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID}) s.save() o := *org s.mu.Unlock() s.audit(o.ID, p.User.Email, "org.create", o.Name) writeJSON(w, 200, o) } func (s *Server) updateOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct { Name string AllowLocal *bool } if !decode(w, r, &in) { return } s.mu.Lock() defer s.mu.Unlock() o := s.findOrg(c.Org.ID) if n := strings.TrimSpace(in.Name); n != "" { o.Name = n } if in.AllowLocal != nil { if !c.User.ServerAdmin { fail(w, 403, errors.New("only a server administrator can change this")) return } o.AllowLocal = *in.AllowLocal } s.bump() ok(w) } func (s *Server) deleteOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) { if !c.User.ServerAdmin { fail(w, 403, errors.New("server administrators only")) return } id := c.Org.ID s.mu.Lock() defer s.mu.Unlock() s.st.Orgs = filter(s.st.Orgs, func(o *Org) bool { return o.ID != id }) s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.OrgID != id }) s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.OrgID != id }) s.st.Invites = filter(s.st.Invites, func(o *Invite) bool { return o.OrgID != id }) s.st.APIKeys = filter(s.st.APIKeys, func(o *APIKey) bool { return o.OrgID != id }) s.st.Sites = filter(s.st.Sites, func(o *model.Site) bool { return o.OrgID != id }) s.st.Domains = filter(s.st.Domains, func(o *model.Domain) bool { return o.OrgID != id }) s.st.Shares = filter(s.st.Shares, func(o *ShareLink) bool { return o.OrgID != id }) s.st.MailEgress = filter(s.st.MailEgress, func(o *model.MailEgress) bool { return o.OrgID != id }) s.st.DKIM = filter(s.st.DKIM, func(o *model.DKIMKey) bool { return o.OrgID != id }) s.st.Channels = filter(s.st.Channels, func(o *AlertChannel) bool { return o.OrgID != id }) for _, m := range s.st.Mappings { if m.OrgID == id { delete(s.st.Secrets, m.ID) delete(s.st.Totals, m.ID) delete(s.status, m.ID) } } s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.OrgID != id }) for _, e := range s.st.Edges { e.OrgIDs = removeStr(e.OrgIDs, id) } s.bump() ok(w) } func removeStr(in []string, v string) []string { out := []string{} for _, x := range in { if x != v { out = append(out, x) } } return out } // ---------- roles ---------- func (s *Server) listRoles(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() writeJSON(w, 200, s.rolesLocked(c.Org.ID)) } func (s *Server) rolesLocked(orgID string) []Role { out := []Role{} for _, r := range s.st.Roles { if r.OrgID == orgID { out = append(out, *r) } } return out } func (s *Server) saveRole(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in Role if !decode(w, r, &in) { return } in.Name = strings.TrimSpace(in.Name) if in.Name == "" { fail(w, 400, errors.New("name is required")) return } s.mu.Lock() defer s.mu.Unlock() for _, o := range s.st.Roles { if o.OrgID == c.Org.ID && strings.EqualFold(o.Name, in.Name) && o.ID != r.PathValue("role") { fail(w, 400, errors.New("a role with that name exists")) return } } if id := r.PathValue("role"); id != "" { role := s.findRole(id) if role == nil || role.OrgID != c.Org.ID { fail(w, 404, errors.New("no such role")) return } if role.Builtin { fail(w, 400, errors.New("built-in roles can't be edited")) return } role.Name, role.Admin = in.Name, in.Admin } else { s.st.Roles = append(s.st.Roles, &Role{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Admin: in.Admin}) } s.save() s.audit(c.Org.ID, c.Actor, "role.save", in.Name) ok(w) } func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("role") s.mu.Lock() defer s.mu.Unlock() role := s.findRole(id) if role == nil || role.OrgID != c.Org.ID { fail(w, 404, errors.New("no such role")) return } if role.Builtin { fail(w, 400, errors.New("built-in roles can't be deleted")) return } for _, m := range s.st.Members { if m.RoleID == id { fail(w, 400, errors.New("that role still has members; move them first")) return } } s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.ID != id }) for _, m := range s.st.Mappings { m.Auth.AllowedRoles = removeStr(m.Auth.AllowedRoles, id) } s.revokeOrgVisitorsLocked(c.Org.ID) s.bump() ok(w) } // ---------- members ---------- func (s *Server) listMembers(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() out := []map[string]any{} for _, m := range s.st.Members { if m.OrgID != c.Org.ID { continue } u := s.findUser(m.UserID) if u == nil { continue } out = append(out, map[string]any{"userId": u.ID, "email": u.Email, "name": u.Name, "roleId": m.RoleID, "totp": u.TOTPEnabled, "sso": u.IdP != ""}) } sort.Slice(out, func(i, j int) bool { return out[i]["email"].(string) < out[j]["email"].(string) }) writeJSON(w, 200, out) } // adminCountLocked counts members with an admin role, excluding skip. func (s *Server) adminCountLocked(orgID, skip string) int { n := 0 for _, m := range s.st.Members { if m.OrgID == orgID && m.UserID != skip { if role := s.findRole(m.RoleID); role != nil && role.Admin { n++ } } } return n } func (s *Server) setMemberRole(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct{ RoleID string } if !decode(w, r, &in) { return } uid := r.PathValue("user") s.mu.Lock() defer s.mu.Unlock() m := s.member(uid, c.Org.ID) role := s.findRole(in.RoleID) if m == nil || role == nil || role.OrgID != c.Org.ID { fail(w, 404, errors.New("no such member or role")) return } if !role.Admin && s.adminCountLocked(c.Org.ID, uid) == 0 { fail(w, 400, errors.New("an organization needs at least one admin")) return } m.RoleID = in.RoleID s.revokeOrgVisitorsLocked(c.Org.ID) // what they may open just changed s.bump() s.audit(c.Org.ID, c.Actor, "member.role", uid+" -> "+role.Name) ok(w) } func (s *Server) removeMember(w http.ResponseWriter, r *http.Request, c *orgCtx) { uid := r.PathValue("user") s.mu.Lock() defer s.mu.Unlock() if s.member(uid, c.Org.ID) == nil { fail(w, 404, errors.New("no such member")) return } if s.adminCountLocked(c.Org.ID, uid) == 0 { fail(w, 400, errors.New("an organization needs at least one admin")) return } s.st.Members = filter(s.st.Members, func(m *Member) bool { return !(m.UserID == uid && m.OrgID == c.Org.ID) }) for _, m := range s.st.Mappings { if m.OrgID == c.Org.ID { m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, uid) } } s.revokeOrgVisitorsLocked(c.Org.ID) // the removed member's open sessions end now s.bump() s.audit(c.Org.ID, c.Actor, "member.remove", uid) ok(w) } // ---------- invites ---------- func (s *Server) listInvites(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() out := []Invite{} for _, i := range s.st.Invites { if i.OrgID == c.Org.ID && time.Now().Before(i.Expires) { cp := *i cp.TokenHash = "" out = append(out, cp) } } writeJSON(w, 200, out) } func (s *Server) createInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct{ Email, RoleID string } if !decode(w, r, &in) { return } in.Email = strings.ToLower(strings.TrimSpace(in.Email)) if !strings.Contains(in.Email, "@") { fail(w, 400, errors.New("enter a valid email address")) return } tok := randHex(20) s.mu.Lock() role := s.findRole(in.RoleID) if role == nil || role.OrgID != c.Org.ID { s.mu.Unlock() fail(w, 400, errors.New("choose a role")) return } s.st.Invites = append(s.st.Invites, &Invite{ID: newID(), OrgID: c.Org.ID, Email: in.Email, RoleID: role.ID, TokenHash: sha(tok), Expires: time.Now().Add(7 * 24 * time.Hour), Created: time.Now()}) s.save() s.mu.Unlock() link := s.baseURL(r) + "/#invite=" + tok emailed := false if s.mailConfigured() { body := fmt.Sprintf("%s invited you to join %q on Hammerhead.\n\nAccept: %s\n\nThe link works for 7 days.", c.Actor, c.Org.Name, link) emailed = s.Mailer(in.Email, "You're invited to "+c.Org.Name, body) == nil } s.audit(c.Org.ID, c.Actor, "invite.create", in.Email) writeJSON(w, 200, map[string]any{"link": link, "emailed": emailed}) } func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("id") s.mu.Lock() defer s.mu.Unlock() s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return !(i.ID == id && i.OrgID == c.Org.ID) }) s.save() ok(w) } func (s *Server) inviteInfo(w http.ResponseWriter, r *http.Request) { h := sha(r.PathValue("token")) s.mu.Lock() defer s.mu.Unlock() for _, i := range s.st.Invites { if i.TokenHash == h && time.Now().Before(i.Expires) { org := s.findOrg(i.OrgID) writeJSON(w, 200, map[string]any{"email": i.Email, "org": org.Name, "existing": s.userByEmail(i.Email) != nil}) return } } fail(w, 404, errors.New("this invitation is invalid or has expired")) } func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) { var in struct{ Token, Name, Password string } if !decode(w, r, &in) { return } if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) { fail(w, 429, errors.New("too many attempts")) return } h := sha(in.Token) p := s.principalFrom(r) var hash string s.mu.Lock() var inv *Invite for _, i := range s.st.Invites { if i.TokenHash == h && time.Now().Before(i.Expires) { inv = i } } existing := (*User)(nil) if inv != nil { existing = s.userByEmail(inv.Email) } s.mu.Unlock() if inv == nil { fail(w, 404, errors.New("this invitation is invalid or has expired")) return } if existing == nil { var err error if hash, err = hashPassword(in.Password); err != nil { fail(w, 400, err) return } } else if p == nil || p.User.ID != existing.ID { fail(w, 400, errors.New("an account with this email already exists: sign in first, then open the link again")) return } s.mu.Lock() uid := "" if existing != nil { uid = existing.ID } else { name := strings.TrimSpace(in.Name) if name == "" { name = inv.Email } u := &User{ID: newID(), Email: inv.Email, Name: name, PassHash: hash, Created: time.Now()} s.st.Users = append(s.st.Users, u) uid = u.ID } if s.member(uid, inv.OrgID) == nil { s.st.Members = append(s.st.Members, &Member{UserID: uid, OrgID: inv.OrgID, RoleID: inv.RoleID}) } s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return i.ID != inv.ID }) s.save() s.mu.Unlock() if existing == nil { s.startSession(w, r, uid) } s.audit(inv.OrgID, inv.Email, "invite.accept", "") ok(w) } // ---------- API keys ---------- func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() out := []APIKey{} for _, k := range s.st.APIKeys { if k.OrgID == c.Org.ID { cp := *k cp.Hash = "" out = append(out, cp) } } writeJSON(w, 200, out) } func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct{ Name string } if !decode(w, r, &in) { return } in.Name = strings.TrimSpace(in.Name) if in.Name == "" { fail(w, 400, errors.New("name is required")) return } id, secret := randHex(4), randHex(24) s.mu.Lock() s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()}) s.save() s.mu.Unlock() s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name) writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret}) } func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("id") s.mu.Lock() defer s.mu.Unlock() s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == c.Org.ID) }) s.save() s.audit(c.Org.ID, c.Actor, "apikey.delete", id) ok(w) } // ---------- logs & stats ---------- func (s *Server) orgLogs(w http.ResponseWriter, r *http.Request, c *orgCtx) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) before, _ := strconv.ParseInt(q.Get("before"), 10, 64) if q.Get("kind") == "audit" { writeJSON(w, 200, s.logs.queryAudit(c.Org.ID, limit, before)) return } class, _ := strconv.Atoi(q.Get("status")) writeJSON(w, 200, s.logs.queryAccess(accessFilter{Org: c.Org.ID, Mapping: q.Get("mapping"), Q: strings.TrimSpace(q.Get("q")), Result: q.Get("result"), StatusClass: class, Before: before, Limit: limit})) } // orgTraffic returns bytes per hour for each of the org's ports, oldest first. func (s *Server) orgTraffic(w http.ResponseWriter, r *http.Request, c *orgCtx) { hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) s.mu.Lock() var ids []string for _, m := range s.st.Mappings { if m.OrgID == c.Org.ID { ids = append(ids, m.ID) } } s.mu.Unlock() writeJSON(w, 200, s.db.trafficHistory(ids, hours)) } func (s *Server) orgStats(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() out := map[string]Total{} for _, m := range s.st.Mappings { if m.OrgID == c.Org.ID { if t := s.st.Totals[m.ID]; t != nil { out[m.ID] = *t } } } writeJSON(w, 200, out) } // ---------- server admin: users ---------- func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, p *principal) { s.mu.Lock() defer s.mu.Unlock() out := []map[string]any{} for _, u := range s.st.Users { orgs := []string{} for _, m := range s.st.Members { if m.UserID == u.ID { if o := s.findOrg(m.OrgID); o != nil { orgs = append(orgs, o.Name) } } } out = append(out, map[string]any{"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin, "totp": u.TOTPEnabled, "sso": u.IdP != "", "orgs": orgs}) } writeJSON(w, 200, out) } func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request, p *principal) { var in struct { Email, Name, Password string ServerAdmin bool } if !decode(w, r, &in) { return } hash, err := hashPassword(in.Password) if err != nil { fail(w, 400, err) return } in.Email = strings.ToLower(strings.TrimSpace(in.Email)) if !strings.Contains(in.Email, "@") { fail(w, 400, errors.New("enter a valid email address")) return } s.mu.Lock() defer s.mu.Unlock() if s.userByEmail(in.Email) != nil { fail(w, 400, errors.New("that email already has an account")) return } name := strings.TrimSpace(in.Name) if name == "" { name = in.Email } s.st.Users = append(s.st.Users, &User{ID: newID(), Email: in.Email, Name: name, PassHash: hash, ServerAdmin: in.ServerAdmin, Created: time.Now()}) s.save() s.audit("", p.User.Email, "user.create", in.Email) ok(w) } func (s *Server) adminUpdateUser(w http.ResponseWriter, r *http.Request, p *principal) { var in struct { ServerAdmin *bool ResetTOTP bool Password string SignOut bool } if !decode(w, r, &in) { return } var hash string if in.Password != "" { var err error if hash, err = hashPassword(in.Password); err != nil { fail(w, 400, err) return } } s.mu.Lock() defer s.mu.Unlock() u := s.findUser(r.PathValue("id")) if u == nil { fail(w, 404, errors.New("no such user")) return } if in.ServerAdmin != nil { if !*in.ServerAdmin && u.ServerAdmin { n := 0 for _, o := range s.st.Users { if o.ServerAdmin { n++ } } if n <= 1 { fail(w, 400, errors.New("there must be at least one server administrator")) return } } u.ServerAdmin = *in.ServerAdmin } if in.ResetTOTP { u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil } if hash != "" || in.SignOut { if hash != "" { u.PassHash = hash } for k, sess := range s.st.Sessions { if sess.UserID == u.ID { delete(s.st.Sessions, k) } } } s.save() s.audit("", p.User.Email, "user.update", u.Email) ok(w) } func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request, p *principal) { id := r.PathValue("id") if id == p.User.ID { fail(w, 400, errors.New("you can't delete yourself")) return } s.mu.Lock() defer s.mu.Unlock() u := s.findUser(id) if u == nil { fail(w, 404, errors.New("no such user")) return } for _, mem := range s.st.Members { if mem.UserID == id { s.revokeOrgVisitorsLocked(mem.OrgID) // their open sessions on protected resources end now } } s.st.Users = filter(s.st.Users, func(o *User) bool { return o.ID != id }) s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.UserID != id }) for k, sess := range s.st.Sessions { if sess.UserID == id { delete(s.st.Sessions, k) } } for _, m := range s.st.Mappings { m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, id) } s.bump() s.audit("", p.User.Email, "user.delete", u.Email) ok(w) }