Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536284ec75 | ||
|
|
9ad690def0 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 <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)**
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
+123
-69
@@ -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 $*"
|
||||
@@ -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 <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 +108,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 +136,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 +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 <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"
|
||||
|
||||
+71
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <SelfDomain>: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
|
||||
|
||||
Reference in new issue
Block a user