7 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 719449f6fa Use the auto-updating Blacktip, Bamboo and Reef; add the -auto-update option
CI / test (push) Successful in 9m38s
Release / release (push) Successful in 9m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:02:53 -06:00
VeillaxandClaude Sonnet 5.5 09f4f30984 Give every dashboard page its own URL and move add-forms into dialogs
CI / test (push) Successful in 7m15s
Pages are served at /resources, /sites, /domains, /mail-egress, /alerts, /access, /logs,
/apps, /account and /server/*, with working links, reloads and back button. Mail egress,
alerts, access, users, organizations, SSO and DKIM follow the same header-button and dialog
pattern as the other pages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:43:46 -06:00
VeillaxandClaude Sonnet 5.5 6f204e71c9 Pangolin-style layout: header action buttons, add-dialogs, table search, neutral palette
CI / test (push) Successful in 2m41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:34:59 -06:00
VeillaxandClaude Sonnet 5.5 b105c974a8 Restyle the dashboard and login page with one shared design-token system
CI / test (push) Successful in 2m51s
Spacing scale, 6/10px radii, flat bordered cards, pill badges, accent-tinted navigation,
segmented tabs and a single control height, in light and dark. Class names are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:31:50 -06:00
VeillaxandClaude Sonnet 5.5 7db686eeb0 Retry PROXY e2e reads while the tunnel handshake completes
CI / test (push) Successful in 7m37s
Release / release (push) Successful in 8m25s
The first connection through the tunnel can exceed the read deadline on a slow CI runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:22:12 -06:00
VeillaxandClaude Sonnet 5.5 58d1cfe9a3 Add deploy/setup.sh for one-command VPS setup
CI / test (push) Successful in 1m41s
Release / release (push) Failing after 3m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 04:25:54 -06:00
VeillaxandClaude Sonnet 5.5 3cfe896fdd Add Gitea Actions CI and release workflows and deployment assets
CI / test (push) Successful in 7m14s
Release / release (push) Successful in 6m41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 03:05:28 -06:00
21 changed files with 938 additions and 113 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# The image only packages the binaries that deploy/build.sh already built.
*
!dist/bin
+4
View File
@@ -38,3 +38,7 @@ HAMMERHEAD_MASTER_KEY=
# Token from Sites -> Show command.
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com
#BAMBOO_TOKEN=
# Check for new releases every few hours and update and restart by itself (Linux and macOS).
# setup.sh turns this on; remove it to update by hand.
#HAMMERHEAD_AUTO_UPDATE=1
+50
View File
@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Go fetches the other sharks modules straight from this server. This is harmless
# for public repositories and is what makes private ones work.
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles
run: |
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
done
+62
View File
@@ -0,0 +1,62 @@
name: Release
# Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.2.0 -m "hammerhead v0.2.0" && git push origin v0.2.0
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Test
run: |
go vet ./...
go test ./...
- name: Build release archives
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
# servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/hammerhead"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
# a container-driver builder can build several architectures in one go
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
+1
View File
@@ -5,3 +5,4 @@ bin/
*.db-wal
*.db-shm
master.key
dist/
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# Packages the binaries deploy/build.sh already built (dist/bin/linux_<arch>/hammerhead),
# so the arm64 image needs no emulation. Build it with: sh deploy/build.sh && docker buildx build .
FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep
RUN apk add --no-cache ca-certificates \
&& mkdir /data && chown 65532:65532 /data
FROM scratch
ARG TARGETARCH
LABEL org.opencontainers.image.title="Hammerhead" \
org.opencontainers.image.description="Control plane for tunnelled ports and websites" \
org.opencontainers.image.source="https://git.veillax.com/Veillax/hammerhead" \
org.opencontainers.image.licenses="AGPL-3.0-only"
COPY --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=prep --chown=65532:65532 /data /data
COPY dist/bin/linux_${TARGETARCH}/hammerhead /hammerhead
USER 65532:65532
ENV HAMMERHEAD_DATA=/data
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/hammerhead"]
CMD ["-listen", ":8080"]
+50
View File
@@ -39,6 +39,56 @@ bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound
ports. Then open ports on the **Resources** page.
## Deploying
Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Hammerhead for Linux (amd64, arm64, armv7), Windows (amd64) and macOS (amd64, arm64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a multi-architecture container image to this server's registry (`git.veillax.com/veillax/hammerhead`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
**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.
```
sudo sh setup.sh --domain hammerhead.example.com # Hammerhead + Caddy for HTTPS
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> --ports 25565,30000-30100
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token>
```
`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.
**On a Linux server (systemd)**
```
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/hammerhead_v0.1.0_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
tar xzf hammerhead_v0.1.0_linux_amd64.tar.gz && cd hammerhead_v0.1.0_linux_amd64
sudo ./install.sh # installs the binary and a hardened systemd service
sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
sudo systemctl start hammerhead
```
Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/hammerhead` and `/var/lib/hammerhead` alone. `hammerhead -version` tells you which build is running.
**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.
**With Docker**
```
cp .env.example .env # fill it in
docker compose -f deploy/docker-compose.yml up -d
```
The image is `git.veillax.com/veillax/hammerhead:<version>` (and `:latest` for full releases). The compose file is in the repository and inside each Linux archive.
**Setting up the build (once)**
- A Gitea Actions runner with the label `ubuntu-latest`, and Docker on it if you want the container image.
- Nothing else is required: the workflows use the automatic job token. If your server doesn't let that token publish releases or packages, add the secrets `RELEASE_TOKEN` and `REGISTRY_TOKEN` (access tokens with release and package write permission) and optionally the variable `REGISTRY_USER`.
- To skip the container image, set the repository variable `PUBLISH_DOCKER` to `false`.
- Build locally with `sh deploy/build.sh` (archives in `dist/`).
## Configuration and secrets
All three programs read a `.env` file from their working directory before they start (or the file
+15 -1
View File
@@ -2,6 +2,7 @@
package main
import (
"context"
"flag"
"fmt"
"log"
@@ -13,8 +14,12 @@ import (
"git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/selfupdate"
)
// version is set at build time (deploy/build.sh).
var version = "dev"
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
@@ -36,7 +41,13 @@ func main() {
url := flag.String("url", env("HAMMERHEAD_URL", ""), "public URL people reach hammerhead at (used for login redirects and SSO); can also be set in Settings")
flag.String("env", ".env", "file to read secrets and settings from")
genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit")
autoUpdate := flag.Bool("auto-update", os.Getenv("HAMMERHEAD_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS)")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
os.Stdout.WriteString("hammerhead " + version + "\n")
return
}
if *genkey {
fmt.Println(secretbox.GenerateKey())
return
@@ -55,7 +66,10 @@ func main() {
log.Fatal(err)
}
defer srv.Close()
log.Printf("hammerhead listening on %s (open it in a browser to finish setup)", *listen)
if *autoUpdate {
go selfupdate.Run(context.Background(), selfupdate.Config{Server: "https://git.veillax.com", Repo: "Veillax/hammerhead", App: "hammerhead", Current: version, Logf: log.Printf, Restart: func(exe string) error { srv.Close(); return selfupdate.Restart(exe) }})
}
log.Printf("hammerhead %s listening on %s (open it in a browser to finish setup)", version, *listen)
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(hs.ListenAndServe())
}
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Builds hammerhead for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/hammerhead the raw binaries (the Docker image uses the linux ones)
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Set TARGETS="linux/amd64 linux/arm64" to build fewer platforms.
set -eu
NAME=hammerhead
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$stage/"
for f in LICENSE README.md .env.example; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = linux ]; then
cp deploy/install.sh deploy/setup.sh "deploy/$NAME.service" deploy/docker-compose.yml "$stage/"
chmod +x "$stage/install.sh"
fi
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+18
View File
@@ -0,0 +1,18 @@
# Run Hammerhead with Docker:
# cp .env.example .env (then fill it in: at least HAMMERHEAD_MASTER_KEY, see `hammerhead -genkey`)
# docker compose up -d
#
# Put it behind HTTPS (a reverse proxy, or Blacktip once it's running) and set
# HAMMERHEAD_URL to the public address in .env.
services:
hammerhead:
image: git.veillax.com/veillax/hammerhead:latest
restart: unless-stopped
env_file: .env
ports:
- "8080:8080"
volumes:
- hammerhead-data:/data
volumes:
hammerhead-data:
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Hammerhead: control plane for tunnelled ports and websites
Documentation=https://git.veillax.com/Veillax/hammerhead
After=network-online.target
Wants=network-online.target
[Service]
User=hammerhead
Group=hammerhead
EnvironmentFile=-/etc/hammerhead/hammerhead.env
ExecStart=/usr/local/lib/hammerhead/hammerhead -listen :8080 -data /var/lib/hammerhead
WorkingDirectory=/var/lib/hammerhead
Restart=on-failure
RestartSec=3
# It only needs its own data folder and the network.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/hammerhead /usr/local/lib/hammerhead
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# Installs hammerhead as a systemd service from the files in this folder.
#
# sudo ./install.sh install or upgrade (your settings and data are kept)
# sudo ./install.sh --start the same, then start (or restart) the service
# sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data)
#
# Files: /usr/local/lib/hammerhead/hammerhead (linked from /usr/local/bin/hammerhead), /etc/systemd/system/hammerhead.service,
# /etc/hammerhead/hammerhead.env (your settings and secrets), /var/lib/hammerhead (data).
# Set DESTDIR=/some/dir to stage everything there without touching the system.
set -eu
NAME=hammerhead
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=${DESTDIR:-}
BIN=$ROOT/usr/local/bin/$NAME
UNIT=$ROOT/etc/systemd/system/$NAME.service
CONF=$ROOT/etc/$NAME
ENVFILE=$CONF/$NAME.env
DATA=$ROOT/var/lib/$NAME
LIB=$ROOT/usr/local/lib/$NAME # the program lives here, owned by the service, so auto-update can replace it
say() { printf '%s\n' "$*"; }
real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system?
if real; then
[ "$(id -u)" = 0 ] || { say "Run this as root: sudo $0 $*"; exit 1; }
command -v systemctl > /dev/null 2>&1 || { say "systemd wasn't found. Run $NAME directly (see the README) or use Docker."; exit 1; }
fi
if [ "${1:-}" = "--uninstall" ]; then
if real; then
systemctl disable --now "$NAME" > /dev/null 2>&1 || true
fi
rm -f "$UNIT" "$BIN" "$LIB/$NAME"
if real; then
systemctl daemon-reload || true
fi
say "Removed $NAME. Left in place: $CONF and $DATA (delete them yourself if you want them gone)."
exit 0
fi
[ -f "$HERE/$NAME" ] || { say "Can't find the $NAME binary next to this script."; exit 1; }
if real && ! id "$NAME" > /dev/null 2>&1; then
useradd --system --home-dir "/var/lib/$NAME" --shell /usr/sbin/nologin "$NAME"
say "Created the system user '$NAME'."
fi
mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA"
mkdir -p "$LIB"
install -m 0755 "$HERE/$NAME" "$LIB/$NAME.new"
mv -f "$LIB/$NAME.new" "$LIB/$NAME" # atomic, so upgrading a running service is safe
rm -f "$BIN"
LINK=/usr/local/lib/$NAME/$NAME
[ -z "${DESTDIR:-}" ] || LINK=$LIB/$NAME # staging: point inside the staging folder
ln -s "$LINK" "$BIN"
install -m 0644 "$HERE/$NAME.service" "$UNIT"
if [ ! -f "$ENVFILE" ]; then
install -m 0600 "$HERE/.env.example" "$ENVFILE"
say "Created $ENVFILE. Edit it before starting."
fi
if real; then
chown -R "$NAME:$NAME" "$DATA" "$LIB"
fi
if real; then
systemctl daemon-reload
systemctl enable "$NAME" > /dev/null 2>&1
if [ "${1:-}" = "--start" ] || systemctl is-active --quiet "$NAME"; then
systemctl restart "$NAME"
say "$NAME is running."
fi
fi
say "Installed $("$LIB/$NAME" -version 2> /dev/null || echo "$NAME")."
say "Next:"
say " 1. edit $ENVFILE"
say " 2. systemctl start $NAME (or run this again with --start)"
say " 3. journalctl -u $NAME -f to watch it"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/hammerhead}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json
for a in json.load(sys.stdin):
print(a["id"], a["name"])')
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
echo "$existing" | while read -r aid aname; do
if [ "$aname" = "$name" ]; then
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
fi
done
echo "uploading $name"
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
done
echo "published $SERVER/$REPO/releases/tag/$TAG"
+225
View File
@@ -0,0 +1,225 @@
#!/bin/sh
# One-shot setup for a VPS. Run as root.
#
# sh setup.sh [--domain hh.example.com] [--version v0.1.0] [--no-auto-update] Hammerhead (dashboard and API)
# sh setup.sh edge --hammerhead URL --token TOKEN [--ports 25565,30000-30100] [--version V]
# sh setup.sh agent --hammerhead URL --token TOKEN [--version V]
#
# Hammerhead: installs it as a systemd service (with a generated master key, kept in
# /etc/hammerhead/hammerhead.env), and with --domain also Caddy, which gets and renews
# the HTTPS certificate for that name. DNS for the name must already point here.
# edge: installs Blacktip, with its bundled Traefik, on this machine (or any other VPS),
# opens the firewall and lists every IP address it can open ports on.
# agent: installs Bamboo, the site agent, on the machine that hosts your services.
#
# Edge and agent need a token, which only exists once Hammerhead is up: create the edge under
# Server -> Edges (or the site under Sites) in the dashboard and copy the values from "Show command".
# Safe to run again: it upgrades and keeps your settings and data.
set -eu
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
MODE=hammerhead
case "${1:-}" in edge | agent) MODE=$1; shift ;; esac
DOMAIN= VERSION= HH= TOKEN= PORTS= AUTOUP=1
while [ $# -gt 0 ]; do
case "$1" in
--domain) DOMAIN=$2; shift 2 ;;
--version) VERSION=$2; shift 2 ;;
--hammerhead) HH=$2; shift 2 ;;
--token) TOKEN=$2; shift 2 ;;
--ports) PORTS=$2; shift 2 ;;
--no-auto-update) AUTOUP=0; shift ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown option $1 (see --help)" >&2; exit 2 ;;
esac
done
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
auto_update() {
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
return 0
}
say() { printf '\n== %s\n' "$*"; }
die() { echo "error: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root: sudo sh $0 $*"
command -v systemctl > /dev/null 2>&1 || die "this needs a systemd-based Linux (Debian, Ubuntu, Fedora, RHEL, Arch...)"
case "$(uname -m)" in
x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
armv7* | armv6* | arm) ARCH=arm ;;
*) die "unsupported CPU: $(uname -m)" ;;
esac
pkg_install() { # installs packages with whichever package manager there is
if command -v apt-get > /dev/null 2>&1; then
apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
elif command -v dnf > /dev/null 2>&1; then
dnf install -y "$@"
elif command -v yum > /dev/null 2>&1; then
yum install -y "$@"
elif command -v pacman > /dev/null 2>&1; then
pacman -S --noconfirm --needed "$@"
else
die "install these yourself and run again: $*"
fi
}
need() { command -v "$1" > /dev/null 2>&1 || { say "installing $1"; pkg_install "$1"; }; }
need curl
need tar
need git
latest() { # newest release tag of repo $1 (read from git, so no API token is needed)
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=$1
ver=${VERSION:-$(latest "$app")}
[ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)"
dir=$(mktemp -d)
say "downloading $app $ver ($ARCH)"
name="${app}_${ver}_linux_${ARCH}"
curl -fsSL -o "$dir/$name.tar.gz" "$BASE/$app/releases/download/$ver/$name.tar.gz" || die "no $app $ver release for linux/$ARCH"
curl -fsSL -o "$dir/SHA256SUMS" "$BASE/$app/releases/download/$ver/SHA256SUMS" || die "couldn't download the checksums"
(cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS | sha256sum -c -) || die "checksum mismatch: not installing"
tar -xzf "$dir/$name.tar.gz" -C "$dir"
(cd "$dir/$name" && sh ./install.sh)
rm -rf "$dir"
}
# set_env <file> <KEY> <value>: sets KEY=value, replacing the example line or an older value
set_env() {
f=$1 k=$2 v=$3
if grep -q "^#\?$k=" "$f"; then
tmp=$(mktemp)
awk -v k="$k" -v v="$v" 'BEGIN{done=0} { if (!done && ($0 ~ "^#?"k"=")) { print k"="v; done=1 } else print }' "$f" > "$tmp"
cat "$tmp" > "$f"
rm -f "$tmp"
else
echo "$k=$v" >> "$f"
fi
}
firewall_open() { # firewall_open 80/tcp 443/tcp ...
if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then
for p in "$@"; do ufw allow "$p" > /dev/null; done
echo "opened in ufw: $*"
elif command -v firewall-cmd > /dev/null 2>&1 && firewall-cmd --state > /dev/null 2>&1; then
for p in "$@"; do firewall-cmd --permanent --add-port="$p" > /dev/null; done
firewall-cmd --reload > /dev/null
echo "opened in firewalld: $*"
else
echo "No active ufw or firewalld found. If this machine has a firewall, or your host has a cloud firewall / security group, open: $*"
fi
}
public_ips() { # global addresses actually configured on this machine
ip -o addr show scope global 2> /dev/null | awk '{print $4}' | cut -d/ -f1 || true
}
wait_active() { # wait_active <service>
sleep 2
systemctl is-active --quiet "$1" || { journalctl -u "$1" -n 20 --no-pager; die "$1 didn't start"; }
}
case $MODE in
hammerhead)
fetch_install hammerhead
ENVF=/etc/hammerhead/hammerhead.env
auto_update "$ENVF" HAMMERHEAD
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
say "generating the master key"
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/bin/hammerhead -genkey)"
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
fi
if [ -n "$DOMAIN" ]; then
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
# only Caddy needs to reach it
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' /etc/systemd/system/hammerhead.service
systemctl daemon-reload
if ! command -v caddy > /dev/null 2>&1; then
say "installing Caddy (HTTPS certificates for $DOMAIN)"
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
fi
mkdir -p /etc/caddy
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
systemctl enable caddy > /dev/null 2>&1 || true
systemctl restart caddy
firewall_open 80/tcp 443/tcp
URL="https://$DOMAIN"
else
firewall_open 8080/tcp
ip=$(public_ips | head -n 1)
URL="http://${ip:-this-server}:8080"
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
fi
systemctl restart hammerhead
wait_active hammerhead
say "Hammerhead is running"
echo "Open $URL to create the first administrator."
echo
echo "Next, in the dashboard: Server -> Edges -> add an edge, then on the machine that has your public IPs run"
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
echo "and on each machine that hosts your services (home server, game server...) run"
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
echo "Either can be this same machine."
;;
edge)
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
fetch_install blacktip
ENVF=/etc/blacktip/blacktip.env
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH"
set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN"
auto_update "$ENVF" BLACKTIP
chmod 600 "$ENVF"
say "network"
# WireGuard moves a lot of UDP; the stock socket buffers are small
printf 'net.core.rmem_max=7500000\nnet.core.wmem_max=7500000\n' > /etc/sysctl.d/90-blacktip.conf
sysctl -q -p /etc/sysctl.d/90-blacktip.conf > /dev/null 2>&1 || true
list="80/tcp 443/tcp 51820/udp"
if [ -n "$PORTS" ]; then
# 25565,30000-30100: each is opened for TCP and UDP (the dashboard decides which one is used)
for p in $(echo "$PORTS" | tr ',' ' '); do
list="$list $p/tcp $p/udp"
done
fi
# ufw wants a range as 30000:30100, firewalld as 30000-30100
if command -v ufw > /dev/null 2>&1; then list=$(echo "$list" | tr '-' ':'); fi
# shellcheck disable=SC2086
firewall_open $list
ips=$(public_ips)
echo
echo "Blacktip can open ports on these addresses of this machine:"
echo "$ips" | sed 's/^/ /'
if [ "$(echo "$ips" | grep -c .)" -lt 2 ]; then
echo "Only one address found. Extra IPs must be added to this machine's network configuration first."
echo "A provider's floating or 1:1 NAT address that isn't listed above can't be bound by Blacktip."
fi
echo "In the dashboard each port picks which of these addresses it listens on."
echo "Ports you open later in the dashboard must also be allowed in the firewall: run this again with --ports."
systemctl restart blacktip
wait_active blacktip
say "Blacktip is running"
;;
agent)
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)"
fetch_install bamboo
ENVF=/etc/bamboo/bamboo.env
set_env "$ENVF" BAMBOO_HAMMERHEAD "$HH"
set_env "$ENVF" BAMBOO_TOKEN "$TOKEN"
auto_update "$ENVF" BAMBOO
chmod 600 "$ENVF"
if command -v docker > /dev/null 2>&1 && getent group docker > /dev/null 2>&1; then
usermod -aG docker bamboo
set_env "$ENVF" BAMBOO_DOCKER 1
echo "Docker found: Bamboo will list your containers as targets."
fi
systemctl restart bamboo
wait_active bamboo
say "Bamboo is running (it only connects outward, so there is nothing to open)"
;;
esac
+4 -4
View File
@@ -91,7 +91,10 @@ func proxyBackend(t *testing.T, seen chan<- string) int {
func askFrom(t *testing.T, from, addr, send string) string {
t.Helper()
var last error
for i := 0; i < 50; i++ {
// The first connection through the tunnel can wait for the WireGuard handshake, which is
// slow on a busy CI runner, so a read timeout is retried until the overall deadline.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}, Timeout: time.Second}
c, err := d.Dial("tcp", addr)
if err == nil {
@@ -102,9 +105,6 @@ func askFrom(t *testing.T, from, addr, send string) string {
if rerr == nil {
return strings.TrimSpace(line)
}
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
t.Fatalf("%s accepted the connection but never answered: %v", addr, rerr) // a real hang, not a startup race
}
last = rerr
} else {
last = err
+3 -3
View File
@@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
go 1.27.3
require (
git.veillax.com/Veillax/bamboo v0.1.0
git.veillax.com/Veillax/blacktip v0.1.0
git.veillax.com/Veillax/reef v0.1.0
git.veillax.com/Veillax/bamboo v0.1.3
git.veillax.com/Veillax/blacktip v0.1.4
git.veillax.com/Veillax/reef v0.1.3
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
+6
View File
@@ -1,9 +1,15 @@
git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg=
git.veillax.com/Veillax/bamboo v0.1.3/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM=
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0=
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+34
View File
@@ -0,0 +1,34 @@
package hammerhead
import (
"os"
"regexp"
"testing"
)
// Every address the dashboard JavaScript navigates to must be served by the server, or a
// reload on that page would 404.
func TestDashboardPathsMatchThePage(t *testing.T) {
src, err := os.ReadFile("web/index.html")
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`TAB_PATH = \{([^}]*)\}`).FindSubmatch(src)
if m == nil {
t.Fatal("TAB_PATH not found in web/index.html")
}
paths := regexp.MustCompile(`'(/[a-z/-]+)'`).FindAllSubmatch(m[1], -1)
if len(paths) < 10 {
t.Fatalf("found only %d page paths", len(paths))
}
for _, p := range paths {
if !isDashboardPath(string(p[1])) {
t.Errorf("%s is a dashboard page but the server doesn't serve it", p[1])
}
}
for _, p := range []string{"/api/me", "/nope", "/server", "/server/nope"} {
if isDashboardPath(p) {
t.Errorf("%s should not serve the dashboard", p)
}
}
}
+12 -1
View File
@@ -21,11 +21,22 @@ func page(html []byte) http.HandlerFunc {
}
}
// dashboardPaths are the addresses the dashboard shows pages at. They all serve the same app,
// which reads the path to decide what to show. Keep in step with TAB_PATH in web/index.html.
var dashboardPaths = map[string]bool{
"/": true, "/apps": true, "/resources": true, "/sites": true, "/domains": true, "/mail-egress": true,
"/alerts": true, "/access": true, "/logs": true, "/account": true,
"/server/edges": true, "/server/organizations": true, "/server/users": true, "/server/sso": true,
"/server/settings": true, "/server/alerts": true, "/server/audit": true,
}
func isDashboardPath(p string) bool { return dashboardPaths[p] }
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
index := page(indexHTML)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
if !isDashboardPath(r.URL.Path) {
http.NotFound(w, r)
return
}
+172 -90
View File
@@ -5,84 +5,139 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hammerhead</title>
<style>
:root { --bg:#0a0a0a; --side:#0f0f10; --panel:#141415; --line:#262628; --text:#fafafa; --dim:#a1a1aa; --muted:#1c1c1e; --accent:#06b6d4; --accent-fg:#fff; --ok:#22c55e; --bad:#ef4444; --warn:#eab308; --shade:rgba(0,0,0,.65); --radius:10px; }
@media (prefers-color-scheme: light) { :root { --bg:#fafafa; --side:#fff; --panel:#fff; --line:#e4e4e7; --text:#18181b; --dim:#71717a; --muted:#f4f4f5; --accent:#0891b2; --shade:rgba(24,24,27,.5); } }
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8;
--text:#09090b; --text-muted:#52525b; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:8px; --radius-lg:12px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46;
--text:#fafafa; --text-muted:#a1a1aa; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
} }
* { box-sizing:border-box; }
html, body { height:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 Inter,system-ui,-apple-system,Segoe UI,sans-serif; -webkit-font-smoothing:antialiased; }
a { color:var(--accent); }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); -webkit-font-smoothing:antialiased; }
a { color:var(--accent-text); text-underline-offset:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
h1, h2 { line-height:1.25; letter-spacing:-.01em; }
/* shell */
.app { display:flex; min-height:100vh; }
.side { width:248px; flex:none; background:var(--side); border-right:1px solid var(--line); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:14px 12px; gap:4px; overflow-y:auto; }
.brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; }
.side { width:var(--sidebar-w); flex:none; background:var(--bg); border-right:1px solid var(--border); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:var(--s5) var(--s3); gap:2px; overflow-y:auto; }
.brand { display:flex; align-items:center; gap:var(--s2); font-weight:700; font-size:var(--text-lg); letter-spacing:-.02em; padding:0 var(--s3) var(--s4); }
.brand svg { color:var(--accent); }
.orgsel { margin:0 0 10px; }
.orgsel select { background:var(--muted); font-weight:600; }
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; }
.navitem { display:flex; align-items:center; gap:10px; width:100%; background:none; border:0; color:var(--dim); padding:8px 10px; border-radius:8px; cursor:pointer; font:inherit; font-weight:500; text-align:left; }
.navitem:hover { background:var(--muted); color:var(--text); }
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
.orgsel { margin:0 0 var(--s2); }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); }
a.navitem { text-decoration:none; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem { border-radius:var(--radius); }
.navitem.on { background:var(--surface-2); color:var(--text); }
.navitem.on svg { color:var(--accent); }
.navitem svg { flex:none; }
.grow { flex:1; }
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; }
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-xs); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { flex:1; min-width:0; }
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:22px; flex-wrap:wrap; }
.page-h h1 { font-size:24px; font-weight:700; margin:0; letter-spacing:-.01em; }
.page-h p { margin:4px 0 0; color:var(--dim); }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:18px; box-shadow:0 1px 2px rgba(0,0,0,.18); }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h h1 { font-size:var(--text-2xl); font-weight:600; margin:0; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); }
/* cards, layout */
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
.card > h2 { font-size:var(--text-lg); font-weight:600; margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; align-items:end; }
.stack { display:flex; flex-direction:column; gap:12px; }
label { display:flex; flex-direction:column; gap:5px; font-size:12.5px; font-weight:500; color:var(--dim); }
label.chk { flex-direction:row; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:400; }
input, select, textarea { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; min-width:0; width:100%; }
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:0; border-color:var(--accent); }
input:disabled { opacity:.6; }
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; }
button.btn:hover { filter:brightness(1.08); }
button.ghost { background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:5px 11px; cursor:pointer; font:inherit; font-weight:500; }
button.ghost:hover { background:var(--muted); }
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
.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); }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:center; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height, one shape; ghost is the quiet variant */
button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.danger:hover { border-color:var(--danger); color:var(--danger); background:var(--danger-bg); }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* toolbar above a table */
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); }
.toolbar input[type=search] { max-width:320px; }
.page-h .btn { white-space:nowrap; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:12px; color:var(--dim); font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; background:color-mix(in srgb, var(--muted) 55%, transparent); }
td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); vertical-align:top; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:-4px 0; }
.tag { display:inline-flex; align-items:center; padding:1px 8px; border-radius:6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; background:var(--muted); border:1px solid var(--line); color:var(--dim); }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--dim); margin-right:7px; flex:none; }
.dot.ok { background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } .dot.bad { background:var(--bad); } .dot.warn { background:var(--warn); }
code, .mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12.5px; }
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:12px; overflow:auto; margin:8px 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
.dim { color:var(--dim); } .err { color:var(--bad); } .small { font-size:12px; }
.empty { color:var(--dim); padding:28px 12px; text-align:center; }
.card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 0; }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; }
.banner { background:color-mix(in srgb, var(--warn) 12%, transparent); border:1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius:var(--radius); padding:12px 16px; margin-bottom:18px; }
.shade { position:fixed; inset:0; background:var(--shade); backdrop-filter:blur(2px); display:flex; align-items:flex-start; justify-content:center; padding:4vh 12px; overflow:auto; z-index:10; }
.modal { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px; width:100%; max-width:780px; box-shadow:0 20px 60px rgba(0,0,0,.45); }
.modal h2 { margin:0 0 16px; font-size:18px; }
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; }
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; }
.sub { color:var(--dim); font-size:12px; font-weight:400; }
.tabs2 { display:flex; gap:4px; margin-bottom:16px; background:var(--muted); padding:4px; border-radius:10px; width:fit-content; max-width:100%; flex-wrap:wrap; }
.tabs2 button { background:none; border:0; color:var(--dim); border-radius:7px; padding:5px 14px; cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button.on { background:var(--panel); color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,.25); }
#toast { position:fixed; bottom:20px; right:20px; background:var(--panel); border:1px solid var(--line); padding:11px 16px; border-radius:10px; z-index:30; display:none; max-width:min(420px,90vw); box-shadow:0 10px 30px rgba(0,0,0,.4); }
#toast.bad { border-color:var(--bad); color:var(--bad); }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
/* badges and status */
.tag { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--text-faint); margin-right:var(--s2); flex:none; }
.dot.ok { background:var(--success); box-shadow:0 0 0 3px var(--success-bg); } .dot.bad { background:var(--danger); } .dot.warn { background:var(--warning); }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
pre { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.empty { color:var(--text-muted); padding:var(--s7) var(--s5); text-align:center; border:1px dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.center { max-width:400px; margin:11vh auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s5); }
.banner { background:var(--warning-bg); border:1px solid var(--warning); color:var(--warning); border-radius:var(--radius); padding:var(--s3) var(--s4); margin-bottom:var(--s4); }
/* dialogs, tabs, toast */
.shade { position:fixed; inset:0; background:var(--overlay); display:flex; align-items:flex-start; justify-content:center; padding:4vh var(--s3); overflow:auto; z-index:10; }
.modal { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:780px; box-shadow:var(--shadow); }
.modal h2 { margin:0 0 var(--s4); font-size:var(--text-lg); }
fieldset { border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:1px solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button + button { border-left:1px solid var(--border-strong); }
.tabs2 button:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
#toast { position:fixed; bottom:var(--s4); right:var(--s4); background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); z-index:30; display:none; max-width:min(420px,90vw); box-shadow:var(--shadow); font-size:var(--text-sm); }
#toast.bad { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (max-width:860px) {
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; }
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; border-right:0; border-bottom:1px solid var(--border); }
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
main { padding:18px 14px 60px; } .tgt { grid-template-columns:1fr 1fr; }
main { padding:var(--s5) var(--s4) var(--s7); } .tgt { grid-template-columns:1fr 1fr; }
}
</style>
@@ -130,6 +185,10 @@ const byId = (list, id) => (list || []).find(x => x.id === id);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))));
}
const cmdBox = t => h('pre', {class:'mono'}, t);
function modal(title, body, opts = {}) {
const shade = h('div', {class:'shade'});
@@ -182,6 +241,8 @@ async function boot() {
if (!S.me) return $app.replaceChildren(loginView());
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
if (S.org) localStorage.setItem('org', S.org);
const fromPath = tabFromPath(location.pathname);
if (fromPath) selectTab(fromPath);
await render();
}
@@ -248,8 +309,17 @@ const PAGES = {
// server pages are top-level sidebar entries that map onto serverViews
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
// every page has its own address, so links, reloads and the back button work
const TAB_PATH = {mine:'/apps', ports:'/resources', sites:'/sites', domains:'/domains', egress:'/mail-egress', alerts:'/alerts', access:'/access', logs:'/logs', account:'/account',
edges:'/server/edges', srvorgs:'/server/organizations', users:'/server/users', idps:'/server/sso', settings:'/server/settings', srvalerts:'/server/alerts', srvaudit:'/server/audit'};
const tabFromPath = p => Object.keys(TAB_PATH).find(k => TAB_PATH[k] === p.replace(/\/+$/, '')) || null;
function selectTab(key) { S.tab = key; if (SRV[key]) serverTab = SRV[key]; }
window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } });
async function render() {
const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label);
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], onclick:e => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, icon(ic), label);
const side = [h('div', {}, brand())];
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
@@ -268,6 +338,7 @@ async function render() {
}
all.push('account');
if (!all.includes(S.tab)) S.tab = all[0];
if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]);
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
@@ -276,7 +347,8 @@ async function render() {
function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', ''];
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null));
const a = S.headerAction;
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, '+ ' + a[0]) : null);
}
let seq = 0;
@@ -284,6 +356,7 @@ async function refresh() {
const my = ++seq;
try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
S.headerAction = null;
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return;
const v = document.getElementById('view');
@@ -321,8 +394,10 @@ views.ports = async () => {
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), 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,
h('div', {class:'card flush'}, table));
S.headerAction = ['Open a port', () => portForm()];
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
return h('div', {}, unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, h('div', {class:'card flush'}, table));
};
function spark(series) {
@@ -503,9 +578,10 @@ views.sites = async () => {
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {},
card('Add a site', 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.'),
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
@@ -523,9 +599,11 @@ views.sites = async () => {
// ----- domains -----
views.domains = async () => {
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
S.headerAction = ['Add domain', () => { const close = modal('Add a domain', h('div', {class:'stack'},
h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'),
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); close(); refresh(); })}, 'Add domain'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {},
card('Add a domain', h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
@@ -584,8 +662,8 @@ views.egress = async () => {
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
S.headerAction = ['Add mail egress', () => mailForm()];
return h('div', {},
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')),
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
h('div', {class:'card flush'}, table),
await dkimCard(),
@@ -605,8 +683,8 @@ async function dkimCard() {
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel),
h('button', {class:'btn', onclick: act(async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, 'Create key')));
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
}
function mailForm(existing) {
@@ -681,11 +759,12 @@ async function alertsView(base, scoped, trusted) {
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
return h('div', {},
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')),
card('Where alerts go',
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
@@ -702,17 +781,17 @@ views.access = async () => {
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
const kname = h('input', {placeholder:'e.g. CI deploy'});
S.headerAction = ['Invite someone', () => dialogForm('Invite someone', 'They get a link that works for 7 days.', [field('Email', email), field('Role', roleSel)], 'Create invitation', async () => {
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })];
return h('div', {},
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
h('td', {}, mm.totp ? '✓' : '—'),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(async () => {
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })}, 'Create invitation')),
invites.length ? h('table', {style:'margin-top:12px'}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null),
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
@@ -771,11 +850,14 @@ serverViews.edges = async () => {
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
S.headerAction = ['Add edge', () => { const close = modal('Add an edge', h('div', {class:'stack'},
h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'),
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge'),
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {},
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), 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)}); showCmd(e); refresh(); })}, 'Add edge')),
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
@@ -786,8 +868,8 @@ serverViews.edges = async () => {
serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0();
S.headerAction = ['New organization', () => dialogForm('New organization', null, [field('Name', name), local], 'Create', async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })];
return h('div', {},
card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))),
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
@@ -800,9 +882,9 @@ async function boot0() { S.me = await api('GET', '/api/me'); }
serverViews.users = async () => {
const users = await api('GET', '/api/admin/users');
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
S.headerAction = ['Add user', () => dialogForm('Add a user', 'To add someone to an organization by email, use an invitation on the Access page instead.', [field('Email', f.e), field('Name', f.n), field('Password', f.p), adm], 'Create',
async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })];
return h('div', {},
card('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'),
h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))),
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
@@ -829,10 +911,10 @@ serverViews.idps = async () => {
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
};
S.headerAction = ['Add provider', () => form()];
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'),
h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider'));
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'));
};
serverViews.settings = async () => {
+42 -14
View File
@@ -5,22 +5,50 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<style>
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; }
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } }
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
} }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.45 system-ui,sans-serif; display:grid; place-items:center; min-height:100vh; padding:16px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; }
h1 { font-size:18px; margin:0 0 4px; }
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; }
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--dim); margin:0; }
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; }
input:focus { outline:2px solid var(--accent); outline-offset:-1px; }
button, a.btn { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:center; text-decoration:none; display:block; }
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); }
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; }
.ok { color:var(--dim); font-size:13px; }
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; }
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); }
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost, a.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
#err { color:var(--danger); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); }
[hidden] { display:none !important; }
</style>
</head>