Add the Blacktip edge

The edge: per-IP TCP/UDP/HTTP/HTTPS/TLS listeners with port ranges and PROXY
protocol, WireGuard tunnel endpoint, logins in front of web ports (built-in
proxy or Traefik), health checks and failover, and mail egress with DKIM
signing and MTA-STS. Split out of the original single repository; depends on
Reef for the shared types.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:45:16 -06:00
1 parent 995ef9a6aa
commit 9448a532e9
17 files changed
+3492 -1

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
package blacktip
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/reef/authtoken"
"git.veillax.com/Veillax/reef/model"
)
const authCookie = "hh_auth"
// recorder captures what a handler sent, without breaking websockets or
// flushing (http.ResponseController follows Unwrap).
type recorder struct {
http.ResponseWriter
status int
bytes int64
}
func (r *recorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *recorder) Write(p []byte) (int, error) {
if r.status == 0 {
r.status = 200
}
n, err := r.ResponseWriter.Write(p)
r.bytes += int64(n)
return n, err
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// initProxy builds the built-in reverse proxy (used when Traefik isn't).
func (ms *mapState) initProxy() {
scheme, port := "http", 80
if ms.m.BackendHTTPS {
scheme, port = "https", 443
}
ms.transport = &http.Transport{
// The request URL host is "t<index>": it names the target and keeps
// pooled connections to different targets apart.
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
idx := -1
if h, _, err := net.SplitHostPort(addr); err == nil && strings.HasPrefix(h, "t") {
if n, err := strconv.Atoi(h[1:]); err == nil {
idx = n
}
}
c, _, err := ms.dial("tcp", idx)
return c, err
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // backends are usually self-signed
TLSHandshakeTimeout: 10 * time.Second,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 60 * time.Second,
}
ms.proxy = &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetXForwarded()
idx := ms.order()[0]
pr.Out.URL.Scheme = scheme
pr.Out.URL.Host = fmt.Sprintf("t%d:%d", idx, port)
if ms.m.RewriteHost != "" {
pr.Out.Host = ms.m.RewriteHost
}
},
Transport: ms.transport,
FlushInterval: -1,
ErrorLog: log.New(io.Discard, "", 0),
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, "Bad gateway: the service behind this address isn't answering.", http.StatusBadGateway)
},
}
}
func ruleMatches(ru model.Rule, path, clientIP string) bool {
switch ru.Match {
case "path":
return strings.HasPrefix(path, ru.Value)
case "ip":
ip := net.ParseIP(clientIP)
if ip == nil {
return false
}
if p := net.ParseIP(ru.Value); p != nil {
return p.Equal(ip)
}
if _, n, err := net.ParseCIDR(ru.Value); err == nil {
return n.Contains(ip)
}
}
return false
}
func safeDest(p string) string {
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
return "/"
}
return p
}
func hostFromAddr(a string) string {
if h, _, err := net.SplitHostPort(a); err == nil {
return h
}
return a
}
// ---------- the access decision, shared by the built-in proxy and the Traefik gate ----------
type authInput struct {
Method, Scheme, Host, URI, ClientIP, Cookie string
Upgrade bool
}
type authDecision struct {
Allow bool
Status int // when !Allow: 302, 401 or 403
Location string // for 302
User string
Cookie string // the request's Cookie header with our own cookie removed
Result string
}
func cookieValue(header, name string) string {
for _, c := range (&http.Request{Header: http.Header{"Cookie": {header}}}).Cookies() {
if c.Name == name {
return c.Value
}
}
return ""
}
func stripCookie(header string) string {
var keep []string
for _, c := range (&http.Request{Header: http.Header{"Cookie": {header}}}).Cookies() {
if c.Name != authCookie {
keep = append(keep, c.Name+"="+c.Value)
}
}
return strings.Join(keep, "; ")
}
// decide applies a web mapping's rules and login policy to one request.
func (b *Blacktip) decide(ms *mapState, in authInput) authDecision {
m := ms.m
path, query, rawPath := "/", url.Values{}, "/"
if u, err := url.ParseRequestURI(in.URI); err == nil {
path, rawPath, query = u.Path, u.EscapedPath(), u.Query()
}
d := authDecision{Cookie: stripCookie(in.Cookie), Result: "allowed"}
bypass := false
for _, ru := range m.Rules {
if !ruleMatches(ru, path, in.ClientIP) {
continue
}
if ru.Action == "deny" {
return authDecision{Status: http.StatusForbidden, Result: "denied"}
}
bypass, d.Result = true, "bypass"
break
}
if !m.Auth.Protected() || bypass {
d.Allow = true
return d
}
b.mu.Lock()
key, dash := b.authKey, b.dashboard
b.mu.Unlock()
if t, ok := authtoken.Verify(key, cookieValue(in.Cookie, authCookie), authtoken.KindSession, m.ID, ms.epoch.Load()); ok {
d.Allow, d.User = true, t.Sub
return d
}
share := query.Get("hh_share")
query.Del("hh_share")
back := in.Scheme + "://" + in.Host + rawPath
if q := query.Encode(); q != "" {
back += "?" + q
}
login := fmt.Sprintf("%s/auth/r/%s?return=%s", strings.TrimRight(dash, "/"), m.ID, url.QueryEscape(back))
if share != "" {
login += "&share=" + url.QueryEscape(share)
}
d.Cookie, d.Result = "", "auth-required"
if (in.Method == http.MethodGet || in.Method == http.MethodHead) && !in.Upgrade {
d.Status, d.Location = http.StatusFound, login
} else {
d.Status = http.StatusUnauthorized
}
return d
}
// callback trades hammerhead's short-lived login token for this visitor's cookie.
func (b *Blacktip) callback(w http.ResponseWriter, r *http.Request, ms *mapState, secure bool) (user, result string) {
b.mu.Lock()
key := b.authKey
b.mu.Unlock()
t, ok := authtoken.Verify(key, r.URL.Query().Get("t"), authtoken.KindCallback, ms.m.ID, ms.epoch.Load())
if !ok {
http.Error(w, "That login link is invalid or has expired. Go back and sign in again.", http.StatusForbidden)
return "", "bad-login-token"
}
hours := ms.m.Auth.SessionHours
if hours <= 0 {
hours = 24
}
sess := authtoken.Sign(key, authtoken.Token{Mapping: ms.m.ID, Sub: t.Sub, Kind: authtoken.KindSession, Epoch: t.Epoch,
Exp: time.Now().Add(time.Duration(hours) * time.Hour).Unix()})
http.SetCookie(w, &http.Cookie{Name: authCookie, Value: sess, Path: "/", HttpOnly: true, Secure: secure,
SameSite: http.SameSiteLaxMode, MaxAge: hours * 3600})
http.Redirect(w, r, safeDest(r.URL.Query().Get("r")), http.StatusFound)
return t.Sub, "login"
}
func (b *Blacktip) logout(w http.ResponseWriter, r *http.Request, secure bool) {
http.SetCookie(w, &http.Cookie{Name: authCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: secure})
http.Redirect(w, r, "/", http.StatusFound)
}
// ServeHTTP handles every request on a built-in http/https listener.
func (l *listener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b := l.b
start := time.Now()
rec := &recorder{ResponseWriter: w}
host := hostOnly(r.Host)
ms, found := route(l.snapshot(), host)
if !found {
http.Error(rec, "Nothing is served at this address.", http.StatusNotFound)
return
}
entry := model.LogEntry{Time: start, Mapping: ms.m.ID, Remote: r.RemoteAddr, Method: r.Method, Host: host, Path: r.URL.Path, Result: "allowed"}
defer func() {
entry.Status = rec.status
if entry.Status == 0 {
entry.Status = 200
if r.Header.Get("Upgrade") != "" {
entry.Status = 101
}
}
if r.ContentLength > 0 {
entry.BytesIn = r.ContentLength
}
entry.BytesOut = rec.bytes
entry.Millis = time.Since(start).Milliseconds()
ms.conns.Add(1)
ms.bytesIn.Add(entry.BytesIn)
ms.bytesOut.Add(entry.BytesOut)
b.logAccess(entry)
}()
secure := l.kind == model.ProtoHTTPS
scheme := "http"
if secure {
scheme = "https"
}
if ms.m.Auth.Protected() {
switch r.URL.Path {
case "/.hammerhead/callback":
entry.User, entry.Result = b.callback(rec, r, ms, secure)
return
case "/.hammerhead/logout":
b.logout(rec, r, secure)
entry.Result = "logout"
return
}
}
d := b.decide(ms, authInput{Method: r.Method, Scheme: scheme, Host: r.Host, URI: r.URL.RequestURI(),
ClientIP: hostFromAddr(r.RemoteAddr), Cookie: r.Header.Get("Cookie"), Upgrade: r.Header.Get("Upgrade") != ""})
entry.Result, entry.User = d.Result, d.User
if !d.Allow {
switch d.Status {
case http.StatusFound:
http.Redirect(rec, r, d.Location, http.StatusFound)
case http.StatusForbidden:
http.Error(rec, "Forbidden", http.StatusForbidden)
default:
http.Error(rec, "Authentication required.", http.StatusUnauthorized)
}
return
}
// the backend never sees our cookie, and can trust X-Hammerhead-User
r.Header.Del("X-Hammerhead-User")
if d.User != "" {
r.Header.Set("X-Hammerhead-User", d.User)
}
if d.Cookie != "" {
r.Header.Set("Cookie", d.Cookie)
} else {
r.Header.Del("Cookie")
}
ms.proxy.ServeHTTP(rec, r)
}