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:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:50:03 -06:00
1 parent 3daa368233
commit f521fa8668
35 files changed
+10392 -1

No files matched your search

+544
View File
@@ -0,0 +1,544 @@
package hammerhead
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/bcrypt"
)
type mappingInput struct {
model.Mapping
NewPassword *string `json:"newPassword"` // nil = keep, "" = remove
NewPin *string `json:"newPin"`
}
type mappingView struct {
model.Mapping
Status model.MappingStatus `json:"status"`
Known bool `json:"statusKnown"`
Total Total `json:"total"`
}
func isWild(ip string) bool {
p := net.ParseIP(ip)
return p != nil && p.IsUnspecified()
}
func overlap(a, b string) bool { return a == b || isWild(a) || isWild(b) }
func netClass(proto string) string {
if proto == model.ProtoUDP {
return "udp"
}
return "tcp"
}
func listFromCSV(in []string) []string {
out := []string{}
seen := map[string]bool{}
for _, v := range in {
v = strings.ToLower(strings.TrimSpace(v))
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
m.Name = strings.TrimSpace(m.Name)
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
m.OrgID = c.Org.ID
switch m.Proto {
case model.ProtoTCP, model.ProtoUDP, model.ProtoHTTP, model.ProtoHTTPS, model.ProtoTLS:
default:
return errors.New("protocol must be tcp, udp, http, https or tls")
}
edge := s.findEdge(m.EdgeID)
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
return errors.New("choose an edge")
}
if len(m.Targets) == 0 {
return errors.New("add at least one target")
}
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
return errors.New("udp ports support one target")
}
for i := range m.Targets {
t := &m.Targets[i]
t.Host = strings.TrimSpace(t.Host)
site := s.findSite(t.SiteID)
if site == nil || !s.siteVisible(c, site) {
return errors.New("choose a site for every target")
}
if site.Local && site.EdgeID != m.EdgeID {
return errors.New("a local site can only be used with its own edge")
}
if t.Host == "" {
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
}
if t.Port < 1 || t.Port > 65535 {
return errors.New("target ports must be 1-65535")
}
}
if m.BindIP == "" {
m.BindIP = "0.0.0.0"
}
ip := net.ParseIP(m.BindIP)
if ip == nil {
return errors.New("bind IP is not a valid address")
}
m.BindIP = ip.String()
if m.Port < 1 || m.Port > 65535 {
return errors.New("public port must be 1-65535")
}
switch {
case m.PortEnd == 0 || m.PortEnd == m.Port:
m.PortEnd = 0
case m.PortEnd < m.Port:
return errors.New("the end of a port range can't be before its start")
case m.PortEnd > 65535:
return errors.New("public ports must be 1-65535")
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
return errors.New("port ranges work for tcp and udp only")
case m.PortEnd-m.Port+1 > 1000:
return errors.New("a range can open at most 1000 ports")
}
for _, t := range m.Targets {
if t.Port+m.LastPort()-m.Port > 65535 {
return errors.New("the target ports would run past 65535: each public port maps to the target port plus the same offset")
}
}
if model.HostBased(m.Proto) {
if m.Hostname == "" {
return errors.New("a hostname is required for http, https and tls ports")
}
if !validHostname(m.Hostname) {
return errors.New("hostname looks invalid (use something like app.example.com or *.example.com)")
}
if !s.hostnameAllowed(c.Org.ID, m.Hostname) {
return fmt.Errorf("%s isn't under one of your domains; add the domain on the Domains tab first", m.Hostname)
}
} else {
m.Hostname = ""
}
switch m.ProxyProtocol {
case "":
case "v1", "v2":
if m.Proto != model.ProtoTCP && m.Proto != model.ProtoTLS {
return errors.New("the PROXY protocol is for tcp and tls ports (web ports already pass the client address in X-Forwarded-For)")
}
default:
return errors.New("PROXY protocol must be v1, v2 or off")
}
// web-only options
if !model.Web(m.Proto) {
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
} else {
a := &m.Auth
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
for _, id := range a.AllowedRoles {
if r := s.findRole(id); r == nil || r.OrgID != c.Org.ID {
return errors.New("an allowed role doesn't belong to this organization")
}
}
for _, id := range a.AllowedUsers {
if s.member(id, c.Org.ID) == nil {
return errors.New("an allowed user isn't a member of this organization")
}
}
if a.Email && len(a.AllowedMails) == 0 {
return errors.New("email codes need at least one allowed address (or *@example.com)")
}
if a.SessionHours < 0 || a.SessionHours > 24*90 {
return errors.New("session length must be between 1 hour and 90 days")
}
for _, r := range m.Rules {
if r.Action != "allow" && r.Action != "deny" {
return errors.New("rule action must be allow or deny")
}
switch r.Match {
case "path":
if !strings.HasPrefix(r.Value, "/") {
return errors.New("path rules must start with /")
}
case "ip":
if net.ParseIP(r.Value) == nil {
if _, _, err := net.ParseCIDR(r.Value); err != nil {
return fmt.Errorf("%q is not an IP address or CIDR range", r.Value)
}
}
default:
return errors.New("rule match must be path or ip")
}
}
m.RewriteHost = strings.TrimSpace(m.RewriteHost)
if m.Proto != model.ProtoHTTPS {
m.RedirectHTTP = false
}
if m.HealthPath != "" && !strings.HasPrefix(m.HealthPath, "/") {
return errors.New("health check path must start with /")
}
}
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
return errors.New("health check interval must be 5-3600 seconds, or off")
}
for _, o := range s.st.Mappings {
if o.ID == m.ID || o.EdgeID != m.EdgeID || netClass(o.Proto) != netClass(m.Proto) || !overlap(o.BindIP, m.BindIP) ||
o.Port > m.LastPort() || m.Port > o.LastPort() {
continue
}
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
continue // same listener, different hostname
}
return fmt.Errorf("%s port %s on %s is already used by %q", netClass(m.Proto), portLabel(*m), o.BindIP, o.Name)
}
if m.Name == "" {
if m.Hostname != "" {
m.Name = m.Hostname
} else {
m.Name = fmt.Sprintf("%s %s:%s", m.Proto, m.BindIP, portLabel(*m))
}
}
return nil
}
// portLabel is "25565" or, for a range, "2456-2458".
func portLabel(m model.Mapping) string {
if m.LastPort() > m.Port {
return fmt.Sprintf("%d-%d", m.Port, m.LastPort())
}
return fmt.Sprint(m.Port)
}
func checkPIN(p string) error {
if len(p) < 4 || len(p) > 12 {
return errors.New("a PIN is 4-12 digits")
}
for _, c := range p {
if c < '0' || c > '9' {
return errors.New("a PIN is digits only")
}
}
return nil
}
// applySecrets updates the password/PIN for mapping id and syncs the Auth flags. Caller holds s.mu.
func (s *Server) applySecrets(m *model.Mapping, pwHash, pinHash *string) {
sec := s.st.Secrets[m.ID]
if sec == nil {
sec = &Secret{}
}
if pwHash != nil {
sec.PasswordHash = *pwHash
}
if pinHash != nil {
sec.PinHash = *pinHash
}
if !m.Auth.Password {
sec.PasswordHash = ""
}
if !m.Auth.Pin {
sec.PinHash = ""
}
m.Auth.Password, m.Auth.Pin = sec.PasswordHash != "", sec.PinHash != ""
if sec.PasswordHash == "" && sec.PinHash == "" {
delete(s.st.Secrets, m.ID)
} else {
s.st.Secrets[m.ID] = sec
}
}
// hashSecrets prepares optional new secrets outside the lock (bcrypt is slow).
func hashSecrets(in *mappingInput) (pw, pin *string, err error) {
one := func(v *string, check func(string) error) (*string, error) {
if v == nil {
return nil, nil
}
if *v == "" {
e := ""
return &e, nil
}
if err := check(*v); err != nil {
return nil, err
}
b, err := bcrypt.GenerateFromPassword([]byte(*v), 10)
h := string(b)
return &h, err
}
if pw, err = one(in.NewPassword, func(p string) error {
if len(p) < 6 {
return errors.New("a resource password needs at least 6 characters")
}
return nil
}); err != nil {
return
}
pin, err = one(in.NewPin, checkPIN)
return
}
func (s *Server) addMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in mappingInput
if !decode(w, r, &in) {
return
}
pw, pin, err := hashSecrets(&in)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
m := in.Mapping
m.ID, m.AuthEpoch = "", 0
if err := s.validateMapping(c, &m); err != nil {
fail(w, 400, err)
return
}
m.ID = newID()
s.applySecrets(&m, pw, pin)
s.st.Mappings = append(s.st.Mappings, &m)
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.create", m.Name)
writeJSON(w, 200, m)
}
// authChanged reports whether an edit changes who may open a resource, which
// must sign existing visitors out (their old sessions were granted under the old rules).
func authChanged(o, n model.Auth, pwChanged, pinChanged bool) bool {
o.SessionHours, n.SessionHours = 0, 0
ob, _ := json.Marshal(o)
nb, _ := json.Marshal(n)
return pwChanged || pinChanged || string(ob) != string(nb)
}
// revokeOrgVisitorsLocked signs out every visitor of the org's protected resources.
// The caller must then call s.bump() so the edges learn the new epoch.
func (s *Server) revokeOrgVisitorsLocked(orgID string) {
for _, m := range s.st.Mappings {
if m.OrgID == orgID && m.Auth.Protected() {
m.AuthEpoch++
}
}
}
// revokeVisitors signs everyone out of one resource.
func (s *Server) revokeVisitors(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(r.PathValue("id"))
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
m.AuthEpoch++
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.signout-visitors", m.Name)
ok(w)
}
func (s *Server) putMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in mappingInput
if !decode(w, r, &in) {
return
}
pw, pin, err := hashSecrets(&in)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
for i, old := range s.st.Mappings {
if old.ID != id || old.OrgID != c.Org.ID {
continue
}
m := in.Mapping
m.ID = id
if err := s.validateMapping(c, &m); err != nil {
fail(w, 400, err)
return
}
s.applySecrets(&m, pw, pin)
m.AuthEpoch = old.AuthEpoch // only hammerhead moves this, never the request
if authChanged(old.Auth, m.Auth, pw != nil, pin != nil) {
m.AuthEpoch++
}
s.st.Mappings[i] = &m
delete(s.status, id)
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.update", m.Name)
writeJSON(w, 200, m)
return
}
fail(w, 404, errors.New("no such port"))
}
func (s *Server) deleteMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(id)
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
s.dropMappingLocked(id)
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.ID != id })
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.delete", m.Name)
ok(w)
}
// ---------- share links ----------
func (s *Server) listShares(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []ShareLink{}
for _, l := range s.st.Shares {
if l.OrgID == c.Org.ID && l.MappingID == r.PathValue("id") {
cp := *l
cp.TokenHash = ""
out = append(out, cp)
}
}
writeJSON(w, 200, out)
}
// publicURL is how visitors reach a web mapping, or "" for wildcard hostnames.
func publicURL(m *model.Mapping) string {
if !model.Web(m.Proto) || m.Hostname == "" || strings.HasPrefix(m.Hostname, "*") {
return ""
}
if m.Proto == model.ProtoHTTPS {
if m.Port == 443 {
return "https://" + m.Hostname
}
return fmt.Sprintf("https://%s:%d", m.Hostname, m.Port)
}
if m.Port == 80 {
return "http://" + m.Hostname
}
return fmt.Sprintf("http://%s:%d", m.Hostname, m.Port)
}
func (s *Server) createShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct {
Name string
Hours int
MaxUses int
}
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(r.PathValue("id"))
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
base := publicURL(m)
if base == "" {
fail(w, 400, errors.New("share links need a web port with one concrete hostname"))
return
}
if !m.Auth.Protected() {
fail(w, 400, errors.New("this port has no login in front of it, so there is nothing to share past"))
return
}
tok := randHex(20)
l := &ShareLink{ID: newID(), MappingID: m.ID, OrgID: c.Org.ID, Name: strings.TrimSpace(in.Name),
TokenHash: sha(tok), MaxUses: in.MaxUses, Created: time.Now()}
if in.Hours > 0 {
l.Expires = time.Now().Add(time.Duration(in.Hours) * time.Hour)
}
s.st.Shares = append(s.st.Shares, l)
s.save()
s.audit(c.Org.ID, c.Actor, "share.create", m.Name)
writeJSON(w, 200, map[string]string{"url": base + "/?hh_share=" + tok})
}
func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("share")
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range s.st.Shares {
if l.ID == id && l.OrgID == c.Org.ID {
// sessions started from a share link can't be told apart from others, so
// revoking one signs out everyone on that resource
if m := s.findMapping(l.MappingID); m != nil {
m.AuthEpoch++
}
}
}
s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return !(l.ID == id && l.OrgID == c.Org.ID) })
s.bump()
ok(w)
}
// ---------- combined state for the UI ----------
func (s *Server) orgState(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
edges := []edgeView{}
for _, e := range s.st.Edges {
if s.edgeAllowedFor(e, c.Org.ID) {
edges = append(edges, s.viewEdge(e, false))
}
}
sites := []model.Site{}
online := map[string]bool{}
for _, st := range s.st.Sites {
if !s.siteVisible(c, st) {
continue
}
cp := *st
if !c.Admin || st.Local {
cp.Token = ""
}
sites = append(sites, cp)
online[st.ID] = s.siteOnline(st)
}
domains := []model.Domain{}
for _, d := range s.st.Domains {
if d.OrgID == c.Org.ID {
domains = append(domains, *d)
}
}
maps := []mappingView{}
for _, m := range s.st.Mappings {
if m.OrgID != c.Org.ID {
continue
}
v := mappingView{Mapping: *m}
v.Status, v.Known = s.status[m.ID]
if t := s.st.Totals[m.ID]; t != nil {
v.Total = *t
}
maps = append(maps, v)
}
writeJSON(w, 200, map[string]any{
"org": c.Org, "admin": c.Admin, "edges": edges, "sites": sites, "online": online,
"domains": domains, "mappings": maps, "roles": s.rolesLocked(c.Org.ID), "mailEgress": s.mailEgressViewsLocked(c.Org.ID),
"mailConfigured": s.st.Settings.SMTP.Host != "",
"dashboardUrl": s.st.Settings.DashboardURL,
})
}