Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd48917c7 | ||
|
|
579ad1521a | ||
|
|
abf352e6c9 | ||
|
|
da3d7201a1 | ||
|
|
536284ec75 | ||
|
|
9ad690def0 |
No files matched your search
@@ -21,6 +21,14 @@ HAMMERHEAD_MASTER_KEY=
|
|||||||
# Leave empty and /metrics does not exist.
|
# Leave empty and /metrics does not exist.
|
||||||
#HAMMERHEAD_METRICS_TOKEN=
|
#HAMMERHEAD_METRICS_TOKEN=
|
||||||
|
|
||||||
|
# Serve the dashboard through a Blacktip edge on this same machine, at this name, with an
|
||||||
|
# automatic HTTPS certificate: no Caddy or other web server in front of Hammerhead. Hammerhead
|
||||||
|
# makes the edge itself and writes its token to <data>/self-edge.env (BLACKTIP_TOKEN=...).
|
||||||
|
# deploy/setup.sh --domain sets this up for you. Needs HAMMERHEAD_URL set to the same name.
|
||||||
|
#HAMMERHEAD_SELF_DOMAIN=hammerhead.example.com
|
||||||
|
# The edge's public WireGuard address for sites; default <HAMMERHEAD_SELF_DOMAIN>:51820.
|
||||||
|
#HAMMERHEAD_SELF_TUNNEL=hammerhead.example.com:51820
|
||||||
|
|
||||||
# How long access logs are kept (days). Default 30.
|
# How long access logs are kept (days). Default 30.
|
||||||
#HAMMERHEAD_LOG_DAYS=30
|
#HAMMERHEAD_LOG_DAYS=30
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
# Catches platform-specific breakage before a release is tagged.
|
# Catches platform-specific breakage before a release is tagged.
|
||||||
- name: Every release platform compiles
|
- name: Every release platform compiles
|
||||||
run: |
|
run: |
|
||||||
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do
|
for t in linux/amd64 windows/amd64; do
|
||||||
echo "building for $t"
|
echo "building for $t"
|
||||||
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
|
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
|
||||||
done
|
done
|
||||||
@@ -59,4 +59,4 @@ jobs:
|
|||||||
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
|
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
|
||||||
*) tags="$tags -t $image:latest" ;;
|
*) tags="$tags -t $image:latest" ;;
|
||||||
esac
|
esac
|
||||||
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
|
docker buildx build --platform linux/amd64 $tags --push .
|
||||||
@@ -41,19 +41,19 @@ ports. Then open ports on the **Resources** page.
|
|||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Hammerhead for Linux (amd64, arm64, armv7), Windows (amd64) and macOS (amd64, arm64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a multi-architecture container image to this server's registry (`git.veillax.com/veillax/hammerhead`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
|
Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Hammerhead for Linux (amd64) and Windows (amd64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a container image (linux/amd64) to this server's registry (`git.veillax.com/veillax/hammerhead`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
|
||||||
|
|
||||||
**Fastest: one script on a fresh VPS**
|
**Fastest: one script on a fresh VPS**
|
||||||
|
|
||||||
`deploy/setup.sh` (also inside the Linux archives) installs everything for you on a systemd Linux: the service, a generated master key, the firewall rules, and optionally HTTPS.
|
`deploy/setup.sh` (also inside the Linux archives) installs everything for you on a systemd Linux: the services, a generated master key, the firewall rules and HTTPS.
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo sh setup.sh --domain hammerhead.example.com # Hammerhead + Caddy for HTTPS
|
sudo sh setup.sh --domain hammerhead.example.com # Hammerhead and an edge on this machine
|
||||||
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> --ports 25565,30000-30100
|
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token> # on each site
|
||||||
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token>
|
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> # more public servers
|
||||||
```
|
```
|
||||||
|
|
||||||
`edge` installs Blacktip (with its bundled Traefik) and lists every IP address on the machine that ports can be opened on; `agent` installs Bamboo on the machine that hosts your services. All three can run on one machine or on three.
|
With `--domain`, Hammerhead listens only on `127.0.0.1:8080` and gets an edge of its own (`HAMMERHEAD_SELF_DOMAIN`): the Blacktip on the same machine serves the dashboard at `https://<domain>` with an automatic certificate, so no Caddy or other web server is needed. The route is built in, not a mapping you can edit or delete by mistake. DNS for the name must point at the server and ports 80 and 443 must be free. `--caddy` keeps the old style: Hammerhead alone, with Caddy providing HTTPS. Add `--ports 25565,30000-30100` to open game or mail ports in the firewall, and `--no-auto-update` to leave updates to you.
|
||||||
|
|
||||||
**On a Linux server (systemd)**
|
**On a Linux server (systemd)**
|
||||||
|
|
||||||
@@ -67,12 +67,14 @@ sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
|
|||||||
sudo systemctl start hammerhead
|
sudo systemctl start hammerhead
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/hammerhead` and `/var/lib/hammerhead` alone. `hammerhead -version` tells you which build is running.
|
Releases are published for x86-64 only; for ARM machines build it yourself (`TARGETS=linux/arm64 sh deploy/build.sh`). To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/hammerhead` and `/var/lib/hammerhead` alone. `hammerhead -version` tells you which build is running.
|
||||||
|
|
||||||
**Updates**
|
**Updates**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**With Docker**
|
**With Docker**
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
cfg.Mappings = append(cfg.Mappings, *m)
|
cfg.Mappings = append(cfg.Mappings, *m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m := s.selfMapping(e); m != nil {
|
||||||
|
cfg.Mappings = append(cfg.Mappings, *m)
|
||||||
|
}
|
||||||
|
cfg.UpdateID = e.UpdateID
|
||||||
cfg.MailEgress = []model.MailPolicy{}
|
cfg.MailEgress = []model.MailPolicy{}
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.EdgeID == e.ID && x.Enabled {
|
if x.EdgeID == e.ID && x.Enabled {
|
||||||
@@ -107,6 +111,7 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
e.LastSeen = time.Now()
|
e.LastSeen = time.Now()
|
||||||
e.IPs = in.IPs
|
e.IPs = in.IPs
|
||||||
|
answered := noteUpdateReport(&e.Version, &e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.EdgeID == e.ID {
|
if x.EdgeID == e.ID {
|
||||||
if chk, found := in.MailChecks[x.ID]; found {
|
if chk, found := in.MailChecks[x.ID]; found {
|
||||||
@@ -143,6 +148,8 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e.PublicKey != in.PublicKey {
|
if e.PublicKey != in.PublicKey {
|
||||||
e.PublicKey = in.PublicKey
|
e.PublicKey = in.PublicKey
|
||||||
s.bump() // sites need the edge's new key
|
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 {
|
} else {
|
||||||
s.save()
|
s.save()
|
||||||
}
|
}
|
||||||
@@ -202,7 +209,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
st.LastSeen = time.Now()
|
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{}
|
seen := map[string]bool{}
|
||||||
addEdge := func(id string) {
|
addEdge := func(id string) {
|
||||||
if seen[id] {
|
if seen[id] {
|
||||||
@@ -246,6 +253,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.Connected = []string{}
|
in.Connected = []string{}
|
||||||
}
|
}
|
||||||
st.Connected = in.Connected
|
st.Connected = in.Connected
|
||||||
|
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
||||||
s.containers[st.ID] = in.Containers
|
s.containers[st.ID] = in.Containers
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.SiteID != st.ID {
|
if x.SiteID != st.ID {
|
||||||
@@ -260,6 +268,8 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
|
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
|
||||||
st.PublicKey = in.PublicKey
|
st.PublicKey = in.PublicKey
|
||||||
s.bump() // every edge this site talks to needs the new key
|
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)
|
ok(w)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -20,6 +21,19 @@ import (
|
|||||||
// version is set at build time (deploy/build.sh).
|
// version is set at build time (deploy/build.sh).
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// localAddr turns a listen address into one this machine can dial: ":8080" and "0.0.0.0:8080"
|
||||||
|
// become "127.0.0.1:8080".
|
||||||
|
func localAddr(listen string) string {
|
||||||
|
host, port, err := net.SplitHostPort(listen)
|
||||||
|
if err != nil {
|
||||||
|
return listen
|
||||||
|
}
|
||||||
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
func env(k, def string) string {
|
func env(k, def string) string {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -61,6 +75,11 @@ func main() {
|
|||||||
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
|
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
|
||||||
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
|
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
|
||||||
LogRetentionDays: days,
|
LogRetentionDays: days,
|
||||||
|
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
|
||||||
|
SelfTarget: localAddr(*listen),
|
||||||
|
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
|
||||||
|
Version: version,
|
||||||
|
AutoUpdate: *autoUpdate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
+3
-2
@@ -8,12 +8,13 @@
|
|||||||
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
|
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
|
||||||
# dist/SHA256SUMS
|
# dist/SHA256SUMS
|
||||||
#
|
#
|
||||||
# Set TARGETS="linux/amd64 linux/arm64" to build fewer platforms.
|
# Releases are published for linux/amd64 and windows/amd64 only. Set TARGETS="linux/arm64 darwin/arm64"
|
||||||
|
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
|
||||||
set -eu
|
set -eu
|
||||||
NAME=hammerhead
|
NAME=hammerhead
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
||||||
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64"}
|
TARGETS=${TARGETS:-"linux/amd64 windows/amd64"}
|
||||||
|
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
mkdir -p dist/bin dist/stage
|
mkdir -p dist/bin dist/stage
|
||||||
|
|||||||
@@ -42,19 +42,30 @@ else
|
|||||||
echo "release $TAG already exists (id $id): replacing its files"
|
echo "release $TAG already exists (id $id): replacing its files"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json
|
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
|
||||||
|
asset_ids() {
|
||||||
|
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
|
||||||
for a in json.load(sys.stdin):
|
for a in json.load(sys.stdin):
|
||||||
print(a["id"], a["name"])')
|
if a["name"] == sys.argv[1]:
|
||||||
|
print(a["id"])' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
|
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
name=$(basename "$f")
|
name=$(basename "$f")
|
||||||
echo "$existing" | while read -r aid aname; do
|
n=1
|
||||||
if [ "$aname" = "$name" ]; then
|
while :; do
|
||||||
|
for aid in $(asset_ids "$name"); do
|
||||||
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
|
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
|
||||||
|
done
|
||||||
|
echo "uploading $name (attempt $n)"
|
||||||
|
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
|
||||||
|
break
|
||||||
fi
|
fi
|
||||||
|
# large uploads can hit a proxy timeout (HTTP 502); try again
|
||||||
|
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
|
||||||
|
n=$((n + 1))
|
||||||
|
sleep $((n * 10))
|
||||||
done
|
done
|
||||||
echo "uploading $name"
|
|
||||||
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
|
|
||||||
done
|
done
|
||||||
echo "published $SERVER/$REPO/releases/tag/$TAG"
|
echo "published $SERVER/$REPO/releases/tag/$TAG"
|
||||||
+124
-72
@@ -1,45 +1,49 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# One-shot setup for a VPS. Run as root.
|
# One-shot setup for a VPS. Run as root.
|
||||||
#
|
#
|
||||||
# sh setup.sh [--domain hh.example.com] [--version v0.1.0] [--no-auto-update] Hammerhead (dashboard and API)
|
# sh setup.sh --domain hh.example.com [options] Hammerhead and a Blacktip edge on this machine
|
||||||
# sh setup.sh edge --hammerhead URL --token TOKEN [--ports 25565,30000-30100] [--version V]
|
# sh setup.sh edge --hammerhead URL --token TOKEN [options] another edge
|
||||||
# sh setup.sh agent --hammerhead URL --token TOKEN [--version V]
|
# sh setup.sh agent --hammerhead URL --token TOKEN [options] a site agent
|
||||||
#
|
#
|
||||||
# Hammerhead: installs it as a systemd service (with a generated master key, kept in
|
# With --domain, this installs Hammerhead (the dashboard) and a Blacktip edge on the same
|
||||||
# /etc/hammerhead/hammerhead.env), and with --domain also Caddy, which gets and renews
|
# machine. The edge serves the dashboard itself at https://<domain> with an automatic
|
||||||
# the HTTPS certificate for that name. DNS for the name must already point here.
|
# certificate, so no separate web server is needed. DNS for the domain must already point here,
|
||||||
# edge: installs Blacktip, with its bundled Traefik, on this machine (or any other VPS),
|
# and ports 80 and 443 must be free (stop Pangolin, Caddy, nginx... first).
|
||||||
# opens the firewall and lists every IP address it can open ports on.
|
|
||||||
# agent: installs Bamboo, the site agent, on the machine that hosts your services.
|
|
||||||
#
|
#
|
||||||
# Edge and agent need a token, which only exists once Hammerhead is up: create the edge under
|
# Options:
|
||||||
# Server -> Edges (or the site under Sites) in the dashboard and copy the values from "Show command".
|
# --version V release of Hammerhead (or of the edge/agent for those modes); default newest
|
||||||
|
# --edge-version V release of the Blacktip installed next to Hammerhead; default newest
|
||||||
|
# --ports 25565,30000-30100 also open these (TCP and UDP) in the firewall
|
||||||
|
# --tunnel HOST:PORT the edge's public WireGuard address; default <domain>:51820
|
||||||
|
# --caddy old style: Hammerhead alone, with Caddy providing HTTPS (no edge here)
|
||||||
|
# --no-auto-update don't let the programs update themselves
|
||||||
|
#
|
||||||
|
# Without --domain, Hammerhead alone is installed on port 8080 over plain HTTP.
|
||||||
|
# Edge and agent need a token: create the edge under Server -> Edges (or the site under Sites)
|
||||||
|
# in the dashboard and copy the values from "Show command".
|
||||||
# Safe to run again: it upgrades and keeps your settings and data.
|
# Safe to run again: it upgrades and keeps your settings and data.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
|
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
|
||||||
MODE=hammerhead
|
MODE=hammerhead
|
||||||
case "${1:-}" in edge | agent) MODE=$1; shift ;; esac
|
case "${1:-}" in edge | agent) MODE=$1; shift ;; esac
|
||||||
DOMAIN= VERSION= HH= TOKEN= PORTS= AUTOUP=1
|
DOMAIN= VERSION= EDGE_VERSION= HH= TOKEN= PORTS= TUNNEL= CADDY=0 AUTOUP=1
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--domain) DOMAIN=$2; shift 2 ;;
|
--domain) DOMAIN=$2; shift 2 ;;
|
||||||
--version) VERSION=$2; shift 2 ;;
|
--version) VERSION=$2; shift 2 ;;
|
||||||
|
--edge-version) EDGE_VERSION=$2; shift 2 ;;
|
||||||
--hammerhead) HH=$2; shift 2 ;;
|
--hammerhead) HH=$2; shift 2 ;;
|
||||||
--token) TOKEN=$2; shift 2 ;;
|
--token) TOKEN=$2; shift 2 ;;
|
||||||
--ports) PORTS=$2; shift 2 ;;
|
--ports) PORTS=$2; shift 2 ;;
|
||||||
|
--tunnel) TUNNEL=$2; shift 2 ;;
|
||||||
|
--caddy) CADDY=1; shift ;;
|
||||||
--no-auto-update) AUTOUP=0; shift ;;
|
--no-auto-update) AUTOUP=0; shift ;;
|
||||||
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
|
-h | --help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||||
*) echo "unknown option $1 (see --help)" >&2; exit 2 ;;
|
*) echo "unknown option $1 (see --help)" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
|
|
||||||
auto_update() {
|
|
||||||
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
say() { printf '\n== %s\n' "$*"; }
|
say() { printf '\n== %s\n' "$*"; }
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
[ "$(id -u)" = 0 ] || die "run as root: sudo sh $0 $*"
|
[ "$(id -u)" = 0 ] || die "run as root: sudo sh $0 $*"
|
||||||
@@ -47,9 +51,7 @@ command -v systemctl > /dev/null 2>&1 || die "this needs a systemd-based Linux (
|
|||||||
|
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
x86_64 | amd64) ARCH=amd64 ;;
|
x86_64 | amd64) ARCH=amd64 ;;
|
||||||
aarch64 | arm64) ARCH=arm64 ;;
|
*) die "releases are published for x86-64 Linux only, not $(uname -m); build it yourself with: TARGETS=linux/arm64 sh deploy/build.sh" ;;
|
||||||
armv7* | armv6* | arm) ARCH=arm ;;
|
|
||||||
*) die "unsupported CPU: $(uname -m)" ;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
pkg_install() { # installs packages with whichever package manager there is
|
pkg_install() { # installs packages with whichever package manager there is
|
||||||
@@ -75,10 +77,10 @@ latest() { # newest release tag of repo $1 (read from git, so no API token is ne
|
|||||||
git ls-remote --tags --refs "$BASE/$1" 2> /dev/null | sed 's|.*refs/tags/||' | grep -E '^v[0-9]' | sort -V | tail -n 1
|
git ls-remote --tags --refs "$BASE/$1" 2> /dev/null | sed 's|.*refs/tags/||' | grep -E '^v[0-9]' | sort -V | tail -n 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# fetch_install <app>: downloads that app's release for this CPU, verifies it and runs its install.sh
|
# fetch_install <app> [version]: downloads that app's release for this CPU, verifies it and runs its install.sh
|
||||||
fetch_install() {
|
fetch_install() {
|
||||||
app=$1
|
app=$1
|
||||||
ver=${VERSION:-$(latest "$app")}
|
ver=${2:-$(latest "$app")}
|
||||||
[ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)"
|
[ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)"
|
||||||
dir=$(mktemp -d)
|
dir=$(mktemp -d)
|
||||||
say "downloading $app $ver ($ARCH)"
|
say "downloading $app $ver ($ARCH)"
|
||||||
@@ -104,6 +106,12 @@ set_env() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
|
||||||
|
auto_update() {
|
||||||
|
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
firewall_open() { # firewall_open 80/tcp 443/tcp ...
|
firewall_open() { # firewall_open 80/tcp 443/tcp ...
|
||||||
if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then
|
if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then
|
||||||
for p in "$@"; do ufw allow "$p" > /dev/null; done
|
for p in "$@"; do ufw allow "$p" > /dev/null; done
|
||||||
@@ -126,54 +134,17 @@ wait_active() { # wait_active <service>
|
|||||||
systemctl is-active --quiet "$1" || { journalctl -u "$1" -n 20 --no-pager; die "$1 didn't start"; }
|
systemctl is-active --quiet "$1" || { journalctl -u "$1" -n 20 --no-pager; die "$1 didn't start"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
case $MODE in
|
# listeners on 80/443 that would stop an edge serving websites
|
||||||
hammerhead)
|
port_users() {
|
||||||
fetch_install hammerhead
|
ss -H -ltnp '( sport = :80 or sport = :443 )' 2> /dev/null | grep -v '"blacktip"\|"traefik"' || true
|
||||||
ENVF=/etc/hammerhead/hammerhead.env
|
}
|
||||||
auto_update "$ENVF" HAMMERHEAD
|
|
||||||
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
|
# edge_setup <hammerhead url> <token> <version>: Blacktip with its bundled Traefik, configured but not started
|
||||||
say "generating the master key"
|
edge_setup() {
|
||||||
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/bin/hammerhead -genkey)"
|
fetch_install blacktip "$3"
|
||||||
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
|
|
||||||
fi
|
|
||||||
if [ -n "$DOMAIN" ]; then
|
|
||||||
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
|
|
||||||
# only Caddy needs to reach it
|
|
||||||
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' /etc/systemd/system/hammerhead.service
|
|
||||||
systemctl daemon-reload
|
|
||||||
if ! command -v caddy > /dev/null 2>&1; then
|
|
||||||
say "installing Caddy (HTTPS certificates for $DOMAIN)"
|
|
||||||
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
|
|
||||||
fi
|
|
||||||
mkdir -p /etc/caddy
|
|
||||||
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
|
|
||||||
systemctl enable caddy > /dev/null 2>&1 || true
|
|
||||||
systemctl restart caddy
|
|
||||||
firewall_open 80/tcp 443/tcp
|
|
||||||
URL="https://$DOMAIN"
|
|
||||||
else
|
|
||||||
firewall_open 8080/tcp
|
|
||||||
ip=$(public_ips | head -n 1)
|
|
||||||
URL="http://${ip:-this-server}:8080"
|
|
||||||
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
|
|
||||||
fi
|
|
||||||
systemctl restart hammerhead
|
|
||||||
wait_active hammerhead
|
|
||||||
say "Hammerhead is running"
|
|
||||||
echo "Open $URL to create the first administrator."
|
|
||||||
echo
|
|
||||||
echo "Next, in the dashboard: Server -> Edges -> add an edge, then on the machine that has your public IPs run"
|
|
||||||
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
|
|
||||||
echo "and on each machine that hosts your services (home server, game server...) run"
|
|
||||||
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
|
|
||||||
echo "Either can be this same machine."
|
|
||||||
;;
|
|
||||||
edge)
|
|
||||||
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
|
|
||||||
fetch_install blacktip
|
|
||||||
ENVF=/etc/blacktip/blacktip.env
|
ENVF=/etc/blacktip/blacktip.env
|
||||||
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH"
|
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$1"
|
||||||
set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN"
|
set_env "$ENVF" BLACKTIP_TOKEN "$2"
|
||||||
auto_update "$ENVF" BLACKTIP
|
auto_update "$ENVF" BLACKTIP
|
||||||
chmod 600 "$ENVF"
|
chmod 600 "$ENVF"
|
||||||
say "network"
|
say "network"
|
||||||
@@ -201,13 +172,94 @@ edge)
|
|||||||
fi
|
fi
|
||||||
echo "In the dashboard each port picks which of these addresses it listens on."
|
echo "In the dashboard each port picks which of these addresses it listens on."
|
||||||
echo "Ports you open later in the dashboard must also be allowed in the firewall: run this again with --ports."
|
echo "Ports you open later in the dashboard must also be allowed in the firewall: run this again with --ports."
|
||||||
|
}
|
||||||
|
|
||||||
|
case $MODE in
|
||||||
|
hammerhead)
|
||||||
|
fetch_install hammerhead "$VERSION"
|
||||||
|
ENVF=/etc/hammerhead/hammerhead.env
|
||||||
|
auto_update "$ENVF" HAMMERHEAD
|
||||||
|
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
|
||||||
|
say "generating the master key"
|
||||||
|
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/lib/hammerhead/hammerhead -genkey)"
|
||||||
|
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
|
||||||
|
fi
|
||||||
|
UNIT=/etc/systemd/system/hammerhead.service
|
||||||
|
if [ -n "$DOMAIN" ]; then
|
||||||
|
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
|
||||||
|
# only something on this machine needs to reach it
|
||||||
|
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' "$UNIT"
|
||||||
|
if [ "$CADDY" = 1 ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
if ! command -v caddy > /dev/null 2>&1; then
|
||||||
|
say "installing Caddy (HTTPS certificates for $DOMAIN)"
|
||||||
|
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
|
||||||
|
fi
|
||||||
|
mkdir -p /etc/caddy
|
||||||
|
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
|
||||||
|
systemctl enable caddy > /dev/null 2>&1 || true
|
||||||
|
systemctl restart caddy
|
||||||
|
firewall_open 80/tcp 443/tcp
|
||||||
|
URL="https://$DOMAIN"
|
||||||
|
else
|
||||||
|
# the dashboard is served by the edge on this machine, so Hammerhead gets its own edge
|
||||||
|
set_env "$ENVF" HAMMERHEAD_SELF_DOMAIN "$DOMAIN"
|
||||||
|
[ -z "$TUNNEL" ] || set_env "$ENVF" HAMMERHEAD_SELF_TUNNEL "$TUNNEL"
|
||||||
|
URL="https://$DOMAIN"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
firewall_open 8080/tcp
|
||||||
|
ip=$(public_ips | head -n 1)
|
||||||
|
URL="http://${ip:-this-server}:8080"
|
||||||
|
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart hammerhead
|
||||||
|
wait_active hammerhead
|
||||||
|
say "Hammerhead is running"
|
||||||
|
|
||||||
|
if [ -n "$DOMAIN" ] && [ "$CADDY" = 0 ]; then
|
||||||
|
TOKENFILE=/var/lib/hammerhead/self-edge.env
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do [ -f "$TOKENFILE" ] && break; sleep 1; done
|
||||||
|
[ -f "$TOKENFILE" ] || die "Hammerhead didn't create $TOKENFILE; check: journalctl -u hammerhead"
|
||||||
|
edge_token=$(sed -n 's/^BLACKTIP_TOKEN=//p' "$TOKENFILE")
|
||||||
|
edge_setup http://127.0.0.1:8080 "$edge_token" "$EDGE_VERSION"
|
||||||
|
# something else on 80/443 would stop the dashboard route from coming up
|
||||||
|
if systemctl is-active --quiet caddy 2> /dev/null; then
|
||||||
|
say "stopping Caddy"
|
||||||
|
echo "Caddy was serving this site and holds ports 80/443, which the edge needs now."
|
||||||
|
systemctl disable --now caddy > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
others=$(port_users)
|
||||||
|
if [ -n "$others" ]; then
|
||||||
|
echo "$others" >&2
|
||||||
|
die "something else is listening on port 80 or 443 (above). Stop it (for Pangolin: docker compose stop), then run: systemctl restart blacktip"
|
||||||
|
fi
|
||||||
|
systemctl restart blacktip
|
||||||
|
wait_active blacktip
|
||||||
|
say "Blacktip is running"
|
||||||
|
echo "The dashboard is served by the edge on this machine at $URL once the certificate is issued"
|
||||||
|
echo "(a few seconds; $DOMAIN must point at this server). Until then, from your own computer:"
|
||||||
|
echo " ssh -L 8080:127.0.0.1:8080 you@this-server then open http://localhost:8080"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "Open $URL to create the first administrator."
|
||||||
|
echo
|
||||||
|
echo "Next: on each machine that hosts your services (home server, game server...) run"
|
||||||
|
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
|
||||||
|
echo "with the token from Sites -> add a site. For more public servers add edges under Server -> Edges and run"
|
||||||
|
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
|
||||||
|
;;
|
||||||
|
edge)
|
||||||
|
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
|
||||||
|
edge_setup "$HH" "$TOKEN" "$VERSION"
|
||||||
systemctl restart blacktip
|
systemctl restart blacktip
|
||||||
wait_active blacktip
|
wait_active blacktip
|
||||||
say "Blacktip is running"
|
say "Blacktip is running"
|
||||||
;;
|
;;
|
||||||
agent)
|
agent)
|
||||||
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)"
|
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)"
|
||||||
fetch_install bamboo
|
fetch_install bamboo "$VERSION"
|
||||||
ENVF=/etc/bamboo/bamboo.env
|
ENVF=/etc/bamboo/bamboo.env
|
||||||
set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH"
|
set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH"
|
||||||
set_env "$ENVF" BAMBOO_TOKEN "$TOKEN"
|
set_env "$ENVF" BAMBOO_TOKEN "$TOKEN"
|
||||||
|
|||||||
+8
-1
@@ -241,6 +241,9 @@ func selfSigned(t *testing.T, host string) *tls.Certificate {
|
|||||||
|
|
||||||
// ---------- the environment ----------
|
// ---------- the environment ----------
|
||||||
|
|
||||||
|
// siteCfgHook lets a test adjust the Bamboo that setup starts.
|
||||||
|
var siteCfgHook func(*bamboo.Config)
|
||||||
|
|
||||||
type world struct {
|
type world struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
ts *httptest.Server
|
ts *httptest.Server
|
||||||
@@ -297,7 +300,11 @@ func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Confi
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
go bt.Run(ctx)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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) (string, error) { siteCalls.Add(1); 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) (string, error) { edgeCalls.Add(1); 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
|
|||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.veillax.com/Veillax/bamboo v0.1.3
|
git.veillax.com/Veillax/bamboo v0.1.4
|
||||||
git.veillax.com/Veillax/blacktip v0.1.4
|
git.veillax.com/Veillax/blacktip v0.1.6
|
||||||
git.veillax.com/Veillax/reef v0.1.3
|
git.veillax.com/Veillax/reef v0.1.6
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
modernc.org/sqlite v1.61.0
|
modernc.org/sqlite v1.61.0
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ 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.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
|
||||||
git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg=
|
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.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/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
|
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.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 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
|
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/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
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.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 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
|
||||||
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
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=
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
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/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ type edgeView struct {
|
|||||||
TunnelAddr string `json:"tunnelAddr,omitempty"`
|
TunnelAddr string `json:"tunnelAddr,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
OrgIDs []string `json:"orgIds,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 {
|
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 {
|
if admin {
|
||||||
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
|
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 {
|
if v.OrgIDs == nil {
|
||||||
v.OrgIDs = []string{}
|
v.OrgIDs = []string{}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,11 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
||||||
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
||||||
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
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/versions", s.withUser(s.latestVersions))
|
||||||
|
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) {
|
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))
|
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
|
|||||||
if s.opts.DashboardURL != "" {
|
if s.opts.DashboardURL != "" {
|
||||||
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
|
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.st.Settings = in
|
||||||
s.bump() // edges receive the dashboard URL and ACME settings
|
s.bump() // edges receive the dashboard URL and ACME settings
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ type Settings struct {
|
|||||||
AcmeEmail string `json:"acmeEmail"`
|
AcmeEmail string `json:"acmeEmail"`
|
||||||
AcmeStaging bool `json:"acmeStaging"`
|
AcmeStaging bool `json:"acmeStaging"`
|
||||||
SMTP SMTP `json:"smtp"`
|
SMTP SMTP `json:"smtp"`
|
||||||
|
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
|
||||||
|
SelfEdgeID string `json:"selfEdgeId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Secret struct {
|
type Secret struct {
|
||||||
@@ -170,6 +172,22 @@ type Options struct {
|
|||||||
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
||||||
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
||||||
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
||||||
|
|
||||||
|
// SelfDomain (HAMMERHEAD_SELF_DOMAIN) gives this server an edge of its own, with a built-in
|
||||||
|
// https route for the dashboard at that name, so no reverse proxy is needed in front of it.
|
||||||
|
// SelfTarget is the address Hammerhead listens on (e.g. 127.0.0.1:8080); SelfTunnel is the
|
||||||
|
// edge's public WireGuard endpoint (default <SelfDomain>:51820).
|
||||||
|
SelfDomain string
|
||||||
|
SelfTarget string
|
||||||
|
SelfTunnel string
|
||||||
|
|
||||||
|
// 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 {
|
type Server struct {
|
||||||
@@ -190,8 +208,11 @@ type Server struct {
|
|||||||
limits map[string][]time.Time
|
limits map[string][]time.Time
|
||||||
oidc map[string]*oidcDoc
|
oidc map[string]*oidcDoc
|
||||||
|
|
||||||
alerts map[string]*alertState // active problems by key (see alerts.go)
|
alerts map[string]*alertState // active problems by key (see alerts.go)
|
||||||
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
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
|
logs *logStore
|
||||||
db *DB
|
db *DB
|
||||||
@@ -258,6 +279,13 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opts.SelfDomain != "" && opts.SelfTunnel == "" {
|
||||||
|
s.opts.SelfTunnel = opts.SelfDomain + ":51820"
|
||||||
|
}
|
||||||
|
if err := s.ensureSelfEdge(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := s.save(); err != nil {
|
if err := s.save(); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
cfg := s.updateConfig("hammerhead", s.opts.Version)
|
||||||
|
cfg.Restart = func(exe string) error { s.Close(); return selfupdate.Restart(exe) }
|
||||||
|
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
|
||||||
|
}()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
-12
@@ -70,6 +70,12 @@
|
|||||||
.row-h h2 { display:none; }
|
.row-h h2 { display:none; }
|
||||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
|
.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); }
|
.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); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
|
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
|
||||||
@@ -184,6 +190,30 @@ const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Da
|
|||||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
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 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}); };
|
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 -----
|
||||||
|
// 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 versionCell = (m, latest, pending, updateUrl) => {
|
||||||
|
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); toast('Update requested'); refresh(); })}, 'Update'));
|
||||||
|
return h('td', {}, h('div', {class:'stack'}, ...kids));
|
||||||
|
};
|
||||||
|
|
||||||
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
|
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
|
||||||
function dialogForm(title, intro, fields, label, submit) {
|
function dialogForm(title, intro, fields, label, submit) {
|
||||||
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
|
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
|
||||||
@@ -391,13 +421,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 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 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 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', {},
|
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
|
||||||
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
|
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('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
|
h('thead', {}, h('tr', {}, ['', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
|
||||||
S.headerAction = ['Open a port', () => portForm()];
|
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'; }); }});
|
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,
|
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) {
|
function spark(series) {
|
||||||
@@ -426,13 +458,13 @@ function mappingRow(m, st, series) {
|
|||||||
}
|
}
|
||||||
const web = m.proto === 'http' || m.proto === 'https';
|
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 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', {},
|
return h('tr', {},
|
||||||
h('td', {title:why}, h('span', {class:dot})),
|
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),
|
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.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),
|
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) => {
|
h('td', {class:'mono small'}, m.targets.map((t, i) => {
|
||||||
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
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);
|
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 +579,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 : ''});
|
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();
|
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')),
|
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,
|
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) {
|
async function shareDialog(m) {
|
||||||
@@ -581,12 +712,14 @@ views.sites = async () => {
|
|||||||
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
|
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('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')))); }];
|
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', {},
|
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('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', {}, 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 ? 'this machine (direct)' : 'bamboo agent'),
|
||||||
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
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`)),
|
||||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
||||||
const l = await api('GET', O(`/sites/${s.id}/containers`));
|
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(', ')))))) :
|
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 +990,11 @@ serverViews.edges = async () => {
|
|||||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(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'),
|
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')))); }];
|
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
|
||||||
|
const latest = await latestVersions();
|
||||||
return h('div', {},
|
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('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'), 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('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.')));
|
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 +1059,29 @@ serverViews.settings = async () => {
|
|||||||
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
|
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,
|
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'); });
|
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';
|
||||||
|
updOut.textContent = 'Updating. Hammerhead restarts in a few seconds; reload this page after that.';
|
||||||
|
})}, 'Update now');
|
||||||
|
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', {},
|
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.'),
|
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)),
|
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.'),
|
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:'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'))),
|
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)),
|
||||||
h('button', {class:'btn', onclick:save}, 'Save settings'));
|
h('button', {class:'btn', onclick:save}, 'Save settings'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user