Files
Hammerhead/store.go
T
VeillaxandClaude Sonnet 5.5 35d4000bc4
CI / test (push) Successful in 5m50s
Release / release (push) Successful in 5m42s
Terminal without an open port: the agent calls back, and the SSH dialog shows its state
Bamboo reports where its SSH server listens. When a terminal is opened,
Hammerhead lists the session in the site's config and the agent connects out and
joins that connection to its SSH server; Hammerhead signs in over it. No port
resource or edge is involved. A site whose Bamboo does not report an SSH server
still works through an SSH port. The long poll now returns at once for a session
the agent has not been told about, so a request between two polls is not missed.

The SSH dialog shows whether the SSH server is running, says when no port is
needed, and offers the port only for an ordinary ssh client. Builds against Reef
v0.1.12 and Bamboo v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 01:40:16 -06:00

641 lines
18 KiB
Go

// 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
// Traffic alerts, applied to every port of the organization. 0 = off.
TrafficSpikeFactor int `json:"trafficSpikeFactor,omitempty"` // alert when a port carries this many times its usual traffic
TrafficDailyMB int `json:"trafficDailyMb,omitempty"` // alert when a port carries more than this many MB in one UTC day
}
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"`
Sandbar SandbarSync `json:"sandbar"`
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
SelfEdgeID string `json:"selfEdgeId,omitempty"`
// Terminal holds the web terminal's SSH key and the host keys it has seen (see terminal.go).
Terminal TerminalSettings `json:"terminal"`
}
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 <data>/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 <SelfDomain>: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
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
terms map[string]*pendingTerm // terminal sessions waiting for an agent to call back, by session 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)
sandbarMu sync.Mutex // one monitor sync at a time (see sandbar.go)
sandbarStatus sandbarSyncStatus // how the last one went
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{},
discovered: map[string][]model.DiscoveredService{},
terms: map[string]*pendingTerm{},
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()
go s.sandbarLoop()
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 <data>/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 { return s.waitUntil(r, have, nil) }
// waitUntil is wait that also returns at once when ready (called with s.mu held) says there is
// something to deliver that does not move the version, such as a terminal waiting for an agent.
// Checking it under the same lock that takes the change channel is what keeps a request that
// arrives between two polls from being missed.
func (s *Server) waitUntil(r *http.Request, have int64, ready func() bool) bool {
s.mu.Lock()
if s.st.Version > have || (ready != nil && ready()) {
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
}