Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Hammerhead
Self-hosted control plane for tunnelled ports and websites: a web dashboard, accounts and SSO, alerts, and a database, with no config files.
Hammerhead is the hub of the sharks stack. It runs the dashboard and API, and keeps your ports, domains, organizations, users, alerts and logs in an encrypted SQLite database. Edges and site agents connect to it and do whatever you set up in the UI.
| Repo | Role | Runs on |
|---|---|---|
| Hammerhead (this repo) | Control plane: dashboard, API, accounts, database | anywhere the other two can reach |
| Blacktip | Edge: owns public IPs, opens ports, WireGuard endpoint, enforces logins (optionally drives Traefik) | public server(s) |
| Bamboo | Site agent: WireGuard peer inside a private network, outbound only | each private network |
| Reef | Shared library imported by all three | build time only |
Every Blacktip also gets a built-in "this machine" site, so services on the same box (e.g. several Minecraft proxies) work without running Bamboo at all.
Quick start
go env -w GOPRIVATE=git.veillax.com # once, so Go fetches straight from this server
# 1. control plane (this repo); open http://localhost:8080 and create the first admin
git clone https://git.veillax.com/Veillax/hammerhead && cd hammerhead
go build -o bin/ ./cmd/hammerhead
cp .env.example .env
bin/hammerhead -genkey # paste the output into .env as HAMMERHEAD_MASTER_KEY=
bin/hammerhead -listen :8080 -url https://hammerhead.example.com
# 2. Server -> Edges -> add an edge (name + public host:UDP-port, e.g. 203.0.113.10:51820);
# the UI shows the command to run on the public server
go install git.veillax.com/Veillax/blacktip/cmd/blacktip@latest
blacktip -hammerhead https://hammerhead.example.com -token <edge token> -tunnel :51820 -docker
# 3. optional, for another network: Sites -> add a site, run on a machine there
go install git.veillax.com/Veillax/bamboo/cmd/bamboo@latest
bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound ports. Then open ports on the Resources page.
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 Hammerhead 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/hammerhead). Every push and pull request runs .gitea/workflows/ci.yml: formatting, vet, tests, and a check that every release platform still compiles.
Fastest: one script on a fresh VPS
deploy/setup.sh (also inside the Linux archives) installs everything for you on a systemd Linux: the services, a generated master key, the firewall rules and HTTPS.
sudo sh setup.sh --domain hammerhead.example.com # Hammerhead and an edge on this machine
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token> # on each site
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> # more public servers
With --domain, Hammerhead listens only on 127.0.0.1:8080 and gets an edge of its own (HAMMERHEAD_SELF_DOMAIN): the Blacktip on the same machine serves the dashboard at https://<domain> with an automatic certificate, so no Caddy or other web server is needed. The route is built in, not a mapping you can edit or delete by mistake. DNS for the name must point at the server and ports 80 and 443 must be free. --caddy keeps the old style: Hammerhead alone, with Caddy providing HTTPS. Add --ports 25565,30000-30100 to open game or mail ports in the firewall, and --no-auto-update to leave updates to you.
On a Linux server (systemd)
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/hammerhead_v0.1.0_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
tar xzf hammerhead_v0.1.0_linux_amd64.tar.gz && cd hammerhead_v0.1.0_linux_amd64
sudo ./install.sh # installs the binary and a hardened systemd service
sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
sudo systemctl start hammerhead
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/hammerhead and /var/lib/hammerhead alone. hammerhead -version tells you which build is running.
Updates
Set HAMMERHEAD_AUTO_UPDATE=1 (or pass -auto-update) and Hammerhead 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.
Edges and sites report their version, so Server → Edges and Sites show which release each one runs and flag a newer one. An Update button appears for any machine that runs with auto-update on (BLACKTIP_AUTO_UPDATE=1 / BAMBOO_AUTO_UPDATE=1): it asks that machine to install the newest release now, and the result is shown in the table. Under Server → Settings, Check for updates does the same lookup for Hammerhead itself, with an Update now button when auto-update is on. A machine without auto-update never installs anything on request. Starting an update opens a progress bar with the live log of what the machine is doing (checking, downloading, verifying, installing, restarting). Hammerhead keeps the log of its own last update in update.log in its data folder, so it is still there after the restart.
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/hammerhead:<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_TOKENandREGISTRY_TOKEN(access tokens with release and package write permission) and optionally the variableREGISTRY_USER. - To skip the container image, set the repository variable
PUBLISH_DOCKERtofalse. - Build locally with
sh deploy/build.sh(archives indist/).
Configuration and secrets
All three programs read a .env file from their working directory before they start (or the file
named by -env path). Real environment variables always win over the file. Copy .env.example,
which lists every setting, and keep .env out of version control (.gitignore already does).
What belongs in it: HAMMERHEAD_MASTER_KEY, the first admin's credentials, the edge and site
..._TOKEN values, HAMMERHEAD_METRICS_TOKEN, and DNS-provider credentials for wildcard certificates.
Secrets at rest. Hammerhead keeps everything in one SQLite file (hammerhead.db in its data
folder) and encrypts the secrets inside it with AES-256-GCM: edge and site tokens, 2FA secrets,
OIDC client secrets, SMTP and relay passwords, DKIM private keys, webhook URLs. Passwords are bcrypt
hashes; PINs and resource passwords likewise. With the wrong key Hammerhead refuses to start rather
than reading garbage. If you don't set HAMMERHEAD_MASTER_KEY, a key is generated into
<data>/master.key so it works out of the box, but then a backup of the folder carries the key too;
copy the file's contents into .env and delete it. Back the key up separately: without it the
encrypted secrets cannot be recovered.
Database
SQLite (pure Go, no C compiler needed), in WAL mode. Configuration is written incrementally in one
transaction per change, so it survives crashes and power loss. Logs, traffic history and alerts are
proper indexed tables: access logs are searchable and paginated, old entries are pruned
(HAMMERHEAD_LOG_DAYS, default 30; audit log a year). An existing state.json and JSONL logs from an
earlier version are imported automatically on first start and kept as *.migrated.
Back up by copying the data folder while Hammerhead is stopped (or use sqlite3 .backup), plus your
master key.
The tunnel: WireGuard
Blacktip and Bamboo are WireGuard peers, using wireguard-go with a userspace network stack (the same approach Newt uses), so there is no root requirement, no kernel interface and no driver; it works on Windows, macOS and Linux alike.
- Hammerhead allocates each edge and site an address in
100.89.0.0/16(it exists only inside the tunnel, so it can't clash with any LAN or VPN) and distributes public keys. Private keys never leave the machine that made them (wg.keyin each agent's data directory). - Bamboo dials out to every edge it is used from and keeps the NAT mapping open (keepalive 25s),
so sites behind NAT or CGNAT work. Each peer is limited to its own
/32. - TCP goes over a TCP connection inside the tunnel; UDP uses native datagrams (no UDP-over-TCP head-of-line blocking, good for game servers).
- Adding or removing a site changes peers in place; existing tunnels are not reset.
Two ways to serve web resources
| built-in proxy (default) | Traefik (-traefik /path/to/traefik) |
|
|---|---|---|
| http/https routing, HTTP/2, websockets | yes | yes |
| Let's Encrypt | TLS-ALPN-01, per-subdomain certificates for wildcard hostnames | TLS-ALPN-01, or DNS-01 with -acme-dns <provider> for true wildcard certificates |
| Logins, rules, share links | in Blacktip | same logic, called by Traefik's forward-auth |
| Dependency | none | a Traefik v3 binary |
With Traefik, Blacktip supervises the process: it writes Traefik's static config, restarts it only when
the set of web ip:port listeners changes (Traefik declares its ports statically), and hot-reloads
routes, logins and targets through the file provider. Targets are reached through a loopback relay port
per resource into the WireGuard tunnel, so health checks and failover work the same. Raw TCP, UDP and
TLS-passthrough ports never go through Traefik: it can't open ports without a restart, and opening
ports from the UI instantly is the point of this project, so Blacktip's own listeners handle those.
-no-acme makes Traefik serve its built-in self-signed certificate (useful on private networks).
DNS-challenge credentials are passed as environment variables to Blacktip, exactly as Traefik documents
for each provider (e.g. CF_DNS_API_TOKEN for cloudflare); .env is the place for them.
Features
Ports
- The same port on different IPs (
x.y.z.104:51000andx.y.z.209:51000go to different targets). Blacktip lists the IPs it finds; you can also type one. - Port ranges for tcp and udp:
2456-2458opens all three, each sent to the target port plus the same offset (up to 1000 ports per entry; a busy port inside a range is named in the status). - TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
- PROXY protocol (v1/v2) on tcp and tls ports: the target is told the real client address, so IP
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
set to expect it and to trust only the edge or site: Velocity/BungeeCord
haproxy-protocol, Postfixpostscreen_upstream_proxy_protocol = haproxy, Dovecothaproxy_trusted_networks, nginxproxy_protocol. - Many hostnames share one
ip:port;*.example.comwildcards work. - Several targets per port: round-robin load balancing with automatic failover.
- Health checks (TCP connect or an HTTP path); unhealthy targets are skipped and shown in the UI.
- Conflict detection (same ip/port/protocol, overlapping ranges, or
0.0.0.0overlapping a specific IP). - Traffic totals and a 24-hour sparkline per port.
HTTPS: automatic Let's Encrypt certificates, optional port-80 redirect, X-Forwarded-*
headers, Host rewrite, HTTPS to the backend, certificate status per port.
Login in front of any web port (like Pangolin's resource auth)
- Sign in with a Hammerhead account (limited to roles or everyone in the org), a shared password, a PIN, or an emailed one-time code.
- Expiring, use-limited share links that skip the login.
- Path and IP/CIDR rules that skip the login or block requests.
- The backend gets
X-Hammerhead-User(client-supplied copies and header aliases are removed) and never sees the session cookie. - Sessions can be revoked. Visitor cookies are stateless tokens carrying a per-resource epoch. Hammerhead bumps it, and edges refuse older tokens within seconds, when a member is removed, a role changes or is deleted, a user is deleted, the password/PIN or allowed methods change, a share link is revoked, or you press Sign out visitors.
Accounts
- Organizations, roles (admin and custom), invitations (by link or email), member management.
- Two-factor authentication (TOTP) with backup codes. Password changes sign out other sessions.
- Signed-in devices on the Account page: see where you're signed in and end any session, or all the others; admins can sign a user out everywhere.
- OpenID Connect single sign-on (Google, Authentik, Keycloak, Entra ID…) with auto-provisioning.
- Org-scoped API keys, a "My apps" page for members, brute-force throttling.
- Domains are owned per org and checked against DNS; hostnames must sit under your own domains.
Alerts
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS, HELO, port 25, DKIM record missing).
- A problem must last the debounce time (default 2 minutes, per organization) before you hear about it, so restarts and blips stay quiet. One message when it starts, one when it clears, optional reminders. Active alerts are stored, so restarting Hammerhead doesn't repeat them, and an alert is only marked resolved once the thing it describes has been re-checked.
- A dead edge silences its own ports' alerts (the edge alert already says it). Channels are per organization; server channels get edge alerts.
- Webhooks can't be pointed at internal addresses (no metadata service, no LAN) unless a server admin allows it for the organization, and redirects are not followed.
/metrics(Prometheus) whenHAMMERHEAD_METRICS_TOKENis set: edge/site/port/target/mail-check gauges, traffic counters, alerts firing. Off (404) otherwise.
Mail egress (send your mail server's outgoing mail from the edge's IP)
- Receiving servers judge mail by the IP it comes from: its reverse DNS, your HELO name and your SPF record all have to agree. A mail server at home usually can't satisfy that (residential IP, port 25 blocked). Mail egress lets it send from a VPS IP you control instead.
- Bamboo opens a small SMTP relay on the mail server's network (default
127.0.0.1:2525). Point your MTA at it (Postfix:relayhost = [127.0.0.1]:2525). Each message crosses the WireGuard tunnel and Blacktip delivers it straight to the recipient's server (MX lookup, MX fallback rules, opportunistic STARTTLS) from the source IP you choose, announcing the HELO name you set. The recipient's replies go straight back, so your server's queue, retries and bounces behave normally. Or set a smarthost to relay through a provider that allows your edge IP. - DKIM signing at the edge. Create a key per sending domain on the Mail egress page (RSA 2048, generated by Hammerhead, private half encrypted at rest and held only by the edge); publish the TXT record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message your mail server already signed for that domain is left alone, and a signing problem sends the mail unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
- MTA-STS enforcement. When a recipient domain publishes an
enforcepolicy, mail only goes to the servers it lists, over TLS with a verified certificate; otherwise it's a temporary failure so your server retries. (testingmode doesn't block.) Policies are cached for theirmax_age. The policy fetch can't reach internal addresses or follow redirects. - The page shows an edge-side deliverability checklist: reverse DNS matches the HELO name, the HELO name resolves back to the IP, outbound port 25 is open, plus the SPF record to publish.
- Guard rails: only the owning site can use an egress; recipients' servers on internal address ranges
are refused; a per-minute send limit (default 60) protects the IP's reputation if the mail server is
compromised; the relay requires a password if it listens beyond
127.0.0.1; optional "require verified TLS"; every delivery and refusal is in the access log (recipient domain, server used, SMTP status; never message content). - One transaction goes to one recipient domain; extra domains get
452and your MTA resends them separately, as MTAs expect. 50 MB limit. - Deliberately no queue at the edge. Delivery is synchronous, so your mail server's own queue does the retrying. Accepting mail at the edge and bouncing it later would create backscatter (bounces to forged senders, which gets IPs blacklisted) and leave stored mail on a public server.
- Not included: smarthost authentication (smarthosts must allow the edge IP), DANE, and receiving mail (use a normal TCP port with PROXY protocol for that).
Operations
- Searchable, paginated access log (per request for web ports, per connection for TCP, per message for mail) and audit log.
- Docker container discovery on any site or edge (
-docker): pick a container as a target. - Server admins decide which orgs may use each edge, and whether an org may target the edge machine itself (off by default for extra orgs, since that includes Hammerhead).
How logins work
Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammerhead signs a short-lived token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same decision function runs in both proxy modes.
Ephemeral exposures (shark expose)
A short-lived port or website for development, created and removed by an API key. shark-expose is the command-line client; the API is plain JSON, with an API key (Access) as the bearer token. - can stand for the key's own organization in the path.
| Call | Does |
|---|---|
POST /api/orgs/-/expose |
{proto: "http"|"https"|"tcp", targetPort, targetHost?, name?, ttlSeconds?, port?, hostname?, edgeId?, auth?: {password?, pin?, sso?}}. Makes a site and a port on an online edge, and returns the public address plus siteToken, the token Bamboo dials in with. |
POST /api/orgs/-/expose/{id}/renew |
Pushes the deadline out by ttlSeconds (default 300) from now. |
GET /api/orgs/-/expose[/{id}] |
Lists exposures, with whether the edge reports the port listening. |
DELETE /api/orgs/-/expose/{id} |
Removes it now. Stopping twice is fine. |
TCP gets a free port from 20000-29999. A website gets <name>-<random>.<one of your domains> (so a wildcard DNS record for that domain should point at the edge) on port 80/443, or on port. An exposure that isn't renewed within its TTL (30 seconds to 24 hours) is removed, along with its site and any login secrets. The dashboard shows them with the ephemeral tag; the deadlines live in memory, so a Hammerhead restart removes any that are still there. An organization can have 20 at a time.
Design
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See docs/design.md. node scripts/lint-design.mjs checks the rules that a machine can check.
Limits and notes
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
- Hammerhead serves plain HTTP; put it behind HTTPS (set
-url) or keep it on a private network. - SQLite means one Hammerhead instance (no clustering); fine for personal and small-team use.
- PROXY protocol covers tcp and tls ports, not udp.
- A Blacktip can only bind IPs that exist on its machine. Cloud elastic IPs that are NATed must be bound by their private address.
- Let's Encrypt needs port 443 on the edge reachable from the internet and DNS pointing at it.
- Traefik's access log is imported into Hammerhead; the file is truncated when it passes 64 MB.
- Tests:
go test ./.... Thee2e/folder runs Hammerhead, Blacktip and Bamboo together (it pulls the other two in as test dependencies); the Traefik test runs whenTRAEFIK_BINpoints at a Traefik binary.