4 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
12 changed files with 309 additions and 118 deletions

No files matched your search

+4
View File
@@ -38,3 +38,7 @@ HAMMERHEAD_MASTER_KEY=
# Token from Sites -> Show command. # Token from Sites -> Show command.
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com #BAMBOO_HAMMERHEAD=https://hammerhead.example.com
#BAMBOO_TOKEN= #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
+4
View File
@@ -69,6 +69,10 @@ 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. 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** **With Docker**
``` ```
+6
View File
@@ -2,6 +2,7 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@@ -13,6 +14,7 @@ import (
"git.veillax.com/Veillax/hammerhead" "git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/dotenv" "git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/selfupdate"
) )
// version is set at build time (deploy/build.sh). // version is set at build time (deploy/build.sh).
@@ -39,6 +41,7 @@ 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") 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") 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") 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") showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
if *showVersion { if *showVersion {
@@ -63,6 +66,9 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
defer srv.Close() defer srv.Close()
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) 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} hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(hs.ListenAndServe()) log.Fatal(hs.ListenAndServe())
+2 -2
View File
@@ -8,7 +8,7 @@ Wants=network-online.target
User=hammerhead User=hammerhead
Group=hammerhead Group=hammerhead
EnvironmentFile=-/etc/hammerhead/hammerhead.env EnvironmentFile=-/etc/hammerhead/hammerhead.env
ExecStart=/usr/local/bin/hammerhead -listen :8080 -data /var/lib/hammerhead ExecStart=/usr/local/lib/hammerhead/hammerhead -listen :8080 -data /var/lib/hammerhead
WorkingDirectory=/var/lib/hammerhead WorkingDirectory=/var/lib/hammerhead
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
@@ -16,7 +16,7 @@ RestartSec=3
# It only needs its own data folder and the network. # It only needs its own data folder and the network.
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/var/lib/hammerhead ReadWritePaths=/var/lib/hammerhead /usr/local/lib/hammerhead
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true
ProtectKernelTunables=true ProtectKernelTunables=true
+12 -6
View File
@@ -5,7 +5,7 @@
# sudo ./install.sh --start the same, then start (or restart) the service # 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) # sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data)
# #
# Files: /usr/local/bin/hammerhead, /etc/systemd/system/hammerhead.service, # 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). # /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 DESTDIR=/some/dir to stage everything there without touching the system.
set -eu set -eu
@@ -17,6 +17,7 @@ UNIT=$ROOT/etc/systemd/system/$NAME.service
CONF=$ROOT/etc/$NAME CONF=$ROOT/etc/$NAME
ENVFILE=$CONF/$NAME.env ENVFILE=$CONF/$NAME.env
DATA=$ROOT/var/lib/$NAME 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' "$*"; } say() { printf '%s\n' "$*"; }
real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system? real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system?
@@ -30,7 +31,7 @@ if [ "${1:-}" = "--uninstall" ]; then
if real; then if real; then
systemctl disable --now "$NAME" > /dev/null 2>&1 || true systemctl disable --now "$NAME" > /dev/null 2>&1 || true
fi fi
rm -f "$UNIT" "$BIN" rm -f "$UNIT" "$BIN" "$LIB/$NAME"
if real; then if real; then
systemctl daemon-reload || true systemctl daemon-reload || true
fi fi
@@ -46,15 +47,20 @@ if real && ! id "$NAME" > /dev/null 2>&1; then
fi fi
mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA" mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA"
install -m 0755 "$HERE/$NAME" "$BIN.new" mkdir -p "$LIB"
mv -f "$BIN.new" "$BIN" # atomic, so upgrading a running service is safe 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" install -m 0644 "$HERE/$NAME.service" "$UNIT"
if [ ! -f "$ENVFILE" ]; then if [ ! -f "$ENVFILE" ]; then
install -m 0600 "$HERE/.env.example" "$ENVFILE" install -m 0600 "$HERE/.env.example" "$ENVFILE"
say "Created $ENVFILE. Edit it before starting." say "Created $ENVFILE. Edit it before starting."
fi fi
if real; then if real; then
chown -R "$NAME:$NAME" "$DATA" chown -R "$NAME:$NAME" "$DATA" "$LIB"
fi fi
if real; then if real; then
@@ -66,7 +72,7 @@ if real; then
fi fi
fi fi
say "Installed $("$BIN" -version 2> /dev/null || echo "$NAME")." say "Installed $("$LIB/$NAME" -version 2> /dev/null || echo "$NAME")."
say "Next:" say "Next:"
say " 1. edit $ENVFILE" say " 1. edit $ENVFILE"
say " 2. systemctl start $NAME (or run this again with --start)" say " 2. systemctl start $NAME (or run this again with --start)"
+12 -2
View File
@@ -1,7 +1,7 @@
#!/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] Hammerhead (dashboard and API) # 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 edge --hammerhead URL --token TOKEN [--ports 25565,30000-30100] [--version V]
# sh setup.sh agent --hammerhead URL --token TOKEN [--version V] # sh setup.sh agent --hammerhead URL --token TOKEN [--version V]
# #
@@ -20,7 +20,7 @@ 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= DOMAIN= VERSION= HH= TOKEN= PORTS= 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 ;;
@@ -28,11 +28,18 @@ while [ $# -gt 0 ]; do
--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 ;;
--no-auto-update) AUTOUP=0; shift ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;; -h | --help) sed -n '2,16p' "$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 $*"
@@ -123,6 +130,7 @@ case $MODE in
hammerhead) hammerhead)
fetch_install hammerhead fetch_install hammerhead
ENVF=/etc/hammerhead/hammerhead.env ENVF=/etc/hammerhead/hammerhead.env
auto_update "$ENVF" HAMMERHEAD
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
say "generating the master key" say "generating the master key"
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/bin/hammerhead -genkey)" set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/bin/hammerhead -genkey)"
@@ -166,6 +174,7 @@ edge)
ENVF=/etc/blacktip/blacktip.env ENVF=/etc/blacktip/blacktip.env
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH" set_env "$ENVF" BLACKTIP_HAMMERHEAD "$HH"
set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN" set_env "$ENVF" BLACKTIP_TOKEN "$TOKEN"
auto_update "$ENVF" BLACKTIP
chmod 600 "$ENVF" chmod 600 "$ENVF"
say "network" say "network"
# WireGuard moves a lot of UDP; the stock socket buffers are small # WireGuard moves a lot of UDP; the stock socket buffers are small
@@ -202,6 +211,7 @@ agent)
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"
auto_update "$ENVF" BAMBOO
chmod 600 "$ENVF" chmod 600 "$ENVF"
if command -v docker > /dev/null 2>&1 && getent group docker > /dev/null 2>&1; then if command -v docker > /dev/null 2>&1 && getent group docker > /dev/null 2>&1; then
usermod -aG docker bamboo usermod -aG docker bamboo
+3 -3
View File
@@ -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.0 git.veillax.com/Veillax/bamboo v0.1.3
git.veillax.com/Veillax/blacktip v0.1.0 git.veillax.com/Veillax/blacktip v0.1.4
git.veillax.com/Veillax/reef v0.1.0 git.veillax.com/Veillax/reef v0.1.3
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
+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 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
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/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM=
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/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
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/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=
+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 { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
index := page(indexHTML) index := page(indexHTML)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if !isDashboardPath(r.URL.Path) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
+172 -90
View File
@@ -5,84 +5,139 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hammerhead</title> <title>Hammerhead</title>
<style> <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; } /* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
@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); } } :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; } * { box-sizing:border-box; }
html, body { height:100%; } 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; } 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); } 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; } .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; } .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:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; } .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); } .brand svg { color:var(--accent); }
.orgsel { margin:0 0 10px; } .orgsel { margin:0 0 var(--s2); }
.orgsel select { background:var(--muted); font-weight:600; } .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); }
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; } a.navitem { text-decoration:none; }
.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 { 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(--muted); color:var(--text); } .navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); } .navitem { border-radius:var(--radius); }
.navitem.on { background:var(--surface-2); color:var(--text); }
.navitem.on svg { color:var(--accent); }
.navitem svg { flex:none; } .navitem svg { flex:none; }
.grow { flex:1; } .grow { flex:1; }
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; } .userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); }
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .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; } .content { flex:1; min-width:0; }
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; } 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:16px; margin-bottom:22px; flex-wrap:wrap; } .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:24px; font-weight:700; margin:0; letter-spacing:-.01em; } .page-h h1 { font-size:var(--text-2xl); font-weight:600; margin:0; }
.page-h p { margin:4px 0 0; color:var(--dim); } .page-h p { margin:var(--s1) 0 0; color:var(--text-muted); }
.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); }
/* 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.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; } .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:10px; margin-bottom:12px; flex-wrap:wrap; } .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; } .row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; 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:12px; } .stack { display:flex; flex-direction:column; gap:var(--s3); }
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; } /* forms */
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%; } label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); } label.chk { flex-direction:row; align-items:center; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
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, 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%; }
input:disabled { opacity:.6; } textarea { min-height:96px; resize:vertical; }
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; } input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; accent-color:var(--accent); }
button.btn:hover { filter:brightness(1.08); } input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
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; } input:disabled, select:disabled { opacity:.6; }
button.ghost:hover { background:var(--muted); } .sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
/* 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; } 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); } 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:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; } td { padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); vertical-align:top; }
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); } tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; } tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:-4px 0; } .card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 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; }
.scroll { overflow-x:auto; } .scroll { overflow-x:auto; }
.actions { display:flex; gap:6px; flex-wrap:wrap; } .actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; } /* badges and status */
.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; } .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; }
.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; } .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--text-faint); margin-right:var(--s2); flex:none; }
.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); } .dot.ok { background:var(--success); box-shadow:0 0 0 3px var(--success-bg); } .dot.bad { background:var(--danger); } .dot.warn { background:var(--warning); }
.modal h2 { margin:0 0 16px; font-size:18px; } code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; } 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; }
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; } .dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; } .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); }
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; } .center { max-width:400px; margin:11vh auto; padding:0 var(--s4); }
.sub { color:var(--dim); font-size:12px; font-weight:400; } .center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s5); }
.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; } .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); }
.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); } /* dialogs, tabs, toast */
#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); } .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; }
#toast.bad { border-color:var(--bad); color:var(--bad); } .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) { @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; } .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> </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 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}); };
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) {
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); const cmdBox = t => h('pre', {class:'mono'}, t);
function modal(title, body, opts = {}) { function modal(title, body, opts = {}) {
const shade = h('div', {class:'shade'}); const shade = h('div', {class:'shade'});
@@ -182,6 +241,8 @@ async function boot() {
if (!S.me) return $app.replaceChildren(loginView()); 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.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
if (S.org) localStorage.setItem('org', S.org); if (S.org) localStorage.setItem('org', S.org);
const fromPath = tabFromPath(location.pathname);
if (fromPath) selectTab(fromPath);
await render(); await render();
} }
@@ -248,8 +309,17 @@ const PAGES = {
// server pages are top-level sidebar entries that map onto serverViews // 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'}; 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() { 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())]; 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(); })}, 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))))); 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'); all.push('account');
if (!all.includes(S.tab)) S.tab = all[0]; 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'), 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'))); 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'})))); $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) { function pageHeader(tab) {
const [t, d] = PAGES[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; let seq = 0;
@@ -284,6 +356,7 @@ async function refresh() {
const my = ++seq; const my = ++seq;
try { try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg(); 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]()); const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return; if (my !== seq) return;
const v = document.getElementById('view'); const v = document.getElementById('view');
@@ -321,8 +394,10 @@ views.ports = async () => {
const table = rows.length ? h('div', {class:'scroll'}, h('table', {}, 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('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.'); 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, S.headerAction = ['Open a port', () => portForm()];
h('div', {class:'card flush'}, table)); 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) { 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 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.'), 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`))); 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', {}, 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)))), 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('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),
@@ -523,9 +599,11 @@ views.sites = async () => {
// ----- domains ----- // ----- domains -----
views.domains = async () => { 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'}); 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', {}, 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)))), 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('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)` : ''), 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', 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('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.'); })))) : 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', {}, 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, !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), h('div', {class:'card flush'}, table),
await dkimCard(), 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', {}, 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('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('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('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',
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'))); async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
} }
function mailForm(existing) { 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', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove'))))))); 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}); 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', {}, 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, !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)))))) : 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.')), 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.')), 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.'), 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), 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 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 rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
const kname = h('input', {placeholder:'e.g. CI deploy'}); 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', {}, return h('div', {},
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))), 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('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', {}, 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.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'))))))), 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 () => { 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()),
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = ''; h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
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),
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'), 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('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')))))), 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 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.'), 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`))); 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', {}, 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)))), 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('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(', ') || '—'), 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 () => { serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false); const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0(); 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', {}, 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.'), 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('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => { 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 () => { serverViews.users = async () => {
const users = await api('GET', '/api/admin/users'); 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); 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', {}, 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)))), 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('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), 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)}; 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}); 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.'), 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'), 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:'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'));
}; };
serverViews.settings = async () => { serverViews.settings = async () => {
+42 -14
View File
@@ -5,22 +5,50 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title> <title>Sign in</title>
<style> <style>
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; } /* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } } :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; } * { 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; } 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(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; } .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:18px; margin:0 0 4px; } h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; } .sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; } .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; } .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; } h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; } 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(--accent); outline-offset:-1px; } input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
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; } :focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); } 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); }
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; } button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
.ok { color:var(--dim); font-size:13px; } 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; } [hidden] { display:none !important; }
</style> </style>
</head> </head>