Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3d7201a1 | ||
|
|
536284ec75 | ||
|
|
9ad690def0 | ||
|
|
719449f6fa | ||
|
|
09f4f30984 | ||
|
|
6f204e71c9 | ||
|
|
b105c974a8 | ||
|
|
7db686eeb0 | ||
|
|
58d1cfe9a3 | ||
|
|
3cfe896fdd |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# The image only packages the binaries that deploy/build.sh already built.
|
||||
*
|
||||
!dist/bin
|
||||
@@ -21,6 +21,14 @@ HAMMERHEAD_MASTER_KEY=
|
||||
# Leave empty and /metrics does not exist.
|
||||
#HAMMERHEAD_METRICS_TOKEN=
|
||||
|
||||
# Serve the dashboard through a Blacktip edge on this same machine, at this name, with an
|
||||
# automatic HTTPS certificate: no Caddy or other web server in front of Hammerhead. Hammerhead
|
||||
# makes the edge itself and writes its token to <data>/self-edge.env (BLACKTIP_TOKEN=...).
|
||||
# deploy/setup.sh --domain sets this up for you. Needs HAMMERHEAD_URL set to the same name.
|
||||
#HAMMERHEAD_SELF_DOMAIN=hammerhead.example.com
|
||||
# The edge's public WireGuard address for sites; default <HAMMERHEAD_SELF_DOMAIN>:51820.
|
||||
#HAMMERHEAD_SELF_TUNNEL=hammerhead.example.com:51820
|
||||
|
||||
# How long access logs are kept (days). Default 30.
|
||||
#HAMMERHEAD_LOG_DAYS=30
|
||||
|
||||
@@ -38,3 +46,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
|
||||
@@ -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 windows/amd64; do
|
||||
echo "building for $t"
|
||||
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
|
||||
done
|
||||
@@ -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 $tags --push .
|
||||
@@ -5,3 +5,4 @@ bin/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
master.key
|
||||
dist/
|
||||
+22
@@ -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"]
|
||||
@@ -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) and Windows (amd64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a container image (linux/amd64) to this server's registry (`git.veillax.com/veillax/hammerhead`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
|
||||
|
||||
**Fastest: one script on a fresh VPS**
|
||||
|
||||
`deploy/setup.sh` (also inside the Linux archives) installs everything for you on a systemd Linux: the services, a generated master key, the firewall rules and HTTPS.
|
||||
|
||||
```
|
||||
sudo sh setup.sh --domain hammerhead.example.com # Hammerhead and an edge on this machine
|
||||
sudo sh setup.sh agent --hammerhead https://hammerhead.example.com --token <site token> # on each site
|
||||
sudo sh setup.sh edge --hammerhead https://hammerhead.example.com --token <edge token> # more public servers
|
||||
```
|
||||
|
||||
With `--domain`, Hammerhead listens only on `127.0.0.1:8080` and gets an edge of its own (`HAMMERHEAD_SELF_DOMAIN`): the Blacktip on the same machine serves the dashboard at `https://<domain>` with an automatic certificate, so no Caddy or other web server is needed. The route is built in, not a mapping you can edit or delete by mistake. DNS for the name must point at the server and ports 80 and 443 must be free. `--caddy` keeps the old style: Hammerhead alone, with Caddy providing HTTPS. Add `--ports 25565,30000-30100` to open game or mail ports in the firewall, and `--no-auto-update` to leave updates to you.
|
||||
|
||||
**On a Linux server (systemd)**
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Releases are published for x86-64 only; for ARM machines build it yourself (`TARGETS=linux/arm64 sh deploy/build.sh`). To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/hammerhead` and `/var/lib/hammerhead` alone. `hammerhead -version` tells you which build is running.
|
||||
|
||||
**Updates**
|
||||
|
||||
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
|
||||
|
||||
@@ -73,6 +73,9 @@ func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg.Mappings = append(cfg.Mappings, *m)
|
||||
}
|
||||
}
|
||||
if m := s.selfMapping(e); m != nil {
|
||||
cfg.Mappings = append(cfg.Mappings, *m)
|
||||
}
|
||||
cfg.MailEgress = []model.MailPolicy{}
|
||||
for _, x := range s.st.MailEgress {
|
||||
if x.EdgeID == e.ID && x.Enabled {
|
||||
|
||||
+32
-1
@@ -2,9 +2,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -13,8 +15,25 @@ 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"
|
||||
|
||||
// localAddr turns a listen address into one this machine can dial: ":8080" and "0.0.0.0:8080"
|
||||
// become "127.0.0.1:8080".
|
||||
func localAddr(listen string) string {
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return listen
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
@@ -36,7 +55,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
|
||||
@@ -50,12 +75,18 @@ func main() {
|
||||
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
|
||||
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
|
||||
LogRetentionDays: days,
|
||||
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
|
||||
SelfTarget: localAddr(*listen),
|
||||
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/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
|
||||
#
|
||||
# Releases are published for linux/amd64 and windows/amd64 only. Set TARGETS="linux/arm64 darwin/arm64"
|
||||
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
|
||||
set -eu
|
||||
NAME=hammerhead
|
||||
cd "$(dirname "$0")/.."
|
||||
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
||||
TARGETS=${TARGETS:-"linux/amd64 windows/amd64"}
|
||||
|
||||
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/"
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/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
|
||||
|
||||
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
|
||||
asset_ids() {
|
||||
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
|
||||
for a in json.load(sys.stdin):
|
||||
if a["name"] == sys.argv[1]:
|
||||
print(a["id"])' "$1"
|
||||
}
|
||||
|
||||
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
n=1
|
||||
while :; do
|
||||
for aid in $(asset_ids "$name"); do
|
||||
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
|
||||
done
|
||||
echo "uploading $name (attempt $n)"
|
||||
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
|
||||
break
|
||||
fi
|
||||
# large uploads can hit a proxy timeout (HTTP 502); try again
|
||||
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
|
||||
n=$((n + 1))
|
||||
sleep $((n * 10))
|
||||
done
|
||||
done
|
||||
echo "published $SERVER/$REPO/releases/tag/$TAG"
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
#!/bin/sh
|
||||
# One-shot setup for a VPS. Run as root.
|
||||
#
|
||||
# sh setup.sh --domain hh.example.com [options] Hammerhead and a Blacktip edge on this machine
|
||||
# sh setup.sh edge --hammerhead URL --token TOKEN [options] another edge
|
||||
# sh setup.sh agent --hammerhead URL --token TOKEN [options] a site agent
|
||||
#
|
||||
# With --domain, this installs Hammerhead (the dashboard) and a Blacktip edge on the same
|
||||
# machine. The edge serves the dashboard itself at https://<domain> with an automatic
|
||||
# certificate, so no separate web server is needed. DNS for the domain must already point here,
|
||||
# and ports 80 and 443 must be free (stop Pangolin, Caddy, nginx... first).
|
||||
#
|
||||
# Options:
|
||||
# --version V release of Hammerhead (or of the edge/agent for those modes); default newest
|
||||
# --edge-version V release of the Blacktip installed next to Hammerhead; default newest
|
||||
# --ports 25565,30000-30100 also open these (TCP and UDP) in the firewall
|
||||
# --tunnel HOST:PORT the edge's public WireGuard address; default <domain>:51820
|
||||
# --caddy old style: Hammerhead alone, with Caddy providing HTTPS (no edge here)
|
||||
# --no-auto-update don't let the programs update themselves
|
||||
#
|
||||
# Without --domain, Hammerhead alone is installed on port 8080 over plain HTTP.
|
||||
# Edge and agent need a token: create the edge under Server -> Edges (or the site under Sites)
|
||||
# in the dashboard and copy the values from "Show command".
|
||||
# Safe to run again: it upgrades and keeps your settings and data.
|
||||
set -eu
|
||||
|
||||
BASE=${SHARKS_BASE:-https://git.veillax.com/Veillax}
|
||||
MODE=hammerhead
|
||||
case "${1:-}" in edge | agent) MODE=$1; shift ;; esac
|
||||
DOMAIN= VERSION= EDGE_VERSION= HH= TOKEN= PORTS= TUNNEL= CADDY=0 AUTOUP=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN=$2; shift 2 ;;
|
||||
--version) VERSION=$2; shift 2 ;;
|
||||
--edge-version) EDGE_VERSION=$2; shift 2 ;;
|
||||
--hammerhead) HH=$2; shift 2 ;;
|
||||
--token) TOKEN=$2; shift 2 ;;
|
||||
--ports) PORTS=$2; shift 2 ;;
|
||||
--tunnel) TUNNEL=$2; shift 2 ;;
|
||||
--caddy) CADDY=1; shift ;;
|
||||
--no-auto-update) AUTOUP=0; shift ;;
|
||||
-h | --help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option $1 (see --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
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 ;;
|
||||
*) die "releases are published for x86-64 Linux only, not $(uname -m); build it yourself with: TARGETS=linux/arm64 sh deploy/build.sh" ;;
|
||||
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> [version]: downloads that app's release for this CPU, verifies it and runs its install.sh
|
||||
fetch_install() {
|
||||
app=$1
|
||||
ver=${2:-$(latest "$app")}
|
||||
[ -n "$ver" ] || die "couldn't find a release of $app at $BASE/$app (use --version, or SHARKS_BASE for a mirror)"
|
||||
dir=$(mktemp -d)
|
||||
say "downloading $app $ver ($ARCH)"
|
||||
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
|
||||
}
|
||||
|
||||
# auto_update <file> <PREFIX>: lets the program update itself unless --no-auto-update was given
|
||||
auto_update() {
|
||||
[ "$AUTOUP" = 1 ] && set_env "$1" "$2_AUTO_UPDATE" 1
|
||||
return 0
|
||||
}
|
||||
|
||||
firewall_open() { # firewall_open 80/tcp 443/tcp ...
|
||||
if command -v ufw > /dev/null 2>&1 && ufw status 2> /dev/null | grep -q "Status: active"; then
|
||||
for p in "$@"; do ufw allow "$p" > /dev/null; done
|
||||
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"; }
|
||||
}
|
||||
|
||||
# listeners on 80/443 that would stop an edge serving websites
|
||||
port_users() {
|
||||
ss -H -ltnp '( sport = :80 or sport = :443 )' 2> /dev/null | grep -v '"blacktip"\|"traefik"' || true
|
||||
}
|
||||
|
||||
# edge_setup <hammerhead url> <token> <version>: Blacktip with its bundled Traefik, configured but not started
|
||||
edge_setup() {
|
||||
fetch_install blacktip "$3"
|
||||
ENVF=/etc/blacktip/blacktip.env
|
||||
set_env "$ENVF" BLACKTIP_HAMMERHEAD "$1"
|
||||
set_env "$ENVF" BLACKTIP_TOKEN "$2"
|
||||
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."
|
||||
}
|
||||
|
||||
case $MODE in
|
||||
hammerhead)
|
||||
fetch_install hammerhead "$VERSION"
|
||||
ENVF=/etc/hammerhead/hammerhead.env
|
||||
auto_update "$ENVF" HAMMERHEAD
|
||||
if ! grep -q '^HAMMERHEAD_MASTER_KEY=.' "$ENVF"; then
|
||||
say "generating the master key"
|
||||
set_env "$ENVF" HAMMERHEAD_MASTER_KEY "$(/usr/local/lib/hammerhead/hammerhead -genkey)"
|
||||
echo "Saved in $ENVF. Back that file up: without the key the encrypted secrets in the database can't be read."
|
||||
fi
|
||||
UNIT=/etc/systemd/system/hammerhead.service
|
||||
if [ -n "$DOMAIN" ]; then
|
||||
set_env "$ENVF" HAMMERHEAD_URL "https://$DOMAIN"
|
||||
# only something on this machine needs to reach it
|
||||
sed -i 's|-listen :8080|-listen 127.0.0.1:8080|' "$UNIT"
|
||||
if [ "$CADDY" = 1 ]; then
|
||||
systemctl daemon-reload
|
||||
if ! command -v caddy > /dev/null 2>&1; then
|
||||
say "installing Caddy (HTTPS certificates for $DOMAIN)"
|
||||
pkg_install caddy || die "couldn't install Caddy from this distribution; see https://caddyserver.com/docs/install and proxy $DOMAIN to 127.0.0.1:8080"
|
||||
fi
|
||||
mkdir -p /etc/caddy
|
||||
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile
|
||||
systemctl enable caddy > /dev/null 2>&1 || true
|
||||
systemctl restart caddy
|
||||
firewall_open 80/tcp 443/tcp
|
||||
URL="https://$DOMAIN"
|
||||
else
|
||||
# the dashboard is served by the edge on this machine, so Hammerhead gets its own edge
|
||||
set_env "$ENVF" HAMMERHEAD_SELF_DOMAIN "$DOMAIN"
|
||||
[ -z "$TUNNEL" ] || set_env "$ENVF" HAMMERHEAD_SELF_TUNNEL "$TUNNEL"
|
||||
URL="https://$DOMAIN"
|
||||
fi
|
||||
else
|
||||
firewall_open 8080/tcp
|
||||
ip=$(public_ips | head -n 1)
|
||||
URL="http://${ip:-this-server}:8080"
|
||||
echo "No --domain given, so the dashboard is plain HTTP. Put it behind HTTPS before you create real logins (run this again with --domain)."
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl restart hammerhead
|
||||
wait_active hammerhead
|
||||
say "Hammerhead is running"
|
||||
|
||||
if [ -n "$DOMAIN" ] && [ "$CADDY" = 0 ]; then
|
||||
TOKENFILE=/var/lib/hammerhead/self-edge.env
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do [ -f "$TOKENFILE" ] && break; sleep 1; done
|
||||
[ -f "$TOKENFILE" ] || die "Hammerhead didn't create $TOKENFILE; check: journalctl -u hammerhead"
|
||||
edge_token=$(sed -n 's/^BLACKTIP_TOKEN=//p' "$TOKENFILE")
|
||||
edge_setup http://127.0.0.1:8080 "$edge_token" "$EDGE_VERSION"
|
||||
# something else on 80/443 would stop the dashboard route from coming up
|
||||
if systemctl is-active --quiet caddy 2> /dev/null; then
|
||||
say "stopping Caddy"
|
||||
echo "Caddy was serving this site and holds ports 80/443, which the edge needs now."
|
||||
systemctl disable --now caddy > /dev/null 2>&1 || true
|
||||
fi
|
||||
others=$(port_users)
|
||||
if [ -n "$others" ]; then
|
||||
echo "$others" >&2
|
||||
die "something else is listening on port 80 or 443 (above). Stop it (for Pangolin: docker compose stop), then run: systemctl restart blacktip"
|
||||
fi
|
||||
systemctl restart blacktip
|
||||
wait_active blacktip
|
||||
say "Blacktip is running"
|
||||
echo "The dashboard is served by the edge on this machine at $URL once the certificate is issued"
|
||||
echo "(a few seconds; $DOMAIN must point at this server). Until then, from your own computer:"
|
||||
echo " ssh -L 8080:127.0.0.1:8080 you@this-server then open http://localhost:8080"
|
||||
fi
|
||||
echo
|
||||
echo "Open $URL to create the first administrator."
|
||||
echo
|
||||
echo "Next: on each machine that hosts your services (home server, game server...) run"
|
||||
echo " sh setup.sh agent --hammerhead $URL --token <site token>"
|
||||
echo "with the token from Sites -> add a site. For more public servers add edges under Server -> Edges and run"
|
||||
echo " sh setup.sh edge --hammerhead $URL --token <edge token>"
|
||||
;;
|
||||
edge)
|
||||
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "edge needs --hammerhead URL and --token TOKEN (Server -> Edges -> Show command)"
|
||||
edge_setup "$HH" "$TOKEN" "$VERSION"
|
||||
systemctl restart blacktip
|
||||
wait_active blacktip
|
||||
say "Blacktip is running"
|
||||
;;
|
||||
agent)
|
||||
[ -n "$HH" ] && [ -n "$TOKEN" ] || die "agent needs --hammerhead URL and --token TOKEN (Sites -> Show command)"
|
||||
fetch_install bamboo "$VERSION"
|
||||
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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
const selfMappingID = "hammerhead-dashboard"
|
||||
|
||||
// ensureSelfEdge makes sure this server has an edge of its own, for a Blacktip on the same
|
||||
// machine, and writes that edge's token to <data>/self-edge.env for the installer. The edge
|
||||
// also carries a built-in https route for the dashboard (see selfMapping), so no separate
|
||||
// reverse proxy is needed in front of Hammerhead.
|
||||
func (s *Server) ensureSelfEdge() error {
|
||||
o := s.opts
|
||||
if o.SelfDomain == "" {
|
||||
return nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(o.SelfTarget)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HAMMERHEAD_SELF_DOMAIN needs a listen address like 127.0.0.1:8080: %w", err)
|
||||
}
|
||||
if n, _ := strconv.Atoi(port); n < 1 || host == "" {
|
||||
return errors.New("the dashboard's listen address needs a host and a port")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e := s.findEdge(s.st.Settings.SelfEdgeID)
|
||||
if e == nil {
|
||||
e = &model.Edge{ID: newID(), Name: "Hammerhead server", Token: randHex(24), AuthKey: randHex(32),
|
||||
TunnelAddr: o.SelfTunnel, TunnelIP: s.allocIPLocked(), OrgIDs: []string{}, IPs: []string{}}
|
||||
s.st.Edges = append(s.st.Edges, e)
|
||||
s.st.Sites = append(s.st.Sites, &model.Site{ID: newID(), Name: e.Name + " (this machine)",
|
||||
Local: true, EdgeID: e.ID, Connected: []string{}})
|
||||
s.st.Settings.SelfEdgeID = e.ID
|
||||
s.bump()
|
||||
}
|
||||
body := "# written by Hammerhead for the Blacktip on this machine; keep it private\nBLACKTIP_TOKEN=" + e.Token + "\n"
|
||||
return os.WriteFile(filepath.Join(s.dir, "self-edge.env"), []byte(body), 0o600)
|
||||
}
|
||||
|
||||
// selfMapping is the dashboard's own route on the self edge: https on the configured name,
|
||||
// forwarded to Hammerhead's listen address through the edge's local site. It is added to that
|
||||
// edge's config and never stored, so it can't be edited or deleted by mistake in the UI.
|
||||
// Caller holds s.mu.
|
||||
func (s *Server) selfMapping(e *model.Edge) *model.Mapping {
|
||||
o := s.opts
|
||||
if o.SelfDomain == "" || e.ID != s.st.Settings.SelfEdgeID {
|
||||
return nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(o.SelfTarget)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n, _ := strconv.Atoi(port)
|
||||
for _, st := range s.st.Sites {
|
||||
if st.Local && st.EdgeID == e.ID {
|
||||
return &model.Mapping{ID: selfMappingID, Name: "Hammerhead dashboard", Proto: model.ProtoHTTPS, EdgeID: e.ID,
|
||||
BindIP: "0.0.0.0", Port: 443, Hostname: strings.ToLower(o.SelfDomain), Enabled: true, RedirectHTTP: true,
|
||||
HealthSeconds: -1, Targets: []model.Target{{SiteID: st.ID, Host: host, Port: n}}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/edge/config?v=0", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var cfg model.EdgeConfig
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("edge config: %s", resp.Status)
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func tokenFile(t *testing.T, dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "self-edge.env"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(strings.SplitN(strings.SplitN(string(b), "BLACKTIP_TOKEN=", 2)[1], "\n", 2)[0])
|
||||
}
|
||||
|
||||
var key = secretbox.GenerateKey()
|
||||
|
||||
func TestSelfEdgeServesTheDashboardWithoutAProxy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := Options{MasterKey: key, SelfDomain: "Hammerhead.Example.com", SelfTarget: "127.0.0.1:8080"}
|
||||
s, err := New(dir, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := tokenFile(t, dir)
|
||||
cfg := edgeConfigOf(t, s, token)
|
||||
if len(cfg.Mappings) != 1 || len(cfg.Local) != 1 {
|
||||
t.Fatalf("mappings=%d local=%d", len(cfg.Mappings), len(cfg.Local))
|
||||
}
|
||||
m := cfg.Mappings[0]
|
||||
if m.Proto != model.ProtoHTTPS || m.Hostname != "hammerhead.example.com" || m.Port != 443 || !m.RedirectHTTP || !m.Enabled ||
|
||||
len(m.Targets) != 1 || m.Targets[0].Host != "127.0.0.1" || m.Targets[0].Port != 8080 || m.Targets[0].SiteID != cfg.Local[0] {
|
||||
t.Fatalf("unexpected dashboard route: %+v", m)
|
||||
}
|
||||
if m.Auth.Protected() {
|
||||
t.Fatal("the dashboard has its own login and must not sit behind resource auth")
|
||||
}
|
||||
edgeID := s.st.Settings.SelfEdgeID
|
||||
if info, _ := os.Stat(filepath.Join(dir, "self-edge.env")); info.Mode().Perm()&0o077 != 0 && os.PathSeparator == '/' {
|
||||
t.Fatalf("token file is readable by others: %v", info.Mode())
|
||||
}
|
||||
s.Close()
|
||||
|
||||
// a restart keeps the same edge and token instead of making a new one
|
||||
s2, err := New(dir, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Close()
|
||||
if s2.st.Settings.SelfEdgeID != edgeID || tokenFile(t, dir) != token || len(s2.st.Edges) != 1 {
|
||||
t.Fatal("restart changed the self edge")
|
||||
}
|
||||
if len(edgeConfigOf(t, s2, token).Mappings) != 1 {
|
||||
t.Fatal("route missing after restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSelfEdgeUnlessAsked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir, Options{MasterKey: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if len(s.st.Edges) != 0 {
|
||||
t.Fatal("an edge appeared without HAMMERHEAD_SELF_DOMAIN")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "self-edge.env")); err == nil {
|
||||
t.Fatal("token file written without HAMMERHEAD_SELF_DOMAIN")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ type Settings struct {
|
||||
AcmeEmail string `json:"acmeEmail"`
|
||||
AcmeStaging bool `json:"acmeStaging"`
|
||||
SMTP SMTP `json:"smtp"`
|
||||
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
|
||||
SelfEdgeID string `json:"selfEdgeId,omitempty"`
|
||||
}
|
||||
|
||||
type Secret struct {
|
||||
@@ -170,6 +172,14 @@ type Options struct {
|
||||
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
||||
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
||||
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
||||
|
||||
// SelfDomain (HAMMERHEAD_SELF_DOMAIN) gives this server an edge of its own, with a built-in
|
||||
// https route for the dashboard at that name, so no reverse proxy is needed in front of it.
|
||||
// SelfTarget is the address Hammerhead listens on (e.g. 127.0.0.1:8080); SelfTunnel is the
|
||||
// edge's public WireGuard endpoint (default <SelfDomain>:51820).
|
||||
SelfDomain string
|
||||
SelfTarget string
|
||||
SelfTunnel string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -258,6 +268,13 @@ func New(dir string, opts Options) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if opts.SelfDomain != "" && opts.SelfTunnel == "" {
|
||||
s.opts.SelfTunnel = opts.SelfDomain + ":51820"
|
||||
}
|
||||
if err := s.ensureSelfEdge(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := s.save(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
|
||||
+172
-90
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user