7 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 2b7cb5dd35 Show a progress bar and live log while Hammerhead, edges and sites update
CI / test (push) Successful in 5m21s
Release / release (push) Successful in 6m9s
The updater reports each step (checking, downloading with percent, verifying, installing, restarting).
Edges and sites send that progress and their log with their reports; Hammerhead serves it to a dialog
that opens when an update starts, and keeps its own last update log on disk across the restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:05:42 -06:00
VeillaxandClaude Sonnet 5.5 9bd48917c7 Add resources through a short step-by-step flow; list websites and ports separately
CI / test (push) Successful in 3m19s
Release / release (push) Successful in 5m13s
A new resource starts by choosing what is being shared (website, TCP, UDP, TCP+UDP, TLS passthrough,
plain HTTP), then where it points, how people reach it, who can open it (websites only) and a final
name-and-review step. Each step shows only its own fields; health checks, rules and redirects sit
under More options. The existing form is still used for editing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:45:49 -06:00
VeillaxandClaude Sonnet 5.5 579ad1521a Show edge and site versions with Update buttons; Update now for Hammerhead itself
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m15s
Edges and sites report their version and whether auto-update is on. Hammerhead can ask a machine that
has auto-update on to install the newest release, and shows the outcome. Requests carry an ID so a
stale result can't answer a new one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:04:56 -06:00
VeillaxandClaude Sonnet 5.5 abf352e6c9 Add a Check for updates button under Settings; keep the self edge when settings are saved
CI / test (push) Successful in 4m48s
Saving settings replaced the whole Settings value, which would have dropped SelfEdgeID and made the
next start create a second self edge with a new token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:02:27 -06:00
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
23 changed files with 1222 additions and 107 deletions

No files matched your search

