3 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 b46e0813fe Answer domain redirects from the edge, with or without Traefik
CI / test (push) Successful in 2m11s
Release / release (push) Successful in 3m45s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:07:03 -06:00
VeillaxandClaude Sonnet 5.5 ad56fa4e3a A website on every address steps aside for websites that name an address on the same port
CI / test (push) Successful in 1m8s
Release / release (push) Successful in 3m53s
The wildcard then listens on the machine's other addresses only, in the built-in proxy and in Traefik,
and follows the machine when it gains or loses an address.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:48:13 -06:00
VeillaxandClaude Sonnet 5.5 246b08c090 Report update progress and log to Hammerhead while updating
CI / test (push) Successful in 2m59s
Release / release (push) Successful in 6m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:58:34 -06:00
9 changed files with 299 additions and 34 deletions

No files matched your search

+125 -8
View File
@@ -43,7 +43,7 @@ type Config struct {
// Hammerhead, by calling UpdateNow (which normally restarts the process into the new binary).
Version string
AutoUpdate bool
UpdateNow func(ctx context.Context) (newVersion string, err error)
UpdateNow func(ctx context.Context, progress func(stage string, pct int, line string)) (newVersion string, err error)
// Traefik, when its Path is set, serves http/https resources instead of the built-in proxy.
Traefik TraefikConfig
@@ -114,8 +114,13 @@ type Blacktip struct {
logq chan model.LogEntry
reportMu sync.Mutex
updating atomic.Bool
addrSig string // machine addresses a wildcard website was spread across, "" when none was
updateMsg string // outcome of the update Hammerhead asked for, reported back
updateID string // the request that updateMsg answers
updStage string // progress of the running update, reported to Hammerhead
updPct int
updLog []string
updSent time.Time
dockerMu sync.Mutex
dockerAt time.Time
dockerList []model.Container
@@ -243,6 +248,50 @@ func (b *Blacktip) fetchConfig(ctx context.Context) (*model.EdgeConfig, error) {
return &cfg, json.NewDecoder(resp.Body).Decode(&cfg)
}
// hostIPv4 lists every IPv4 address on this machine, loopback included: what a wildcard bind covers.
func hostIPv4() []string {
var ips []string
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
ips = append(ips, ipn.IP.String())
}
}
sort.Strings(ips)
return ips
}
// bindIPs says which addresses a mapping listens on. Normally that is its own BindIP. A website on
// 0.0.0.0 steps aside for websites of the same kind that name an address on the same port: it then
// listens on the machine's other addresses only, so a wildcard never takes a port from a specific IP.
func bindIPs(m model.Mapping, all []model.Mapping) []string {
if !model.Web(m.Proto) {
return []string{m.BindIP}
}
if ip := net.ParseIP(m.BindIP); ip == nil || !ip.IsUnspecified() {
return []string{m.BindIP}
}
named := map[string]bool{}
for _, o := range all {
if o.ID == m.ID || o.Proto != m.Proto || o.Port != m.Port {
continue
}
if ip := net.ParseIP(o.BindIP); ip != nil && !ip.IsUnspecified() {
named[ip.String()] = true
}
}
if len(named) == 0 {
return []string{m.BindIP}
}
var out []string
for _, ip := range hostIPv4() {
if !named[ip] {
out = append(out, ip)
}
}
return out
}
func listenKey(m model.Mapping) string {
n := "tcp"
if m.Proto == model.ProtoUDP {
@@ -307,9 +356,10 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
continue
}
// a range opens one listener per port; each shifts the target port by its offset
for _, ip := range bindIPs(m, cfg.Mappings) {
for p := m.Port; p <= m.LastPort(); p++ {
mm := m
mm.Port = p
mm.BindIP, mm.Port = ip, p
k := listenKey(mm)
if want[k] == nil {
want[k] = &bindSet{off: p - m.Port}
@@ -317,16 +367,19 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
want[k].ms = append(want[k].ms, b.maps[m.ID])
}
}
}
// https ports can also answer on :80 with a redirect, unless something else owns that port
redirects := map[string][]*mapState{}
for _, m := range cfg.Mappings {
if b.tr == nil && m.Proto == model.ProtoHTTPS && m.RedirectHTTP {
k := "tcp|" + net.JoinHostPort(m.BindIP, "80")
for _, ip := range bindIPs(m, cfg.Mappings) {
k := "tcp|" + net.JoinHostPort(ip, "80")
if _, taken := want[k]; !taken {
redirects[k] = append(redirects[k], b.maps[m.ID])
}
}
}
}
for k, l := range b.listeners {
_, w := want[k]
@@ -336,6 +389,13 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
delete(b.listeners, k)
}
}
b.addrSig = ""
for _, m := range cfg.Mappings {
if ips := bindIPs(m, cfg.Mappings); len(ips) != 1 || ips[0] != m.BindIP {
b.addrSig = strings.Join(hostIPv4(), ",")
break
}
}
b.status = map[string]string{}
bring := func(k string, ms []*mapState, off int, redirect bool) {
if l, ok := b.listeners[k]; ok {
@@ -345,7 +405,9 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
l.close()
delete(b.listeners, k)
}
l, err := b.open(ms, off, redirect)
_, addr, _ := strings.Cut(k, "|")
host, _, _ := net.SplitHostPort(addr)
l, err := b.open(host, ms, off, redirect)
if err != nil {
log.Printf("open %s: %v", k, err)
if !redirect {
@@ -372,7 +434,11 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
bring(k, ms, 0, true)
}
if b.tr != nil {
b.tr.sync(b.ctx, webMaps, cfg.AcmeEmail, cfg.AcmeStages)
binds := map[string][]string{}
for _, ms := range webMaps {
binds[ms.m.ID] = bindIPs(ms.m, cfg.Mappings)
}
b.tr.sync(b.ctx, webMaps, binds, cfg.AcmeEmail, cfg.AcmeStages)
if e := b.tr.err(); e != "" {
for _, ms := range webMaps {
b.status[ms.m.ID] = e
@@ -439,14 +505,14 @@ func (b *Blacktip) newMapState(m model.Mapping, sig string) *mapState {
for i := range ms.health {
ms.health[i] = model.TargetHealth{Healthy: true, Detail: "not checked yet"}
}
if model.Web(m.Proto) {
if model.Web(m.Proto) && !m.IsRedirect() { // a redirect has nothing behind it to relay to or check
if b.tr != nil {
ms.startRelay()
} else {
ms.initProxy()
}
}
if m.Proto != model.ProtoUDP && m.HealthSeconds != -1 {
if m.Proto != model.ProtoUDP && m.HealthSeconds != -1 && !m.IsRedirect() {
go b.healthLoop(ctx, ms)
}
return ms
@@ -534,6 +600,7 @@ func (b *Blacktip) report() {
}
b.mu.Lock()
rep.UpdateMsg, rep.UpdateID = b.updateMsg, b.updateID
rep.UpdateStage, rep.UpdatePct, rep.UpdateLog = b.updStage, b.updPct, append([]string(nil), b.updLog...)
for id, chk := range b.mailChecks {
rep.MailChecks[id] = chk
}
@@ -580,10 +647,25 @@ func (b *Blacktip) reportLoop(ctx context.Context) {
return
case <-t.C:
b.report()
b.recheckAddresses()
}
}
}
// recheckAddresses re-applies the config when this machine gains or loses an address that a wildcard
// website is spread across, so the listeners follow the machine.
func (b *Blacktip) recheckAddresses() {
b.mu.Lock()
last, sig := b.last, b.addrSig
b.mu.Unlock()
if last == nil || sig == "" {
return
}
if strings.Join(hostIPv4(), ",") != sig {
b.apply(last)
}
}
// ---------- access logs ----------
func (b *Blacktip) logAccess(e model.LogEntry) {
@@ -760,19 +842,25 @@ func (b *Blacktip) requestUpdate(id string) {
return // already handled, or in progress
}
b.updateID, b.updateMsg = id, ""
b.updStage, b.updPct, b.updLog = "starting", 0, nil
b.updNote("Update requested from the dashboard")
if !b.cfg.AutoUpdate || b.cfg.UpdateNow == nil {
b.updateMsg = "auto-update is off on this machine (set BLACKTIP_AUTO_UPDATE=1 and restart it)"
b.updStage = "failed"
b.updNote("Refused: " + b.updateMsg)
go b.report()
return
}
if !b.updating.CompareAndSwap(false, true) {
b.updateMsg = "another update is already running"
b.updStage = "failed"
b.updNote("Refused: " + b.updateMsg)
go b.report()
return
}
go func() {
defer b.updating.Store(false)
tag, err := b.cfg.UpdateNow(b.ctx) // on success this restarts the process and does not return
tag, err := b.cfg.UpdateNow(b.ctx, b.updProgress) // on success this restarts the process and does not return
b.mu.Lock()
switch {
case err != nil:
@@ -786,3 +874,32 @@ func (b *Blacktip) requestUpdate(id string) {
b.report()
}()
}
// updNote adds a timestamped line to the update log. Caller holds b.mu.
func (b *Blacktip) updNote(line string) {
b.updLog = append(b.updLog, time.Now().Format("15:04:05")+" "+line)
if len(b.updLog) > 200 {
b.updLog = b.updLog[len(b.updLog)-200:]
}
}
// updProgress is called by the updater as it works; Hammerhead gets a fresh report at most once a
// second (and straight away for every new log line), so its progress bar follows along.
func (b *Blacktip) updProgress(stage string, pct int, line string) {
b.mu.Lock()
b.updStage = stage
if pct >= 0 {
b.updPct = pct
}
if line != "" {
b.updNote(line)
}
send := line != "" || time.Since(b.updSent) > time.Second
if send {
b.updSent = time.Now()
}
b.mu.Unlock()
if send {
go b.report()
}
}
+5 -1
View File
@@ -55,7 +55,11 @@ func main() {
Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, TunnelAddr: *tun, DataDir: *data, Docker: *dockerOn,
Traefik: blacktip.TraefikConfig{Path: *traefikPath, ACMEDNS: *acmeDNS, NoACME: *noACME},
Version: version, AutoUpdate: *autoUpdate,
UpdateNow: func(ctx context.Context) (string, error) { return selfupdate.CheckOnce(ctx, upd) },
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
c := upd
c.Progress = func(e selfupdate.Event) { progress(e.Stage, e.Pct, e.Line) }
return selfupdate.CheckOnce(ctx, c)
},
})
if err != nil {
log.Fatal(err)
+1 -1
View File
@@ -3,7 +3,7 @@ module git.veillax.com/Veillax/blacktip
go 1.27.3
require (
git.veillax.com/Veillax/reef v0.1.6
git.veillax.com/Veillax/reef v0.1.10
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
)
+2 -8
View File
@@ -1,11 +1,5 @@
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=
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.5 h1:BydjLJsfGgU4zEIqUsePJRjPi9NgbFdmvq0M8dvJCgM=
git.veillax.com/Veillax/reef v0.1.5/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo=
git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
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/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
+3 -3
View File
@@ -35,12 +35,12 @@ type listener struct {
sessions map[string]*udpSession
}
func (b *Blacktip) open(ms []*mapState, off int, redirect bool) (*listener, error) {
func (b *Blacktip) open(ip string, ms []*mapState, off int, redirect bool) (*listener, error) {
m := ms[0].m
addr := net.JoinHostPort(m.BindIP, strconv.Itoa(m.Port+off))
addr := net.JoinHostPort(ip, strconv.Itoa(m.Port+off))
l := &listener{b: b, kind: m.Proto, redirect: redirect, maps: ms, off: off, done: make(chan struct{}), sessions: map[string]*udpSession{}}
if redirect {
addr = net.JoinHostPort(m.BindIP, "80")
addr = net.JoinHostPort(ip, "80")
l.kind = model.ProtoHTTP
}
if m.Proto == model.ProtoUDP {
+44
View File
@@ -0,0 +1,44 @@
package blacktip
import (
"net/http"
"net/http/httptest"
"testing"
"git.veillax.com/Veillax/reef/model"
)
// A redirect mapping answers from the edge itself: no proxy, relay or target is needed.
func TestRedirectMappingAnswersWithoutATarget(t *testing.T) {
b := &Blacktip{logq: make(chan model.LogEntry, 8)}
mk := func(m model.Mapping) *listener {
return &listener{b: b, kind: model.ProtoHTTP, maps: []*mapState{{b: b, m: m}}}
}
cases := []struct {
name string
m model.Mapping
uri string
code int
loc string
}{
{"temporary, no path", model.Mapping{Hostname: "old.test", RedirectTo: "https://new.test/home"}, "/a?b=1", http.StatusFound, "https://new.test/home"},
{"permanent keeps path and query", model.Mapping{Hostname: "old.test", RedirectTo: "https://new.test", RedirectPermanent: true, RedirectKeepPath: true}, "/a/b?c=d", http.StatusMovedPermanently, "https://new.test/a/b?c=d"},
}
for _, c := range cases {
r := httptest.NewRequest("GET", "http://old.test"+c.uri, nil)
w := httptest.NewRecorder()
mk(c.m).ServeHTTP(w, r)
if w.Code != c.code || w.Header().Get("Location") != c.loc {
t.Errorf("%s: got %d %q, want %d %q", c.name, w.Code, w.Header().Get("Location"), c.code, c.loc)
}
}
// a hostname nobody serves is still a 404
w := httptest.NewRecorder()
mk(cases[0].m).ServeHTTP(w, httptest.NewRequest("GET", "http://other.test/", nil))
if w.Code != http.StatusNotFound {
t.Errorf("unknown host: got %d", w.Code)
}
if e := <-b.logq; e.Result != "redirect" || e.Status != http.StatusFound {
t.Errorf("access log entry: %+v", e)
}
}
+72 -13
View File
@@ -49,6 +49,7 @@ type traefikMgr struct {
stop context.CancelFunc // stops the current traefik process
lastErr string
maps map[string]*mapState
binds map[string][]string // mapping ID -> the addresses it listens on (a wildcard steps aside for named ones)
ports map[string]int // mapping ID -> relay port, kept stable across edits so Traefik's backend URL never changes
dynBytes []byte
}
@@ -217,20 +218,23 @@ func hostRule(host string) (string, bool) {
// sync rewrites Traefik's configuration for the given web mappings and
// (re)starts the process if its static part changed. Called with b.mu held.
func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail string, staging bool) {
func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, binds map[string][]string, acmeEmail string, staging bool) {
t.mu.Lock()
defer t.mu.Unlock()
t.binds = binds
t.maps = map[string]*mapState{}
eps := map[string]string{} // name -> address
needACME := false
for _, ms := range maps {
t.maps[ms.m.ID] = ms
eps[epName(ms.m.BindIP, ms.m.Port)] = net.JoinHostPort(ms.m.BindIP, strconv.Itoa(ms.m.Port))
for _, ip := range t.bindsOf(ms.m) {
eps[epName(ip, ms.m.Port)] = net.JoinHostPort(ip, strconv.Itoa(ms.m.Port))
if ms.m.Proto == model.ProtoHTTPS && ms.m.RedirectHTTP {
eps[epName(ip, 80)] = net.JoinHostPort(ip, "80")
}
}
if ms.m.Proto == model.ProtoHTTPS {
needACME = true
if ms.m.RedirectHTTP {
eps[epName(ms.m.BindIP, 80)] = net.JoinHostPort(ms.m.BindIP, "80")
}
}
}
routers := map[string]any{}
@@ -240,11 +244,45 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
}
for _, ms := range maps {
m := ms.m
if m.IsRedirect() { // answered by Traefik itself: no relay, login or target
rule, wildcard := hostRule(m.Hostname)
var eps []string
for _, ip := range t.bindsOf(m) {
eps = append(eps, epName(ip, m.Port))
}
prio := 5000
if wildcard {
prio = 2000
}
to := strings.ReplaceAll(m.RedirectTo, "$", "$$") // "$" would otherwise start a group reference
regex, repl := "^.*", to
if m.RedirectKeepPath {
regex, repl = "^https?://[^/]+(.*)", strings.TrimRight(to, "/")+"${1}"
}
mws["rd-"+m.ID] = map[string]any{"redirectRegex": map[string]any{"regex": regex, "replacement": repl, "permanent": m.RedirectPermanent}}
router := map[string]any{"rule": rule, "entryPoints": eps, "service": "noop@internal", "middlewares": []string{"rd-" + m.ID}, "priority": prio}
if m.Proto == model.ProtoHTTPS {
tlsCfg := map[string]any{}
if !t.cfg.NoACME {
tlsCfg["certResolver"] = "le"
if wildcard {
tlsCfg["domains"] = []map[string]any{{"main": m.Hostname}}
}
}
router["tls"] = tlsCfg
}
routers["m-"+m.ID] = router
continue
}
if ms.relayPort == 0 {
continue
}
rule, wildcard := hostRule(m.Hostname)
ep := epName(m.BindIP, m.Port)
var mainEPs, redirEPs []string
for _, ip := range t.bindsOf(m) {
mainEPs = append(mainEPs, epName(ip, m.Port))
redirEPs = append(redirEPs, epName(ip, 80))
}
prio := 5000
if wildcard {
prio = 2000
@@ -274,7 +312,7 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
mws["host-"+m.ID] = map[string]any{"headers": map[string]any{"customRequestHeaders": map[string]string{"Host": m.RewriteHost}}}
chain = append(chain, "host-"+m.ID)
}
router := map[string]any{"rule": rule, "entryPoints": []string{ep}, "service": "s-" + m.ID, "middlewares": chain, "priority": prio}
router := map[string]any{"rule": rule, "entryPoints": mainEPs, "service": "s-" + m.ID, "middlewares": chain, "priority": prio}
var tlsCfg map[string]any
if m.Proto == model.ProtoHTTPS {
tlsCfg = map[string]any{}
@@ -292,7 +330,7 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
services["g-"+m.ID] = map[string]any{"loadBalancer": map[string]any{
"servers": []map[string]string{{"url": fmt.Sprintf("http://127.0.0.1:%d", t.gatePort)}}, "passHostHeader": true}}
mws["gate-"+m.ID] = map[string]any{"headers": map[string]any{"customRequestHeaders": map[string]string{"X-Hammerhead-Mapping": m.ID}}}
g := map[string]any{"rule": rule + " && PathPrefix(`/.hammerhead/`)", "entryPoints": []string{ep}, "service": "g-" + m.ID,
g := map[string]any{"rule": rule + " && PathPrefix(`/.hammerhead/`)", "entryPoints": mainEPs, "service": "g-" + m.ID,
"middlewares": []string{"gate-" + m.ID}, "priority": prio + 1000}
if tlsCfg != nil {
g["tls"] = tlsCfg
@@ -305,7 +343,7 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
redirPort = strconv.Itoa(m.Port)
}
mws["redir-"+m.ID] = map[string]any{"redirectScheme": map[string]any{"scheme": "https", "port": redirPort, "permanent": true}}
routers["r-"+m.ID] = map[string]any{"rule": rule, "entryPoints": []string{epName(m.BindIP, 80)}, "service": "noop@internal",
routers["r-"+m.ID] = map[string]any{"rule": rule, "entryPoints": redirEPs, "service": "noop@internal",
"middlewares": []string{"redir-" + m.ID}, "priority": prio}
}
}
@@ -545,10 +583,7 @@ func (t *traefikMgr) probeCerts(ctx context.Context) {
t.mu.Unlock()
changed := false
for _, ms := range targets {
ip := ms.m.BindIP
if pip := net.ParseIP(ip); pip != nil && pip.IsUnspecified() {
ip = "127.0.0.1"
}
ip := t.probeAddr(ms.m)
status := t.probeOne(net.JoinHostPort(ip, strconv.Itoa(ms.m.Port)), ms.m.Hostname)
t.b.mu.Lock()
if t.b.acme.certs == nil {
@@ -598,3 +633,27 @@ func containsStr(list []string, s string) bool {
}
return false
}
// bindsOf is the addresses a web mapping listens on; t.mu is held, or the caller does not race sync.
func (t *traefikMgr) bindsOf(m model.Mapping) []string {
if ips := t.binds[m.ID]; len(ips) > 0 {
return ips
}
return []string{m.BindIP}
}
// probeAddr picks an address on this machine to test a mapping's certificate through.
func (t *traefikMgr) probeAddr(m model.Mapping) string {
t.mu.Lock()
ips := t.bindsOf(m)
t.mu.Unlock()
for _, ip := range ips {
if ip == "127.0.0.1" {
return ip
}
}
if p := net.ParseIP(ips[0]); p != nil && p.IsUnspecified() {
return "127.0.0.1"
}
return ips[0]
}
+10
View File
@@ -264,6 +264,16 @@ func (l *listener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b.logAccess(entry)
}()
if ms.m.IsRedirect() {
code := http.StatusFound
if ms.m.RedirectPermanent {
code = http.StatusMovedPermanently
}
entry.Result = "redirect"
http.Redirect(rec, r, ms.m.RedirectLocation(r.URL.RequestURI()), code)
return
}
secure := l.kind == model.ProtoHTTPS
scheme := "http"
if secure {
+37
View File
@@ -0,0 +1,37 @@
package blacktip
import (
"testing"
"git.veillax.com/Veillax/reef/model"
)
func TestWildcardWebsitesStepAsideForNamedAddresses(t *testing.T) {
all := []model.Mapping{
{ID: "a", Proto: model.ProtoHTTPS, BindIP: "0.0.0.0", Port: 443, Hostname: "a.test"},
{ID: "b", Proto: model.ProtoHTTPS, BindIP: "127.0.0.1", Port: 443, Hostname: "b.test"},
{ID: "c", Proto: model.ProtoHTTPS, BindIP: "0.0.0.0", Port: 8443, Hostname: "c.test"},
{ID: "d", Proto: model.ProtoTCP, BindIP: "0.0.0.0", Port: 443},
}
// the wildcard on 443 gives up the address that "b" names
for _, ip := range bindIPs(all[0], all) {
if ip == "127.0.0.1" {
t.Fatalf("wildcard still listens on 127.0.0.1: %v", bindIPs(all[0], all))
}
}
// the named address keeps its own listener
if got := bindIPs(all[1], all); len(got) != 1 || got[0] != "127.0.0.1" {
t.Fatalf("named mapping: %v", got)
}
// a different port, or a raw TCP port, is not affected
if got := bindIPs(all[2], all); len(got) != 1 || got[0] != "0.0.0.0" {
t.Fatalf("other port: %v", got)
}
if got := bindIPs(all[3], all); len(got) != 1 || got[0] != "0.0.0.0" {
t.Fatalf("raw tcp: %v", got)
}
// with nothing named on that port the wildcard stays a single wildcard listener
if got := bindIPs(all[0], all[:1]); len(got) != 1 || got[0] != "0.0.0.0" {
t.Fatalf("alone: %v", got)
}
}