CI / test (push) Failing after 2m18s
A website on 0.0.0.0 and websites on named addresses can use the same port. An https resource with the redirect on also uses port 80 at its address, so a raw port there is refused, in either order. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
561 lines
16 KiB
Go
561 lines
16 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
|
|
}
|
|
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,
|
|
})
|
|
}
|