2 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 38ab4f7db2 Use Blacktip v0.1.8
CI / test (push) Successful in 4m54s
Release / release (push) Successful in 5m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 23:10:47 -06:00
VeillaxandClaude Sonnet 5.5 f93698f63f Let websites share a port across addresses, and refuse an HTTPS redirect that would take port 80 from a raw port
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>
2026-10-26 22:48:17 -06:00
5 changed files with 161 additions and 1 deletions

No files matched your search

+66
View File
@@ -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")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ 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.7 git.veillax.com/Veillax/blacktip v0.1.8
git.veillax.com/Veillax/reef v0.1.7 git.veillax.com/Veillax/reef v0.1.7
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
+2
View File
@@ -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.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 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ=
git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc= 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 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= 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= git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
+16
View File
@@ -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 { if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
continue // same listener, different 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) 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.Name == "" {
if m.Hostname != "" { if m.Hostname != "" {
m.Name = m.Hostname m.Name = m.Hostname
+76
View File
@@ -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")
}