Files
VeillaxandClaude Opus 5.5 c585ddbf63
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Review pass on the Lightwell redesign: layout, copy and behavior fixes
Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:38:56 -06:00

341 lines
10 KiB
Go

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("This sign-in link is incomplete. Open the page you wanted again and you will be brought back here.")
}
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
return nil, errors.New("This sign-in link is not valid. Open the page you wanted again and you will be brought back here.")
}
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("This sign-in link points somewhere else. Open the page you wanted again and you will be brought back here.")
}
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 page does not need a sign-in, or it no longer exists. Check the address, or ask the person who shared it."))
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("This server is not set up for sign-ins yet. Ask the administrator to finish setting it up."))
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 and try again."))
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("That page does not exist. Check the address."))
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("That password is not right. Check it and try again."))
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("That PIN is not right. Check it and try again."))
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, then try again."))
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 does not have access to this page. Ask the person who shared it to add you."))
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 turned on for this page. Use another sign-in method."))
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("That code is wrong or has expired. Request a new one and try again."))
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 already used up. Ask for a new one."))
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)
}