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:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
+340
@@ -0,0 +1,340 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/authtoken"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed web/resource.html
|
||||
var resourceHTML []byte
|
||||
|
||||
// The visitor-facing login flow for protected web ports.
|
||||
//
|
||||
// 1. blacktip sees an unauthenticated request and redirects to
|
||||
// <dashboard>/auth/r/<mapping>?return=<original url>
|
||||
// 2. the visitor proves who they are here (account, password, PIN, emailed
|
||||
// code or share link)
|
||||
// 3. we redirect back to <original host>/.hammerhead/callback?t=<signed token>
|
||||
// and blacktip turns that into a cookie.
|
||||
|
||||
type otpEntry struct {
|
||||
hash string
|
||||
expires time.Time
|
||||
tries int
|
||||
}
|
||||
|
||||
func (s *Server) resourcePage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Write(resourceHTML)
|
||||
}
|
||||
|
||||
// protectedMapping loads a mapping that has a login in front of it. Caller holds s.mu.
|
||||
func (s *Server) protectedMapping(id string) *model.Mapping {
|
||||
m := s.findMapping(id)
|
||||
if m == nil || !model.Web(m.Proto) || !m.Auth.Protected() || !m.Enabled {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// checkReturn makes sure the return URL points at this mapping, so the login can't be used as an open redirect.
|
||||
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("invalid return address")
|
||||
}
|
||||
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
|
||||
return nil, errors.New("return address uses the wrong scheme")
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
if u.Scheme == "https" {
|
||||
port = "443"
|
||||
}
|
||||
}
|
||||
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
|
||||
return nil, errors.New("return address doesn't belong to this resource")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// canAccess applies the SSO policy. Caller holds s.mu.
|
||||
func (s *Server) canAccess(u *User, m *model.Mapping) bool {
|
||||
if u.ServerAdmin {
|
||||
return true
|
||||
}
|
||||
mem := s.member(u.ID, m.OrgID)
|
||||
if mem == nil {
|
||||
return false
|
||||
}
|
||||
if role := s.findRole(mem.RoleID); role != nil && role.Admin {
|
||||
return true
|
||||
}
|
||||
a := m.Auth
|
||||
if len(a.AllowedRoles) == 0 && len(a.AllowedUsers) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, id := range a.AllowedRoles {
|
||||
if id == mem.RoleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, id := range a.AllowedUsers {
|
||||
if id == u.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
p := s.principalFrom(r)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.protectedMapping(r.PathValue("id"))
|
||||
if m == nil {
|
||||
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist"))
|
||||
return
|
||||
}
|
||||
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
info := map[string]any{
|
||||
"name": m.Name, "host": m.Hostname,
|
||||
"sso": m.Auth.SSO, "password": m.Auth.Password, "pin": m.Auth.Pin,
|
||||
"email": m.Auth.Email && s.st.Settings.SMTP.Host != "",
|
||||
"providers": []map[string]string{},
|
||||
}
|
||||
if m.Auth.SSO {
|
||||
info["providers"] = s.providerList()
|
||||
}
|
||||
if p != nil && p.User.ID != "" {
|
||||
u := s.findUser(p.User.ID)
|
||||
info["user"] = map[string]any{"email": u.Email, "name": u.Name, "allowed": s.canAccess(u, m)}
|
||||
}
|
||||
writeJSON(w, 200, info)
|
||||
}
|
||||
|
||||
// grant mints the callback token and tells the browser where to go.
|
||||
func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, sub string) {
|
||||
s.mu.Lock()
|
||||
e := s.findEdge(m.EdgeID)
|
||||
var key string
|
||||
if e != nil {
|
||||
key = e.AuthKey
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if key == "" {
|
||||
fail(w, 500, errors.New("edge is not set up for logins"))
|
||||
return
|
||||
}
|
||||
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
|
||||
dest := fmt.Sprintf("%s://%s/.hammerhead/callback?t=%s&r=%s", back.Scheme, back.Host, tok, url.QueryEscape(back.RequestURI()))
|
||||
s.audit(m.OrgID, sub, "resource.login", m.Name)
|
||||
writeJSON(w, 200, map[string]string{"redirect": dest})
|
||||
}
|
||||
|
||||
// resourceAuth wraps a method handler: it loads the mapping, checks the return URL and rate-limits.
|
||||
func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
|
||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
pm := s.protectedMapping(r.PathValue("id"))
|
||||
var m model.Mapping
|
||||
if pm != nil {
|
||||
m = *pm
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if pm == nil {
|
||||
fail(w, 404, errors.New("no such resource"))
|
||||
return
|
||||
}
|
||||
back, err := checkReturn(&m, body["return"])
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
fn(w, r, &m, back, body)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
s.mu.Lock()
|
||||
sec := s.st.Secrets[m.ID]
|
||||
s.mu.Unlock()
|
||||
hash := string(dummyHash)
|
||||
if sec != nil && sec.PasswordHash != "" && m.Auth.Password {
|
||||
hash = sec.PasswordHash
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fail(w, 401, errors.New("wrong password"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, "password")
|
||||
}
|
||||
|
||||
func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
s.mu.Lock()
|
||||
sec := s.st.Secrets[m.ID]
|
||||
s.mu.Unlock()
|
||||
hash := string(dummyHash)
|
||||
if sec != nil && sec.PinHash != "" && m.Auth.Pin {
|
||||
hash = sec.PinHash
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fail(w, 401, errors.New("wrong PIN"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, "pin")
|
||||
}
|
||||
|
||||
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
p := s.principalFrom(r)
|
||||
if p == nil || p.User.ID == "" || !m.Auth.SSO {
|
||||
fail(w, 401, errors.New("sign in first"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
u := s.findUser(p.User.ID)
|
||||
allowed := u != nil && s.canAccess(u, m)
|
||||
s.mu.Unlock()
|
||||
if !allowed {
|
||||
fail(w, 403, errors.New("your account doesn't have access to this resource"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, p.User.Email)
|
||||
}
|
||||
|
||||
func mailAllowed(list []string, email string) bool {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
for _, a := range list {
|
||||
if a == email || (strings.HasPrefix(a, "*@") && strings.HasSuffix(email, a[1:])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
||||
if !m.Auth.Email || !s.mailConfigured() {
|
||||
fail(w, 400, errors.New("email codes are not available for this resource"))
|
||||
return
|
||||
}
|
||||
// always answer the same way so addresses can't be probed
|
||||
reply := func() {
|
||||
writeJSON(w, 200, map[string]string{"status": "If that address has access, a code is on its way."})
|
||||
}
|
||||
if !mailAllowed(m.Auth.AllowedMails, email) || !s.allow("otp|"+email, 3, 10*time.Minute) {
|
||||
reply()
|
||||
return
|
||||
}
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
code := fmt.Sprintf("%06d", n.Int64())
|
||||
s.mu.Lock()
|
||||
s.otps[m.ID+"|"+email] = otpEntry{hash: sha(code), expires: time.Now().Add(10 * time.Minute)}
|
||||
s.mu.Unlock()
|
||||
go s.Mailer(email, "Your sign-in code for "+m.Name, fmt.Sprintf("Your code is %s\n\nIt works for 10 minutes. If you didn't ask for it, ignore this email.", code))
|
||||
reply()
|
||||
}
|
||||
|
||||
func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
||||
key := m.ID + "|" + email
|
||||
s.mu.Lock()
|
||||
e, found := s.otps[key]
|
||||
good := false
|
||||
if found && time.Now().Before(e.expires) && e.tries < 5 {
|
||||
e.tries++
|
||||
s.otps[key] = e
|
||||
good = e.hash == sha(strings.TrimSpace(body["code"]))
|
||||
if good {
|
||||
delete(s.otps, key)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !good || !mailAllowed(m.Auth.AllowedMails, email) {
|
||||
fail(w, 401, errors.New("wrong or expired code"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, email)
|
||||
}
|
||||
|
||||
func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
h := sha(body["token"])
|
||||
s.mu.Lock()
|
||||
var link *ShareLink
|
||||
for _, l := range s.st.Shares {
|
||||
if l.MappingID == m.ID && l.TokenHash == h {
|
||||
link = l
|
||||
}
|
||||
}
|
||||
valid := link != nil && (link.Expires.IsZero() || time.Now().Before(link.Expires)) && (link.MaxUses == 0 || link.Uses < link.MaxUses)
|
||||
name := ""
|
||||
if valid {
|
||||
link.Uses++
|
||||
name = link.Name
|
||||
s.save()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !valid {
|
||||
fail(w, 401, errors.New("this share link is invalid, expired or used up"))
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "link"
|
||||
}
|
||||
s.grant(w, r, m, back, "share:"+name)
|
||||
}
|
||||
|
||||
// myResources lists the web ports the signed-in user may open.
|
||||
func (s *Server) myResources(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(p.User.ID)
|
||||
out := []map[string]string{}
|
||||
for _, m := range s.st.Mappings {
|
||||
if !m.Enabled || !model.Web(m.Proto) {
|
||||
continue
|
||||
}
|
||||
if m.Auth.Protected() && !(m.Auth.SSO && s.canAccess(u, m)) {
|
||||
continue
|
||||
}
|
||||
if !m.Auth.Protected() && s.member(u.ID, m.OrgID) == nil && !u.ServerAdmin {
|
||||
continue
|
||||
}
|
||||
if link := publicURL(m); link != "" {
|
||||
org := ""
|
||||
if o := s.findOrg(m.OrgID); o != nil {
|
||||
org = o.Name
|
||||
}
|
||||
out = append(out, map[string]string{"name": m.Name, "url": link, "org": org})
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
Reference in new issue
Block a user