+8
View File
@@ -21,6 +21,14 @@ HAMMERHEAD_MASTER_KEY=
# Leave empty and /metrics does not exist.
#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.
#HAMMERHEAD_LOG_DAYS=30
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles
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"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
done
+1 -1
View File
@@ -59,4 +59,4 @@ jobs:
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
docker buildx build --platform linux/amd64 $tags --push .
+9 -7
View File
@@ -41,19 +41,19 @@ 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, 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**
`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 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>
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
```
`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)**
@@ -67,12 +67,14 @@ sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
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**
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**
```
+15 -1
View File
@@ -73,6 +73,10 @@ func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
cfg.Mappings = append(cfg.Mappings, *m)
}
}
if m := s.selfMapping(e); m != nil {
cfg.Mappings = append(cfg.Mappings, *m)
}
cfg.UpdateID = e.UpdateID
cfg.MailEgress = []model.MailPolicy{}
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID && x.Enabled {
@@ -107,6 +111,9 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
}
e.LastSeen = time.Now()
e.IPs = in.IPs
oldVersion := e.Version
answered := noteUpdateReport(&e.Version, &e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
s.recordUpdateProgress(e.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, e.Version)
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID {
if chk, found := in.MailChecks[x.ID]; found {
@@ -143,6 +150,8 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
if e.PublicKey != in.PublicKey {
e.PublicKey = in.PublicKey
s.bump() // sites need the edge's new key
} else if answered {
s.bump() // the edge's config no longer carries the update request
} else {
s.save()
}
@@ -202,7 +211,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
return
}
st.LastSeen = time.Now()
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}}
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}, UpdateID: st.UpdateID}
seen := map[string]bool{}
addEdge := func(id string) {
if seen[id] {
@@ -246,6 +255,9 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
in.Connected = []string{}
}
st.Connected = in.Connected
oldVersion := st.Version
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
s.containers[st.ID] = in.Containers
for _, x := range s.st.MailEgress {
if x.SiteID != st.ID {
@@ -260,6 +272,8 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
st.PublicKey = in.PublicKey
s.bump() // every edge this site talks to needs the new key
} else if answered {
s.bump() // the site's config no longer carries the update request
}
ok(w)
}
+19
View File
@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"strconv"
@@ -20,6 +21,19 @@ import (
// version is set at build time (deploy/build.sh).
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 {
if v := os.Getenv(k); v != "" {
return v
@@ -61,6 +75,11 @@ func main() {
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
LogRetentionDays: days,
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
SelfTarget: localAddr(*listen),
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
Version: version,
AutoUpdate: *autoUpdate,
})
if err != nil {
log.Fatal(err)
+3 -2
View File
@@ -8,12 +8,13 @@
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
# 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
NAME=hammerhead
cd "$(dirname "$0")/.."
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
mkdir -p dist/bin dist/stage
+18 -7
View File
@@ -42,19 +42,30 @@ else
echo "release $TAG already exists (id $id): replacing its files"
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):
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
[ -f "$f" ] || continue
name=$(basename "$f")
echo "$existing" | while read -r aid aname; do
if [ "$aname" = "$name" ]; then
n=1
while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
fi
done
echo "uploading $name"
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
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"
+124 -72
View File
@@ -1,45 +1,49 @@
#!/bin/sh
# 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 edge --hammerhead URL --token TOKEN [--ports 25565,30000-30100] [--version V]
# sh setup.sh agent --hammerhead URL --token TOKEN [--version V]
# sh setup.sh --domain hh.example.com [options] Hammerhead and a Blacktip edge on this machine
# sh setup.sh edge --hammerhead URL --token TOKEN [options] another edge
# 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
# /etc/hammerhead/hammerhead.env), and with --domain also Caddy, which gets and renews
# the HTTPS certificate for that name. DNS for the name must already point here.
# edge: installs Blacktip, with its bundled Traefik, on this machine (or any other VPS),
# 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.
# With --domain, this installs Hammerhead (the dashboard) and a Blacktip edge on the same
# machine. The edge serves the dashboard itself at https://<domain> with an automatic
# certificate, so no separate web server is needed. DNS for the domain must already point here,
# and ports 80 and 443 must be free (stop Pangolin, Caddy, nginx... first).
#
# Edge and agent need a token, which only exists once Hammerhead is up: create the edge under
# Server -> Edges (or the site under Sites) in the dashboard and copy the values from "Show command".
# Options:
# --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.
set -eu
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
MODE=hammerhead
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
case "$1" in
--domain) DOMAIN=$2; shift 2 ;;
--version) VERSION=$2; shift 2 ;;
--edge-version) EDGE_VERSION=$2; shift 2 ;;
--hammerhead) HH=$2; shift 2 ;;
--token) TOKEN=$2; shift 2 ;;
--ports) PORTS=$2; shift 2 ;;
--tunnel) TUNNEL=$2; shift 2 ;;
--caddy) CADDY=1; 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 ;;
esac
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' "$*"; }
die() { echo "error: $*" >&2; exit 1; }
[ "$(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
x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
armv7* | armv6* | arm) ARCH=arm ;;
*) die "unsupported CPU: $(uname -m)" ;;
*) die "releases are published for x86-64 Linux only, not $(uname -m); build it yourself with: TARGETS=linux/arm64 sh deploy/build.sh" ;;
esac
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
}
# 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() {
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)"
dir=$(mktemp -d)
say "downloading $app $ver ($ARCH)"
@@ -104,6 +106,12 @@ set_env() {
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 ...
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
@@ -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"; }
}
case $MODE in
hammerhead)
fetch_install hammerhead
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/bin/hammerhead -genkey)"
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
# listeners on 80/443 that would stop an edge serving websites
port_users() {
ss -H -ltnp '( sport = :80 or sport = :443 )' 2> /dev/null | grep -v '"blacktip"\|"traefik"' || true
}
# edge_setup <hammerhead url> <token> <version>: Blacktip with its bundled Traefik, configured but not started
edge_setup() {
fetch_install blacktip "$3"
ENVF=/etc/blacktip/blacktip.env
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH"
set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN"
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$1"
set_env "$ENVF" BLACKTIP_TOKEN "$2"
auto_update "$ENVF" BLACKTIP
chmod 600 "$ENVF"
say "network"
@@ -201,13 +172,94 @@ edge)
fi
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."
}
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
wait_active blacktip
say "Blacktip is running"
;;
agent)
[ -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
set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH"
set_env "$ENVF" BAMBOO_TOKEN "$TOKEN"
+8 -1
View File
@@ -241,6 +241,9 @@ func selfSigned(t *testing.T, host string) *tls.Certificate {
// ---------- the environment ----------
// siteCfgHook lets a test adjust the Bamboo that setup starts.
var siteCfgHook func(*bamboo.Config)
type world struct {
t *testing.T
ts *httptest.Server
@@ -297,7 +300,11 @@ func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Confi
t.Fatal(err)
}
go bt.Run(ctx)
bb, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()})
bbCfg := bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()}
if siteCfgHook != nil {
siteCfgHook(&bbCfg)
}
bb, err := bamboo.New(bbCfg)
if err != nil {
t.Fatal(err)
}
+132
View File
@@ -0,0 +1,132 @@
package e2e
import (
"context"
"strings"
"sync/atomic"
"testing"
"time"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
type edgeRow struct {
ID, Version, UpdateMsg string
AutoUpdate bool
UpdatePending bool
Online bool
}
func (w *world) edgeRow() edgeRow {
var edges []edgeRow
w.admin.must("GET", "/api/edges", nil, &edges)
for _, e := range edges {
if e.ID == w.edge {
return e
}
}
w.t.Fatal("edge not listed")
return edgeRow{}
}
func (w *world) siteRow() model.Site {
var st struct{ Sites []model.Site }
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
for _, s := range st.Sites {
if s.ID == w.site {
return s
}
}
w.t.Fatal("site not listed")
return model.Site{}
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
for i := 0; i < 150; i++ {
if ok() {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func TestEdgesAndSitesReportVersionsAndUpdateOnRequest(t *testing.T) {
var edgeCalls, siteCalls atomic.Int32
siteCfgHook = func(c *bamboo.Config) {
c.Version, c.AutoUpdate = "v0.1.0", true
c.UpdateNow = func(ctx context.Context, progress func(string, int, string)) (string, error) {
siteCalls.Add(1)
progress("checking", 10, "Checking for the newest release")
progress("done", 100, "Already on the newest release")
return "", nil
}
}
defer func() { siteCfgHook = nil }()
w := setup(t, blacktip.TraefikConfig{}, func(c *blacktip.Config) {
c.Version, c.AutoUpdate = "v0.1.0", true
c.UpdateNow = func(ctx context.Context, progress func(string, int, string)) (string, error) {
edgeCalls.Add(1)
progress("downloading", 40, "Downloading blacktip_v0.2.0_linux_amd64.tar.gz")
progress("downloading", 70, "Downloaded 28.0 of 40.0 MB (70%)")
progress("restarting", 100, "Installed v0.2.0; restarting into it")
return "v0.2.0", nil
}
})
waitFor(t, "the edge to report its version", func() bool { e := w.edgeRow(); return e.Version == "v0.1.0" && e.AutoUpdate })
waitFor(t, "the site to report its version", func() bool { s := w.siteRow(); return s.Version == "v0.1.0" && s.AutoUpdate })
w.admin.must("POST", "/api/edges/"+w.edge+"/update", nil, nil)
waitFor(t, "the edge to answer the update request", func() bool {
e := w.edgeRow()
return !e.UpdatePending && e.UpdateMsg == "updated to v0.2.0, restarting"
})
if edgeCalls.Load() != 1 {
t.Fatalf("the edge ran its update %d times, want 1", edgeCalls.Load())
}
var log struct {
Running bool
Stage string
Pct int
Lines []string
}
w.admin.must("GET", "/api/edges/"+w.edge+"/update/log", nil, &log)
text := strings.Join(log.Lines, "; ")
if log.Running || log.Stage != "restarting" || log.Pct != 100 || !strings.Contains(text, "Update requested") || !strings.Contains(text, "Downloaded 28.0 of 40.0 MB") || !strings.Contains(text, "restarting into it") {
t.Fatalf("edge update log: %+v (%s)", log, text)
}
w.admin.must("POST", "/api/orgs/"+w.org+"/sites/"+w.site+"/update", nil, nil)
waitFor(t, "the site to answer the update request", func() bool {
s := w.siteRow()
return s.UpdateID == "" && s.UpdateMsg == "already on the newest release"
})
if siteCalls.Load() != 1 {
t.Fatalf("the site ran its update %d times, want 1", siteCalls.Load())
}
w.admin.must("GET", "/api/orgs/"+w.org+"/sites/"+w.site+"/update/log", nil, &log)
if log.Running || log.Stage != "done" || !strings.Contains(strings.Join(log.Lines, "; "), "Already on the newest release") {
t.Fatalf("site update log: %+v", log)
}
// asking again works and is a fresh request, not a leftover answer
w.admin.must("POST", "/api/edges/"+w.edge+"/update", nil, nil)
waitFor(t, "a second update request to be answered", func() bool { return edgeCalls.Load() == 2 && !w.edgeRow().UpdatePending })
}
func TestUpdatesNeedAutoUpdateOnTheMachine(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{}) // no version, auto-update off
waitFor(t, "the edge to be online", func() bool { return w.edgeRow().Online })
code, body := w.admin.do("POST", "/api/edges/"+w.edge+"/update", nil, nil)
if code != 400 || !strings.Contains(body, "BLACKTIP_AUTO_UPDATE") {
t.Fatalf("update on an edge without auto-update: %d %s", code, body)
}
code, body = w.admin.do("POST", "/api/orgs/"+w.org+"/sites/"+w.local+"/update", nil, nil)
if code != 400 {
t.Fatalf("update on a local site: %d %s", code, body)
}
}
+3 -3
View File
@@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
go 1.27.3
require (
git.veillax.com/Veillax/bamboo v0.1.3
git.veillax.com/Veillax/blacktip v0.1.4
git.veillax.com/Veillax/reef v0.1.3
git.veillax.com/Veillax/bamboo v0.1.5
git.veillax.com/Veillax/blacktip v0.1.7
git.veillax.com/Veillax/reef v0.1.7
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
+14
View File
@@ -2,14 +2,28 @@ git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg=
git.veillax.com/Veillax/bamboo v0.1.3/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM=
git.veillax.com/Veillax/bamboo v0.1.4 h1:pgiBJA46CBJx7T3bloCjPekb6wl6W5H8teokFAH5gj0=
git.veillax.com/Veillax/bamboo v0.1.4/go.mod h1:wzSpfql7U4a3b9mCVH01OPG9H9szrzwSFDufWw02e0g=
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0=
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
git.veillax.com/Veillax/blacktip v0.1.6 h1:dUJzNGXgieWX05ftWjnMfh6norhaeKTqo1P13lJvjtk=
git.veillax.com/Veillax/blacktip v0.1.6/go.mod h1:ZnPRc2gPh9ElILfPH39G/x3B6KAiHQyeoDLycSDgVPM=
git.veillax.com/Veillax/blacktip v0.1.7 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ=
git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc=
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.4 h1:T2+B8P1GAitEyTYRkwMT3UHbG9mN5zXb/inE2muXdQ4=
git.veillax.com/Veillax/reef v0.1.4/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo=
git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU=
git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+6
View File
@@ -24,6 +24,11 @@ type edgeView struct {
TunnelAddr string `json:"tunnelAddr,omitempty"`
Token string `json:"token,omitempty"`
OrgIDs []string `json:"orgIds,omitempty"`
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdatePending bool `json:"updatePending,omitempty"`
UpdateMsg string `json:"updateMsg,omitempty"`
}
func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
@@ -33,6 +38,7 @@ func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
}
if admin {
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
v.Version, v.AutoUpdate, v.UpdatePending, v.UpdateMsg = e.Version, e.AutoUpdate, e.UpdateID != "", e.UpdateMsg
if v.OrgIDs == nil {
v.OrgIDs = []string{}
}
+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
}
+121
View File
@@ -0,0 +1,121 @@
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")
}
}
func TestSavingSettingsKeepsTheSelfEdge(t *testing.T) {
dir := t.TempDir()
opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, SelfDomain: "hh.example.com", SelfTarget: "127.0.0.1:8080"}
s, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
token := tokenFile(t, dir)
do := apiClient(t, s)
if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "https://hh.example.com", "acmeEmail": "me@example.com"}); code != 200 {
t.Fatalf("settings: %d %s", code, b)
}
s.Close()
s2, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if len(s2.st.Edges) != 1 || tokenFile(t, dir) != token {
t.Fatalf("saving settings lost the self edge: %d edges", len(s2.st.Edges))
}
}
+8
View File
@@ -97,6 +97,14 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
mux.HandleFunc("GET /api/admin/update", s.withServerAdmin(s.checkUpdate))
mux.HandleFunc("POST /api/admin/update/apply", s.withServerAdmin(s.applyUpdate))
mux.HandleFunc("GET /api/admin/update/status", s.withServerAdmin(s.updateStatus))
mux.HandleFunc("GET /api/versions", s.withUser(s.latestVersions))
mux.HandleFunc("GET /api/edges/{id}/update/log", s.withServerAdmin(s.edgeUpdateLog))
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/update/log", s.withOrg(true, s.siteUpdateLog))
mux.HandleFunc("POST /api/edges/{id}/update", s.withServerAdmin(s.updateEdge))
mux.HandleFunc("POST /api/orgs/{org}/sites/{id}/update", s.withOrg(true, s.updateSite))
mux.HandleFunc("GET /api/admin/audit", s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
}))
+1
View File
@@ -41,6 +41,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
if s.opts.DashboardURL != "" {
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
}
in.SelfEdgeID = s.st.Settings.SelfEdgeID // not editable here; losing it would make the next start create a second self edge
s.st.Settings = in
s.bump() // edges receive the dashboard URL and ACME settings
s.mu.Unlock()
+32
View File
@@ -111,6 +111,8 @@ type Settings struct {
AcmeEmail string `json:"acmeEmail"`
AcmeStaging bool `json:"acmeStaging"`
SMTP SMTP `json:"smtp"`
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
SelfEdgeID string `json:"selfEdgeId,omitempty"`
}
type Secret struct {
@@ -170,6 +172,22 @@ type Options struct {
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
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
// 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
// Version is the running release (e.g. v0.1.5), shown under Settings with a check for updates.
Version string
// AutoUpdate says whether this process updates itself (HAMMERHEAD_AUTO_UPDATE), for the same screen.
AutoUpdate bool
// UpdateServer and UpdateRepo say where releases are published; the defaults are this project's own.
UpdateServer string
UpdateRepo string
}
type Server struct {
@@ -191,6 +209,11 @@ type Server struct {
oidc map[string]*oidcDoc
alerts map[string]*alertState // active problems by key (see alerts.go)
updRuns map[string]*updRun // progress of updates running on edges and sites, by ID
selfUpd selfUpdate // progress of this server's own update
verMu sync.Mutex // guards verCache and verAt, the latest releases seen by /api/versions
verCache map[string]string
verAt time.Time
mtaSent map[string]time.Time // reminder throttle for repeat notifications
logs *logStore
@@ -232,6 +255,7 @@ func New(dir string, opts Options) (*Server, error) {
oidc: map[string]*oidcDoc{},
alerts: map[string]*alertState{},
mtaSent: map[string]time.Time{},
updRuns: map[string]*updRun{},
}
s.st.Sessions = map[string]*Session{}
s.st.Secrets = map[string]*Secret{}
@@ -258,6 +282,14 @@ func New(dir string, opts Options) (*Server, error) {
return nil, err
}
}
if opts.SelfDomain != "" && opts.SelfTunnel == "" {
s.opts.SelfTunnel = opts.SelfDomain + ":51820"
}
s.finishSelfUpdateLog()
if err := s.ensureSelfEdge(); err != nil {
db.Close()
return nil, err
}
if err := s.save(); err != nil {
db.Close()
return nil, err
+183
View File
@@ -0,0 +1,183 @@
package hammerhead
import (
"context"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/selfupdate"
)
func (s *Server) updateConfig(app, current string) selfupdate.Config {
o := s.opts
server := o.UpdateServer
if server == "" {
server = "https://git.veillax.com"
}
owner := "Veillax"
if o.UpdateRepo != "" {
owner = strings.SplitN(o.UpdateRepo, "/", 2)[0]
}
return selfupdate.Config{Server: server, Repo: owner + "/" + app, App: app, Current: current}
}
// checkUpdate asks the release server whether a newer Hammerhead exists. It only looks.
func (s *Server) checkUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
o := s.opts
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out := map[string]any{"current": o.Version, "autoUpdate": o.AutoUpdate, "checked": time.Now()}
latest, newer, err := selfupdate.Check(ctx, s.updateConfig("hammerhead", o.Version))
out["latest"], out["newer"] = latest, newer
if err != nil {
out["error"] = err.Error()
}
writeJSON(w, 200, out)
}
// applyUpdate installs the newest release of Hammerhead itself and restarts into it. Like the
// scheduled check it needs auto-update to be on, which is also what makes the binary replaceable.
func (s *Server) applyUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
if !s.opts.AutoUpdate {
fail(w, 400, errors.New("auto-update is off: set HAMMERHEAD_AUTO_UPDATE=1 and restart Hammerhead, or update it by hand"))
return
}
u := &s.selfUpd
u.mu.Lock()
if u.running {
u.mu.Unlock()
fail(w, 409, errors.New("an update is already running"))
return
}
u.running, u.stage, u.pct, u.lines = true, "starting", 0, nil
u.mu.Unlock()
os.WriteFile(s.updateLogPath(), nil, 0o600) // a new run starts a new log
cfg := s.updateConfig("hammerhead", s.opts.Version)
cfg.Progress = func(e selfupdate.Event) { s.selfNote(e.Stage, e.Pct, e.Line) }
cfg.Restart = func(exe string) error {
s.selfNote("restarting", 100, "Closing the database and restarting")
s.Close()
return selfupdate.Restart(exe)
}
s.selfNote("starting", 0, "Update requested by "+p.User.Email)
s.audit("", p.User.Email, "server.update", "")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
selfupdate.CheckOnce(ctx, cfg) // on success the process is replaced and this never returns
u.mu.Lock()
u.running = false
u.mu.Unlock()
}()
writeJSON(w, 200, map[string]any{"started": true})
}
// latestVersions returns the newest release of each program, for "update available" badges. Results
// are cached for ten minutes so a busy dashboard doesn't hammer the release server.
func (s *Server) latestVersions(w http.ResponseWriter, r *http.Request, p *principal) {
s.verMu.Lock()
if time.Since(s.verAt) > 10*time.Minute || s.verCache == nil {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
out := map[string]string{}
var mu sync.Mutex
var wg sync.WaitGroup
for _, app := range []string{"hammerhead", "blacktip", "bamboo"} {
wg.Add(1)
go func(app string) {
defer wg.Done()
if tag, _, err := selfupdate.Check(ctx, s.updateConfig(app, "")); err == nil && tag != "" {
mu.Lock()
out[app] = tag
mu.Unlock()
}
}(app)
}
wg.Wait()
cancel()
s.verCache, s.verAt = out, time.Now()
}
out := s.verCache
s.verMu.Unlock()
writeJSON(w, 200, out)
}
// noteUpdateReport records what an edge or site says about its version and any update that was
// requested of it. It reports whether a pending request was answered, which means the config the
// agent polls has changed (it no longer carries the request).
func noteUpdateReport(version *string, auto *bool, updateID, updateMsg *string, inVersion string, inAuto bool, inID, inMsg string) bool {
upgraded := *version != "" && inVersion != "" && inVersion != *version
if inVersion != "" {
*version = inVersion
}
*auto = inAuto
if *updateID == "" {
return false
}
switch {
case inID == *updateID && inMsg != "":
*updateMsg = inMsg
case upgraded:
*updateMsg = "updated to " + inVersion
default:
return false
}
*updateID = ""
return true
}
func (s *Server) updateEdge(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(r.PathValue("id"))
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
if err := requestUpdate(&e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, s.edgeOnline(e), "BLACKTIP"); err != nil {
fail(w, 400, err)
return
}
delete(s.updRuns, e.ID) // a new request starts a new log
s.bump()
s.audit("", p.User.Email, "edge.update", e.Name)
ok(w)
}
func (s *Server) updateSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || !s.siteVisible(c, st) {
fail(w, 404, errors.New("no such site"))
return
}
if st.Local {
fail(w, 400, errors.New("this site is the edge's own machine; update the edge instead"))
return
}
if err := requestUpdate(&st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, s.siteOnline(st), "BAMBOO"); err != nil {
fail(w, 400, err)
return
}
delete(s.updRuns, st.ID)
s.bump()
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
ok(w)
}
// requestUpdate marks an update as wanted. The agent installs it only if it has auto-update on, and
// the request stays until the agent answers (or reports a new version). Asking again replaces it.
func requestUpdate(auto *bool, updateID, updateMsg *string, online bool, prefix string) error {
if !online {
return errors.New("it is offline right now")
}
if !*auto {
return errors.New("it isn't running with auto-update on, so it won't install updates from here (set " + prefix + "_AUTO_UPDATE=1 and restart it)")
}
*updateID, *updateMsg = randHex(8), ""
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package hammerhead
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckForUpdates(t *testing.T) {
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/Veillax/hammerhead/releases/latest" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `{"tag_name":"v0.3.0","prerelease":false,"assets":[]}`)
}))
defer gitea.Close()
for _, c := range []struct {
version string
newer bool
}{{"v0.2.0", true}, {"v0.3.0", false}, {"dev", false}} {
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key,
Version: c.version, UpdateServer: gitea.URL, UpdateRepo: "Veillax/hammerhead", AutoUpdate: true})
if err != nil {
t.Fatal(err)
}
do := apiClient(t, s)
code, b := do("GET", "/api/admin/update", nil)
var out struct {
Current, Latest, Error string
Newer, AutoUpdate bool
}
json.Unmarshal(b, &out)
if code != 200 || out.Current != c.version || out.Latest != "v0.3.0" || out.Newer != c.newer || !out.AutoUpdate || out.Error != "" {
t.Errorf("%s: %d %s", c.version, code, b)
}
s.Close()
}
// an unreachable release server is reported, not fatal
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, Version: "v0.1.0", UpdateServer: "http://127.0.0.1:1"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
code, b := apiClient(t, s)("GET", "/api/admin/update", nil)
var out struct{ Error string }
json.Unmarshal(b, &out)
if code != 200 || out.Error == "" {
t.Errorf("unreachable server: %d %s", code, b)
}
}
func TestSelfUpdateLogSurvivesTheRestartItEndsWith(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "update.log"), []byte("12:00:00 Checking\n12:00:09 Installed v0.3.0; restarting into it\n"), 0o600); err != nil {
t.Fatal(err)
}
s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, Version: "v0.3.0"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
do := apiClient(t, s)
code, b := do("GET", "/api/admin/update/status", nil)
var out struct {
Running bool
Stage string
Pct int
Lines []string
Version string
}
json.Unmarshal(b, &out)
last := ""
if len(out.Lines) > 0 {
last = out.Lines[len(out.Lines)-1]
}
if code != 200 || out.Running || out.Stage != "done" || out.Pct != 100 || len(out.Lines) != 3 || !strings.Contains(last, "now running v0.3.0") {
t.Fatalf("status: %d %s", code, b)
}
// starting an update needs auto-update on
if code, b := do("POST", "/api/admin/update/apply", nil); code != 400 || !strings.Contains(string(b), "HAMMERHEAD_AUTO_UPDATE") {
t.Fatalf("apply without auto-update: %d %s", code, b)
}
}
+152
View File
@@ -0,0 +1,152 @@
package hammerhead
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const maxUpdateLines = 200
// updRun is the progress of one update an edge or site is carrying out, as that agent reported it.
type updRun struct {
ID string // the update request it belongs to
Stage string
Pct int
Lines []string
}
// recordUpdateProgress keeps what an agent says about the update it is running. When the agent
// reports a new version, the update is finished: its log gets a closing line and is then left alone,
// so the freshly restarted program (which starts a new, empty log) can't overwrite it.
// Caller holds s.mu.
func (s *Server) recordUpdateProgress(agentID, reqID, stage string, pct int, lines []string, oldVersion, newVersion string) {
run := s.updRuns[agentID]
if run != nil && run.Stage != "done" && oldVersion != "" && newVersion != "" && newVersion != oldVersion {
run.Lines = append(run.Lines, time.Now().Format("15:04:05")+" Now running "+newVersion)
run.Stage, run.Pct = "done", 100
return
}
if reqID == "" || (stage == "" && len(lines) == 0) {
return
}
if run != nil && run.ID == reqID && run.Stage == "done" {
return
}
s.updRuns[agentID] = &updRun{ID: reqID, Stage: stage, Pct: pct, Lines: lines}
}
func (s *Server) updateLogView(agentID string, pending bool, msg, version string) map[string]any {
out := map[string]any{"running": pending, "msg": msg, "version": version, "stage": "", "pct": 0, "lines": []string{}}
if run := s.updRuns[agentID]; run != nil {
out["stage"], out["pct"], out["lines"] = run.Stage, run.Pct, run.Lines
}
return out
}
func (s *Server) edgeUpdateLog(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(r.PathValue("id"))
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
writeJSON(w, 200, s.updateLogView(e.ID, e.UpdateID != "", e.UpdateMsg, e.Version))
}
func (s *Server) siteUpdateLog(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || !s.siteVisible(c, st) {
fail(w, 404, errors.New("no such site"))
return
}
writeJSON(w, 200, s.updateLogView(st.ID, st.UpdateID != "", st.UpdateMsg, st.Version))
}
// selfUpdate is the progress of this server's own update. It is kept in memory and in <data>/update.log,
// so the page can still show it after the restart that ends a successful update.
type selfUpdate struct {
mu sync.Mutex
running bool
stage string
pct int
lines []string
}
func (s *Server) updateLogPath() string { return filepath.Join(s.dir, "update.log") }
func (s *Server) selfNote(stage string, pct int, line string) {
u := &s.selfUpd
u.mu.Lock()
defer u.mu.Unlock()
if stage != "" {
u.stage = stage
}
if pct >= 0 {
u.pct = pct
}
if line == "" {
return
}
line = time.Now().Format("15:04:05") + " " + line
u.lines = append(u.lines, line)
if len(u.lines) > maxUpdateLines {
u.lines = u.lines[len(u.lines)-maxUpdateLines:]
}
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
f.WriteString(line + "\n")
f.Close()
}
}
func readLines(path string) []string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) == 1 && lines[0] == "" {
return nil
}
if len(lines) > maxUpdateLines {
lines = lines[len(lines)-maxUpdateLines:]
}
return lines
}
// finishSelfUpdateLog runs at startup: if the last run ended by restarting into this process, say so.
func (s *Server) finishSelfUpdateLog() {
lines := readLines(s.updateLogPath())
if n := len(lines); n > 0 && strings.Contains(lines[n-1], "restarting") && s.opts.Version != "" {
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_WRONLY, 0o600); err == nil {
f.WriteString(time.Now().Format("15:04:05") + " Hammerhead restarted: now running " + s.opts.Version + "\n")
f.Close()
}
}
}
// updateStatus returns the progress and log of this server's own update, for the Settings page.
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request, p *principal) {
u := &s.selfUpd
u.mu.Lock()
out := map[string]any{"running": u.running, "stage": u.stage, "pct": u.pct, "lines": append([]string(nil), u.lines...), "version": s.opts.Version}
u.mu.Unlock()
if lines, _ := out["lines"].([]string); len(lines) == 0 {
// nothing in memory: this is a fresh process, so show the last update's log from disk
if saved := readLines(s.updateLogPath()); len(saved) > 0 {
stage := "done"
if strings.Contains(saved[len(saved)-1], "Failed:") {
stage = "failed"
}
out["lines"], out["stage"], out["pct"] = saved, stage, 100
}
}
writeJSON(w, 200, out)
}
+203 -12
View File
@@ -70,6 +70,16 @@
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
.stack { display:flex; flex-direction:column; gap:var(--s3); }
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { border-color:var(--border-strong); background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:1px solid var(--border); }
.progress { height:8px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width .4s ease; }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
@@ -184,6 +194,60 @@ const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Da
const byId = (list, id) => (list || []).find(x => x.id === id);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
// ----- versions and updates -----
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
// until the update is over, and keeps going through the gap while a program restarts into its new version.
function updatePanel(load, onDone) {
const bar = h('div', {style:'width:0%'}), stageEl = h('div', {class:'small'}, 'Starting…'), logEl = h('pre', {class:'logbox'}, 'Waiting for the first message…');
const el = h('div', {class:'stack'}, stageEl, h('div', {class:'progress'}, bar), logEl);
const label = {starting:'Starting', checking:'Checking for the release', downloading:'Downloading', verifying:'Verifying', installing:'Installing', restarting:'Restarting', done:'Done', failed:'Failed'};
let misses = 0;
const tick = async () => {
if (!el.isConnected) return; // the dialog or card was closed
let again = true;
try {
const s = await load(); misses = 0;
const pct = Math.max(0, Math.min(100, s.pct || 0)), lines = s.lines || [];
bar.style.width = pct + '%'; bar.className = s.stage === 'failed' ? 'bad' : '';
stageEl.textContent = (label[s.stage] || s.stage || 'Waiting') + (pct ? ' · ' + pct + '%' : '');
if (lines.length) { const atEnd = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4; logEl.textContent = lines.join('\n'); if (atEnd) logEl.scrollTop = logEl.scrollHeight; }
if (!s.running && (s.stage === 'done' || s.stage === 'failed')) { again = false; if (onDone) onDone(s); }
} catch (e) {
misses++; // the server is probably restarting into the new version
if (misses > 2) stageEl.textContent = 'Restarting… waiting for it to come back';
if (misses > 150) { again = false; stageEl.textContent = 'It has not come back after a few minutes. Check its log on the server.'; }
}
if (again) setTimeout(tick, 800);
};
setTimeout(tick, 100);
return el;
}
// compares vX.Y.Z strings: -1 if a is older than b, 1 if newer, 0 if equal or not comparable
const cmpVer = (a, b) => {
const p = v => (/^v(\d+)\.(\d+)\.(\d+)$/.exec(v || '') || []).slice(1).map(Number), x = p(a), y = p(b);
if (x.length < 3 || y.length < 3) return 0;
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
};
const latestVersions = async () => { try { return await api('GET', '/api/versions'); } catch (e) { return {}; } };
// A table cell with a machine's version, a note when a newer release exists, and an Update button
// when that machine allows being updated from here (it must run with auto-update on).
const updateDialog = (m, logUrl) => modal('Updating ' + m.name, h('div', {class:'stack'}, updatePanel(() => api('GET', logUrl), () => refresh()), h('p', {class:'small dim'}, 'You can close this; the update carries on.')));
const versionCell = (m, latest, pending, updateUrl, logUrl) => {
const behind = latest && m.version && cmpVer(m.version, latest) < 0, kids = [h('span', {class:'mono small'}, m.version || 'unknown')];
if (behind) kids.push(h('div', {class:'small'}, latest + ' is available'));
if (pending) {
kids.push(h('div', {class:'small dim'}, 'Updating…'));
clearTimeout(S.updTimer); S.updTimer = setTimeout(() => refresh(), 4000); // look again for the result
} else if (m.updateMsg) kids.push(h('div', {class:'small dim'}, m.updateMsg));
if (behind && !pending) kids.push(h('button', {class:'ghost', disabled:!m.autoUpdate,
title:m.autoUpdate ? 'Install ' + latest + ' now' : 'Turn on auto-update on that machine (…_AUTO_UPDATE=1) to update it from here',
onclick: act(async () => { await api('POST', updateUrl); updateDialog(m, logUrl); refresh(); })}, 'Update'));
if (pending || m.updateMsg) kids.push(h('button', {class:'ghost', onclick:() => updateDialog(m, logUrl)}, 'Log'));
return h('td', {}, h('div', {class:'stack'}, ...kids));
};
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
@@ -391,13 +455,15 @@ views.ports = async () => {
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
S.headerAction = ['Open a port', () => portForm()];
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(m => mappingRow(m, st, traffic[m.id])))))) : null;
const table = rows.length ? h('div', {}, section('Websites', rows.filter(isWebRow)), section('Ports', rows.filter(m => !isWebRow(m)))) : h('div', {class:'card'}, h('div', {class:'empty'}, 'Nothing here yet. Use Add resource to share a website or open a port.'));
S.headerAction = ['Add resource', () => portForm()];
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
return h('div', {}, unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, h('div', {class:'card flush'}, table));
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
};
function spark(series) {
@@ -426,13 +492,13 @@ function mappingRow(m, st, series) {
}
const web = m.proto === 'http' || m.proto === 'https';
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
const pubAddr = (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
return h('tr', {},
h('td', {title:why}, h('span', {class:dot})),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
s.error ? h('div', {class:'err small mono'}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, edge ? edge.name : '?')),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'mono small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
@@ -547,10 +613,109 @@ function portForm(existing) {
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
close(); toast('Saved'); refresh();
});
const close = modal(existing ? 'Edit port' : 'Open a port', h('div', {class:'stack'},
const close = existing ? modal('Edit port', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true}) : wizardModal();
// The add flow for a new resource: a few short steps that reuse the controls above.
function wizardModal() {
const TYPES = [
['https', '🌐', 'Website', 'A site or web app, with an automatic HTTPS certificate.'],
['tcp', '🎮', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'],
['udp', '📡', 'UDP port', 'Voice chat, some games, DNS.'],
['both', '🔁', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', '🔒', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', '🌍', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
];
const [accessFs, rulesFs, advFs] = [...webBox.children];
let step = 0, chosen = false;
const isWeb = () => protoSel.value === 'http' || protoSel.value === 'https';
const flow = () => isWeb() ? ['type', 'target', 'reach', 'access', 'finish'] : ['type', 'target', 'reach', 'finish'];
const titles = {type:'What are you sharing?', target:'Where does it point?', reach:'How do people reach it?', access:'Who can open it?', finish:'Name it and review'};
const body = h('div', {class:'stack'}), crumbs = h('div', {class:'small dim'}), err = h('div', {class:'err small'});
const back = h('button', {class:'ghost', type:'button', onclick:() => go(-1)}, 'Back');
const next = h('button', {class:'btn', type:'button', onclick:() => go(1)}, 'Next');
const portRe = /^(\d+)(?:\s*-\s*(\d+))?$/;
const summary = () => {
const t = targets.map(x => ((byId(st.sites, x.siteId) || {name:'?'}).name) + ' → ' + x.host + ':' + x.port).join(', '), pr = (port.value || '').trim();
const protoName = {both:'TCP + UDP', tcp:'TCP', udp:'UDP', tls:'TLS'}[protoSel.value];
const where = isWeb() ? protoSel.value + '://' + (host.value.trim() || '*') + (pr && pr !== '443' && pr !== '80' ? ':' + pr : '')
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value.trim() || 'every address');
const login = ssoC.box.checked || pwC.box.checked || pinC.box.checked || mailC.box.checked;
return h('div', {class:'stack'}, h('div', {}, h('b', {class:'mono'}, where)), h('div', {class:'small'}, '→ ' + t),
isWeb() ? h('div', {class:'small dim'}, login ? 'Visitors have to sign in.' : 'Anyone can open it.') : null);
};
const validate = cur => {
if (cur === 'type') return chosen ? '' : 'Choose what you are sharing.';
if (cur === 'target') return targets.every(t => t.siteId && String(t.host).trim() && t.port > 0) ? '' : 'Choose a site and enter the host and port for every target.';
if (cur === 'reach') {
if ((isWeb() || protoSel.value === 'tls') && !host.value.trim()) return 'Enter the hostname people will visit.';
return portRe.test((port.value || '').trim()) ? '' : 'Enter a public port like 25565, or a range like 2456-2458.';
}
if (cur === 'access') {
if (pwC.box.checked && !pwIn.value) return 'Enter the shared password, or turn that option off.';
if (pinC.box.checked && !pinIn.value) return 'Enter a PIN, or turn that option off.';
}
return '';
};
const prefill = () => {
if (port.value) return;
const t = protoSel.value;
port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
};
const pick = v => {
protoSel.value = v; chosen = true; protoSel.onchange();
if (v === 'https') redir.box.checked = true;
port.value = ''; step = 1; draw();
};
const go = dir => {
const cur = flow()[step];
if (dir > 0) {
const msg = validate(cur);
if (msg) { err.textContent = msg; return; }
if (cur === 'finish') {
if (!name.value.trim()) name.value = host.value.trim() || (protoSel.value.toUpperCase() + ' ' + port.value.trim());
save(); return;
}
step++;
} else step = Math.max(0, step - 1);
draw();
};
function draw() {
const f = flow(), cur = f[step];
err.textContent = '';
crumbs.textContent = 'Step ' + (step + 1) + ' of ' + f.length;
next.textContent = cur === 'finish' ? (isWeb() ? 'Add website' : 'Open port') : 'Next';
next.style.display = cur === 'type' ? 'none' : '';
back.style.display = step ? '' : 'none';
const kids = [h('h3', {style:'margin:0'}, titles[cur])];
if (cur === 'type') {
kids.push(h('div', {class:'choices'}, TYPES.map(([v, icon, t, d]) => h('button', {type:'button', class:'choice' + (chosen && protoSel.value === v ? ' on' : ''), onclick:() => pick(v)},
h('div', {class:'choice-t'}, icon + ' ' + t), h('div', {class:'small dim'}, d)))));
} else if (cur === 'target') {
if (st.edges.length > 1) kids.push(field('Public server (edge)', edgeSel, 'The server that listens for visitors.'));
kids.push(h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), proxyBox);
} else if (cur === 'reach') {
prefill();
const web = isWeb();
kids.push(h('div', {class:'grid'}, web || protoSel.value === 'tls' ? hostBox : null,
field('Public port', port, web ? 'Websites normally use ' + (protoSel.value === 'https' ? '443' : '80') + '.' : 'One port, or a range like 2456-2458: each port goes to the target port plus the same offset.'),
field('Public IP', ipIn, '0.0.0.0 means every address on the edge.')), ipList, domList);
} else if (cur === 'access') {
kids.push(accessFs);
} else {
kids.push(field('Name', name, 'Optional. Shown in the list.'), h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'),
h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : []))));
}
body.replaceChildren(...kids);
}
draw();
return modal('Add a resource', h('div', {class:'stack'}, crumbs, body, err,
h('div', {class:'actions'}, back, next, h('button', {class:'ghost', type:'button', onclick:() => close()}, 'Cancel'))), {sticky:true});
}
}
async function shareDialog(m) {
@@ -581,12 +746,14 @@ views.sites = async () => {
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions();
return h('div', {},
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, '—') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
@@ -857,10 +1024,11 @@ serverViews.edges = async () => {
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge'),
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions();
return h('div', {},
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Orgs', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
};
@@ -925,12 +1093,35 @@ serverViews.settings = async () => {
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
const updNow = h('button', {class:'btn', style:'display:none', onclick: act(async () => {
await api('POST', '/api/admin/update/apply');
updNow.style.display = 'none';
showPanel();
})}, 'Update now');
const updBox = h('div', {style:'margin-top:12px'});
const showPanel = () => { updOut.textContent = 'Updating Hammerhead. The page reconnects by itself after the restart.'; updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'), async s => { if (s.stage === 'done') updOut.textContent = 'Updated: Hammerhead is now running ' + s.version + '.'; })); };
api('GET', '/api/admin/update/status').then(s => {
if (s.running) showPanel();
else if ((s.lines || []).length) updBox.append(h('button', {class:'ghost', onclick:() => { updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'))); }}, 'Show the last update log'));
}).catch(() => {});
const checkUpdate = act(async () => {
updOut.textContent = 'Checking…'; updNow.style.display = 'none';
const u = await api('GET', '/api/admin/update');
const dev = !/^v\d+\.\d+\.\d+$/.test(u.current || '');
if (u.error) updOut.textContent = 'Running ' + (u.current || 'an unknown version') + '. Could not check for updates: ' + u.error;
else if (dev) updOut.textContent = 'This is a development build (' + (u.current || 'unversioned') + '). The newest release is ' + u.latest + '.';
else if (u.newer) { updOut.textContent = 'Running ' + u.current + '. ' + u.latest + ' is available. ' + (u.autoUpdate ? 'Auto-update is on, so it installs by itself within about six hours, or you can install it now.' : 'Auto-update is off: install it yourself, or set HAMMERHEAD_AUTO_UPDATE=1 and restart.'); if (u.autoUpdate) updNow.style.display = ''; }
else updOut.textContent = 'Running ' + u.current + ', which is the newest release.';
});
return h('div', {},
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox),
h('button', {class:'btn', onclick:save}, 'Save settings'));
};