// Package hammerhead is the control plane: JSON API, web UI, accounts and state store. package hammerhead import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "git.veillax.com/Veillax/reef/model" "git.veillax.com/Veillax/reef/secretbox" "unicode" "unicode/utf8" ) type Org struct { ID string `json:"id"` Name string `json:"name"` AllowLocal bool `json:"allowLocal"` // may target an edge's built-in local site (the edge machine itself) AlertDebounceMin int `json:"alertDebounceMin,omitempty"` // minutes a problem must last before alerting; 0 = 2 AlertRepeatHours int `json:"alertRepeatHours,omitempty"` // remind about an unresolved problem this often; 0 = never } type Role struct { ID string `json:"id"` OrgID string `json:"orgId"` Name string `json:"name"` Admin bool `json:"admin"` Builtin bool `json:"builtin"` } type Member struct { UserID string `json:"userId"` OrgID string `json:"orgId"` RoleID string `json:"roleId"` } type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` PassHash string `json:"passHash,omitempty"` ServerAdmin bool `json:"serverAdmin"` TOTPSecret string `json:"totpSecret,omitempty"` TOTPEnabled bool `json:"totpEnabled"` BackupCodes []string `json:"backupCodes,omitempty"` // sha256 hashes IdP string `json:"idp,omitempty"` IdPSub string `json:"idpSub,omitempty"` Created time.Time `json:"created"` } type Session struct { UA string `json:"ua,omitempty"` UserID string `json:"userId"` Expires time.Time `json:"expires"` Created time.Time `json:"created"` IP string `json:"ip"` } type Invite struct { ID string `json:"id"` OrgID string `json:"orgId"` Email string `json:"email"` RoleID string `json:"roleId"` TokenHash string `json:"tokenHash"` Expires time.Time `json:"expires"` Created time.Time `json:"created"` } type APIKey struct { ID string `json:"id"` OrgID string `json:"orgId"` Name string `json:"name"` Hash string `json:"hash"` Created time.Time `json:"created"` LastUsed time.Time `json:"lastUsed"` } type IdP struct { ID string `json:"id"` Name string `json:"name"` Issuer string `json:"issuer"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret,omitempty"` Scopes string `json:"scopes"` // default "openid email profile" AutoProvision bool `json:"autoProvision"` OrgID string `json:"orgId"` // org new users are added to RoleID string `json:"roleId"` // ...with this role AllowedDomains []string `json:"allowedDomains"` } type SMTP struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user"` Pass string `json:"pass,omitempty"` From string `json:"from"` Mode string `json:"mode"` // starttls, tls, none } type Settings struct { DashboardURL string `json:"dashboardUrl"` AcmeEmail string `json:"acmeEmail"` AcmeStaging bool `json:"acmeStaging"` SMTP SMTP `json:"smtp"` // SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN). SelfEdgeID string `json:"selfEdgeId,omitempty"` } type Secret struct { PasswordHash string `json:"passwordHash,omitempty"` PinHash string `json:"pinHash,omitempty"` } type ShareLink struct { ID string `json:"id"` MappingID string `json:"mappingId"` OrgID string `json:"orgId"` Name string `json:"name"` TokenHash string `json:"tokenHash"` Expires time.Time `json:"expires"` // zero = never MaxUses int `json:"maxUses"` // 0 = unlimited Uses int `json:"uses"` Created time.Time `json:"created"` } type Total struct { Conns int64 `json:"conns"` BytesIn int64 `json:"bytesIn"` BytesOut int64 `json:"bytesOut"` } type state struct { Version int64 `json:"version"` Settings Settings `json:"settings"` Users []*User `json:"users"` Sessions map[string]*Session `json:"sessions"` // by sha256(token) Orgs []*Org `json:"orgs"` Roles []*Role `json:"roles"` Members []*Member `json:"members"` Invites []*Invite `json:"invites"` APIKeys []*APIKey `json:"apiKeys"` IdPs []*IdP `json:"idps"` Edges []*model.Edge `json:"edges"` Sites []*model.Site `json:"sites"` Domains []*model.Domain `json:"domains"` Mappings []*model.Mapping `json:"mappings"` MailEgress []*model.MailEgress `json:"mailEgress"` DKIM []*model.DKIMKey `json:"dkim"` Channels []*AlertChannel `json:"channels"` Secrets map[string]*Secret `json:"secrets"` // by mapping ID Shares []*ShareLink `json:"shares"` Totals map[string]*Total `json:"totals"` // by mapping ID } type Options struct { AdminEmail string // optional: create this server admin on first run AdminPassword string DashboardURL string // optional: overrides the saved setting MasterKey string // encrypts secrets in the database (HAMMERHEAD_MASTER_KEY); generated into /master.key if empty MetricsToken string // enables GET /metrics for a bearer token (HAMMERHEAD_METRICS_TOKEN) LogRetentionDays int // access logs are kept this long; 0 = 30 AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests) OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s // SelfDomain (HAMMERHEAD_SELF_DOMAIN) gives this server an edge of its own, with a built-in // https route for the dashboard at that name, so no reverse proxy is needed in front of it. // SelfTarget is the address Hammerhead listens on (e.g. 127.0.0.1:8080); SelfTunnel is the // edge's public WireGuard endpoint (default :51820). SelfDomain string SelfTarget string SelfTunnel string // Version is the running release (e.g. v0.1.5), shown under Settings with a check for updates. Version string // AutoUpdate says whether this process updates itself (HAMMERHEAD_AUTO_UPDATE), for the same screen. AutoUpdate bool // UpdateServer and UpdateRepo say where releases are published; the defaults are this project's own. UpdateServer string UpdateRepo string } type Server struct { mu sync.Mutex dir string st state opts Options status map[string]model.MappingStatus mailStatus map[string]string // mail egress ID -> "" (listening) or an error mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report containers map[string][]model.Container // by site ID changed chan struct{} tickets map[string]ticket oauth map[string]oauthState otps map[string]otpEntry limits map[string][]time.Time oidc map[string]*oidcDoc alerts map[string]*alertState // active problems by key (see alerts.go) updRuns map[string]*updRun // progress of updates running on edges and sites, by ID selfUpd selfUpdate // progress of this server's own update verMu sync.Mutex // guards verCache and verAt, the latest releases seen by /api/versions verCache map[string]string verAt time.Time mtaSent map[string]time.Time // reminder throttle for repeat notifications expose map[string]time.Time // deadline of each ephemeral mapping, by ID (see expose.go) logs *logStore db *DB stop chan struct{} once sync.Once // Mailer sends email; tests replace it. Mailer func(to, subject, body string) error } func New(dir string, opts Options) (*Server, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } box, err := masterBox(dir, opts.MasterKey) if err != nil { return nil, err } db, err := openDB(filepath.Join(dir, "hammerhead.db"), box) if err != nil { return nil, err } s := &Server{ dir: dir, opts: opts, db: db, logs: &logStore{db: db}, stop: make(chan struct{}), status: map[string]model.MappingStatus{}, mailStatus: map[string]string{}, mailChecks: map[string]model.MailCheck{}, containers: map[string][]model.Container{}, changed: make(chan struct{}), tickets: map[string]ticket{}, oauth: map[string]oauthState{}, otps: map[string]otpEntry{}, limits: map[string][]time.Time{}, oidc: map[string]*oidcDoc{}, alerts: map[string]*alertState{}, mtaSent: map[string]time.Time{}, updRuns: map[string]*updRun{}, expose: map[string]time.Time{}, } s.st.Sessions = map[string]*Session{} s.st.Secrets = map[string]*Secret{} s.st.Totals = map[string]*Total{} had, err := db.load(&s.st) if err != nil { db.Close() return nil, err } if !had { if err := s.migrateLegacy(); err != nil { db.Close() return nil, err } } if opts.DashboardURL != "" { s.st.Settings.DashboardURL = strings.TrimRight(opts.DashboardURL, "/") } s.backfillTunnelIPs() s.Mailer = s.smtpSend if len(s.st.Users) == 0 && opts.AdminEmail != "" && opts.AdminPassword != "" { if err := s.createFirstAdmin(opts.AdminEmail, "Admin", opts.AdminPassword, "Default"); err != nil { db.Close() return nil, err } } if opts.SelfDomain != "" && opts.SelfTunnel == "" { s.opts.SelfTunnel = opts.SelfDomain + ":51820" } s.finishSelfUpdateLog() if err := s.ensureSelfEdge(); err != nil { db.Close() return nil, err } if err := s.save(); err != nil { db.Close() return nil, err } s.loadAlerts() go s.housekeeping() go s.exposeLoop() go s.alertLoop() return s, nil } // masterBox builds the encryption box for secrets at rest. The key normally // comes from HAMMERHEAD_MASTER_KEY (e.g. in .env). If none is given one is // generated into /master.key so the server works out of the box, but that // file sits next to the database, so a backup of the folder carries both: keep // the key somewhere else for real protection. func masterBox(dir, key string) (*secretbox.Box, error) { if key != "" { return secretbox.New(key) } path := filepath.Join(dir, "master.key") if b, err := os.ReadFile(path); err == nil { return secretbox.New(string(b)) } k := secretbox.GenerateKey() if err := os.WriteFile(path, []byte(k+"\n"), 0o600); err != nil { return nil, err } log.Printf("no HAMMERHEAD_MASTER_KEY set: generated an encryption key in %s. For real protection copy its contents into your .env as HAMMERHEAD_MASTER_KEY=... and delete the file, so a backup of the data folder doesn't carry the key too", path) return secretbox.New(k) } // migrateLegacy imports a state.json (and JSONL logs) from before the database existed. func (s *Server) migrateLegacy() error { path := filepath.Join(s.dir, "state.json") b, err := os.ReadFile(path) if err != nil { return nil // a fresh install } if err := json.Unmarshal(b, &s.st); err != nil { return fmt.Errorf("import %s: %w", path, err) } if s.st.Sessions == nil { s.st.Sessions = map[string]*Session{} } if s.st.Secrets == nil { s.st.Secrets = map[string]*Secret{} } if s.st.Totals == nil { s.st.Totals = map[string]*Total{} } if err := s.db.persist(&s.st); err != nil { return err } s.importLegacyLogs() os.Rename(path, path+".migrated") log.Printf("imported %s into the database (kept as %s.migrated)", path, path) return nil } // Close stops background work and closes the database. func (s *Server) Close() { s.once.Do(func() { close(s.stop) s.db.Close() }) } // housekeeping prunes old logs. func (s *Server) housekeeping() { days := s.opts.LogRetentionDays if days <= 0 { days = 30 } t := time.NewTicker(time.Hour) defer t.Stop() for { s.db.purge(days, 365) select { case <-s.stop: return case <-t.C: } } } // save must be called with s.mu held (or before serving). func (s *Server) save() error { err := s.db.persist(&s.st) if err != nil { log.Printf("save: %v", err) } return err } // bump records a change that agents care about and wakes every long-poller. Caller holds s.mu. func (s *Server) bump() { s.st.Version++ s.save() close(s.changed) s.changed = make(chan struct{}) } // wait blocks until the version moves past have, or 25s pass. It returns false on timeout. func (s *Server) wait(r *http.Request, have int64) bool { s.mu.Lock() if s.st.Version > have { s.mu.Unlock() return true } ch := s.changed s.mu.Unlock() select { case <-ch: return true case <-time.After(25 * time.Second): return false case <-r.Context().Done(): return false } } // ---------- small helpers ---------- func randHex(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b) } func newID() string { return randHex(6) } func sha(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:]) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } // fail sends an error to the browser. Messages are shown to people, so they start with a capital letter. func fail(w http.ResponseWriter, code int, err error) { msg := err.Error() if r, n := utf8.DecodeRuneInString(msg); n > 0 { msg = string(unicode.ToUpper(r)) + msg[n:] } writeJSON(w, code, map[string]string{"error": msg}) } func failf(w http.ResponseWriter, code int, format string, a ...any) { fail(w, code, fmt.Errorf(format, a...)) } func ok(w http.ResponseWriter) { writeJSON(w, 200, map[string]bool{"ok": true}) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil { fail(w, 400, errors.New("invalid request body")) return false } return true } func filter[T any](in []*T, keep func(*T) bool) []*T { out := in[:0:0] for _, v := range in { if keep(v) { out = append(out, v) } } return out } func bearer(r *http.Request) string { return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } func (s *Server) dashboardURL() string { return s.st.Settings.DashboardURL } // allow is a sliding-window rate limiter. Caller must NOT hold s.mu. func (s *Server) allow(key string, max int, window time.Duration) bool { s.mu.Lock() defer s.mu.Unlock() now := time.Now() kept := s.limits[key][:0] for _, t := range s.limits[key] { if now.Sub(t) < window { kept = append(kept, t) } } if len(kept) >= max { s.limits[key] = kept return false } s.limits[key] = append(kept, now) return true } // ---------- lookups (caller holds s.mu) ---------- func (s *Server) findOrg(id string) *Org { for _, o := range s.st.Orgs { if o.ID == id { return o } } return nil } func (s *Server) findRole(id string) *Role { for _, r := range s.st.Roles { if r.ID == id { return r } } return nil } func (s *Server) findUser(id string) *User { for _, u := range s.st.Users { if u.ID == id { return u } } return nil } func (s *Server) userByEmail(email string) *User { email = strings.ToLower(strings.TrimSpace(email)) for _, u := range s.st.Users { if strings.ToLower(u.Email) == email { return u } } return nil } func (s *Server) findEdge(id string) *model.Edge { for _, e := range s.st.Edges { if e.ID == id { return e } } return nil } func (s *Server) findSite(id string) *model.Site { for _, e := range s.st.Sites { if e.ID == id { return e } } return nil } func (s *Server) findMapping(id string) *model.Mapping { for _, m := range s.st.Mappings { if m.ID == id { return m } } return nil } func (s *Server) member(userID, orgID string) *Member { for _, m := range s.st.Members { if m.UserID == userID && m.OrgID == orgID { return m } } return nil } // allocIPLocked picks the next free address in the tunnel network (100.89.0.0/16). // The network only exists inside WireGuard's userspace stack, so it can never // clash with a LAN or VPN on the machines involved. Caller holds s.mu. func (s *Server) allocIPLocked() string { used := map[string]bool{} for _, e := range s.st.Edges { used[e.TunnelIP] = true } for _, st := range s.st.Sites { used[st.TunnelIP] = true } for i := 1; i < 65535; i++ { ip := fmt.Sprintf("100.89.%d.%d", i>>8, i&0xff) if i&0xff != 0 && i&0xff != 255 && !used[ip] { return ip } } return "" } // backfillTunnelIPs gives addresses to records created before WireGuard existed. func (s *Server) backfillTunnelIPs() { for _, e := range s.st.Edges { if e.TunnelIP == "" { e.TunnelIP = s.allocIPLocked() } } for _, st := range s.st.Sites { if !st.Local && st.TunnelIP == "" { st.TunnelIP = s.allocIPLocked() } } } func (s *Server) edgeAllowedFor(e *model.Edge, orgID string) bool { if len(e.OrgIDs) == 0 { return true } for _, id := range e.OrgIDs { if id == orgID { return true } } return false }