From 39659f01440d5709c65d58c31dd973874c28b070 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:15:40 -0600 Subject: [PATCH] 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 --- go.mod | 4 +- go.sum | 8 ++-- infra.go | 2 +- mappings.go | 47 +++++++++++++++++++- redirects_test.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 53 ++++++++++++++++++++-- 6 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 redirects_test.go diff --git a/go.mod b/go.mod index 04a0fc6..d7678ef 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.27.3 require ( git.veillax.com/Veillax/bamboo v0.1.5 - git.veillax.com/Veillax/blacktip v0.1.8 - git.veillax.com/Veillax/reef v0.1.9 + git.veillax.com/Veillax/blacktip v0.1.9 + git.veillax.com/Veillax/reef v0.1.10 github.com/emersion/go-msgauth v0.7.0 golang.org/x/crypto v0.58.0 modernc.org/sqlite v1.61.0 diff --git a/go.sum b/go.sum index e31c67b..12a2eaf 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk= git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw= -git.veillax.com/Veillax/blacktip v0.1.8 h1:TR4HfXlGwrNjCqOY+xUnJCJwESRTwFerb2wVhq/zbUk= -git.veillax.com/Veillax/blacktip v0.1.8/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc= -git.veillax.com/Veillax/reef v0.1.9 h1:dmC+oPmRQIOVfx2kgS95QvRDn2wudwpZw7H9p0m6W8Y= -git.veillax.com/Veillax/reef v0.1.9/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= +git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo= +git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY= +git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg= +git.veillax.com/Veillax/reef v0.1.10/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= diff --git a/infra.go b/infra.go index 254ae49..0c6efe3 100644 --- a/infra.go +++ b/infra.go @@ -270,7 +270,7 @@ func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) { } } } - s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 }) + s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 || m.IsRedirect() }) s.bump() s.audit(c.Org.ID, c.Actor, "site.delete", st.Name) ok(w) diff --git a/mappings.go b/mappings.go index 635443f..d3abe8e 100644 --- a/mappings.go +++ b/mappings.go @@ -6,6 +6,8 @@ import ( "fmt" "net" "net/http" + "net/url" + "strconv" "strings" "time" @@ -97,7 +99,16 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) { return errors.New("choose an edge") } - if len(m.Targets) == 0 { + 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 { @@ -173,9 +184,19 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { return errors.New("PROXY protocol must be v1, v2 or off") } // web-only options - if !model.Web(m.Proto) { + 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) @@ -262,6 +283,28 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { 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 { diff --git a/redirects_test.go b/redirects_test.go new file mode 100644 index 0000000..a3d130e --- /dev/null +++ b/redirects_test.go @@ -0,0 +1,109 @@ +package hammerhead + +import ( + "encoding/json" + "strings" + "testing" +) + +// a redirect is a website with a destination instead of a target +func TestDomainRedirects(t *testing.T) { + s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + do := apiClient(t, s) + var me struct{ Orgs []struct{ ID string } } + _, b := do("GET", "/api/me", nil) + json.Unmarshal(b, &me) + org := me.Orgs[0].ID + var edge, site struct{ ID string } + _, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"}) + json.Unmarshal(b, &edge) + _, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"}) + json.Unmarshal(b, &site) + if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 { + t.Fatalf("domain: %d %s", code, b) + } + base := "/api/orgs/" + org + "/mappings" + open := func(m map[string]any) (int, []byte) { + m["edgeId"], m["enabled"], m["bindIp"] = edge.ID, true, "0.0.0.0" + return do("POST", base, m) + } + + // the redirect needs no target, and anything that only makes sense with one is dropped + code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "old.example.test", "redirectTo": " https://new.example.test/home ", "redirectPermanent": true, "redirectKeepPath": true, + "auth": map[string]any{"password": true}, "rewriteHost": "x", "healthPath": "/h"}) + if code != 200 { + t.Fatalf("create redirect: %d %s", code, raw) + } + var m struct { + ID, RedirectTo, RewriteHost, HealthPath string + RedirectPermanent, RedirectKeepPath bool + Targets []any + HealthSeconds int + Auth struct{ Password bool } + } + json.Unmarshal(raw, &m) + if m.RedirectTo != "https://new.example.test/home" || !m.RedirectPermanent || !m.RedirectKeepPath || len(m.Targets) != 0 || + m.Auth.Password || m.RewriteHost != "" || m.HealthPath != "" || m.HealthSeconds != -1 { + t.Fatalf("redirect was not normalised: %s", raw) + } + + // it shares the port with other websites by hostname, like any website + if code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "app.example.test", "targets": []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}}); code != 200 { + t.Fatalf("a website beside the redirect: %d %s", code, raw) + } + // and its plain-HTTP twin on port 80 is its own resource + if code, raw := open(map[string]any{"proto": "http", "port": 80, "hostname": "old.example.test", "redirectTo": "https://new.example.test/home"}); code != 200 { + t.Fatalf("http side of the redirect: %d %s", code, raw) + } + + for name, in := range map[string]map[string]any{ + "not a web port": {"proto": "tcp", "port": 9000, "redirectTo": "https://new.example.test"}, + "no scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "new.example.test"}, + "other scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "ftp://new.example.test"}, + "credentials": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://u:p@new.example.test"}, + "a dollar sign": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://new.example.test/$1"}, + "back to itself": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}, + "itself with a path": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/x", "redirectKeepPath": true}, + "host not in domains": {"proto": "https", "port": 443, "hostname": "a.elsewhere.test", "redirectTo": "https://new.example.test"}, + } { + if code, raw := open(in); code != 400 { + t.Errorf("%s: expected 400, got %d %s", name, code, raw) + } + } + // http to https on the same name is the normal use, and is allowed + if code, raw := open(map[string]any{"proto": "http", "port": 8080, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}); code != 200 { + t.Errorf("an http to https redirect: %d %s", code, raw) + } + + // turning a redirect into a normal website needs a target again + code, raw = do("PUT", base+"/"+m.ID, map[string]any{"proto": "https", "edgeId": edge.ID, "bindIp": "0.0.0.0", "port": 443, "hostname": "old.example.test", "enabled": true}) + if code != 400 || !strings.Contains(string(raw), "target") { + t.Errorf("a website with no target and no redirect: %d %s", code, raw) + } + + // deleting a site must not sweep away redirects, which have no targets + if code, raw := do("DELETE", "/api/orgs/"+org+"/sites/"+site.ID, nil); code != 200 { + t.Fatalf("delete site: %d %s", code, raw) + } + _, raw = do("GET", "/api/orgs/"+org+"/state", nil) + var st struct { + Mappings []struct{ Hostname, RedirectTo string } + } + json.Unmarshal(raw, &st) + var redirects, apps int + for _, x := range st.Mappings { + if x.RedirectTo != "" { + redirects++ + } + if x.Hostname == "app.example.test" { + apps++ + } + } + if redirects != 3 || apps != 0 { + t.Errorf("after deleting the site: %d redirects (want 3), %d apps (want 0): %s", redirects, apps, raw) + } +} diff --git a/web/index.html b/web/index.html index fa0b9f5..8b70d41 100644 --- a/web/index.html +++ b/web/index.html @@ -666,24 +666,69 @@ function mappingRow(m, st, series) { const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m); return h('tr', {}, h('td', {title:why}, badge(label, tone)), - h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null), + h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.redirectTo ? h('span', {class:'tag', style:'margin-left:4px'}, 'redirect') : null,m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null), tagList(m), s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null, s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null), h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))), - h('td', {class:'small'}, m.targets.map((t, i) => { + h('td', {class:'small'}, m.redirectTo ? h('div', {}, h('span', {class:'mono clip', title:m.redirectTo}, '→ ' + m.redirectTo), + h('div', {class:'dim'}, (m.redirectPermanent ? 'Permanent (301)' : 'Temporary (302)') + (m.redirectKeepPath ? ', keeps the path' : ''))) : m.targets.map((t, i) => { const th = (s.targets || [])[i], site = byId(st.sites, t.siteId); return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port)); })), h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)), h('td', {}, h('div', {class:'actions'}, - h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'), + h('button', {class:'ghost', onclick:() => m.redirectTo ? redirectForm(m) : portForm(m)}, 'Edit'), h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'), web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null, web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null, h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close')))); } +// A redirect sends visitors from one hostname to another address. New ones open the HTTPS side on port 443 and, +// if asked, the plain HTTP side on port 80, so both ways of typing the old name land on the new one. +function redirectForm(existing) { + const st = S.st, m = existing || {proto:'https', enabled:true, bindIp:'0.0.0.0', redirectPermanent:true, redirectKeepPath:true}; + const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)')))); + const ipIn = h('select', {}); + const refreshIPs = () => { + const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0'; + const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : []; + const ips = [...new Set([...((e && e.ips) || []), ...saved])]; + const label = ip => { const d = st.domains.filter(x => x.ip === ip).map(x => x.name); return d.length ? `${ip} (${d.join(', ')})` : ip; }; + ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, label(ip)))); + ipIn.value = ips.includes(keep) ? keep : '0.0.0.0'; + }; + edgeSel.onchange = refreshIPs; refreshIPs(); + const from = h('input', {value:m.hostname || '', list:'rdoms', placeholder:'old.example.com or *.example.com'}); + const doms = h('datalist', {id:'rdoms'}, st.domains.map(d => h('option', {value:d.name}))); + const to = h('input', {value:m.redirectTo || '', placeholder:'https://new.example.com'}); + const kind = h('select', {}, [[true, 'Permanent (301): browsers and search engines remember it'], [false, 'Temporary (302): checked every time']].map(([v, t]) => h('option', {value:String(v), selected:!!m.redirectPermanent === v}, t))); + const keep = chk('Keep the path and query, so old.example.com/a?b=1 goes to the same place on the new address', m.redirectKeepPath); + const both = chk('Also redirect plain HTTP (port 80)', true); + const name = h('input', {value:m.name || '', placeholder:'optional'}), tg = tagInput(m.tags, allTags(st.mappings)); + const save = act(async () => { + const base = {name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', hostname:from.value, redirectTo:to.value, redirectPermanent:kind.value === 'true', + redirectKeepPath:keep.box.checked, tags:tg.get(), enabled:m.enabled !== false, targets:[]}; + if (existing) { await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, port:m.port}); } + else { + await api('POST', O('/mappings'), {...base, proto:'https', port:443}); + if (both.box.checked) { + try { await api('POST', O('/mappings'), {...base, proto:'http', port:80, name:name.value ? name.value + ' (HTTP)' : ''}); } + catch (e) { throw new Error('The HTTPS redirect was added, but the HTTP one could not be: ' + e.message); } + } + } + close(); toast(existing ? 'Redirect saved' : 'Redirect added'); refresh(); + }); + const close = modal(existing ? 'Edit redirect' : 'Add a domain redirect', h('div', {class:'stack'}, + h('p', {class:'dim'}, existing ? `This redirect answers ${m.proto === 'https' ? 'HTTPS on port ' : 'HTTP on port '}${m.port}.` : 'Visitors who open the old address are sent to the new one. HTTPS gets its certificate automatically.'), + h('div', {class:'grid'}, field('Redirect this address', from, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first'), field('To', to, 'A full address, starting with http:// or https://')), + doms, field('Type', kind), keep, existing ? null : both, + h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it')), + h('div', {class:'grid'}, field('Name', name), tg.el), + h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add redirect'))), {sticky:true}); +} + function portForm(existing) { const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] }; const a = m.auth || {}; @@ -817,6 +862,7 @@ function portForm(existing) { ['both', 'TCP + UDP', 'The same port for both, as many games want.'], ['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'], ['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'], + ['redirect', 'Domain redirect', 'Send visitors from one address to another, such as old.example.com to new.example.com.'], ]; const [accessFs, rulesFs, advFs] = [...webBox.children]; let step = 0, chosen = false; @@ -856,6 +902,7 @@ function portForm(existing) { port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : ''); }; const pick = v => { + if (v === 'redirect') { close(); redirectForm(); return; } protoSel.value = v; chosen = true; protoSel.onchange(); if (v === 'https') redir.box.checked = true; port.value = ''; step = 1; draw();