Files
Hammerhead/mappings.go
T
VeillaxandClaude Sonnet 5.5 39659f0144
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m10s
Domain redirects: send an address to another, over HTTP and HTTPS
A website resource can now redirect to a full address (301 or 302), optionally
keeping the path and query, with no target or login. The Add resource wizard
has a Domain redirect choice that opens the HTTPS and HTTP sides together.
Deleting a site no longer removes redirects. Uses Reef v0.1.10 and Blacktip v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:15:40 -06:00

634 lines
18 KiB
Go

package hammerhead
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"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")
}
redirect := strings.TrimSpace(m.RedirectTo) != ""
if redirect {
if !model.Web(m.Proto) {
return errors.New("only websites (http or https) can redirect")
}
m.Targets = []model.Target{} // a redirect has nothing behind it
} else {
m.RedirectTo, m.RedirectPermanent, m.RedirectKeepPath = "", false, false
}
if len(m.Targets) == 0 && !redirect {
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 redirect {
to, err := cleanRedirect(m)
if err != nil {
return err
}
m.RedirectTo = to
}
if !model.Web(m.Proto) || redirect {
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
if redirect {
m.HealthSeconds = -1 // nothing to check
}
} 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
}
// cleanRedirect checks where a redirect sends visitors and refuses a destination that would
// send them straight back to the same address.
func cleanRedirect(m *model.Mapping) (string, error) {
to := strings.TrimSpace(m.RedirectTo)
u, err := url.Parse(to)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
return "", errors.New("Redirect to a full address such as https://www.example.com.")
}
if len(to) > 2048 || strings.ContainsAny(to, " \t\r\n$") {
return "", errors.New("The redirect address is too long or contains spaces or a dollar sign.")
}
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
same := u.Scheme == m.Proto && strings.EqualFold(u.Hostname(), m.Hostname) && port == strconv.Itoa(m.Port)
if same && (m.RedirectKeepPath || u.Path == "" || u.Path == "/") {
return "", errors.New("That redirect would send visitors back to the same address.")
}
return to, 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,
})
}