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>
This commit is contained in:
1 parent
227c890409
commit
39659f0144
6 files changed
+211
-12
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user