Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ab4f7db2 | ||
|
|
f93698f63f | ||
|
|
c2845d8630 | ||
|
|
42eb6410b4 |
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// A website on every address does not take the port from a website that names an address: each
|
||||
// listens where it should, and the named address answers only for its own site.
|
||||
func TestWildcardWebsiteStepsAsideForANamedAddress(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
wild, named := webBackend(t, "wild", 200), webBackend(t, "named", 200)
|
||||
port := freePort(t)
|
||||
|
||||
w.open(model.Mapping{Proto: "http", BindIP: "0.0.0.0", Port: port, Hostname: "wild.example.test", Targets: tgt(w.site, wild)})
|
||||
w.open(model.Mapping{Proto: "http", BindIP: "127.0.0.2", Port: port, Hostname: "named.example.test", Targets: tgt(w.site, named)})
|
||||
|
||||
ask := func(ip, host string) (int, string) {
|
||||
c := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
|
||||
}}}
|
||||
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var body map[string]string
|
||||
json.Unmarshal(b, &body)
|
||||
return resp.StatusCode, body["backend"]
|
||||
}
|
||||
wait := func(ip, host, wantBackend string) {
|
||||
t.Helper()
|
||||
var code int
|
||||
var got string
|
||||
for i := 0; i < 60; i++ {
|
||||
if code, got = ask(ip, host); code == 200 && got == wantBackend {
|
||||
return
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s via %s: status %d backend %q, want %q", host, ip, code, got, wantBackend)
|
||||
}
|
||||
|
||||
wait("127.0.0.2", "named.example.test", "named") // the named address serves its own site
|
||||
wait("127.0.0.1", "wild.example.test", "wild") // the wildcard still covers the other addresses
|
||||
// and the wildcard gave up the named address: its site is not served there
|
||||
if code, _ := ask("127.0.0.2", "wild.example.test"); code == 200 {
|
||||
t.Errorf("the wildcard website still answers on the address another website names")
|
||||
}
|
||||
if code, _ := ask("127.0.0.1", "named.example.test"); code == 200 {
|
||||
t.Errorf("a website answers on an address it was not given")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ go 1.27.3
|
||||
|
||||
require (
|
||||
git.veillax.com/Veillax/bamboo v0.1.5
|
||||
git.veillax.com/Veillax/blacktip v0.1.7
|
||||
git.veillax.com/Veillax/blacktip v0.1.8
|
||||
git.veillax.com/Veillax/reef v0.1.7
|
||||
github.com/emersion/go-msgauth v0.7.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
|
||||
@@ -14,6 +14,8 @@ git.veillax.com/Veillax/blacktip v0.1.6 h1:dUJzNGXgieWX05ftWjnMfh6norhaeKTqo1P13
|
||||
git.veillax.com/Veillax/blacktip v0.1.6/go.mod h1:ZnPRc2gPh9ElILfPH39G/x3B6KAiHQyeoDLycSDgVPM=
|
||||
git.veillax.com/Veillax/blacktip v0.1.7 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ=
|
||||
git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc=
|
||||
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.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
|
||||
|
||||
+16
@@ -204,8 +204,24 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// websites share a port by hostname, and a website on every address steps aside for one that names an address
|
||||
func TestWebsitesShareAPortAndWildcardsStepAside(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, string) {
|
||||
m["edgeId"] = edge.ID
|
||||
m["enabled"] = true
|
||||
m["targets"] = []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}
|
||||
code, b := do("POST", base, m)
|
||||
return code, string(b)
|
||||
}
|
||||
want := func(code int, got int, body, what string) {
|
||||
t.Helper()
|
||||
if code != got {
|
||||
t.Errorf("%s: got %d, want %d (%s)", what, got, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
c, body := open(map[string]any{"proto": "https", "bindIp": "0.0.0.0", "port": 443, "hostname": "a.example.test"})
|
||||
want(200, c, body, "first website on every address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||
want(200, c, body, "a website naming an address shares the port with the wildcard one")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.11", "port": 443, "hostname": "c.example.test"})
|
||||
want(200, c, body, "a second named address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "d.example.test"})
|
||||
want(200, c, body, "another hostname on a named address")
|
||||
// still refused: the same hostname twice on one listener, a raw port on a website's port, and another protocol
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||
want(400, c, body, "the same hostname twice on one address")
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "0.0.0.0", "port": 443})
|
||||
want(400, c, body, "a raw port on top of websites")
|
||||
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.10", "port": 443, "hostname": "e.example.test"})
|
||||
want(400, c, body, "http on an https port")
|
||||
|
||||
// the HTTP to HTTPS redirect also uses port 80 at its address
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.20", "port": 80})
|
||||
want(200, c, body, "a raw port 80 on its own address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test", "redirectHttp": true})
|
||||
want(400, c, body, "a redirect onto a raw port 80")
|
||||
if !strings.Contains(body, "port 80") {
|
||||
t.Errorf("the error should name port 80: %s", body)
|
||||
}
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test"})
|
||||
want(200, c, body, "the same website without the redirect")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.21", "port": 443, "hostname": "g.example.test", "redirectHttp": true})
|
||||
want(200, c, body, "a redirect where port 80 is free")
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.21", "port": 80})
|
||||
want(400, c, body, "a raw port 80 where a redirect lives")
|
||||
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.21", "port": 80, "hostname": "h.example.test"})
|
||||
want(200, c, body, "an http website beside a redirect on port 80")
|
||||
}
|
||||
+38
-7
@@ -662,7 +662,8 @@ 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 || {};
|
||||
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('input', {list:'ips', value:m.bindIp}), ipList = h('datalist', {id:'ips'});
|
||||
// the public address is picked from the edge's own addresses, each shown with the domains pointed at it
|
||||
const ipIn = h('select', {}), ipList = document.createTextNode('');
|
||||
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
|
||||
@@ -687,7 +688,25 @@ function portForm(existing) {
|
||||
const webBox = h('div', {class:'stack'});
|
||||
const hostLbl = hostBox;
|
||||
|
||||
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipList.replaceChildren(...['0.0.0.0', ...((e && e.ips) || [])].map(ip => h('option', {value:ip}))); };
|
||||
const ipLabel = (e, ip) => {
|
||||
const doms = st.domains.filter(d => d.ip === ip && (!d.edgeId || !e || d.edgeId === e.id)).map(d => d.name);
|
||||
return doms.length ? `${ip} (${doms.join(', ')})` : ip;
|
||||
};
|
||||
const refreshIPs = () => {
|
||||
const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0';
|
||||
// an address saved earlier stays selectable even if the edge has not reported it yet
|
||||
const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : [];
|
||||
const ips = [...new Set([...((e && e.ips) || []), ...saved])];
|
||||
ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, ipLabel(e, ip))));
|
||||
ipIn.value = ips.includes(keep) ? keep : '0.0.0.0';
|
||||
};
|
||||
// choosing a hostname under a domain that points at one of the edge's addresses selects that address
|
||||
const pickIPForHost = () => {
|
||||
const name = host.value.trim().toLowerCase().replace(/^\*\./, ''), e = byId(st.edges, edgeSel.value);
|
||||
const d = st.domains.find(d => d.ip && (name === d.name || name.endsWith('.' + d.name)) && (!d.edgeId || d.edgeId === edgeSel.value));
|
||||
if (d && ipIn.value === '0.0.0.0' && e && (e.ips || []).includes(d.ip)) ipIn.value = d.ip;
|
||||
};
|
||||
host.addEventListener('change', pickIPForHost);
|
||||
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
|
||||
const drawTargets = () => {
|
||||
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
|
||||
@@ -743,7 +762,7 @@ function portForm(existing) {
|
||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
||||
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||
const base = {
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value.trim() || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
||||
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
|
||||
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||
@@ -758,7 +777,7 @@ function portForm(existing) {
|
||||
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
|
||||
});
|
||||
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
|
||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
|
||||
|
||||
@@ -786,7 +805,7 @@ function portForm(existing) {
|
||||
const t = targets.map(x => ((byId(st.sites, x.siteId) || {name:'?'}).name) + ' → ' + x.host + ':' + x.port).join(', '), pr = (port.value || '').trim();
|
||||
const protoName = {both:'TCP + UDP', tcp:'TCP', udp:'UDP', tls:'TLS'}[protoSel.value];
|
||||
const where = isWeb() ? protoSel.value + '://' + (host.value.trim() || '*') + (pr && pr !== '443' && pr !== '80' ? ':' + pr : '')
|
||||
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value.trim() || 'every address');
|
||||
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value === '0.0.0.0' ? 'every address' : ipIn.selectedOptions[0].textContent);
|
||||
const login = ssoC.box.checked || pwC.box.checked || pinC.box.checked || mailC.box.checked;
|
||||
return h('div', {class:'stack'}, h('div', {}, h('b', {class:'mono'}, where)), h('div', {class:'small'}, '→ ' + t),
|
||||
isWeb() ? h('div', {class:'small dim'}, login ? 'Visitors have to sign in.' : 'Anyone can open it.') : null);
|
||||
@@ -846,7 +865,7 @@ function portForm(existing) {
|
||||
const web = isWeb();
|
||||
kids.push(h('div', {class:'grid'}, web || protoSel.value === 'tls' ? hostBox : null,
|
||||
field('Public port', port, web ? 'Websites normally use ' + (protoSel.value === 'https' ? '443' : '80') + '.' : 'One port, or a range like 2456-2458: each port goes to the target port plus the same offset.'),
|
||||
field('Public IP', ipIn, '0.0.0.0 means every address on the edge.')), ipList, domList);
|
||||
field('Public IP', ipIn, (byId(st.edges, edgeSel.value) || {}).ips && byId(st.edges, edgeSel.value).ips.length ? 'Domains pointed at an address are shown next to it.' : 'The edge has not reported its addresses yet. Start it to pick a specific one.')), ipList, domList);
|
||||
} else if (cur === 'access') {
|
||||
kids.push(accessFs);
|
||||
} else {
|
||||
@@ -1168,12 +1187,24 @@ serverViews.edges = async () => {
|
||||
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => {
|
||||
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge')))); }];
|
||||
// rename an edge, move its endpoint, or change which organizations may use it
|
||||
const editEdge = e => {
|
||||
const n = h('input', {value:e.name}), a = h('input', {value:e.tunnelAddr});
|
||||
const checks = orgs.map(o => ({o, c:chk(o.name, (e.orgIds || []).includes(o.id))}));
|
||||
dialogForm('Edit ' + e.name, 'Sites use the WireGuard endpoint to reach this edge, so change it only when the address or port has changed.',
|
||||
[field('Name', n), field('WireGuard endpoint (public host:UDP port)', a, 'host:port'),
|
||||
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, checks.map(x => x.c))) : null],
|
||||
'Save edge', async () => {
|
||||
await api('PUT', '/api/edges/' + e.id, {name:n.value, tunnelAddr:a.value, orgIds:orgs.length > 1 ? checks.filter(x => x.c.box.checked).map(x => x.o.id) : (e.orgIds || [])});
|
||||
toast('Saved ' + n.value.trim()); refresh();
|
||||
});
|
||||
};
|
||||
const latest = await latestVersions();
|
||||
return h('div', {},
|
||||
card('', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
||||
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => editEdge(e)}, 'Edit'), h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${e.name}?`, body:'Every port on this edge is deleted and its tunnel closes. This cannot be undone.', confirm:'Delete edge'}, async () => { await api('DELETE', '/api/edges/' + e.id); refresh(); })}, 'Delete'))))))) : emptyState('edges', 'No edges yet', 'An edge is a public server running Blacktip. Add one to start opening ports.')));
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user