From 536284ec750a8c2e91cea2a5848b072e531f5eb8 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:38:16 -0600 Subject: [PATCH] Give Hammerhead its own edge with a built-in dashboard route; setup.sh installs it with Blacktip instead of Caddy HAMMERHEAD_SELF_DOMAIN creates an edge for this machine, writes its token to /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 --- .env.example | 8 ++ README.md | 10 +-- agent.go | 3 + cmd/hammerhead/main.go | 17 ++++ deploy/setup.sh | 192 ++++++++++++++++++++++++++--------------- selfedge.go | 71 +++++++++++++++ selfedge_test.go | 98 +++++++++++++++++++++ store.go | 17 ++++ 8 files changed, 342 insertions(+), 74 deletions(-) create mode 100644 selfedge.go create mode 100644 selfedge_test.go diff --git a/.env.example b/.env.example index c25e5d0..999dbb2 100644 --- a/.env.example +++ b/.env.example @@ -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 /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 :51820. +#HAMMERHEAD_SELF_TUNNEL=hammerhead.example.com:51820 + # How long access logs are kept (days). Default 30. #HAMMERHEAD_LOG_DAYS=30 diff --git a/README.md b/README.md index b6631cd..197bc6f 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,15 @@ Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea **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 --ports 25565,30000-30100 -sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --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 # on each site +sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --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://` 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)** diff --git a/agent.go b/agent.go index a6c2b89..6a89776 100644 --- a/agent.go +++ b/agent.go @@ -73,6 +73,9 @@ 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.MailEgress = []model.MailPolicy{} for _, x := range s.st.MailEgress { if x.EdgeID == e.ID && x.Enabled { diff --git a/cmd/hammerhead/main.go b/cmd/hammerhead/main.go index 04fb778..5ac1ebb 100644 --- a/cmd/hammerhead/main.go +++ b/cmd/hammerhead/main.go @@ -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,9 @@ 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"), }) if err != nil { log.Fatal(err) diff --git a/deploy/setup.sh b/deploy/setup.sh index e85bf14..e75c3ed 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -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:// 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 :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 : 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 $*" @@ -75,10 +79,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 : downloads that app's release for this CPU, verifies it and runs its install.sh +# fetch_install [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 +108,12 @@ set_env() { fi } +# auto_update : 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 +136,17 @@ wait_active() { # wait_active 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 " - echo "and on each machine that hosts your services (home server, game server...) run" - echo " sh setup.sh agent --hammerhead $URL --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 : 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 +174,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 " + 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) + [ -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" diff --git a/selfedge.go b/selfedge.go new file mode 100644 index 0000000..2de064d --- /dev/null +++ b/selfedge.go @@ -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 /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 +} diff --git a/selfedge_test.go b/selfedge_test.go new file mode 100644 index 0000000..d9a8ae9 --- /dev/null +++ b/selfedge_test.go @@ -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") + } +} diff --git a/store.go b/store.go index 333aaff..ca70380 100644 --- a/store.go +++ b/store.go @@ -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,14 @@ 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 :51820). + SelfDomain string + SelfTarget string + SelfTunnel string } type Server struct { @@ -258,6 +268,13 @@ func New(dir string, opts Options) (*Server, error) { 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 { db.Close() return nil, err