3 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 da3d7201a1 Build and publish only linux/amd64 and windows/amd64
CI / test (push) Successful in 2m25s
Release / release (push) Successful in 2m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:48:53 -06:00
VeillaxandClaude Sonnet 5.5 536284ec75 Give Hammerhead its own edge with a built-in dashboard route; setup.sh installs it with Blacktip instead of Caddy
Release / release (push) Failing after 13m6s
CI / test (push) Failing after 18m11s
HAMMERHEAD_SELF_DOMAIN creates an edge for this machine, writes its token to <data>/self-edge.env
and adds an https route for the dashboard to that edge's config, so no reverse proxy is needed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:38:16 -06:00
VeillaxandClaude Sonnet 5.5 9ad690def0 Retry release uploads that hit a proxy timeout, and replace every existing asset on re-run
CI / test (push) Successful in 2m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:57:05 -06:00
12 changed files with 367 additions and 89 deletions

No files matched your search

+8
View File
@@ -21,6 +21,14 @@ HAMMERHEAD_MASTER_KEY=
# Leave empty and /metrics does not exist. # Leave empty and /metrics does not exist.
#HAMMERHEAD_METRICS_TOKEN= #HAMMERHEAD_METRICS_TOKEN=
# Serve the dashboard through a Blacktip edge on this same machine, at this name, with an
# automatic HTTPS certificate: no Caddy or other web server in front of Hammerhead. Hammerhead
# makes the edge itself and writes its token to <data>/self-edge.env (BLACKTIP_TOKEN=...).
# deploy/setup.sh --domain sets this up for you. Needs HAMMERHEAD_URL set to the same name.
#HAMMERHEAD_SELF_DOMAIN=hammerhead.example.com
# The edge's public WireGuard address for sites; default <HAMMERHEAD_SELF_DOMAIN>:51820.
#HAMMERHEAD_SELF_TUNNEL=hammerhead.example.com:51820
# How long access logs are kept (days). Default 30. # How long access logs are kept (days). Default 30.
#HAMMERHEAD_LOG_DAYS=30 #HAMMERHEAD_LOG_DAYS=30
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# Catches platform-specific breakage before a release is tagged. # Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles - name: Every release platform compiles
run: | run: |
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do for t in linux/amd64 windows/amd64; do
echo "building for $t" echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
done done
+1 -1
View File
@@ -59,4 +59,4 @@ jobs:
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest *-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;; *) tags="$tags -t $image:latest" ;;
esac esac
docker buildx build --platform linux/amd64,linux/arm64 $tags --push . docker buildx build --platform linux/amd64 $tags --push .
+7 -7
View File
@@ -41,19 +41,19 @@ ports. Then open ports on the **Resources** page.
## Deploying ## 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, arm64, armv7), Windows (amd64) and macOS (amd64, arm64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a multi-architecture container image 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. 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** **Fastest: one script on a fresh VPS**
`deploy/setup.sh` (also inside the Linux archives) installs everything for you on a systemd Linux: the service, a generated master key, the firewall rules, and optionally HTTPS. `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 + Caddy for HTTPS sudo sh setup.sh --domain hammerhead.example.com # Hammerhead and an edge on this machine
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> --ports 25565,30000-30100 sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token> # on each site
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token> sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> # more public servers
``` ```
`edge` installs Blacktip (with its bundled Traefik) and lists every IP address on the machine that ports can be opened on; `agent` installs Bamboo on the machine that hosts your services. All three can run on one machine or on three. 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)** **On a Linux server (systemd)**
@@ -67,7 +67,7 @@ sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
sudo systemctl start hammerhead sudo systemctl start hammerhead
``` ```
Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. 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. 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** **Updates**
+3
View File
@@ -73,6 +73,9 @@ func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
cfg.Mappings = append(cfg.Mappings, *m) cfg.Mappings = append(cfg.Mappings, *m)
} }
} }
if m := s.selfMapping(e); m != nil {
cfg.Mappings = append(cfg.Mappings, *m)
}
cfg.MailEgress = []model.MailPolicy{} cfg.MailEgress = []model.MailPolicy{}
for _, x := range s.st.MailEgress { for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID && x.Enabled { if x.EdgeID == e.ID && x.Enabled {
+17
View File
@@ -6,6 +6,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@@ -20,6 +21,19 @@ import (
// version is set at build time (deploy/build.sh). // version is set at build time (deploy/build.sh).
var version = "dev" var version = "dev"
// localAddr turns a listen address into one this machine can dial: ":8080" and "0.0.0.0:8080"
// become "127.0.0.1:8080".
func localAddr(listen string) string {
host, port, err := net.SplitHostPort(listen)
if err != nil {
return listen
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
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
@@ -61,6 +75,9 @@ func main() {
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"), MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"), MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
LogRetentionDays: days, LogRetentionDays: days,
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
SelfTarget: localAddr(*listen),
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
}) })
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
+3 -2
View File
@@ -8,12 +8,13 @@
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip # dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS # dist/SHA256SUMS
# #
# Set TARGETS="linux/amd64 linux/arm64" to build fewer platforms. # 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 set -eu
NAME=hammerhead NAME=hammerhead
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)} VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64"} TARGETS=${TARGETS:-"linux/amd64 windows/amd64"}
rm -rf dist rm -rf dist
mkdir -p dist/bin dist/stage mkdir -p dist/bin dist/stage
+17 -6
View File
@@ -42,19 +42,30 @@ else
echo "release $TAG already exists (id $id): replacing its files" echo "release $TAG already exists (id $id): replacing its files"
fi fi
existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json # 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): for a in json.load(sys.stdin):
print(a["id"], a["name"])') if a["name"] == sys.argv[1]:
print(a["id"])' "$1"
}
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
name=$(basename "$f") name=$(basename "$f")
echo "$existing" | while read -r aid aname; do n=1
if [ "$aname" = "$name" ]; then while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null 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 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 "uploading $name"
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
done done
echo "published $SERVER/$REPO/releases/tag/$TAG" echo "published $SERVER/$REPO/releases/tag/$TAG"
+124 -72
View File
@@ -1,45 +1,49 @@
#!/bin/sh #!/bin/sh
# One-shot setup for a VPS. Run as root. # One-shot setup for a VPS. Run as root.
# #
# sh setup.sh [--domain hh.example.com] [--version v0.1.0] [--no-auto-update] Hammerhead (dashboard and API) # sh setup.sh --domain hh.example.com [options] Hammerhead and a Blacktip edge on this machine
# sh setup.sh edge --hammerhead URL --token TOKEN [--ports 25565,30000-30100] [--version V] # sh setup.sh edge --hammerhead URL --token TOKEN [options] another edge
# sh setup.sh agent --hammerhead URL --token TOKEN [--version V] # sh setup.sh agent --hammerhead URL --token TOKEN [options] a site agent
# #
# Hammerhead: installs it as a systemd service (with a generated master key, kept in # With --domain, this installs Hammerhead (the dashboard) and a Blacktip edge on the same
# /etc/hammerhead/hammerhead.env), and with --domain also Caddy, which gets and renews # machine. The edge serves the dashboard itself at https://<domain> with an automatic
# the HTTPS certificate for that name. DNS for the name must already point here. # certificate, so no separate web server is needed. DNS for the domain must already point here,
# edge: installs Blacktip, with its bundled Traefik, on this machine (or any other VPS), # and ports 80 and 443 must be free (stop Pangolin, Caddy, nginx... first).
# opens the firewall and lists every IP address it can open ports on.
# agent: installs Bamboo, the site agent, on the machine that hosts your services.
# #
# Edge and agent need a token, which only exists once Hammerhead is up: create the edge under # Options:
# Server -> Edges (or the site under Sites) in the dashboard and copy the values from "Show command". # --version V release of Hammerhead (or of the edge/agent for those modes); default newest
# --edge-version V release of the Blacktip installed next to Hammerhead; default newest
# --ports 25565,30000-30100 also open these (TCP and UDP) in the firewall
# --tunnel HOST:PORT the edge's public WireGuard address; default <domain>:51820
# --caddy old style: Hammerhead alone, with Caddy providing HTTPS (no edge here)
# --no-auto-update don't let the programs update themselves
#
# Without --domain, Hammerhead alone is installed on port 8080 over plain HTTP.
# Edge and agent need a token: create the edge under Server -> Edges (or the site under Sites)
# in the dashboard and copy the values from "Show command".
# Safe to run again: it upgrades and keeps your settings and data. # Safe to run again: it upgrades and keeps your settings and data.
set -eu set -eu
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax} BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
MODE=hammerhead MODE=hammerhead
case "${1:-}" in edge | agent) MODE=$1; shift ;; esac case "${1:-}" in edge | agent) MODE=$1; shift ;; esac
DOMAIN= VERSION= HH= TOKEN= PORTS= AUTOUP=1 DOMAIN= VERSION= EDGE_VERSION= HH= TOKEN= PORTS= TUNNEL= CADDY=0 AUTOUP=1
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--domain) DOMAIN=$2; shift 2 ;; --domain) DOMAIN=$2; shift 2 ;;
--version) VERSION=$2; shift 2 ;; --version) VERSION=$2; shift 2 ;;
--edge-version) EDGE_VERSION=$2; shift 2 ;;
--hammerhead) HH=$2; shift 2 ;; --hammerhead) HH=$2; shift 2 ;;
--token) TOKEN=$2; shift 2 ;; --token) TOKEN=$2; shift 2 ;;
--ports) PORTS=$2; shift 2 ;; --ports) PORTS=$2; shift 2 ;;
--tunnel) TUNNEL=$2; shift 2 ;;
--caddy) CADDY=1; shift ;;
--no-auto-update) AUTOUP=0; shift ;; --no-auto-update) AUTOUP=0; shift ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;; -h | --help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "unknown option $1 (see --help)" >&2; exit 2 ;; *) echo "unknown option $1 (see --help)" >&2; exit 2 ;;
esac esac
done done
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
auto_update() {
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
return 0
}
say() { printf '\n== %s\n' "$*"; } say() { printf '\n== %s\n' "$*"; }
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root: sudo sh $0 $*" [ "$(id -u)" = 0 ] || die "run as root: sudo sh $0 $*"
@@ -47,9 +51,7 @@ command -v systemctl > /dev/null 2>&1 || die "this needs a systemd-based Linux (
case "$(uname -m)" in case "$(uname -m)" in
x86_64 | amd64) ARCH=amd64 ;; x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;; *) die "releases are published for x86-64 Linux only, not $(uname -m); build it yourself with: TARGETS=linux/arm64 sh deploy/build.sh" ;;
armv7* | armv6* | arm) ARCH=arm ;;
*) die "unsupported CPU: $(uname -m)" ;;
esac esac
pkg_install() { # installs packages with whichever package manager there is pkg_install() { # installs packages with whichever package manager there is
@@ -75,10 +77,10 @@ latest() { # newest release tag of repo $1 (read from git, so no API token is ne
git ls-remote --tags --refs "$BASE/$1" 2> /dev/null | sed 's|.*refs/tags/||' | grep -E '^v[0-9]' | sort -V | tail -n 1 git ls-remote --tags --refs "$BASE/$1" 2> /dev/null | sed 's|.*refs/tags/||' | grep -E '^v[0-9]' | sort -V | tail -n 1
} }
# fetch_install <app>: downloads that app's release for this CPU, verifies it and runs its install.sh # fetch_install <app> [version]: downloads that app's release for this CPU, verifies it and runs its install.sh
fetch_install() { fetch_install() {
app=$1 app=$1
ver=${VERSION:-$(latest "$app")} ver=${2:-$(latest "$app")}
[ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)" [ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)"
dir=$(mktemp -d) dir=$(mktemp -d)
say "downloading $app $ver ($ARCH)" say "downloading $app $ver ($ARCH)"
@@ -104,6 +106,12 @@ set_env() {
fi fi
} }
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
auto_update() {
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
return 0
}
firewall_open() { # firewall_open 80/tcp 443/tcp ... firewall_open() { # firewall_open 80/tcp 443/tcp ...
if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then
for p in "$@"; do ufw allow "$p" > /dev/null; done for p in "$@"; do ufw allow "$p" > /dev/null; done
@@ -126,54 +134,17 @@ wait_active() { # wait_active <service>
systemctl is-active --quiet "$1" || { journalctl -u "$1" -n 20 --no-pager; die "$1 didn't start"; } systemctl is-active --quiet "$1" || { journalctl -u "$1" -n 20 --no-pager; die "$1 didn't start"; }
} }
case $MODE in # listeners on 80/443 that would stop an edge serving websites
hammerhead) port_users() {
fetch_install hammerhead ss -H -ltnp '( sport = :80 or sport = :443 )' 2> /dev/null | grep -v '"blacktip"\|"traefik"' || true
ENVF=/etc/hammerhead/hammerhead.env }
auto_update "$ENVF" HAMMERHEAD
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then # edge_setup <hammerhead url> <token> <version>: Blacktip with its bundled Traefik, configured but not started
say "generating the master key" edge_setup() {
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/bin/hammerhead -genkey)" fetch_install blacktip "$3"
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
fi
if [ -n "$DOMAIN" ]; then
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
# only Caddy needs to reach it
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' /etc/systemd/system/hammerhead.service
systemctl daemon-reload
if ! command -v caddy > /dev/null 2>&1; then
say "installing Caddy (HTTPS certificates for $DOMAIN)"
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
fi
mkdir -p /etc/caddy
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
systemctl enable caddy > /dev/null 2>&1 || true
systemctl restart caddy
firewall_open 80/tcp 443/tcp
URL="https://$DOMAIN"
else
firewall_open 8080/tcp
ip=$(public_ips | head -n 1)
URL="http://${ip:-this-server}:8080"
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
fi
systemctl restart hammerhead
wait_active hammerhead
say "Hammerhead is running"
echo "Open $URL to create the first administrator."
echo
echo "Next, in the dashboard: Server -> Edges -> add an edge, then on the machine that has your public IPs run"
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
echo "and on each machine that hosts your services (home server, game server...) run"
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
echo "Either can be this same machine."
;;
edge)
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
fetch_install blacktip
ENVF=/etc/blacktip/blacktip.env ENVF=/etc/blacktip/blacktip.env
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH" set_env "$ENVF" BLACKTIP_HAMMERHEAD "$1"
set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN" set_env "$ENVF" BLACKTIP_TOKEN "$2"
auto_update "$ENVF" BLACKTIP auto_update "$ENVF" BLACKTIP
chmod 600 "$ENVF" chmod 600 "$ENVF"
say "network" say "network"
@@ -201,13 +172,94 @@ edge)
fi fi
echo "In the dashboard each port picks which of these addresses it listens on." echo "In the dashboard each port picks which of these addresses it listens on."
echo "Ports you open later in the dashboard must also be allowed in the firewall: run this again with --ports." echo "Ports you open later in the dashboard must also be allowed in the firewall: run this again with --ports."
}
case $MODE in
hammerhead)
fetch_install hammerhead "$VERSION"
ENVF=/etc/hammerhead/hammerhead.env
auto_update "$ENVF" HAMMERHEAD
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
say "generating the master key"
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/lib/hammerhead/hammerhead -genkey)"
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
fi
UNIT=/etc/systemd/system/hammerhead.service
if [ -n "$DOMAIN" ]; then
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
# only something on this machine needs to reach it
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' "$UNIT"
if [ "$CADDY" = 1 ]; then
systemctl daemon-reload
if ! command -v caddy > /dev/null 2>&1; then
say "installing Caddy (HTTPS certificates for $DOMAIN)"
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
fi
mkdir -p /etc/caddy
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
systemctl enable caddy > /dev/null 2>&1 || true
systemctl restart caddy
firewall_open 80/tcp 443/tcp
URL="https://$DOMAIN"
else
# the dashboard is served by the edge on this machine, so Hammerhead gets its own edge
set_env "$ENVF" HAMMERHEAD_SELF_DOMAIN "$DOMAIN"
[ -z "$TUNNEL" ] || set_env "$ENVF" HAMMERHEAD_SELF_TUNNEL "$TUNNEL"
URL="https://$DOMAIN"
fi
else
firewall_open 8080/tcp
ip=$(public_ips | head -n 1)
URL="http://${ip:-this-server}:8080"
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
fi
systemctl daemon-reload
systemctl restart hammerhead
wait_active hammerhead
say "Hammerhead is running"
if [ -n "$DOMAIN" ] && [ "$CADDY" = 0 ]; then
TOKENFILE=/var/lib/hammerhead/self-edge.env
for i in 1 2 3 4 5 6 7 8 9 10; do [ -f "$TOKENFILE" ] && break; sleep 1; done
[ -f "$TOKENFILE" ] || die "Hammerhead didn't create $TOKENFILE; check: journalctl -u hammerhead"
edge_token=$(sed -n 's/^BLACKTIP_TOKEN=//p' "$TOKENFILE")
edge_setup http://127.0.0.1:8080 "$edge_token" "$EDGE_VERSION"
# something else on 80/443 would stop the dashboard route from coming up
if systemctl is-active --quiet caddy 2> /dev/null; then
say "stopping Caddy"
echo "Caddy was serving this site and holds ports 80/443, which the edge needs now."
systemctl disable --now caddy > /dev/null 2>&1 || true
fi
others=$(port_users)
if [ -n "$others" ]; then
echo "$others" >&2
die "something else is listening on port 80 or 443 (above). Stop it (for Pangolin: docker compose stop), then run: systemctl restart blacktip"
fi
systemctl restart blacktip
wait_active blacktip
say "Blacktip is running"
echo "The dashboard is served by the edge on this machine at $URL once the certificate is issued"
echo "(a few seconds; $DOMAIN must point at this server). Until then, from your own computer:"
echo " ssh -L 8080:127.0.0.1:8080 you@this-server then open http://localhost:8080"
fi
echo
echo "Open $URL to create the first administrator."
echo
echo "Next: on each machine that hosts your services (home server, game server...) run"
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
echo "with the token from Sites -> add a site. For more public servers add edges under Server -> Edges and run"
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
;;
edge)
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
edge_setup "$HH" "$TOKEN" "$VERSION"
systemctl restart blacktip systemctl restart blacktip
wait_active blacktip wait_active blacktip
say "Blacktip is running" say "Blacktip is running"
;; ;;
agent) agent)
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)" [ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)"
fetch_install bamboo fetch_install bamboo "$VERSION"
ENVF=/etc/bamboo/bamboo.env ENVF=/etc/bamboo/bamboo.env
set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH" set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH"
set_env "$ENVF" BAMBOO_TOKEN "$TOKEN" set_env "$ENVF" BAMBOO_TOKEN "$TOKEN"
+71
View File
@@ -0,0 +1,71 @@
package hammerhead
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"git.veillax.com/Veillax/reef/model"
)
const selfMappingID = "hammerhead-dashboard"
// ensureSelfEdge makes sure this server has an edge of its own, for a Blacktip on the same
// machine, and writes that edge's token to <data>/self-edge.env for the installer. The edge
// also carries a built-in https route for the dashboard (see selfMapping), so no separate
// reverse proxy is needed in front of Hammerhead.
func (s *Server) ensureSelfEdge() error {
o := s.opts
if o.SelfDomain == "" {
return nil
}
host, port, err := net.SplitHostPort(o.SelfTarget)
if err != nil {
return fmt.Errorf("HAMMERHEAD_SELF_DOMAIN needs a listen address like 127.0.0.1:8080: %w", err)
}
if n, _ := strconv.Atoi(port); n < 1 || host == "" {
return errors.New("the dashboard's listen address needs a host and a port")
}
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(s.st.Settings.SelfEdgeID)
if e == nil {
e = &model.Edge{ID: newID(), Name: "Hammerhead server", Token: randHex(24), AuthKey: randHex(32),
TunnelAddr: o.SelfTunnel, TunnelIP: s.allocIPLocked(), OrgIDs: []string{}, IPs: []string{}}
s.st.Edges = append(s.st.Edges, e)
s.st.Sites = append(s.st.Sites, &model.Site{ID: newID(), Name: e.Name + " (this machine)",
Local: true, EdgeID: e.ID, Connected: []string{}})
s.st.Settings.SelfEdgeID = e.ID
s.bump()
}
body := "# written by Hammerhead for the Blacktip on this machine; keep it private\nBLACKTIP_TOKEN=" + e.Token + "\n"
return os.WriteFile(filepath.Join(s.dir, "self-edge.env"), []byte(body), 0o600)
}
// selfMapping is the dashboard's own route on the self edge: https on the configured name,
// forwarded to Hammerhead's listen address through the edge's local site. It is added to that
// edge's config and never stored, so it can't be edited or deleted by mistake in the UI.
// Caller holds s.mu.
func (s *Server) selfMapping(e *model.Edge) *model.Mapping {
o := s.opts
if o.SelfDomain == "" || e.ID != s.st.Settings.SelfEdgeID {
return nil
}
host, port, err := net.SplitHostPort(o.SelfTarget)
if err != nil {
return nil
}
n, _ := strconv.Atoi(port)
for _, st := range s.st.Sites {
if st.Local && st.EdgeID == e.ID {
return &model.Mapping{ID: selfMappingID, Name: "Hammerhead dashboard", Proto: model.ProtoHTTPS, EdgeID: e.ID,
BindIP: "0.0.0.0", Port: 443, Hostname: strings.ToLower(o.SelfDomain), Enabled: true, RedirectHTTP: true,
HealthSeconds: -1, Targets: []model.Target{{SiteID: st.ID, Host: host, Port: n}}}
}
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package hammerhead
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model"
)
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
t.Helper()
ts := httptest.NewServer(s.Handler())
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/edge/config?v=0", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var cfg model.EdgeConfig
if resp.StatusCode != 200 {
t.Fatalf("edge config: %s", resp.Status)
}
json.NewDecoder(resp.Body).Decode(&cfg)
return cfg
}
func tokenFile(t *testing.T, dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "self-edge.env"))
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(strings.SplitN(strings.SplitN(string(b), "BLACKTIP_TOKEN=", 2)[1], "\n", 2)[0])
}
var key = secretbox.GenerateKey()
func TestSelfEdgeServesTheDashboardWithoutAProxy(t *testing.T) {
dir := t.TempDir()
opts := Options{MasterKey: key, SelfDomain: "Hammerhead.Example.com", SelfTarget: "127.0.0.1:8080"}
s, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
token := tokenFile(t, dir)
cfg := edgeConfigOf(t, s, token)
if len(cfg.Mappings) != 1 || len(cfg.Local) != 1 {
t.Fatalf("mappings=%d local=%d", len(cfg.Mappings), len(cfg.Local))
}
m := cfg.Mappings[0]
if m.Proto != model.ProtoHTTPS || m.Hostname != "hammerhead.example.com" || m.Port != 443 || !m.RedirectHTTP || !m.Enabled ||
len(m.Targets) != 1 || m.Targets[0].Host != "127.0.0.1" || m.Targets[0].Port != 8080 || m.Targets[0].SiteID != cfg.Local[0] {
t.Fatalf("unexpected dashboard route: %+v", m)
}
if m.Auth.Protected() {
t.Fatal("the dashboard has its own login and must not sit behind resource auth")
}
edgeID := s.st.Settings.SelfEdgeID
if info, _ := os.Stat(filepath.Join(dir, "self-edge.env")); info.Mode().Perm()&0o077 != 0 && os.PathSeparator == '/' {
t.Fatalf("token file is readable by others: %v", info.Mode())
}
s.Close()
// a restart keeps the same edge and token instead of making a new one
s2, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if s2.st.Settings.SelfEdgeID != edgeID || tokenFile(t, dir) != token || len(s2.st.Edges) != 1 {
t.Fatal("restart changed the self edge")
}
if len(edgeConfigOf(t, s2, token).Mappings) != 1 {
t.Fatal("route missing after restart")
}
}
func TestNoSelfEdgeUnlessAsked(t *testing.T) {
dir := t.TempDir()
s, err := New(dir, Options{MasterKey: key})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if len(s.st.Edges) != 0 {
t.Fatal("an edge appeared without HAMMERHEAD_SELF_DOMAIN")
}
if _, err := os.Stat(filepath.Join(dir, "self-edge.env")); err == nil {
t.Fatal("token file written without HAMMERHEAD_SELF_DOMAIN")
}
}
+17
View File
@@ -111,6 +111,8 @@ type Settings struct {
AcmeEmail string `json:"acmeEmail"` AcmeEmail string `json:"acmeEmail"`
AcmeStaging bool `json:"acmeStaging"` AcmeStaging bool `json:"acmeStaging"`
SMTP SMTP `json:"smtp"` SMTP SMTP `json:"smtp"`
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
SelfEdgeID string `json:"selfEdgeId,omitempty"`
} }
type Secret struct { type Secret struct {
@@ -170,6 +172,14 @@ type Options struct {
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests) AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
// SelfDomain (HAMMERHEAD_SELF_DOMAIN) gives this server an edge of its own, with a built-in
// https route for the dashboard at that name, so no reverse proxy is needed in front of it.
// SelfTarget is the address Hammerhead listens on (e.g. 127.0.0.1:8080); SelfTunnel is the
// edge's public WireGuard endpoint (default <SelfDomain>:51820).
SelfDomain string
SelfTarget string
SelfTunnel string
} }
type Server struct { type Server struct {
@@ -258,6 +268,13 @@ func New(dir string, opts Options) (*Server, error) {
return nil, err return nil, err
} }
} }
if opts.SelfDomain != "" && opts.SelfTunnel == "" {
s.opts.SelfTunnel = opts.SelfDomain + ":51820"
}
if err := s.ensureSelfEdge(); err != nil {
db.Close()
return nil, err
}
if err := s.save(); err != nil { if err := s.save(); err != nil {
db.Close() db.Close()
return nil, err return nil, err