Files
Hammerhead/mappings.go
T
VeillaxandClaude Sonnet 5.5 227c890409
CI / test (push) Successful in 4m52s
Release / release (push) Successful in 5m24s
Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
Sandbar channels raise and resolve alerts through its API, with the API key
sealed at rest and never returned to the browser. Resources and sites can be
tagged and filtered by tag; sites can be renamed. The secretbox package now
comes from Reef v0.1.9 instead of a private copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:32:43 -06:00

591 lines
17 KiB
Go

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
}
// normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins)
// and keeps the list short enough to stay readable in the dashboard.
func normalizeTags(in []string) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, t := range in {
t = strings.Join(strings.Fields(t), " ")
if t == "" || seen[strings.ToLower(t)] {
continue
}
if len([]rune(t)) > 32 {
return nil, errors.New("A tag can be at most 32 characters.")
}
if strings.ContainsAny(t, ",<>\"") {
return nil, errors.New("Tags can't contain commas, quotes or angle brackets.")
}
seen[strings.ToLower(t)] = true
out = append(out, t)
}
if len(out) > 10 {
return nil, errors.New("Use at most 10 tags.")
}
return out, nil
}
// 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)
tags, err := normalizeTags(m.Tags)
if err != nil {
return err
}
m.Tags = tags
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 between 1 and 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("The public port must be between 1 and 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 between 1 and 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("The health check interval must be 5 to 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
}
if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) {
continue // a website on every address steps aside for one that names an address
}
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
}
// an https resource with the redirect on also answers on port 80 at its address, so a raw port
// there would clash with it (and the other way round)
for _, o := range s.st.Mappings {
if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) {
continue
}
switch {
case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort():
return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name)
case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort():
return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", 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 to 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,
})
}