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>
545 lines
15 KiB
Go
545 lines
15 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
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
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,
|
|
})
|
|
}
|