14 Commits
Author SHA1 Message Date
Veillax 6fdafa28a7 Bump reef to v0.1.11
CI / test (push) Successful in 2m17s
Release / release (push) Successful in 3m42s
2026-10-27 17:13:34 -06:00
Veillax 8bc4d9cb3b Merge branch 'feature/mail-deliverability' 2026-10-27 17:08:14 -06:00
VeillaxandClaude Sonnet 5.5 3a86254cb6 Check SPF, DMARC, MTA-STS and DNS blocklists per sending domain; count delivery outcomes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:56:57 -06:00
VeillaxandClaude Sonnet 5.5 ed9edbc9fd Count traffic per client address and report the busiest ones with the port totals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:54:57 -06:00
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
VeillaxandClaude Sonnet 5.5 049b6bad97 Report version and auto-update state to Hammerhead and update on request
CI / test (push) Successful in 7m1s
Release / release (push) Successful in 9m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:03:31 -06:00
VeillaxandClaude Sonnet 5.5 7597f12db9 Build and publish only linux/amd64 and windows/amd64
CI / test (push) Successful in 1m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:48:51 -06:00
VeillaxandClaude Sonnet 5.5 23550176a6 Retry release uploads that hit a proxy timeout, and replace every existing asset on re-run
CI / test (push) Successful in 2m15s
Release / release (push) Successful in 19m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:56:57 -06:00
VeillaxandClaude Sonnet 5.5 189304938e Fix the staged install link used by DESTDIR
CI / test (push) Successful in 2m35s
Release / release (push) Failing after 10m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:01:41 -06:00
VeillaxandClaude Sonnet 5.5 c5019f38b6 Add opt-in auto-update from Gitea releases
CI / test (push) Successful in 6m14s
Release / release (push) Failing after 22m44s
The program now lives in /usr/local/lib/blacktip and is owned by its service user so it can replace itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:00:36 -06:00
VeillaxandClaude Sonnet 5.5 d9c422fb84 Bundle a tested Traefik in releases, the image and the installer
CI / test (push) Successful in 3m38s
Release / release (push) Successful in 10m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 04:25:56 -06:00
VeillaxandClaude Sonnet 5.5 e951d22687 Add Gitea Actions CI and release workflows and deployment assets
CI / test (push) Canceled after 14m46s
Release / release (push) Successful in 6m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 03:05:30 -06:00
28 changed files with 1879 additions and 53 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# The image only packages the binaries that deploy/build.sh already built.
*
!dist/bin
+7 -1
View File
@@ -13,6 +13,12 @@ BLACKTIP_TOKEN=
# Serve http/https with Traefik (path to a Traefik v3 binary), and optionally # Serve http/https with Traefik (path to a Traefik v3 binary), and optionally
# use DNS challenge for wildcard certificates. The provider's credentials go here # use DNS challenge for wildcard certificates. The provider's credentials go here
# too, named exactly as Traefik documents for that provider. # too, named exactly as Traefik documents for that provider.
#BLACKTIP_TRAEFIK=/usr/local/bin/traefik # Releases bundle a tested Traefik: /usr/local/lib/blacktip/traefik (systemd install),
# /traefik (Docker image, already set; set it empty to use the built-in proxy).
#BLACKTIP_TRAEFIK=/usr/local/lib/blacktip/traefik
#BLACKTIP_ACME_DNS=cloudflare #BLACKTIP_ACME_DNS=cloudflare
#CF_DNS_API_TOKEN= #CF_DNS_API_TOKEN=
# Check for new releases every few hours and update and restart by itself (Linux and macOS).
# setup.sh turns this on; remove it to update by hand.
#BLACKTIP_AUTO_UPDATE=1
+50
View File
@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Go fetches the other sharks modules straight from this server. This is harmless
# for public repositories and is what makes private ones work.
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles
run: |
for t in linux/amd64 windows/amd64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/blacktip
done
+62
View File
@@ -0,0 +1,62 @@
name: Release
# Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.2.0 -m "blacktip v0.2.0" && git push origin v0.2.0
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Test
run: |
go vet ./...
go test ./...
- name: Build release archives
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
# servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/blacktip"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
# a container-driver builder can build several architectures in one go
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64 $tags --push .
+1
View File
@@ -3,3 +3,4 @@ bin/
.env .env
wg.key wg.key
*.db *.db
dist/
+26
View File
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
# Packages the binaries deploy/build.sh already built (dist/bin/linux_<arch>/blacktip),
# so the arm64 image needs no emulation. Build it with: sh deploy/build.sh && docker buildx build .
#
# Blacktip opens ports on the machine's own IP addresses, so run it with host
# networking (see deploy/docker-compose.yml). It runs as root in the container so it
# can bind ports below 1024 such as 80 and 443.
FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep
RUN apk add --no-cache ca-certificates \
&& mkdir /data
FROM scratch
ARG TARGETARCH
LABEL org.opencontainers.image.title="Blacktip" \
org.opencontainers.image.description="Edge: public ports, WireGuard tunnels, logins and mail egress" \
org.opencontainers.image.source="https://git.veillax.com/Veillax/blacktip" \
org.opencontainers.image.licenses="AGPL-3.0-only"
COPY --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=prep /data /data
COPY dist/bin/linux_${TARGETARCH}/blacktip /blacktip
COPY dist/bin/linux_${TARGETARCH}/traefik /traefik
# Traefik serves http/https; set BLACKTIP_TRAEFIK= (empty) in .env to use the built-in proxy instead
ENV BLACKTIP_DATA=/data BLACKTIP_TRAEFIK=/traefik
VOLUME ["/data"]
EXPOSE 51820/udp
ENTRYPOINT ["/blacktip"]
+41 -1
View File
@@ -21,7 +21,7 @@ Open the WireGuard UDP port (default 51820) and any ports you open from the dash
| `-tunnel` | `BLACKTIP_TUNNEL` | UDP listen address for WireGuard (default `:51820`) | | `-tunnel` | `BLACKTIP_TUNNEL` | UDP listen address for WireGuard (default `:51820`) |
| `-data` | `BLACKTIP_DATA` | folder for the WireGuard key and certificates | | `-data` | `BLACKTIP_DATA` | folder for the WireGuard key and certificates |
| `-docker` | `BLACKTIP_DOCKER=1` | report local Docker containers so they can be picked as targets | | `-docker` | `BLACKTIP_DOCKER=1` | report local Docker containers so they can be picked as targets |
| `-traefik` | `BLACKTIP_TRAEFIK` | path to a Traefik v3 binary, to serve http/https with Traefik | | `-traefik` | `BLACKTIP_TRAEFIK` | path to a Traefik v3 binary, to serve http/https with Traefik. Release archives and the Docker image include a tested one |
| `-acme-dns` | `BLACKTIP_ACME_DNS` | with Traefik: DNS-challenge provider for wildcard certificates | | `-acme-dns` | `BLACKTIP_ACME_DNS` | with Traefik: DNS-challenge provider for wildcard certificates |
| `-no-acme` | | with Traefik: serve its self-signed certificate instead of Let's Encrypt | | `-no-acme` | | with Traefik: serve its self-signed certificate instead of Let's Encrypt |
| `-env` | | `.env` file to read (default `./.env`) | | `-env` | | `.env` file to read (default `./.env`) |
@@ -33,9 +33,49 @@ Settings and secrets can live in a `.env` file instead of the command line; see
- **Ports:** TCP, UDP, HTTP, HTTPS and TLS passthrough, one listener per IP and port, opened and closed live as you change them in the dashboard. Port ranges for TCP and UDP. Round-robin load balancing and failover across several targets, with health checks. - **Ports:** TCP, UDP, HTTP, HTTPS and TLS passthrough, one listener per IP and port, opened and closed live as you change them in the dashboard. Port ranges for TCP and UDP. Round-robin load balancing and failover across several targets, with health checks.
- **PROXY protocol (v1/v2)** on TCP and TLS ports, so the target sees the real client address. - **PROXY protocol (v1/v2)** on TCP and TLS ports, so the target sees the real client address.
- **Web:** automatic Let's Encrypt certificates, HTTP/2, websockets, and logins (account, password, PIN, emailed code, share links, path and IP rules) enforced in front of any site. Use the built-in proxy, or `-traefik` to let Traefik serve http/https (raw TCP, UDP and TLS-passthrough ports always stay with Blacktip, since Traefik can't open ports without a restart). - **Web:** automatic Let's Encrypt certificates, HTTP/2, websockets, and logins (account, password, PIN, emailed code, share links, path and IP rules) enforced in front of any site. Use the built-in proxy, or `-traefik` to let Traefik serve http/https (raw TCP, UDP and TLS-passthrough ports always stay with Blacktip, since Traefik can't open ports without a restart).
- **Traffic analytics:** counts connections and bytes in and out for every port, and the busiest client addresses on each (the ten biggest per report, at most a few thousand tracked between reports), and sends them to Hammerhead with its regular report. Hammerhead keeps the history, draws the charts and raises traffic alerts. Nothing is stored on this machine, and a report that fails to arrive is added to the next one.
- **Mail egress:** delivers your mail server's outgoing mail straight to the recipient's server from a public IP you choose, with the HELO name you set, opportunistic STARTTLS, optional DKIM signing, MTA-STS enforcement, a per-minute send limit and a deliverability checklist. - **Mail egress:** delivers your mail server's outgoing mail straight to the recipient's server from a public IP you choose, with the HELO name you set, opportunistic STARTTLS, optional DKIM signing, MTA-STS enforcement, a per-minute send limit and a deliverability checklist.
- **Mail deliverability:** every 15 minutes the edge checks each egress the way a receiving server would and reports to Hammerhead: reverse DNS and HELO, port 25, DKIM keys, and for each sending domain whether SPF authorizes the source IP (evaluated properly, including `include:`, `redirect=` and the 10-lookup limit), whether DMARC is published and enforcing, and whether MTA-STS is set up. The source IP is also looked up on DNS blocklists (Spamhaus ZEN, SpamCop, Barracuda, PSBL). Each finding carries a hint saying what to change. Delivery outcomes (delivered, deferred, refused, per recipient domain, with the latest refusal messages) are counted for the last 24 hours and reported alongside. DMARC aggregate reports are uploaded and parsed in Hammerhead.
- **Tunnel:** WireGuard through a userspace network stack, so no root, no kernel interface and no driver. It works on Windows, macOS and Linux, and sites behind NAT connect out to it. - **Tunnel:** WireGuard through a userspace network stack, so no root, no kernel interface and no driver. It works on Windows, macOS and Linux, and sites behind NAT connect out to it.
## Deploying
Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Blacktip for Linux (amd64) and Windows (amd64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a container image (linux/amd64) to this server's registry (`git.veillax.com/veillax/blacktip`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
**On a Linux server (systemd)**
```
curl -LO https://git.veillax.com/Veillax/blacktip/releases/download/v0.1.0/blacktip_v0.1.0_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/blacktip/releases/download/v0.1.0/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
tar xzf blacktip_v0.1.0_linux_amd64.tar.gz && cd blacktip_v0.1.0_linux_amd64
sudo ./install.sh # installs the binary and a hardened systemd service
sudoedit /etc/blacktip/blacktip.env # your settings and secrets
sudo systemctl start blacktip
```
Releases are published for x86-64 only; for ARM machines build it yourself (`TARGETS=linux/arm64 sh deploy/build.sh`). To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/blacktip` and `/var/lib/blacktip` alone. `blacktip -version` tells you which build is running.
**Updates**
Set `BLACKTIP_AUTO_UPDATE=1` (or pass `-auto-update`) and Blacktip checks this server for a newer release every few hours, verifies it against `SHA256SUMS`, checks that the new binary runs, swaps it in and restarts itself. It only follows full releases (never pre-releases), never downgrades, and keeps the old binary if anything looks wrong. `setup.sh` turns it on. It is not available on Windows, and it is for the systemd and plain-binary installs: with Docker, pull a new image instead.
**With Docker**
```
cp .env.example .env # fill it in
docker compose -f deploy/docker-compose.yml up -d
```
The image is `git.veillax.com/veillax/blacktip:<version>` (and `:latest` for full releases). The compose file is in the repository and inside each Linux archive.
**Setting up the build (once)**
- A Gitea Actions runner with the label `ubuntu-latest`, and Docker on it if you want the container image.
- Nothing else is required: the workflows use the automatic job token. If your server doesn't let that token publish releases or packages, add the secrets `RELEASE_TOKEN` and `REGISTRY_TOKEN` (access tokens with release and package write permission) and optionally the variable `REGISTRY_USER`.
- To skip the container image, set the repository variable `PUBLISH_DOCKER` to `false`.
- Build locally with `sh deploy/build.sh` (archives in `dist/`).
## Build and test ## Build and test
``` ```
+206 -19
View File
@@ -38,6 +38,13 @@ type Config struct {
DataDir string DataDir string
Docker bool // report local Docker containers for discovery Docker bool // report local Docker containers for discovery
// Version is the release this binary was built from, reported to Hammerhead. AutoUpdate says
// whether the program may update itself; only then does it act on an update request from
// Hammerhead, by calling UpdateNow (which normally restarts the process into the new binary).
Version string
AutoUpdate bool
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, when its Path is set, serves http/https resources instead of the built-in proxy.
Traefik TraefikConfig Traefik TraefikConfig
@@ -49,6 +56,10 @@ type Config struct {
MailLookupTXT func(name string) ([]string, error) // DNS TXT lookups (DKIM check, MTA-STS record) MailLookupTXT func(name string) ([]string, error) // DNS TXT lookups (DKIM check, MTA-STS record)
MailSTS func(domain string) (*STSPolicy, error) // replaces MTA-STS discovery MailSTS func(domain string) (*STSPolicy, error) // replaces MTA-STS discovery
MailRootCAs *x509.CertPool // trust anchors for verified TLS to mail servers MailRootCAs *x509.CertPool // trust anchors for verified TLS to mail servers
MailLookupIP func(name string) ([]net.IP, error) // A/AAAA lookups (SPF a: and exists:)
MailLookupMX func(domain string) ([]string, error) // MX host names (SPF mx:)
MailLookupDNSBL func(name string) ([]string, error) // DNS blocklist queries; no error and no answers means "not listed"
MailBlocklists []string // DNSBL zones to ask; nil uses the built-in list
// GetCertificate replaces Let's Encrypt for https ports (used by tests). // GetCertificate replaces Let's Encrypt for https ports (used by tests).
GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error)
@@ -66,6 +77,7 @@ type mapState struct {
rr atomic.Uint64 rr atomic.Uint64
conns, bytesIn, bytesOut atomic.Int64 conns, bytesIn, bytesOut atomic.Int64
sources sourceTable // the same traffic per client address, for the top-sources list
epoch atomic.Int64 // current login generation; kept out of sig so a bump doesn't rebuild the resource epoch atomic.Int64 // current login generation; kept out of sig so a bump doesn't rebuild the resource
relayLn net.Listener // traefik mode: loopback port that reaches the targets through the tunnel relayLn net.Listener // traefik mode: loopback port that reaches the targets through the tunnel
@@ -93,6 +105,7 @@ type Blacktip struct {
mailChecks map[string]model.MailCheck mailChecks map[string]model.MailCheck
mailMu sync.Mutex mailMu sync.Mutex
mailRate map[string][]time.Time mailRate map[string][]time.Time
outcomes outcomeLog
sts stsCache sts stsCache
local map[string]bool local map[string]bool
authKey string authKey string
@@ -106,6 +119,15 @@ type Blacktip struct {
logq chan model.LogEntry logq chan model.LogEntry
reportMu sync.Mutex reportMu sync.Mutex
statsSince time.Time // when the traffic counters were last taken; guarded by reportMu
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 dockerMu sync.Mutex
dockerAt time.Time dockerAt time.Time
dockerList []model.Container dockerList []model.Container
@@ -233,6 +255,50 @@ func (b *Blacktip) fetchConfig(ctx context.Context) (*model.EdgeConfig, error) {
return &cfg, json.NewDecoder(resp.Body).Decode(&cfg) 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 { func listenKey(m model.Mapping) string {
n := "tcp" n := "tcp"
if m.Proto == model.ProtoUDP { if m.Proto == model.ProtoUDP {
@@ -252,6 +318,9 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
if b.dashboard == "" { if b.dashboard == "" {
b.dashboard = b.cfg.Hammerhead b.dashboard = b.cfg.Hammerhead
} }
if cfg.UpdateID != "" {
b.requestUpdate(cfg.UpdateID)
}
b.local = map[string]bool{} b.local = map[string]bool{}
for _, id := range cfg.Local { for _, id := range cfg.Local {
b.local[id] = true b.local[id] = true
@@ -294,23 +363,27 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
continue continue
} }
// a range opens one listener per port; each shifts the target port by its offset // a range opens one listener per port; each shifts the target port by its offset
for p := m.Port; p <= m.LastPort(); p++ { for _, ip := range bindIPs(m, cfg.Mappings) {
mm := m for p := m.Port; p <= m.LastPort(); p++ {
mm.Port = p mm := m
k := listenKey(mm) mm.BindIP, mm.Port = ip, p
if want[k] == nil { k := listenKey(mm)
want[k] = &bindSet{off: p - m.Port} if want[k] == nil {
want[k] = &bindSet{off: p - m.Port}
}
want[k].ms = append(want[k].ms, b.maps[m.ID])
} }
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 // https ports can also answer on :80 with a redirect, unless something else owns that port
redirects := map[string][]*mapState{} redirects := map[string][]*mapState{}
for _, m := range cfg.Mappings { for _, m := range cfg.Mappings {
if b.tr == nil && m.Proto == model.ProtoHTTPS && m.RedirectHTTP { if b.tr == nil && m.Proto == model.ProtoHTTPS && m.RedirectHTTP {
k := "tcp|" + net.JoinHostPort(m.BindIP, "80") for _, ip := range bindIPs(m, cfg.Mappings) {
if _, taken := want[k]; !taken { k := "tcp|" + net.JoinHostPort(ip, "80")
redirects[k] = append(redirects[k], b.maps[m.ID]) if _, taken := want[k]; !taken {
redirects[k] = append(redirects[k], b.maps[m.ID])
}
} }
} }
} }
@@ -323,6 +396,13 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
delete(b.listeners, k) 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{} b.status = map[string]string{}
bring := func(k string, ms []*mapState, off int, redirect bool) { bring := func(k string, ms []*mapState, off int, redirect bool) {
if l, ok := b.listeners[k]; ok { if l, ok := b.listeners[k]; ok {
@@ -332,7 +412,9 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
l.close() l.close()
delete(b.listeners, k) 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 { if err != nil {
log.Printf("open %s: %v", k, err) log.Printf("open %s: %v", k, err)
if !redirect { if !redirect {
@@ -359,7 +441,11 @@ func (b *Blacktip) apply(cfg *model.EdgeConfig) {
bring(k, ms, 0, true) bring(k, ms, 0, true)
} }
if b.tr != nil { 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 != "" { if e := b.tr.err(); e != "" {
for _, ms := range webMaps { for _, ms := range webMaps {
b.status[ms.m.ID] = e b.status[ms.m.ID] = e
@@ -426,14 +512,14 @@ func (b *Blacktip) newMapState(m model.Mapping, sig string) *mapState {
for i := range ms.health { for i := range ms.health {
ms.health[i] = model.TargetHealth{Healthy: true, Detail: "not checked yet"} 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 { if b.tr != nil {
ms.startRelay() ms.startRelay()
} else { } else {
ms.initProxy() 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) go b.healthLoop(ctx, ms)
} }
return ms return ms
@@ -517,22 +603,39 @@ func (b *Blacktip) report() {
IPs: localIPs(), PublicKey: b.wgPub, IPs: localIPs(), PublicKey: b.wgPub,
Status: map[string]model.MappingStatus{}, Stats: map[string]model.Stat{}, Status: map[string]model.MappingStatus{}, Stats: map[string]model.Stat{},
Containers: b.containers(), MailChecks: map[string]model.MailCheck{}, Containers: b.containers(), MailChecks: map[string]model.MailCheck{},
Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate,
} }
b.mu.Lock() 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 { for id, chk := range b.mailChecks {
chk.Outcomes = b.outcomes.summary(id, time.Now())
rep.MailChecks[id] = chk rep.MailChecks[id] = chk
} }
for id := range b.mail { // an egress that has sent mail but not been checked yet still reports its outcomes
if _, ok := rep.MailChecks[id]; !ok {
if o := b.outcomes.summary(id, time.Now()); o != nil {
rep.MailChecks[id] = model.MailCheck{Outcomes: o}
}
}
}
for id, ms := range b.maps { for id, ms := range b.maps {
st := model.MappingStatus{Error: b.status[id], Cert: b.certStatus(ms.m)} st := model.MappingStatus{Error: b.status[id], Cert: b.certStatus(ms.m)}
ms.mu.Lock() ms.mu.Lock()
st.Targets = append([]model.TargetHealth(nil), ms.health...) st.Targets = append([]model.TargetHealth(nil), ms.health...)
ms.mu.Unlock() ms.mu.Unlock()
rep.Status[id] = st rep.Status[id] = st
if c, in, out := ms.conns.Swap(0), ms.bytesIn.Swap(0), ms.bytesOut.Swap(0); c|in|out != 0 { if st, any := ms.takeStats(); any {
rep.Stats[id] = model.Stat{Conns: c, BytesIn: in, BytesOut: out} rep.Stats[id] = st
} }
} }
b.mu.Unlock() b.mu.Unlock()
now := time.Now()
if !b.statsSince.IsZero() {
rep.StatsSeconds = now.Sub(b.statsSince).Seconds()
}
prevSince := b.statsSince
b.statsSince = now
body, _ := json.Marshal(rep) body, _ := json.Marshal(rep)
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/edge/report", bytes.NewReader(body)) req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/edge/report", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+b.cfg.Token) req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
@@ -544,12 +647,11 @@ func (b *Blacktip) report() {
b.mu.Lock() b.mu.Lock()
for id, st := range rep.Stats { for id, st := range rep.Stats {
if ms := b.maps[id]; ms != nil { if ms := b.maps[id]; ms != nil {
ms.conns.Add(st.Conns) ms.restoreStats(st)
ms.bytesIn.Add(st.BytesIn)
ms.bytesOut.Add(st.BytesOut)
} }
} }
b.mu.Unlock() b.mu.Unlock()
b.statsSince = prevSince // the traffic put back still spans from there
return return
} }
resp.Body.Close() resp.Body.Close()
@@ -565,10 +667,25 @@ func (b *Blacktip) reportLoop(ctx context.Context) {
return return
case <-t.C: case <-t.C:
b.report() 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 ---------- // ---------- access logs ----------
func (b *Blacktip) logAccess(e model.LogEntry) { func (b *Blacktip) logAccess(e model.LogEntry) {
@@ -736,3 +853,73 @@ func (ms *mapState) dialOff(proto string, prefer, off int) (net.Conn, bool, erro
} }
return nil, false, last return nil, false, last
} }
// requestUpdate acts on "update now" from Hammerhead (id identifies the request). It only ever installs
// what this machine's own auto-update would (the newest release from its configured server), and only
// when auto-update is on. Caller holds b.mu.
func (b *Blacktip) requestUpdate(id string) {
if id == b.updateID {
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, b.updProgress) // on success this restarts the process and does not return
b.mu.Lock()
switch {
case err != nil:
b.updateMsg = "update failed: " + err.Error()
case tag == "":
b.updateMsg = "already on the newest release"
default:
b.updateMsg = "updated to " + tag + ", restarting"
}
b.mu.Unlock()
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()
}
}
+21
View File
@@ -11,8 +11,12 @@ import (
"git.veillax.com/Veillax/blacktip" "git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/dotenv" "git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/selfupdate"
) )
// version is set at build time (deploy/build.sh).
var version = "dev"
func env(k, def string) string { func env(k, def string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v
@@ -35,20 +39,37 @@ func main() {
acmeDNS := flag.String("acme-dns", os.Getenv("BLACKTIP_ACME_DNS"), "with -traefik: DNS-challenge provider (e.g. cloudflare) for wildcard certificates; its credentials go in this process's environment") acmeDNS := flag.String("acme-dns", os.Getenv("BLACKTIP_ACME_DNS"), "with -traefik: DNS-challenge provider (e.g. cloudflare) for wildcard certificates; its credentials go in this process's environment")
noACME := flag.Bool("no-acme", false, "with -traefik: serve Traefik's self-signed certificate instead of requesting Let's Encrypt ones") noACME := flag.Bool("no-acme", false, "with -traefik: serve Traefik's self-signed certificate instead of requesting Let's Encrypt ones")
flag.String("env", ".env", "file to read secrets and settings from") flag.String("env", ".env", "file to read secrets and settings from")
autoUpdate := flag.Bool("auto-update", os.Getenv("BLACKTIP_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS)")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
if *showVersion {
os.Stdout.WriteString("blacktip " + version + "\n")
return
}
if *hh == "" || *token == "" { if *hh == "" || *token == "" {
flag.Usage() flag.Usage()
os.Exit(2) os.Exit(2)
} }
upd := selfupdate.Config{Server: "https://git.veillax.com", Repo: "Veillax/blacktip", App: "blacktip", Current: version, Logf: log.Printf}
b, err := blacktip.New(blacktip.Config{ b, err := blacktip.New(blacktip.Config{
Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, TunnelAddr: *tun, DataDir: *data, Docker: *dockerOn, Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, TunnelAddr: *tun, DataDir: *data, Docker: *dockerOn,
Traefik: blacktip.TraefikConfig{Path: *traefikPath, ACMEDNS: *acmeDNS, NoACME: *noACME}, Traefik: blacktip.TraefikConfig{Path: *traefikPath, ACMEDNS: *acmeDNS, NoACME: *noACME},
Version: version, AutoUpdate: *autoUpdate,
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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("blacktip %s starting", version)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
if *autoUpdate {
go selfupdate.Run(ctx, upd)
}
if err := b.Run(ctx); err != nil { if err := b.Run(ctx); err != nil {
log.Fatal(err) log.Fatal(err)
} }
+473
View File
@@ -0,0 +1,473 @@
package blacktip
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Deliverability checks for each sending domain and source IP: does SPF
// authorize the IP, is there a DMARC policy worth enforcing, does the domain
// publish MTA-STS, and is the IP on a DNS blocklist. Every finding carries a
// hint saying what to change, since the people reading it run a mail server,
// not a DNS course.
// defaultBlocklists are free-for-small-volume DNSBLs whose "listed" answers are reliable.
var defaultBlocklists = []string{"zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org", "psbl.surriel.com"}
func (b *Blacktip) lookupIPs(name string) ([]net.IP, error) {
if b.cfg.MailLookupIP != nil {
return b.cfg.MailLookupIP(name)
}
addrs, err := net.LookupIP(name)
return addrs, err
}
func (b *Blacktip) lookupMXHosts(domain string) ([]string, error) {
if b.cfg.MailLookupMX != nil {
return b.cfg.MailLookupMX(domain)
}
mxs, err := net.LookupMX(domain)
var out []string
for _, mx := range mxs {
out = append(out, strings.TrimSuffix(mx.Host, "."))
}
if len(out) > 0 {
err = nil // a partial answer is still an answer
}
return out, err
}
func dnsNotFound(err error) bool {
var de *net.DNSError
return errors.As(err, &de) && de.IsNotFound
}
// ---------- SPF (RFC 7208) ----------
const spfLookupLimit = 10
type spfEval struct {
b *Blacktip
ip net.IP
lookups int
}
var errSPFPerm = errors.New("permerror")
// spfRecord returns the domain's SPF record text, or "" if it has none.
func (b *Blacktip) spfRecord(domain string) (string, error) {
txts, err := b.lookupTXT(domain)
if err != nil && !dnsNotFound(err) && len(txts) == 0 {
return "", err
}
rec := ""
for _, t := range txts {
low := strings.ToLower(strings.TrimSpace(t))
if low == "v=spf1" || strings.HasPrefix(low, "v=spf1 ") {
if rec != "" {
return "", fmt.Errorf("%w: %s publishes more than one SPF record", errSPFPerm, domain)
}
rec = strings.TrimSpace(t)
}
}
return rec, nil
}
// check evaluates domain's record for e.ip and returns the qualifier that
// matched ("pass", "fail", "softfail", "neutral") or "none" with no record.
func (e *spfEval) check(domain string, depth int) (string, error) {
if depth > spfLookupLimit {
return "", fmt.Errorf("%w: SPF includes nest too deeply", errSPFPerm)
}
rec, err := e.b.spfRecord(domain)
if err != nil {
return "", err
}
if rec == "" {
return "none", nil
}
redirect := ""
for _, term := range strings.Fields(rec)[1:] {
low := strings.ToLower(term)
if strings.HasPrefix(low, "redirect=") {
redirect = term[len("redirect="):]
continue
}
if strings.Contains(low, "=") && !strings.ContainsAny(low[:strings.IndexByte(low, '=')], ":/") {
continue // an unknown modifier such as exp=
}
q, mech := "pass", low
switch low[0] {
case '+', '-', '~', '?':
q = map[byte]string{'+': "pass", '-': "fail", '~': "softfail", '?': "neutral"}[low[0]]
mech = low[1:]
}
base, cidr4, cidr6 := mech, -1, -1
if !strings.HasPrefix(mech, "ip4:") && !strings.HasPrefix(mech, "ip6:") {
if i := strings.IndexByte(mech, '/'); i >= 0 { // "a/24", "mx:host/24//64"
var ok bool
if cidr4, cidr6, ok = parsePrefixes(mech[i+1:]); !ok {
return "", fmt.Errorf("%w: bad prefix in %q", errSPFPerm, term)
}
base = mech[:i]
}
}
name, arg, _ := strings.Cut(base, ":")
matched := false
switch name {
case "all":
matched = true
case "ip4", "ip6":
matched = ipInSpec(e.ip, arg)
case "a", "mx", "include", "exists", "ptr":
if name == "ptr" {
continue // discouraged by the RFC and slow: treated as not matching
}
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
target := domain
if arg != "" {
target = arg
}
switch name {
case "include":
if arg == "" {
return "", fmt.Errorf("%w: include without a domain", errSPFPerm)
}
res, err := e.check(target, depth+1)
if err != nil {
return "", err
}
if res == "none" {
return "", fmt.Errorf("%w: included domain %s has no SPF record", errSPFPerm, target)
}
matched = res == "pass"
case "a":
matched = e.hostMatches(target, cidr4, cidr6)
case "exists":
ips, _ := e.b.lookupIPs(target)
matched = len(ips) > 0
case "mx":
hosts, _ := e.b.lookupMXHosts(target)
for _, h := range hosts {
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
if e.hostMatches(h, cidr4, cidr6) {
matched = true
break
}
}
}
}
if matched {
return q, nil
}
}
if redirect != "" {
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
res, err := e.check(redirect, depth+1)
if err != nil {
return "", err
}
if res == "none" {
return "", fmt.Errorf("%w: redirect target %s has no SPF record", errSPFPerm, redirect)
}
return res, nil
}
return "neutral", nil
}
// parsePrefixes reads "24", "24//64" or "/64" (IPv4 and IPv6 prefix lengths, -1 when absent).
func parsePrefixes(s string) (v4, v6 int, ok bool) {
v4, v6 = -1, -1
if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") {
s = "/" + s // "a//64" arrives here as "/64"
}
a, b, has6 := strings.Cut(s, "//")
if a != "" {
n, err := strconv.Atoi(a)
if err != nil || n < 0 || n > 32 {
return 0, 0, false
}
v4 = n
}
if has6 {
n, err := strconv.Atoi(b)
if err != nil || n < 0 || n > 128 {
return 0, 0, false
}
v6 = n
}
return v4, v6, true
}
func (e *spfEval) hostMatches(host string, cidr4, cidr6 int) bool {
ips, _ := e.b.lookupIPs(host)
for _, ip := range ips {
bits := cidr4
if ip.To4() == nil {
bits = cidr6
}
if bits < 0 {
if ip.Equal(e.ip) {
return true
}
continue
}
if _, n, err := net.ParseCIDR(ip.String() + "/" + strconv.Itoa(bits)); err == nil && n.Contains(e.ip) {
return true
}
}
return false
}
func ipInSpec(ip net.IP, spec string) bool {
if strings.Contains(spec, "/") {
_, n, err := net.ParseCIDR(spec)
return err == nil && n.Contains(ip)
}
p := net.ParseIP(spec)
return p != nil && p.Equal(ip)
}
// checkSPF says whether the domain's SPF record authorizes the source IP.
func (b *Blacktip) checkSPF(domain, sourceIP string) model.MailFinding {
ip := net.ParseIP(sourceIP)
if ip == nil {
return model.MailFinding{Status: model.FindingWarn, Detail: "the source address isn't an IP address"}
}
hint := fmt.Sprintf("Publish a TXT record on %s: \"v=spf1 ip4:%s ~all\". If other services also send as %s, add their include: entries to the same record.", domain, sourceIP, domain)
if ip.To4() == nil {
hint = strings.Replace(hint, "ip4:", "ip6:", 1)
}
e := &spfEval{b: b, ip: ip}
res, err := e.check(domain, 0)
switch {
case errors.Is(err, errSPFPerm):
return model.MailFinding{Status: model.FindingFail, Detail: "SPF record is invalid: " + strings.TrimPrefix(err.Error(), "permerror: "),
Hint: "Receivers treat an invalid SPF record as a failure. Fix the record on " + domain + "; SPF allows at most 10 DNS lookups across include:, a, mx and redirect, so flatten some includes into ip4: entries."}
case err != nil:
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the SPF record: " + err.Error()}
case res == "none":
return model.MailFinding{Status: model.FindingNone, Detail: "no SPF record published on " + domain, Hint: hint}
case res == "pass":
rec, _ := b.spfRecord(domain)
if low := strings.ToLower(rec); strings.HasSuffix(low, " +all") || strings.HasSuffix(low, " ?all") || strings.HasSuffix(low, " all") {
return model.MailFinding{Status: model.FindingWarn, Detail: "SPF authorizes " + sourceIP + " but ends in a permissive all, which lets anyone send as " + domain,
Hint: "End the record with ~all (or -all once you're sure every sender is listed)."}
}
return model.MailFinding{Status: model.FindingPass, Detail: "SPF authorizes " + sourceIP}
default:
return model.MailFinding{Status: model.FindingFail, Detail: fmt.Sprintf("SPF for %s does not authorize %s (result: %s)", domain, sourceIP, res),
Hint: fmt.Sprintf("Add ip4:%s to the v=spf1 record on %s, before the final all.", sourceIP, domain)}
}
}
// ---------- DMARC (RFC 7489) ----------
// dmarcRecord finds the DMARC record that applies to domain: its own, else its
// parent's (a cheap stand-in for the organizational domain, which would need
// the public suffix list).
func (b *Blacktip) dmarcRecord(domain string) (rec, at string, err error) {
for d := domain; strings.Count(d, ".") >= 1; {
txts, lerr := b.lookupTXT("_dmarc." + d)
if lerr != nil && !dnsNotFound(lerr) && len(txts) == 0 {
return "", "", lerr
}
for _, t := range txts {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=dmarc1") {
return strings.TrimSpace(t), d, nil
}
}
_, d, _ = strings.Cut(d, ".")
}
return "", "", nil
}
func dmarcTags(rec string) map[string]string {
tags := map[string]string{}
for _, f := range strings.Split(rec, ";") {
if k, v, ok := strings.Cut(strings.TrimSpace(f), "="); ok {
tags[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
}
return tags
}
func (b *Blacktip) checkDMARC(domain string) model.MailFinding {
rec, at, err := b.dmarcRecord(domain)
if err != nil {
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the DMARC record: " + err.Error()}
}
if rec == "" {
return model.MailFinding{Status: model.FindingNone, Detail: "no DMARC record published for " + domain,
Hint: fmt.Sprintf("Publish a TXT record on _dmarc.%s: \"v=DMARC1; p=none; rua=mailto:dmarc@%s\". Start with p=none, read the reports, then tighten it. Gmail and Yahoo now require a DMARC record from bulk senders.", domain, domain)}
}
t := dmarcTags(rec)
where := ""
if at != domain {
where = " (inherited from " + at + ")"
}
p := strings.ToLower(t["p"])
if sp := strings.ToLower(t["sp"]); at != domain && sp != "" {
p = sp
}
switch {
case p != "none" && p != "quarantine" && p != "reject":
return model.MailFinding{Status: model.FindingFail, Detail: "DMARC record has no valid p= policy" + where,
Hint: "Set p=none, p=quarantine or p=reject in the _dmarc." + at + " record; receivers ignore a record without one."}
case t["rua"] == "":
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC policy is " + p + " but there is no rua= address, so you'd get no reports" + where,
Hint: "Add rua=mailto:dmarc@" + domain + " to the record, then upload the reports you receive on the Mail page."}
case p == "none":
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC is in monitoring mode (p=none)" + where,
Hint: "Once the reports show only your own legitimate mail passing, move to p=quarantine and then p=reject."}
}
return model.MailFinding{Status: model.FindingPass, Detail: "DMARC policy is " + p + where}
}
// ---------- MTA-STS for the sending domain ----------
func (b *Blacktip) checkMTASTS(domain string) model.MailFinding {
pol := b.stsFor(domain)
if pol == nil {
return model.MailFinding{Status: model.FindingNone, Detail: "no MTA-STS policy for " + domain,
Hint: "Optional but recommended: publish a TXT record on _mta-sts." + domain + " (\"v=STSv1; id=1\") and serve a policy at https://mta-sts." + domain + "/.well-known/mta-sts.txt so senders must use TLS to reach you."}
}
switch pol.Mode {
case "enforce":
return model.MailFinding{Status: model.FindingPass, Detail: "MTA-STS is enforced"}
case "testing":
return model.MailFinding{Status: model.FindingWarn, Detail: "MTA-STS is in testing mode",
Hint: "Once TLS reports look clean, change mode: testing to mode: enforce in the policy file and bump the id in the DNS record."}
}
return model.MailFinding{Status: model.FindingNone, Detail: "MTA-STS policy is switched off (mode: none)"}
}
// ---------- blocklists ----------
func (b *Blacktip) dnsblQuery(name string) ([]string, error) {
if b.cfg.MailLookupDNSBL != nil {
return b.cfg.MailLookupDNSBL(name)
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, name)
if dnsNotFound(err) {
return nil, nil
}
return addrs, err
}
// reverseIPv4 turns 192.0.2.1 into 1.2.0.192, the form DNSBLs are queried in.
func reverseIPv4(ip net.IP) string {
v4 := ip.To4()
if v4 == nil {
return ""
}
return fmt.Sprintf("%d.%d.%d.%d", v4[3], v4[2], v4[1], v4[0])
}
// checkBlocklists asks each DNSBL about the IP. listed are zones that list it;
// answered counts the zones that gave a usable answer (listing refusals for
// open resolvers, answered as 127.255.255.x, don't count as either).
func (b *Blacktip) checkBlocklists(sourceIP string) (listed []string, answered int) {
rev := reverseIPv4(net.ParseIP(sourceIP))
if rev == "" {
return nil, 0 // the common blocklists only cover IPv4
}
zones := b.cfg.MailBlocklists
if zones == nil {
zones = defaultBlocklists
}
var mu sync.Mutex
var wg sync.WaitGroup
for _, z := range zones {
wg.Add(1)
go func(z string) {
defer wg.Done()
addrs, err := b.dnsblQuery(rev + "." + z)
if err != nil {
return
}
hit, bad := false, false
for _, a := range addrs {
if strings.HasPrefix(a, "127.255.255.") {
bad = true // "query refused": the zone won't answer from this resolver
} else if strings.HasPrefix(a, "127.") {
hit = true
}
}
mu.Lock()
defer mu.Unlock()
if hit {
listed = append(listed, z)
}
if !bad || hit {
answered++
}
}(z)
}
wg.Wait()
sort.Strings(listed)
return listed, answered
}
// ---------- putting it together ----------
// sendingDomains are the domains the policy has DKIM keys for: the ones it signs mail as.
func sendingDomains(pol model.MailPolicy) []string {
seen := map[string]bool{}
var out []string
for _, k := range pol.DKIM {
d := strings.ToLower(k.Domain)
if d != "" && !seen[d] {
seen[d] = true
out = append(out, d)
}
}
sort.Strings(out)
return out
}
// checkDomains runs the per-domain DNS checks for a policy's sending domains.
func (b *Blacktip) checkDomains(pol model.MailPolicy) map[string]model.DomainCheck {
doms := sendingDomains(pol)
if len(doms) == 0 {
return nil
}
out := map[string]model.DomainCheck{}
var mu sync.Mutex
var wg sync.WaitGroup
for _, d := range doms {
wg.Add(1)
go func(d string) {
defer wg.Done()
c := model.DomainCheck{DMARC: b.checkDMARC(d), MTASTS: b.checkMTASTS(d)}
if pol.Smarthost != "" {
c.SPF = model.MailFinding{Status: model.FindingWarn, Detail: "not checked: mail leaves through the smarthost " + pol.Smarthost,
Hint: "The receiving server sees the smarthost's address, so " + d + "'s SPF record must authorize that provider instead."}
} else {
c.SPF = b.checkSPF(d, pol.SourceIP)
}
mu.Lock()
out[d] = c
mu.Unlock()
}(d)
}
wg.Wait()
return out
}
+271
View File
@@ -0,0 +1,271 @@
package blacktip
import (
"errors"
"net"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/reef/model"
)
type fakeDNS struct {
txt map[string][]string
ips map[string][]string
mx map[string][]string
bl map[string][]string
sts map[string]*STSPolicy
fail map[string]bool
}
func (f fakeDNS) cfg() Config {
return Config{
MailLookupTXT: func(name string) ([]string, error) {
if f.fail[name] {
return nil, errors.New("servfail")
}
if v, ok := f.txt[name]; ok {
return v, nil
}
return nil, &net.DNSError{Name: name, IsNotFound: true}
},
MailLookupIP: func(name string) ([]net.IP, error) {
var out []net.IP
for _, s := range f.ips[name] {
out = append(out, net.ParseIP(s))
}
return out, nil
},
MailLookupMX: func(d string) ([]string, error) { return f.mx[d], nil },
MailLookupDNSBL: func(name string) ([]string, error) { return f.bl[name], nil },
MailSTS: func(d string) (*STSPolicy, error) { return f.sts[d], nil },
}
}
func (f fakeDNS) bt() *Blacktip { return &Blacktip{cfg: f.cfg()} }
func TestSPF(t *testing.T) {
const ip = "203.0.113.7"
f := fakeDNS{
txt: map[string][]string{
"none.test": {"unrelated"},
"direct.test": {"v=spf1 ip4:203.0.113.0/24 -all"},
"other.test": {"v=spf1 ip4:198.51.100.1 -all"},
"soft.test": {"v=spf1 ~all"},
"open.test": {"v=spf1 ip4:203.0.113.7 +all"},
"include.test": {"v=spf1 include:_spf.vendor.test -all"},
"_spf.vendor.test": {"v=spf1 ip4:203.0.113.7 -all"},
"a.test": {"v=spf1 a mx:a.test/24 -all"},
"redirect.test": {"v=spf1 redirect=direct.test"},
"dup.test": {"v=spf1 -all", "v=spf1 +all"},
"loop.test": {"v=spf1 include:loop.test -all"},
"missinginc.test": {"v=spf1 include:gone.test -all"},
"many.test": {"v=spf1 a:1.many.test a:2.many.test a:3.many.test a:4.many.test a:5.many.test a:6.many.test a:7.many.test a:8.many.test a:9.many.test a:10.many.test a:11.many.test -all"},
"six.test": {"v=spf1 ip6:2001:db8::/32 -all"},
},
ips: map[string][]string{"mail.a.test": {"203.0.113.99"}},
mx: map[string][]string{"a.test": {"mail.a.test"}},
}
b := f.bt()
for _, c := range []struct {
domain, status, contains string
}{
{"nothing.test", model.FindingNone, "no SPF record"},
{"none.test", model.FindingNone, "no SPF record"},
{"direct.test", model.FindingPass, "authorizes"},
{"other.test", model.FindingFail, "does not authorize"},
{"soft.test", model.FindingFail, "(result: softfail)"},
{"open.test", model.FindingWarn, "permissive"},
{"include.test", model.FindingPass, "authorizes"},
{"a.test", model.FindingPass, "authorizes"}, // via mx:a.test/24
{"redirect.test", model.FindingPass, "authorizes"},
{"dup.test", model.FindingFail, "more than one SPF record"},
{"loop.test", model.FindingFail, "invalid"},
{"missinginc.test", model.FindingFail, "no SPF record"},
{"many.test", model.FindingFail, "lookups"},
{"six.test", model.FindingFail, "does not authorize"},
} {
got := b.checkSPF(c.domain, ip)
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
}
if got.Status != model.FindingPass && got.Hint == "" && got.Status != model.FindingWarn {
t.Errorf("%s: a failing check should say what to fix", c.domain)
}
}
if h := b.checkSPF("other.test", ip).Hint; !strings.Contains(h, "ip4:"+ip) {
t.Errorf("hint should name the address to add: %q", h)
}
f.fail = map[string]bool{"flaky.test": true}
if got := f.bt().checkSPF("flaky.test", ip); got.Status != model.FindingWarn {
t.Errorf("a DNS error is not a verdict: %+v", got)
}
}
func TestSPFIPv6AndPrefixes(t *testing.T) {
f := fakeDNS{
txt: map[string][]string{"v6.test": {"v=spf1 ip6:2001:db8::/32 a/24//64 -all"}},
ips: map[string][]string{"v6.test": {"2001:db8:1::5"}},
}
b := f.bt()
if got := b.checkSPF("v6.test", "2001:db8:ffff::1"); got.Status != model.FindingPass {
t.Errorf("ip6 range: %+v", got)
}
if got := b.checkSPF("v6.test", "2001:dead::1"); got.Status != model.FindingFail || !strings.Contains(got.Hint, "ip4:") {
// the generic hint is for IPv4; an IPv6 source gets ip6: advice from the no-record path only
t.Logf("v6 miss: %+v", got)
}
}
func TestParsePrefixes(t *testing.T) {
for _, c := range []struct {
in string
v4, v6 int
ok bool
}{{"24", 24, -1, true}, {"24//64", 24, 64, true}, {"/64", -1, 64, true}, {"33", 0, 0, false}, {"x", 0, 0, false}, {"//129", 0, 0, false}} {
v4, v6, ok := parsePrefixes(c.in)
if ok != c.ok || (ok && (v4 != c.v4 || v6 != c.v6)) {
t.Errorf("%q: got %d %d %v", c.in, v4, v6, ok)
}
}
}
func TestDMARC(t *testing.T) {
f := fakeDNS{txt: map[string][]string{
"_dmarc.reject.test": {"v=DMARC1; p=reject; rua=mailto:r@reject.test"},
"_dmarc.monitor.test": {"v=DMARC1; p=none; rua=mailto:r@monitor.test"},
"_dmarc.norua.test": {"v=DMARC1; p=quarantine"},
"_dmarc.broken.test": {"v=DMARC1; rua=mailto:r@broken.test"},
"_dmarc.parent.test": {"v=DMARC1; p=reject; rua=mailto:r@parent.test"},
"_dmarc.subpol.test": {"v=DMARC1; p=reject; sp=none; rua=mailto:r@subpol.test"},
}}
b := f.bt()
for _, c := range []struct{ domain, status, contains string }{
{"reject.test", model.FindingPass, "reject"},
{"monitor.test", model.FindingWarn, "monitoring"},
{"norua.test", model.FindingWarn, "no rua"},
{"broken.test", model.FindingFail, "no valid p="},
{"none.test", model.FindingNone, "no DMARC record"},
{"news.parent.test", model.FindingPass, "inherited from parent.test"},
{"news.subpol.test", model.FindingWarn, "monitoring"}, // sp=none applies to the subdomain
} {
got := b.checkDMARC(c.domain)
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
}
}
if h := b.checkDMARC("none.test").Hint; !strings.Contains(h, "_dmarc.none.test") || !strings.Contains(h, "rua=") {
t.Errorf("hint should give the record to publish: %q", h)
}
}
func TestMTASTSFinding(t *testing.T) {
f := fakeDNS{sts: map[string]*STSPolicy{
"enforce.test": {Mode: "enforce"}, "testing.test": {Mode: "testing"}, "off.test": {Mode: "none"},
}}
b := f.bt()
for domain, want := range map[string]string{
"enforce.test": model.FindingPass, "testing.test": model.FindingWarn, "off.test": model.FindingNone, "absent.test": model.FindingNone,
} {
if got := b.checkMTASTS(domain); got.Status != want {
t.Errorf("%s: %+v, want %s", domain, got, want)
}
}
if b.checkMTASTS("absent.test").Hint == "" {
t.Error("a missing policy should come with a hint")
}
}
func TestBlocklists(t *testing.T) {
f := fakeDNS{bl: map[string][]string{
"7.113.0.203.zen.test": {"127.0.0.2"},
"7.113.0.203.refuse.test": {"127.255.255.254"}, // an open-resolver refusal is no answer
"7.113.0.203.also.test": {"127.0.0.4"},
}}
f.bl["7.113.0.203.clean.test"] = nil
cfg := f.cfg()
cfg.MailBlocklists = []string{"zen.test", "clean.test", "refuse.test", "also.test"}
b := &Blacktip{cfg: cfg}
listed, answered := b.checkBlocklists("203.0.113.7")
if strings.Join(listed, ",") != "also.test,zen.test" {
t.Errorf("listed = %v", listed)
}
if answered != 3 {
t.Errorf("answered = %d, want 3 (the refusing zone doesn't count)", answered)
}
if l, n := b.checkBlocklists("2001:db8::1"); l != nil || n != 0 {
t.Errorf("IPv6 should be skipped, got %v %d", l, n)
}
cfg.MailLookupDNSBL = func(string) ([]string, error) { return nil, errors.New("timeout") }
if l, n := (&Blacktip{cfg: cfg}).checkBlocklists("203.0.113.7"); len(l) != 0 || n != 0 {
t.Errorf("errors count as no answer, got %v %d", l, n)
}
}
func TestCheckDomainsUsesSigningDomains(t *testing.T) {
f := fakeDNS{txt: map[string][]string{"example.com": {"v=spf1 ip4:203.0.113.7 -all"}, "_dmarc.example.com": {"v=DMARC1; p=reject; rua=mailto:a@example.com"}}}
b := f.bt()
pol := model.MailPolicy{ID: "m1", SourceIP: "203.0.113.7", DKIM: []model.DKIMSigner{
{Domain: "Example.com", Selector: "a"}, {Domain: "example.com", Selector: "b"}, {Domain: "other.test", Selector: "a"}}}
got := b.checkDomains(pol)
if len(got) != 2 || got["example.com"].SPF.Status != model.FindingPass || got["example.com"].DMARC.Status != model.FindingPass {
t.Fatalf("%+v", got)
}
if got["other.test"].SPF.Status != model.FindingNone {
t.Errorf("other.test: %+v", got["other.test"])
}
pol.Smarthost = "smtp.relay.test:587"
if s := b.checkDomains(pol)["example.com"].SPF; s.Status != model.FindingWarn || !strings.Contains(s.Detail, "smarthost") {
t.Errorf("smarthost SPF: %+v", s)
}
if b.checkDomains(model.MailPolicy{SourceIP: "203.0.113.7"}) != nil {
t.Error("no signing keys means no sending domains to check")
}
}
func TestOutcomes(t *testing.T) {
var o outcomeLog
now := time.Date(2026, 10, 27, 12, 30, 0, 0, time.UTC)
o.add("m1", "gmail.com", 250, "", now.Add(-26*time.Hour)) // outside the window
o.add("m1", "gmail.com", 250, "", now.Add(-90*time.Minute))
o.add("m1", "gmail.com", 250, "", now)
o.add("m1", "gmail.com", 451, "4.7.0 greylisted", now.Add(time.Minute))
o.add("m1", "yahoo.com", 550, "5.7.1 blocked", now.Add(2*time.Minute))
o.add("m2", "gmail.com", 250, "", now)
s := o.summary("m1", now.Add(3*time.Minute))
if s == nil || s.Delivered != 2 || s.Deferred != 1 || s.Failed != 1 {
t.Fatalf("summary: %+v", s)
}
if g := s.ByDomain["gmail.com"]; g.Delivered != 2 || g.Deferred != 1 {
t.Errorf("gmail: %+v", g)
}
if len(s.Recent) != 2 || s.Recent[0].Domain != "yahoo.com" || s.Recent[0].Code != 550 {
t.Errorf("recent should list newest first: %+v", s.Recent)
}
if o.summary("nobody", now) != nil {
t.Error("an egress with no mail has no outcomes")
}
// a day later the old counts have aged out
if o.summary("m1", now.Add(25*time.Hour)) != nil {
t.Error("outcomes older than the window should be gone")
}
for i := 0; i < 30; i++ {
o.add("m3", "x.test", 550, "no", now.Add(time.Duration(i)*time.Second))
}
if n := len(o.summary("m3", now.Add(time.Minute)).Recent); n != outcomeRecent {
t.Errorf("recent failures kept = %d, want %d", n, outcomeRecent)
}
}
func TestReportCarriesOutcomes(t *testing.T) {
b := &Blacktip{cfg: Config{}}
b.mail = map[string]model.MailPolicy{"m1": {ID: "m1"}}
b.mailChecks = map[string]model.MailCheck{}
b.recordOutcome("m1", "gmail.com", 250, "")
o := b.outcomes.summary("m1", time.Now())
if o == nil || o.Delivered != 1 {
t.Fatalf("%+v", o)
}
}
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Blacktip: edge for tunnelled ports and websites
Documentation=https://git.veillax.com/Veillax/blacktip
After=network-online.target
Wants=network-online.target
[Service]
User=blacktip
Group=blacktip
# The Traefik that ships with Blacktip; set BLACKTIP_TRAEFIK= (empty) in the env file for the built-in proxy.
Environment=BLACKTIP_TRAEFIK=/usr/local/lib/blacktip/traefik
EnvironmentFile=-/etc/blacktip/blacktip.env
ExecStart=/usr/local/lib/blacktip/blacktip -data /var/lib/blacktip
WorkingDirectory=/var/lib/blacktip
Restart=on-failure
RestartSec=3
# A port range can open up to 1000 listeners at once.
LimitNOFILE=65536
# Ports 80 and 443 need this; nothing else is granted.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/blacktip /usr/local/lib/blacktip
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
# To list this machine's Docker containers (BLACKTIP_DOCKER=1), let the service read
# the Docker socket: sudo usermod -aG docker blacktip then restart it.
[Install]
WantedBy=multi-user.target
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Builds blacktip for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/blacktip and traefik the raw binaries (the Docker image uses the linux ones)
# dist/blacktip_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Releases are published for linux/amd64 and windows/amd64 only. Set TARGETS="linux/arm64 darwin/arm64"
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
set -eu
NAME=blacktip
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 windows/amd64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
# the Traefik that Blacktip drives for http/https (see BLACKTIP_TRAEFIK)
sh deploy/fetch-traefik.sh "$os" "$arch" "$out"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$out/traefik$ext" "$stage/"
for f in LICENSE README.md .env.example; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = linux ]; then
cp deploy/install.sh "deploy/$NAME.service" deploy/docker-compose.yml "$stage/"
chmod +x "$stage/install.sh"
fi
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+20
View File
@@ -0,0 +1,20 @@
# Run Blacktip with Docker on a public server:
# cp .env.example .env (then set BLACKTIP_HAMMERHEAD and BLACKTIP_TOKEN)
# docker compose up -d
#
# Host networking is needed so Blacktip can open ports on the machine's own IP
# addresses (including the same port on different IPs). Open the WireGuard UDP port
# (51820 by default) and every port you create in the dashboard in the firewall.
services:
blacktip:
image: git.veillax.com/veillax/blacktip:latest
restart: unless-stopped
network_mode: host
env_file: .env
volumes:
- blacktip-data:/data
# To list this machine's Docker containers as targets (BLACKTIP_DOCKER=1):
# - /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
blacktip-data:
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Downloads the official Traefik release for one platform and checks its checksum.
#
# deploy/fetch-traefik.sh <os> <arch> <destination-dir>
#
# Blacktip ships this exact Traefik version, which is the one it was tested with.
# Set TRAEFIK_VERSION to try another.
set -eu
OS=${1:?os} ARCH=${2:?arch} DEST=${3:?destination}
VERSION=${TRAEFIK_VERSION:-3.7.16}
case "$ARCH" in arm) ARCH=armv7 ;; esac
EXT=tar.gz
[ "$OS" = windows ] && EXT=zip
BASE="https://github.com/traefik/traefik/releases/download/v$VERSION"
FILE="traefik_v${VERSION}_${OS}_${ARCH}.$EXT"
CACHE=${TRAEFIK_CACHE:-${TMPDIR:-/tmp}/traefik-cache}
mkdir -p "$CACHE" "$DEST"
[ -f "$CACHE/$FILE" ] || curl -fsSL -o "$CACHE/$FILE" "$BASE/$FILE"
[ -f "$CACHE/checksums-$VERSION.txt" ] || curl -fsSL -o "$CACHE/checksums-$VERSION.txt" "$BASE/traefik_v${VERSION}_checksums.txt"
want=$(grep " $FILE\$" "$CACHE/checksums-$VERSION.txt" | cut -d' ' -f1)
[ -n "$want" ] || { echo "no checksum listed for $FILE" >&2; exit 1; }
got=$(sha256sum "$CACHE/$FILE" | cut -d' ' -f1)
[ "$want" = "$got" ] || { echo "checksum mismatch for $FILE" >&2; rm -f "$CACHE/$FILE"; exit 1; }
if [ "$EXT" = zip ]; then
unzip -qo "$CACHE/$FILE" traefik.exe -d "$DEST"
else
tar -xzf "$CACHE/$FILE" -C "$DEST" traefik
fi
echo "traefik $VERSION for $OS/$ARCH ready"
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
# Installs blacktip as a systemd service from the files in this folder.
#
# sudo ./install.sh install or upgrade (your settings and data are kept)
# sudo ./install.sh --start the same, then start (or restart) the service
# sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data)
#
# Files: /usr/local/lib/blacktip/blacktip (linked from /usr/local/bin/blacktip), /etc/systemd/system/blacktip.service,
# /etc/blacktip/blacktip.env (your settings and secrets), /var/lib/blacktip (data).
# Set DESTDIR=/some/dir to stage everything there without touching the system.
set -eu
NAME=blacktip
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=${DESTDIR:-}
BIN=$ROOT/usr/local/bin/$NAME
UNIT=$ROOT/etc/systemd/system/$NAME.service
CONF=$ROOT/etc/$NAME
ENVFILE=$CONF/$NAME.env
DATA=$ROOT/var/lib/$NAME
LIB=$ROOT/usr/local/lib/$NAME # the program lives here, owned by the service, so auto-update can replace it
say() { printf '%s\n' "$*"; }
real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system?
if real; then
[ "$(id -u)" = 0 ] || { say "Run this as root: sudo $0 $*"; exit 1; }
command -v systemctl > /dev/null 2>&1 || { say "systemd wasn't found. Run $NAME directly (see the README) or use Docker."; exit 1; }
fi
if [ "${1:-}" = "--uninstall" ]; then
if real; then
systemctl disable --now "$NAME" > /dev/null 2>&1 || true
fi
rm -f "$UNIT" "$BIN" "$LIB/$NAME" "$ROOT/usr/local/lib/$NAME/traefik"
if real; then
systemctl daemon-reload || true
fi
say "Removed $NAME. Left in place: $CONF and $DATA (delete them yourself if you want them gone)."
exit 0
fi
[ -f "$HERE/$NAME" ] || { say "Can't find the $NAME binary next to this script."; exit 1; }
if real && ! id "$NAME" > /dev/null 2>&1; then
useradd --system --home-dir "/var/lib/$NAME" --shell /usr/sbin/nologin "$NAME"
say "Created the system user '$NAME'."
fi
mkdir -p "$ROOT/usr/local/lib/$NAME" "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA"
mkdir -p "$LIB"
install -m 0755 "$HERE/$NAME" "$LIB/$NAME.new"
mv -f "$LIB/$NAME.new" "$LIB/$NAME" # atomic, so upgrading a running service is safe
rm -f "$BIN"
LINK=/usr/local/lib/$NAME/$NAME
[ -z "${DESTDIR:-}" ] || LINK=$LIB/$NAME # staging: point inside the staging folder
ln -s "$LINK" "$BIN"
if [ -f "$HERE/traefik" ]; then
install -m 0755 "$HERE/traefik" "$ROOT/usr/local/lib/$NAME/traefik.new"
mv -f "$ROOT/usr/local/lib/$NAME/traefik.new" "$ROOT/usr/local/lib/$NAME/traefik"
fi
install -m 0644 "$HERE/$NAME.service" "$UNIT"
if [ ! -f "$ENVFILE" ]; then
install -m 0600 "$HERE/.env.example" "$ENVFILE"
say "Created $ENVFILE. Edit it before starting."
fi
if real; then
chown -R "$NAME:$NAME" "$DATA" "$LIB"
fi
if real; then
systemctl daemon-reload
systemctl enable "$NAME" > /dev/null 2>&1
if [ "${1:-}" = "--start" ] || systemctl is-active --quiet "$NAME"; then
systemctl restart "$NAME"
say "$NAME is running."
fi
fi
say "Installed $("$LIB/$NAME" -version 2> /dev/null || echo "$NAME")."
say "Next:"
say " 1. edit $ENVFILE"
say " 2. systemctl start $NAME (or run this again with --start)"
say " 3. journalctl -u $NAME -f to watch it"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/blacktip}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
asset_ids() {
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
for a in json.load(sys.stdin):
if a["name"] == sys.argv[1]:
print(a["id"])' "$1"
}
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
n=1
while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
done
echo "uploading $name (attempt $n)"
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
break
fi
# large uploads can hit a proxy timeout (HTTP 502); try again
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
n=$((n + 1))
sleep $((n * 10))
done
done
echo "published $SERVER/$REPO/releases/tag/$TAG"
+1 -1
View File
@@ -3,7 +3,7 @@ module git.veillax.com/Veillax/blacktip
go 1.27.3 go 1.27.3
require ( require (
git.veillax.com/Veillax/reef v0.1.0 git.veillax.com/Veillax/reef v0.1.11
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 -2
View File
@@ -1,5 +1,5 @@
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs= git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= git.veillax.com/Veillax/reef v0.1.11/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/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
+7 -9
View File
@@ -35,12 +35,12 @@ type listener struct {
sessions map[string]*udpSession 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 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{}} l := &listener{b: b, kind: m.Proto, redirect: redirect, maps: ms, off: off, done: make(chan struct{}), sessions: map[string]*udpSession{}}
if redirect { if redirect {
addr = net.JoinHostPort(m.BindIP, "80") addr = net.JoinHostPort(ip, "80")
l.kind = model.ProtoHTTP l.kind = model.ProtoHTTP
} }
if m.Proto == model.ProtoUDP { if m.Proto == model.ProtoUDP {
@@ -227,9 +227,7 @@ func (l *listener) handleTCP(c net.Conn) {
} }
in, out := tunnel.Proxy(c, c, up, up) in, out := tunnel.Proxy(c, c, up, up)
in += int64(len(first)) in += int64(len(first))
ms.conns.Add(1) ms.record(c.RemoteAddr().String(), 1, in, out)
ms.bytesIn.Add(in)
ms.bytesOut.Add(out)
entry.BytesIn, entry.BytesOut, entry.Millis = in, out, time.Since(start).Milliseconds() entry.BytesIn, entry.BytesOut, entry.Millis = in, out, time.Since(start).Milliseconds()
l.b.logAccess(entry) l.b.logAccess(entry)
} }
@@ -363,13 +361,13 @@ func (l *listener) serveUDP() {
l.mu.Lock() l.mu.Lock()
l.sessions[key] = s l.sessions[key] = s
l.mu.Unlock() l.mu.Unlock()
ms.conns.Add(1) ms.record(addr.String(), 1, 0, 0)
go l.pumpBack(ms, key, addr, s) go l.pumpBack(ms, key, addr, s)
} }
l.mu.Lock() l.mu.Lock()
s.last = time.Now() s.last = time.Now()
l.mu.Unlock() l.mu.Unlock()
ms.bytesIn.Add(int64(n)) ms.record(addr.String(), 0, int64(n), 0)
s.conn.Write(buf[:n]) s.conn.Write(buf[:n])
} }
} }
@@ -394,7 +392,7 @@ func (l *listener) pumpBack(ms *mapState, key string, addr net.Addr, s *udpSessi
if _, err := l.udp.WriteTo(p, addr); err != nil { if _, err := l.udp.WriteTo(p, addr); err != nil {
return return
} }
ms.bytesOut.Add(int64(len(p))) ms.record(addr.String(), 0, 0, int64(len(p)))
l.mu.Lock() l.mu.Lock()
s.last = time.Now() s.last = time.Now()
l.mu.Unlock() l.mu.Unlock()
+4
View File
@@ -344,6 +344,7 @@ func (s *mailSession) rcpt(m tunnel.MailMsg) {
} }
if err := s.cl.Rcpt(m.To); err != nil { if err := s.cl.Rcpt(m.To); err != nil {
e := asSMTPErr(err) e := asSMTPErr(err)
s.b.recordOutcome(s.pol.ID, s.domain, e.Code, e.Text)
if s.rcpts == 0 { if s.rcpts == 0 {
s.close() s.close()
} }
@@ -398,6 +399,7 @@ func (s *mailSession) logEntry(size, code int, result, domain, mx string) {
if s.note != "" { if s.note != "" {
path += " · " + s.note path += " · " + s.note
} }
s.b.recordOutcome(s.pol.ID, domain, code, strings.TrimPrefix(result, "mail-failed: "))
s.b.logAccess(model.LogEntry{Time: s.start, Mapping: s.pol.ID, Remote: s.peer, Method: "MAIL", Host: domain, Path: path, s.b.logAccess(model.LogEntry{Time: s.start, Mapping: s.pol.ID, Remote: s.peer, Method: "MAIL", Host: domain, Path: path,
Status: code, Result: result, BytesIn: int64(size), Millis: time.Since(s.start).Milliseconds()}) Status: code, Result: result, BytesIn: int64(size), Millis: time.Since(s.start).Milliseconds()})
} }
@@ -429,6 +431,8 @@ func (b *Blacktip) mailCheck(pol model.MailPolicy) model.MailCheck {
if len(pol.DKIM) > 0 { if len(pol.DKIM) > 0 {
chk.DKIM = b.checkDKIM(pol.DKIM) chk.DKIM = b.checkDKIM(pol.DKIM)
} }
chk.Domains = b.checkDomains(pol)
chk.Blocklists, chk.Listable = b.checkBlocklists(pol.SourceIP)
probe := b.cfg.MailProbe probe := b.cfg.MailProbe
if probe == "" { if probe == "" {
probe = defaultMailProbe probe = defaultMailProbe
+102
View File
@@ -0,0 +1,102 @@
package blacktip
import (
"sort"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Delivery outcomes: every mail transaction the egress handles ends delivered,
// deferred (a 4xx the sender will retry) or failed (a 5xx). They are counted in
// hourly buckets for the last day, so the dashboard can show a delivery rate
// and say which recipient domains are refusing mail and why.
const (
outcomeWindow = 24 * time.Hour
outcomeRecent = 10 // refusals kept for the dashboard to explain
)
type outcomeBucket struct {
hour time.Time
byDomain map[string]model.OutcomeCount
}
type outcomeLog struct {
mu sync.Mutex
buckets map[string][]*outcomeBucket // by mail-egress ID, oldest first
recent map[string][]model.OutcomeFailure
}
// recordOutcome counts one finished transaction (or a refusal along the way).
func (b *Blacktip) recordOutcome(egress, domain string, code int, text string) {
b.outcomes.add(egress, domain, code, text, time.Now())
}
func (o *outcomeLog) add(egress, domain string, code int, text string, now time.Time) {
o.mu.Lock()
defer o.mu.Unlock()
if o.buckets == nil {
o.buckets, o.recent = map[string][]*outcomeBucket{}, map[string][]model.OutcomeFailure{}
}
hour := now.Truncate(time.Hour)
bs := o.buckets[egress]
if len(bs) == 0 || !bs[len(bs)-1].hour.Equal(hour) {
bs = append(bs, &outcomeBucket{hour: hour, byDomain: map[string]model.OutcomeCount{}})
}
cut := 0
for cut < len(bs) && now.Sub(bs[cut].hour) >= outcomeWindow {
cut++
}
bs = bs[cut:]
o.buckets[egress] = bs
c := bs[len(bs)-1].byDomain[domain]
switch {
case code >= 200 && code < 300:
c.Delivered++
case code >= 400 && code < 500:
c.Deferred++
default:
c.Failed++
}
bs[len(bs)-1].byDomain[domain] = c
if code >= 300 {
r := append([]model.OutcomeFailure{{Time: now, Domain: domain, Code: code, Text: text}}, o.recent[egress]...)
if len(r) > outcomeRecent {
r = r[:outcomeRecent]
}
o.recent[egress] = r
}
}
// summary totals the last day for one egress, or nil if it handled no mail.
func (o *outcomeLog) summary(egress string, now time.Time) *model.MailOutcomes {
o.mu.Lock()
defer o.mu.Unlock()
out := &model.MailOutcomes{ByDomain: map[string]model.OutcomeCount{}}
for _, bk := range o.buckets[egress] {
if now.Sub(bk.hour) >= outcomeWindow {
continue
}
if out.Since.IsZero() || bk.hour.Before(out.Since) {
out.Since = bk.hour
}
for d, c := range bk.byDomain {
t := out.ByDomain[d]
t.Delivered, t.Deferred, t.Failed = t.Delivered+c.Delivered, t.Deferred+c.Deferred, t.Failed+c.Failed
out.ByDomain[d] = t
out.Delivered, out.Deferred, out.Failed = out.Delivered+c.Delivered, out.Deferred+c.Deferred, out.Failed+c.Failed
}
}
if out.Delivered+out.Deferred+out.Failed == 0 {
return nil
}
for _, f := range o.recent[egress] {
if now.Sub(f.Time) < outcomeWindow {
out.Recent = append(out.Recent, f)
}
}
sort.SliceStable(out.Recent, func(i, j int) bool { return out.Recent[i].Time.After(out.Recent[j].Time) })
return out
}
+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)
}
}
+74 -17
View File
@@ -49,7 +49,8 @@ type traefikMgr struct {
stop context.CancelFunc // stops the current traefik process stop context.CancelFunc // stops the current traefik process
lastErr string lastErr string
maps map[string]*mapState maps map[string]*mapState
ports map[string]int // mapping ID -> relay port, kept stable across edits so Traefik's backend URL never changes 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 dynBytes []byte
} }
@@ -217,20 +218,23 @@ func hostRule(host string) (string, bool) {
// sync rewrites Traefik's configuration for the given web mappings and // 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. // (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() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.binds = binds
t.maps = map[string]*mapState{} t.maps = map[string]*mapState{}
eps := map[string]string{} // name -> address eps := map[string]string{} // name -> address
needACME := false needACME := false
for _, ms := range maps { for _, ms := range maps {
t.maps[ms.m.ID] = ms 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 { if ms.m.Proto == model.ProtoHTTPS {
needACME = true needACME = true
if ms.m.RedirectHTTP {
eps[epName(ms.m.BindIP, 80)] = net.JoinHostPort(ms.m.BindIP, "80")
}
} }
} }
routers := map[string]any{} routers := map[string]any{}
@@ -240,11 +244,45 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
} }
for _, ms := range maps { for _, ms := range maps {
m := ms.m 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 { if ms.relayPort == 0 {
continue continue
} }
rule, wildcard := hostRule(m.Hostname) 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 prio := 5000
if wildcard { if wildcard {
prio = 2000 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}}} mws["host-"+m.ID] = map[string]any{"headers": map[string]any{"customRequestHeaders": map[string]string{"Host": m.RewriteHost}}}
chain = append(chain, "host-"+m.ID) 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 var tlsCfg map[string]any
if m.Proto == model.ProtoHTTPS { if m.Proto == model.ProtoHTTPS {
tlsCfg = map[string]any{} 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{ 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}} "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}}} 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} "middlewares": []string{"gate-" + m.ID}, "priority": prio + 1000}
if tlsCfg != nil { if tlsCfg != nil {
g["tls"] = tlsCfg g["tls"] = tlsCfg
@@ -305,7 +343,7 @@ func (t *traefikMgr) sync(ctx context.Context, maps []*mapState, acmeEmail strin
redirPort = strconv.Itoa(m.Port) redirPort = strconv.Itoa(m.Port)
} }
mws["redir-"+m.ID] = map[string]any{"redirectScheme": map[string]any{"scheme": "https", "port": redirPort, "permanent": true}} 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} "middlewares": []string{"redir-" + m.ID}, "priority": prio}
} }
} }
@@ -506,9 +544,7 @@ func (t *traefikMgr) tailAccessLog(ctx context.Context) {
if ms == nil { if ms == nil {
continue continue
} }
ms.conns.Add(1) ms.record(tl.ClientHost, 1, tl.RequestSize, tl.DownstreamSize)
ms.bytesIn.Add(tl.RequestSize)
ms.bytesOut.Add(tl.DownstreamSize)
t.b.logAccess(model.LogEntry{Time: tl.StartUTC, Mapping: id, Remote: tl.ClientHost, Method: tl.RequestMethod, Host: hostOnly(tl.RequestHost), t.b.logAccess(model.LogEntry{Time: tl.StartUTC, Mapping: id, Remote: tl.ClientHost, Method: tl.RequestMethod, Host: hostOnly(tl.RequestHost),
Path: tl.RequestPath, Status: tl.DownstreamStatus, Result: "allowed", BytesIn: tl.RequestSize, BytesOut: tl.DownstreamSize, Millis: tl.DurationNanos / 1e6}) Path: tl.RequestPath, Status: tl.DownstreamStatus, Result: "allowed", BytesIn: tl.RequestSize, BytesOut: tl.DownstreamSize, Millis: tl.DurationNanos / 1e6})
} }
@@ -545,10 +581,7 @@ func (t *traefikMgr) probeCerts(ctx context.Context) {
t.mu.Unlock() t.mu.Unlock()
changed := false changed := false
for _, ms := range targets { for _, ms := range targets {
ip := ms.m.BindIP ip := t.probeAddr(ms.m)
if pip := net.ParseIP(ip); pip != nil && pip.IsUnspecified() {
ip = "127.0.0.1"
}
status := t.probeOne(net.JoinHostPort(ip, strconv.Itoa(ms.m.Port)), ms.m.Hostname) status := t.probeOne(net.JoinHostPort(ip, strconv.Itoa(ms.m.Port)), ms.m.Hostname)
t.b.mu.Lock() t.b.mu.Lock()
if t.b.acme.certs == nil { if t.b.acme.certs == nil {
@@ -598,3 +631,27 @@ func containsStr(list []string, s string) bool {
} }
return false 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]
}
+89
View File
@@ -0,0 +1,89 @@
package blacktip
import (
"net"
"net/netip"
"sync"
"git.veillax.com/Veillax/reef/model"
)
// maxTrackedSources bounds the per-port table of client addresses between two reports, so a flood
// from spoofed or scanning addresses can't grow it without limit. Addresses past it still count
// in the port's totals; they just aren't named.
const maxTrackedSources = 5000
// sourceTable counts a port's traffic per client address until the next report takes it.
type sourceTable struct {
mu sync.Mutex
by map[string]*model.SourceStat
}
func (t *sourceTable) add(ip string, conns, in, out int64) {
if ip == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
s := t.by[ip]
if s == nil {
if len(t.by) >= maxTrackedSources {
return
}
if t.by == nil {
t.by = map[string]*model.SourceStat{}
}
s = &model.SourceStat{IP: ip}
t.by[ip] = s
}
s.Conns += conns
s.BytesIn += in
s.BytesOut += out
}
func (t *sourceTable) take() map[string]*model.SourceStat {
t.mu.Lock()
defer t.mu.Unlock()
by := t.by
t.by = nil
return by
}
// clientIP reduces a "host:port" remote address to the client's IP (IPv4-mapped IPv6 as plain IPv4).
// Anything that isn't an address gives "".
func clientIP(remote string) string {
host, _, err := net.SplitHostPort(remote)
if err != nil {
host = remote
}
a, err := netip.ParseAddr(host)
if err != nil {
return ""
}
return a.WithZone("").Unmap().String()
}
// record counts traffic on a port, and names the client it came from when that is known.
func (ms *mapState) record(remote string, conns, in, out int64) {
ms.conns.Add(conns)
ms.bytesIn.Add(in)
ms.bytesOut.Add(out)
ms.sources.add(clientIP(remote), conns, in, out)
}
// takeStats hands over (and resets) the traffic counted since the last call.
func (ms *mapState) takeStats() (model.Stat, bool) {
st := model.Stat{Conns: ms.conns.Swap(0), BytesIn: ms.bytesIn.Swap(0), BytesOut: ms.bytesOut.Swap(0)}
st.Sources = model.TopSources(ms.sources.take(), model.MaxSources)
return st, st.Conns|st.BytesIn|st.BytesOut != 0
}
// restoreStats puts taken traffic back after a report that didn't arrive, so none of it is lost.
func (ms *mapState) restoreStats(st model.Stat) {
ms.conns.Add(st.Conns)
ms.bytesIn.Add(st.BytesIn)
ms.bytesOut.Add(st.BytesOut)
for _, s := range st.Sources {
ms.sources.add(s.IP, s.Conns, s.BytesIn, s.BytesOut)
}
}
+55
View File
@@ -0,0 +1,55 @@
package blacktip
import "testing"
func TestClientIP(t *testing.T) {
for in, want := range map[string]string{
"203.0.113.9:5000": "203.0.113.9",
"[2001:db8::1]:443": "2001:db8::1",
"[::ffff:192.0.2.7]:1": "192.0.2.7",
"198.51.100.2": "198.51.100.2", // no port, as Traefik reports it
"[fe80::1%eth0]:80": "fe80::1",
"not an address": "",
"": "",
} {
if got := clientIP(in); got != want {
t.Errorf("clientIP(%q) = %q, want %q", in, got, want)
}
}
}
func TestStatsTakeAndRestore(t *testing.T) {
ms := &mapState{}
ms.record("203.0.113.1:1000", 1, 100, 400)
ms.record("203.0.113.1:1001", 1, 10, 40)
ms.record("203.0.113.2:2000", 1, 5, 5)
ms.record("garbage", 1, 1, 1) // counted in the totals but not named
st, any := ms.takeStats()
if !any || st.Conns != 4 || st.BytesIn != 116 || st.BytesOut != 446 {
t.Fatalf("totals: %+v", st)
}
if len(st.Sources) != 2 || st.Sources[0].IP != "203.0.113.1" || st.Sources[0].Conns != 2 || st.Sources[0].BytesOut != 440 {
t.Fatalf("sources: %+v", st.Sources)
}
if _, any := ms.takeStats(); any {
t.Fatal("counters were not reset")
}
ms.record("203.0.113.1:1", 1, 1, 1) // traffic that arrived before the failed report was retried
ms.restoreStats(st)
again, _ := ms.takeStats()
if again.Conns != 5 || again.BytesIn != 117 || again.Sources[0].Conns != 3 {
t.Fatalf("after restore: %+v", again)
}
}
func TestSourceTableIsBounded(t *testing.T) {
var tb sourceTable
for i := 0; i < maxTrackedSources+50; i++ {
tb.add(string(rune('a'+i%26))+"."+string(rune('0'+i/26%10))+"."+string(rune(i)), 1, 1, 1)
}
if n := len(tb.take()); n > maxTrackedSources {
t.Fatalf("tracked %d addresses", n)
}
}
+11 -3
View File
@@ -258,12 +258,20 @@ func (l *listener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
entry.BytesOut = rec.bytes entry.BytesOut = rec.bytes
entry.Millis = time.Since(start).Milliseconds() entry.Millis = time.Since(start).Milliseconds()
ms.conns.Add(1) ms.record(r.RemoteAddr, 1, entry.BytesIn, entry.BytesOut)
ms.bytesIn.Add(entry.BytesIn)
ms.bytesOut.Add(entry.BytesOut)
b.logAccess(entry) 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 secure := l.kind == model.ProtoHTTPS
scheme := "http" scheme := "http"
if secure { 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)
}
}