1 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 39659f0144 Domain redirects: send an address to another, over HTTP and HTTPS
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m10s
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
6 changed files with 211 additions and 12 deletions

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@ go 1.27.3
require ( require (
git.veillax.com/Veillax/bamboo v0.1.5 git.veillax.com/Veillax/bamboo v0.1.5
git.veillax.com/Veillax/blacktip v0.1.8 git.veillax.com/Veillax/blacktip v0.1.9
git.veillax.com/Veillax/reef v0.1.9 git.veillax.com/Veillax/reef v0.1.10
github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0 modernc.org/sqlite v1.61.0
+4 -4
View File
@@ -1,9 +1,9 @@
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk= 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/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.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
git.veillax.com/Veillax/blacktip v0.1.8/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc= git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
git.veillax.com/Veillax/reef v0.1.9 h1:dmC+oPmRQIOVfx2kgS95QvRDn2wudwpZw7H9p0m6W8Y= git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg=
git.veillax.com/Veillax/reef v0.1.9/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= 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 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+1 -1
View File
@@ -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.bump()
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name) s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
ok(w) ok(w)
+45 -2
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -97,7 +99,16 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) { if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
return errors.New("choose an edge") 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") return errors.New("add at least one target")
} }
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 { 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") return errors.New("PROXY protocol must be v1, v2 or off")
} }
// web-only options // 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.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, "" m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
if redirect {
m.HealthSeconds = -1 // nothing to check
}
} else { } else {
a := &m.Auth a := &m.Auth
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails) 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 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". // portLabel is "25565" or, for a range, "2456-2458".
func portLabel(m model.Mapping) string { func portLabel(m model.Mapping) string {
if m.LastPort() > m.Port { if m.LastPort() > m.Port {
+109
View File
@@ -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)
}
}
+50 -3
View File
@@ -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); 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', {}, return h('tr', {},
h('td', {title:why}, badge(label, tone)), 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), tagList(m),
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null, 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), 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', {}, 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); 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)); 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', {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('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'), 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', 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, 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')))); 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) { 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 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 || {}; const a = m.auth || {};
@@ -817,6 +862,7 @@ function portForm(existing) {
['both', 'TCP + UDP', 'The same port for both, as many games want.'], ['both', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'], ['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'], ['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]; const [accessFs, rulesFs, advFs] = [...webBox.children];
let step = 0, chosen = false; 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) : ''); port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
}; };
const pick = v => { const pick = v => {
if (v === 'redirect') { close(); redirectForm(); return; }
protoSel.value = v; chosen = true; protoSel.onchange(); protoSel.value = v; chosen = true; protoSel.onchange();
if (v === 'https') redir.box.checked = true; if (v === 'https') redir.box.checked = true;
port.value = ''; step = 1; draw(); port.value = ''; step = 1; draw();