Compare commits
22
Commits
v0.1.0
...
39659f0144
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.
|
# Leave empty and /metrics does not exist.
|
||||||
#HAMMERHEAD_METRICS_TOKEN=
|
#HAMMERHEAD_METRICS_TOKEN=
|
||||||
|
|
||||||
|
# Serve the dashboard through a Blacktip edge on this same machine, at this name, with an
|
||||||
|
# automatic HTTPS certificate: no Caddy or other web server in front of Hammerhead. Hammerhead
|
||||||
|
# makes the edge itself and writes its token to <data>/self-edge.env (BLACKTIP_TOKEN=...).
|
||||||
|
# deploy/setup.sh --domain sets this up for you. Needs HAMMERHEAD_URL set to the same name.
|
||||||
|
#HAMMERHEAD_SELF_DOMAIN=hammerhead.example.com
|
||||||
|
# The edge's public WireGuard address for sites; default <HAMMERHEAD_SELF_DOMAIN>:51820.
|
||||||
|
#HAMMERHEAD_SELF_TUNNEL=hammerhead.example.com:51820
|
||||||
|
|
||||||
# How long access logs are kept (days). Default 30.
|
# How long access logs are kept (days). Default 30.
|
||||||
#HAMMERHEAD_LOG_DAYS=30
|
#HAMMERHEAD_LOG_DAYS=30
|
||||||
|
|
||||||
@@ -38,3 +46,7 @@ HAMMERHEAD_MASTER_KEY=
|
|||||||
# Token from Sites -> Show command.
|
# Token from Sites -> Show command.
|
||||||
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com
|
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com
|
||||||
#BAMBOO_TOKEN=
|
#BAMBOO_TOKEN=
|
||||||
|
|
||||||
|
# Check for new releases every few hours and update and restart by itself (Linux and macOS).
|
||||||
|
# setup.sh turns this on; remove it to update by hand.
|
||||||
|
#HAMMERHEAD_AUTO_UPDATE=1
|
||||||
@@ -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-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
master.key
|
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,58 @@ 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
|
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.
|
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.
|
||||||
|
|
||||||
|
Edges and sites report their version, so Server → Edges and Sites show which release each one runs and flag a newer one. An **Update** button appears for any machine that runs with auto-update on (`BLACKTIP_AUTO_UPDATE=1` / `BAMBOO_AUTO_UPDATE=1`): it asks that machine to install the newest release now, and the result is shown in the table. Under Server → Settings, **Check for updates** does the same lookup for Hammerhead itself, with an **Update now** button when auto-update is on. A machine without auto-update never installs anything on request. Starting an update opens a progress bar with the live log of what the machine is doing (checking, downloading, verifying, installing, restarting). Hammerhead keeps the log of its own last update in `update.log` in its data folder, so it is still there after the restart.
|
||||||
|
|
||||||
|
**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
|
## Configuration and secrets
|
||||||
|
|
||||||
All three programs read a `.env` file from their working directory before they start (or the file
|
All three programs read a `.env` file from their working directory before they start (or the file
|
||||||
@@ -207,6 +259,10 @@ Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammer
|
|||||||
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
|
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
|
||||||
decision function runs in both proxy modes.
|
decision function runs in both proxy modes.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check.
|
||||||
|
|
||||||
## Limits and notes
|
## Limits and notes
|
||||||
|
|
||||||
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
|
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
|
||||||
|
|||||||
+11
-11
@@ -66,7 +66,7 @@ func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := s.principalFrom(r)
|
p := s.principalFrom(r)
|
||||||
if p == nil || p.User.ID == "" {
|
if p == nil || p.User.ID == "" {
|
||||||
fail(w, 401, errors.New("not logged in"))
|
fail(w, 401, errors.New("Sign in to continue."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fn(w, r, p)
|
fn(w, r, p)
|
||||||
@@ -96,7 +96,7 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := s.principalFrom(r)
|
p := s.principalFrom(r)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
fail(w, 401, errors.New("not logged in"))
|
fail(w, 401, errors.New("Sign in to continue."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("org")
|
id := r.PathValue("org")
|
||||||
@@ -169,7 +169,7 @@ func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if len(s.st.Users) != 0 {
|
if len(s.st.Users) != 0 {
|
||||||
return errors.New("setup already done")
|
return errors.New("Hammerhead is already set up. Sign in instead.")
|
||||||
}
|
}
|
||||||
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
|
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
|
||||||
s.st.Users = append(s.st.Users, u)
|
s.st.Users = append(s.st.Users, u)
|
||||||
@@ -240,7 +240,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
email := strings.ToLower(strings.TrimSpace(in.Email))
|
email := strings.ToLower(strings.TrimSpace(in.Email))
|
||||||
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
|
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
|
||||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -254,7 +254,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u == nil || !good || u.PassHash == "" {
|
if u == nil || !good || u.PassHash == "" {
|
||||||
s.audit("", email, "login.failed", clientIP(r))
|
s.audit("", email, "login.failed", clientIP(r))
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
fail(w, 401, errors.New("wrong email or password"))
|
fail(w, 401, errors.New("That email and password do not match. Check them and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.TOTPEnabled {
|
if u.TOTPEnabled {
|
||||||
@@ -276,7 +276,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
|
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
|
||||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -307,7 +307,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !good {
|
if !good {
|
||||||
fail(w, 401, errors.New("wrong code"))
|
fail(w, 401, errors.New("That code is not right. Check your authenticator and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.startSession(w, r, u.ID)
|
s.startSession(w, r, u.ID)
|
||||||
@@ -413,7 +413,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *princ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
|
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
|
||||||
fail(w, 400, errors.New("current password is wrong"))
|
fail(w, 400, errors.New("Your current password is not right. Check it and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := hashPassword(in.New)
|
hash, err := hashPassword(in.New)
|
||||||
@@ -464,7 +464,7 @@ func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u := s.findUser(p.User.ID)
|
u := s.findUser(p.User.ID)
|
||||||
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
||||||
fail(w, 400, errors.New("that code doesn't match; check your authenticator's clock"))
|
fail(w, 400, errors.New("That code does not match. Check that your authenticator's clock is right and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
codes, hashes := make([]string, 8), make([]string, 8)
|
codes, hashes := make([]string, 8), make([]string, 8)
|
||||||
@@ -483,14 +483,14 @@ func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
|
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
|
||||||
fail(w, 400, errors.New("password is wrong"))
|
fail(w, 400, errors.New("That password is not right. Check it and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u := s.findUser(p.User.ID)
|
u := s.findUser(p.User.ID)
|
||||||
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
||||||
fail(w, 400, errors.New("code is wrong"))
|
fail(w, 400, errors.New("That code is not right. Check your authenticator and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
cfg.Mappings = append(cfg.Mappings, *m)
|
cfg.Mappings = append(cfg.Mappings, *m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m := s.selfMapping(e); m != nil {
|
||||||
|
cfg.Mappings = append(cfg.Mappings, *m)
|
||||||
|
}
|
||||||
|
cfg.UpdateID = e.UpdateID
|
||||||
cfg.MailEgress = []model.MailPolicy{}
|
cfg.MailEgress = []model.MailPolicy{}
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.EdgeID == e.ID && x.Enabled {
|
if x.EdgeID == e.ID && x.Enabled {
|
||||||
@@ -107,6 +111,9 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
e.LastSeen = time.Now()
|
e.LastSeen = time.Now()
|
||||||
e.IPs = in.IPs
|
e.IPs = in.IPs
|
||||||
|
oldVersion := e.Version
|
||||||
|
answered := noteUpdateReport(&e.Version, &e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
||||||
|
s.recordUpdateProgress(e.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, e.Version)
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.EdgeID == e.ID {
|
if x.EdgeID == e.ID {
|
||||||
if chk, found := in.MailChecks[x.ID]; found {
|
if chk, found := in.MailChecks[x.ID]; found {
|
||||||
@@ -143,6 +150,8 @@ func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e.PublicKey != in.PublicKey {
|
if e.PublicKey != in.PublicKey {
|
||||||
e.PublicKey = in.PublicKey
|
e.PublicKey = in.PublicKey
|
||||||
s.bump() // sites need the edge's new key
|
s.bump() // sites need the edge's new key
|
||||||
|
} else if answered {
|
||||||
|
s.bump() // the edge's config no longer carries the update request
|
||||||
} else {
|
} else {
|
||||||
s.save()
|
s.save()
|
||||||
}
|
}
|
||||||
@@ -202,7 +211,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
st.LastSeen = time.Now()
|
st.LastSeen = time.Now()
|
||||||
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}}
|
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}, UpdateID: st.UpdateID}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
addEdge := func(id string) {
|
addEdge := func(id string) {
|
||||||
if seen[id] {
|
if seen[id] {
|
||||||
@@ -246,6 +255,9 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.Connected = []string{}
|
in.Connected = []string{}
|
||||||
}
|
}
|
||||||
st.Connected = in.Connected
|
st.Connected = in.Connected
|
||||||
|
oldVersion := st.Version
|
||||||
|
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
||||||
|
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
||||||
s.containers[st.ID] = in.Containers
|
s.containers[st.ID] = in.Containers
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.SiteID != st.ID {
|
if x.SiteID != st.ID {
|
||||||
@@ -260,6 +272,8 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
|
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
|
||||||
st.PublicKey = in.PublicKey
|
st.PublicKey = in.PublicKey
|
||||||
s.bump() // every edge this site talks to needs the new key
|
s.bump() // every edge this site talks to needs the new key
|
||||||
|
} else if answered {
|
||||||
|
s.bump() // the site's config no longer carries the update request
|
||||||
}
|
}
|
||||||
ok(w)
|
ok(w)
|
||||||
}
|
}
|
||||||
@@ -28,14 +28,17 @@ const (
|
|||||||
defaultOffline = 90 * time.Second
|
defaultOffline = 90 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertChannel is somewhere alerts are sent: an email address or a webhook.
|
// AlertChannel is somewhere alerts are sent: an email address, a webhook, or a
|
||||||
|
// Sandbar server (which pages whoever is on call and tracks the alert).
|
||||||
type AlertChannel struct {
|
type AlertChannel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"` // "email" or "webhook"
|
Kind string `json:"kind"` // "email", "webhook" or "sandbar"
|
||||||
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens)
|
Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens)
|
||||||
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
||||||
|
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser)
|
||||||
|
Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone
|
||||||
AllowPrivate bool `json:"allowPrivate"`
|
AllowPrivate bool `json:"allowPrivate"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,7 @@ type alertState struct {
|
|||||||
type problem struct{ org, kind, name, msg string }
|
type problem struct{ org, kind, name, msg string }
|
||||||
|
|
||||||
type alertNote struct {
|
type alertNote struct {
|
||||||
|
Key string `json:"key"` // stable per problem, so a recovery can find its alert
|
||||||
Status string `json:"status"` // "firing", "resolved" or "test"
|
Status string `json:"status"` // "firing", "resolved" or "test"
|
||||||
Org string `json:"org"`
|
Org string `json:"org"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -251,7 +255,7 @@ func (s *Server) evaluateAlerts(now time.Time) {
|
|||||||
chans = append(chans, *c)
|
chans = append(chans, *c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
out = append(out, delivery{alertNote{Key: st.Key, Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
||||||
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(probs))
|
keys := make([]string, 0, len(probs))
|
||||||
@@ -362,6 +366,8 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
|
case "sandbar":
|
||||||
|
return s.sendSandbar(ch, n)
|
||||||
case "webhook":
|
case "webhook":
|
||||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||||
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
||||||
@@ -385,6 +391,47 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
|||||||
return errors.New("unknown channel kind")
|
return errors.New("unknown channel kind")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendSandbar raises the alert on a Sandbar server, or resolves it there when the
|
||||||
|
// problem clears. Sandbar de-duplicates by key, so a reminder updates the alert
|
||||||
|
// that is already open instead of opening another. A test raises an info alert
|
||||||
|
// with no policy (nobody is paged) and resolves it straight away.
|
||||||
|
func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error {
|
||||||
|
post := func(body map[string]string) error {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/api/v1/alerts", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ch.Token)
|
||||||
|
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||||
|
return errors.New("Sandbar rejected the API key")
|
||||||
|
case resp.StatusCode >= 300:
|
||||||
|
return fmt.Errorf("Sandbar answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := "hammerhead:" + n.Key
|
||||||
|
switch n.Status {
|
||||||
|
case "resolved":
|
||||||
|
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||||
|
case "test":
|
||||||
|
key = "hammerhead:test:" + ch.ID
|
||||||
|
if err := post(map[string]string{"key": key, "source": "hammerhead", "summary": "Hammerhead test alert", "detail": n.text(), "severity": "info"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||||
|
}
|
||||||
|
return post(map[string]string{"key": key, "source": "hammerhead", "summary": n.Name + ": " + n.Message,
|
||||||
|
"detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy})
|
||||||
|
}
|
||||||
|
|
||||||
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
||||||
text := n.subject() + "\n" + n.text()
|
text := n.subject() + "\n" + n.text()
|
||||||
switch format {
|
switch format {
|
||||||
@@ -415,12 +462,25 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
|||||||
return errors.New("enter one or more email addresses, separated by commas")
|
return errors.New("enter one or more email addresses, separated by commas")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ch.Format, ch.AllowPrivate = "", false
|
ch.Format, ch.Token, ch.Policy, ch.AllowPrivate = "", "", "", false
|
||||||
|
case "sandbar":
|
||||||
|
u, err := url.Parse(ch.Target)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||||
|
}
|
||||||
|
if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" {
|
||||||
|
return errors.New("enter an API key from Sandbar's Account page")
|
||||||
|
}
|
||||||
|
ch.Policy, ch.Format = strings.TrimSpace(ch.Policy), ""
|
||||||
|
if ch.AllowPrivate && !trusted {
|
||||||
|
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||||
|
}
|
||||||
case "webhook":
|
case "webhook":
|
||||||
u, err := url.Parse(ch.Target)
|
u, err := url.Parse(ch.Target)
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
return errors.New("the webhook must be an http:// or https:// URL")
|
return errors.New("the webhook must be an http:// or https:// URL")
|
||||||
}
|
}
|
||||||
|
ch.Token, ch.Policy = "", ""
|
||||||
switch ch.Format {
|
switch ch.Format {
|
||||||
case "json", "slack", "discord", "ntfy":
|
case "json", "slack", "discord", "ntfy":
|
||||||
case "":
|
case "":
|
||||||
@@ -432,7 +492,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
|||||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return errors.New("a channel is an email or a webhook")
|
return errors.New("a channel is an email, a webhook or a Sandbar server")
|
||||||
}
|
}
|
||||||
if ch.Name == "" {
|
if ch.Name == "" {
|
||||||
ch.Name = ch.Kind
|
ch.Name = ch.Kind
|
||||||
|
|||||||
+6
-3
@@ -23,7 +23,7 @@ type channelView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preview(ch AlertChannel) string {
|
func preview(ch AlertChannel) string {
|
||||||
if ch.Kind != "webhook" {
|
if ch.Kind == "email" {
|
||||||
return ch.Target
|
return ch.Target
|
||||||
}
|
}
|
||||||
if u, err := url.Parse(ch.Target); err == nil {
|
if u, err := url.Parse(ch.Target); err == nil {
|
||||||
@@ -47,7 +47,7 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
|||||||
for _, c := range s.st.Channels {
|
for _, c := range s.st.Channels {
|
||||||
if c.OrgID == sc.org {
|
if c.OrgID == sc.org {
|
||||||
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
||||||
v.Target = "" // webhook URLs often contain tokens
|
v.Target, v.Token = "", "" // webhook URLs often contain tokens
|
||||||
if c.Kind == "email" {
|
if c.Kind == "email" {
|
||||||
v.Target = c.Target
|
v.Target = c.Target
|
||||||
}
|
}
|
||||||
@@ -95,6 +95,9 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco
|
|||||||
if in.Target == "" {
|
if in.Target == "" {
|
||||||
in.Target = existing.Target // blank keeps the saved URL/address
|
in.Target = existing.Target // blank keeps the saved URL/address
|
||||||
}
|
}
|
||||||
|
if id != "" && in.Token == "" && in.Kind == "sandbar" {
|
||||||
|
in.Token = existing.Token // blank keeps the saved API key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trusted := sc.trusted
|
trusted := sc.trusted
|
||||||
if err := s.validateChannel(&in, trusted); err != nil {
|
if err := s.validateChannel(&in, trusted); err != nil {
|
||||||
@@ -156,7 +159,7 @@ func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertS
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
|
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
|
||||||
fail(w, 400, errors.New("wait 1-120 minutes before alerting; remind every 0-168 hours (0 = never)"))
|
fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
+55
-1
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hook struct {
|
type hook struct {
|
||||||
@@ -487,3 +487,57 @@ func TestPromEscape(t *testing.T) {
|
|||||||
t.Errorf("promEscape = %q", got)
|
t.Errorf("promEscape = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSandbarChannelRaisesAndResolves(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
h := newHook(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Channels = []*AlertChannel{{ID: "c2", OrgID: f.org, Name: "pager", Kind: "sandbar", Target: strings.TrimSuffix(h.URL, "/hook/secret-token"),
|
||||||
|
Token: "sb_secret", Policy: "pol1", AllowPrivate: true, Enabled: true}}
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
|
||||||
|
f.eval(f.base)
|
||||||
|
f.setHealthy(false)
|
||||||
|
f.eval(f.base.Add(10 * time.Second))
|
||||||
|
f.eval(f.base.Add(3 * time.Minute))
|
||||||
|
var up map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &up); err != nil {
|
||||||
|
t.Fatal("no Sandbar request:", err)
|
||||||
|
}
|
||||||
|
if up["key"] != "hammerhead:target:m1:0" || up["policy"] != "pol1" || up["severity"] != "critical" || !strings.Contains(up["summary"], "Minecraft") {
|
||||||
|
t.Fatalf("unexpected alert sent to Sandbar: %v", up)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
auth := h.hdrs[0].Get("Authorization")
|
||||||
|
h.mu.Unlock()
|
||||||
|
if auth != "Bearer sb_secret" {
|
||||||
|
t.Fatalf("API key not sent: %q", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.setHealthy(true)
|
||||||
|
f.eval(f.base.Add(4 * time.Minute))
|
||||||
|
var down map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &down); err != nil {
|
||||||
|
t.Fatal("no resolve request:", err)
|
||||||
|
}
|
||||||
|
if down["key"] != up["key"] || down["status"] != "resolved" {
|
||||||
|
t.Fatalf("recovery did not resolve the same alert: %v", down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarChannelValidation(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
for _, ch := range []AlertChannel{
|
||||||
|
{Kind: "sandbar", Target: "not a url", Token: "k"},
|
||||||
|
{Kind: "sandbar", Target: "https://sandbar.example.com"}, // no API key
|
||||||
|
} {
|
||||||
|
ch := ch
|
||||||
|
if err := f.s.validateChannel(&ch, true); err == nil {
|
||||||
|
t.Fatalf("accepted %+v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := AlertChannel{Kind: "sandbar", Target: "https://sandbar.example.com", Token: " k ", Policy: " p "}
|
||||||
|
if err := f.s.validateChannel(&ok, true); err != nil || ok.Token != "k" || ok.Policy != "p" {
|
||||||
|
t.Fatalf("%v %+v", err, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-2
@@ -2,19 +2,38 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead"
|
"git.veillax.com/Veillax/hammerhead"
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/dotenv"
|
"git.veillax.com/Veillax/reef/dotenv"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
|
"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 {
|
func env(k, def string) string {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
return 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")
|
url := flag.String("url", env("HAMMERHEAD_URL", ""), "public URL people reach hammerhead at (used for login redirects and SSO); can also be set in Settings")
|
||||||
flag.String("env", ".env", "file to read secrets and settings from")
|
flag.String("env", ".env", "file to read secrets and settings from")
|
||||||
genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit")
|
genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit")
|
||||||
|
autoUpdate := flag.Bool("auto-update", os.Getenv("HAMMERHEAD_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS)")
|
||||||
|
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if *showVersion {
|
||||||
|
os.Stdout.WriteString("hammerhead " + version + "\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
if *genkey {
|
if *genkey {
|
||||||
fmt.Println(secretbox.GenerateKey())
|
fmt.Println(secretbox.GenerateKey())
|
||||||
return
|
return
|
||||||
@@ -50,12 +75,20 @@ func main() {
|
|||||||
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
|
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
|
||||||
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
|
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
|
||||||
LogRetentionDays: days,
|
LogRetentionDays: days,
|
||||||
|
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
|
||||||
|
SelfTarget: localAddr(*listen),
|
||||||
|
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
|
||||||
|
Version: version,
|
||||||
|
AutoUpdate: *autoUpdate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer srv.Close()
|
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}
|
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||||
log.Fatal(hs.ListenAndServe())
|
log.Fatal(hs.ListenAndServe())
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
|
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ func (d *DB) sealed(v any) any {
|
|||||||
return &c
|
return &c
|
||||||
case *AlertChannel:
|
case *AlertChannel:
|
||||||
c := *x
|
c := *x
|
||||||
c.Target = b.Seal(c.Target)
|
c.Target, c.Token = b.Seal(c.Target), b.Seal(c.Token)
|
||||||
return &c
|
return &c
|
||||||
case Settings:
|
case Settings:
|
||||||
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
||||||
@@ -144,7 +144,7 @@ func (d *DB) unseal(v any) error {
|
|||||||
case *model.DKIMKey:
|
case *model.DKIMKey:
|
||||||
return d.open1(&x.PrivateKey)
|
return d.open1(&x.PrivateKey)
|
||||||
case *AlertChannel:
|
case *AlertChannel:
|
||||||
return d.open1(&x.Target)
|
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
|
||||||
case *Settings:
|
case *Settings:
|
||||||
return d.open1(&x.SMTP.Pass)
|
return d.open1(&x.SMTP.Pass)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Hammerhead design language
|
||||||
|
|
||||||
|
The dashboard and the visitor sign-in page follow the Lightwell design language. The reference is `docs/design.md` in the Lightwell repository. This page records how that language is applied here and where Hammerhead needs its own pieces. When the two disagree, Lightwell's document wins.
|
||||||
|
|
||||||
|
## What is shared with Lightwell
|
||||||
|
|
||||||
|
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`.
|
||||||
|
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
|
||||||
|
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result, and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`fail` in `store.go`).
|
||||||
|
* **Status carries text.** A status is a badge with a label ("Online", "Listening", "Down"), never a colored dot on its own.
|
||||||
|
|
||||||
|
## Where it lives
|
||||||
|
|
||||||
|
| File | What it holds |
|
||||||
|
| --- | --- |
|
||||||
|
| `web/index.html` | The dashboard. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
|
||||||
|
| `web/resource.html` | The sign-in page that visitors see in front of a protected resource. Same token block. |
|
||||||
|
|
||||||
|
Both are single files with no build step, so the token block is repeated in each. Change a token in both.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Built from the helpers at the top of the script in `web/index.html`. Feature code uses these and does not style one-off.
|
||||||
|
|
||||||
|
| Helper | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `card`, `pageHeader` | Surfaces and the page title with its one primary action |
|
||||||
|
| `field`, `chk` | Label above the control, hint below |
|
||||||
|
| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral |
|
||||||
|
| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence |
|
||||||
|
| `modal`, `dialogForm` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener |
|
||||||
|
| `confirmDialog`, `ask` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone |
|
||||||
|
| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts |
|
||||||
|
| `skeleton` | Placeholder rows while a page loads, not a spinner |
|
||||||
|
| `updatePanel` | Progress bar and live log for an update |
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
|
||||||
|
|
||||||
|
Approved names: `fin`, `mine`, `ports`, `sites`, `domains`, `access`, `logs`, `edges`, `orgs`, `users`, `idps`, `settings`, `audit`, `account`, `egress`, `alerts`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
* **One primary action per view.** It sits in the page header. Smaller forms inside a page use the default button.
|
||||||
|
* **List pages** show the page header, then the table in a card with no second title.
|
||||||
|
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 8 characters" are hints below them, not part of the label.
|
||||||
|
* **Dates** have the month written out ("Nov 2, 2026"), with `fmtDate` and `fmtTime`.
|
||||||
|
* **Empty states** say why the list is empty: nothing yet, or nothing matching the filters.
|
||||||
|
* **Long machine messages**, such as certificate errors, wrap and stop at three lines (`.clip`), with the full text in a tooltip.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
* Admin content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter.
|
||||||
|
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
|
||||||
|
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
|
||||||
|
|
||||||
|
## Checking a change
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/lint-design.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
It fails on em and en dashes, emoji, and raw colors outside the token block, in the pages, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
|
||||||
+8
-1
@@ -241,6 +241,9 @@ func selfSigned(t *testing.T, host string) *tls.Certificate {
|
|||||||
|
|
||||||
// ---------- the environment ----------
|
// ---------- the environment ----------
|
||||||
|
|
||||||
|
// siteCfgHook lets a test adjust the Bamboo that setup starts.
|
||||||
|
var siteCfgHook func(*bamboo.Config)
|
||||||
|
|
||||||
type world struct {
|
type world struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
ts *httptest.Server
|
ts *httptest.Server
|
||||||
@@ -297,7 +300,11 @@ func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Confi
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
go bt.Run(ctx)
|
go bt.Run(ctx)
|
||||||
bb, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()})
|
bbCfg := bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()}
|
||||||
|
if siteCfgHook != nil {
|
||||||
|
siteCfgHook(&bbCfg)
|
||||||
|
}
|
||||||
|
bb, err := bamboo.New(bbCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ func proxyBackend(t *testing.T, seen chan<- string) int {
|
|||||||
func askFrom(t *testing.T, from, addr, send string) string {
|
func askFrom(t *testing.T, from, addr, send string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var last error
|
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}
|
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}, Timeout: time.Second}
|
||||||
c, err := d.Dial("tcp", addr)
|
c, err := d.Dial("tcp", addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -102,9 +105,6 @@ func askFrom(t *testing.T, from, addr, send string) string {
|
|||||||
if rerr == nil {
|
if rerr == nil {
|
||||||
return strings.TrimSpace(line)
|
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
|
last = rerr
|
||||||
} else {
|
} else {
|
||||||
last = err
|
last = err
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type edgeRow struct {
|
||||||
|
ID, Version, UpdateMsg string
|
||||||
|
AutoUpdate bool
|
||||||
|
UpdatePending bool
|
||||||
|
Online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *world) edgeRow() edgeRow {
|
||||||
|
var edges []edgeRow
|
||||||
|
w.admin.must("GET", "/api/edges", nil, &edges)
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.ID == w.edge {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.t.Fatal("edge not listed")
|
||||||
|
return edgeRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *world) siteRow() model.Site {
|
||||||
|
var st struct{ Sites []model.Site }
|
||||||
|
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
|
||||||
|
for _, s := range st.Sites {
|
||||||
|
if s.ID == w.site {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.t.Fatal("site not listed")
|
||||||
|
return model.Site{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, what string, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 150; i++ {
|
||||||
|
if ok() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("timed out waiting for %s", what)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdgesAndSitesReportVersionsAndUpdateOnRequest(t *testing.T) {
|
||||||
|
var edgeCalls, siteCalls atomic.Int32
|
||||||
|
siteCfgHook = func(c *bamboo.Config) {
|
||||||
|
c.Version, c.AutoUpdate = "v0.1.0", true
|
||||||
|
c.UpdateNow = func(ctx context.Context, progress func(string, int, string)) (string, error) {
|
||||||
|
siteCalls.Add(1)
|
||||||
|
progress("checking", 10, "Checking for the newest release")
|
||||||
|
progress("done", 100, "Already on the newest release")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() { siteCfgHook = nil }()
|
||||||
|
w := setup(t, blacktip.TraefikConfig{}, func(c *blacktip.Config) {
|
||||||
|
c.Version, c.AutoUpdate = "v0.1.0", true
|
||||||
|
c.UpdateNow = func(ctx context.Context, progress func(string, int, string)) (string, error) {
|
||||||
|
edgeCalls.Add(1)
|
||||||
|
progress("downloading", 40, "Downloading blacktip_v0.2.0_linux_amd64.tar.gz")
|
||||||
|
progress("downloading", 70, "Downloaded 28.0 of 40.0 MB (70%)")
|
||||||
|
progress("restarting", 100, "Installed v0.2.0; restarting into it")
|
||||||
|
return "v0.2.0", nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
waitFor(t, "the edge to report its version", func() bool { e := w.edgeRow(); return e.Version == "v0.1.0" && e.AutoUpdate })
|
||||||
|
waitFor(t, "the site to report its version", func() bool { s := w.siteRow(); return s.Version == "v0.1.0" && s.AutoUpdate })
|
||||||
|
|
||||||
|
w.admin.must("POST", "/api/edges/"+w.edge+"/update", nil, nil)
|
||||||
|
waitFor(t, "the edge to answer the update request", func() bool {
|
||||||
|
e := w.edgeRow()
|
||||||
|
return !e.UpdatePending && e.UpdateMsg == "updated to v0.2.0, restarting"
|
||||||
|
})
|
||||||
|
if edgeCalls.Load() != 1 {
|
||||||
|
t.Fatalf("the edge ran its update %d times, want 1", edgeCalls.Load())
|
||||||
|
}
|
||||||
|
var log struct {
|
||||||
|
Running bool
|
||||||
|
Stage string
|
||||||
|
Pct int
|
||||||
|
Lines []string
|
||||||
|
}
|
||||||
|
w.admin.must("GET", "/api/edges/"+w.edge+"/update/log", nil, &log)
|
||||||
|
text := strings.Join(log.Lines, "; ")
|
||||||
|
if log.Running || log.Stage != "restarting" || log.Pct != 100 || !strings.Contains(text, "Update requested") || !strings.Contains(text, "Downloaded 28.0 of 40.0 MB") || !strings.Contains(text, "restarting into it") {
|
||||||
|
t.Fatalf("edge update log: %+v (%s)", log, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.admin.must("POST", "/api/orgs/"+w.org+"/sites/"+w.site+"/update", nil, nil)
|
||||||
|
waitFor(t, "the site to answer the update request", func() bool {
|
||||||
|
s := w.siteRow()
|
||||||
|
return s.UpdateID == "" && s.UpdateMsg == "already on the newest release"
|
||||||
|
})
|
||||||
|
if siteCalls.Load() != 1 {
|
||||||
|
t.Fatalf("the site ran its update %d times, want 1", siteCalls.Load())
|
||||||
|
}
|
||||||
|
w.admin.must("GET", "/api/orgs/"+w.org+"/sites/"+w.site+"/update/log", nil, &log)
|
||||||
|
if log.Running || log.Stage != "done" || !strings.Contains(strings.Join(log.Lines, "; "), "Already on the newest release") {
|
||||||
|
t.Fatalf("site update log: %+v", log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asking again works and is a fresh request, not a leftover answer
|
||||||
|
w.admin.must("POST", "/api/edges/"+w.edge+"/update", nil, nil)
|
||||||
|
waitFor(t, "a second update request to be answered", func() bool { return edgeCalls.Load() == 2 && !w.edgeRow().UpdatePending })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatesNeedAutoUpdateOnTheMachine(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{}) // no version, auto-update off
|
||||||
|
waitFor(t, "the edge to be online", func() bool { return w.edgeRow().Online })
|
||||||
|
code, body := w.admin.do("POST", "/api/edges/"+w.edge+"/update", nil, nil)
|
||||||
|
if code != 400 || !strings.Contains(body, "BLACKTIP_AUTO_UPDATE") {
|
||||||
|
t.Fatalf("update on an edge without auto-update: %d %s", code, body)
|
||||||
|
}
|
||||||
|
code, body = w.admin.do("POST", "/api/orgs/"+w.org+"/sites/"+w.local+"/update", nil, nil)
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("update on a local site: %d %s", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A website on every address does not take the port from a website that names an address: each
|
||||||
|
// listens where it should, and the named address answers only for its own site.
|
||||||
|
func TestWildcardWebsiteStepsAsideForANamedAddress(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||||
|
wild, named := webBackend(t, "wild", 200), webBackend(t, "named", 200)
|
||||||
|
port := freePort(t)
|
||||||
|
|
||||||
|
w.open(model.Mapping{Proto: "http", BindIP: "0.0.0.0", Port: port, Hostname: "wild.example.test", Targets: tgt(w.site, wild)})
|
||||||
|
w.open(model.Mapping{Proto: "http", BindIP: "127.0.0.2", Port: port, Hostname: "named.example.test", Targets: tgt(w.site, named)})
|
||||||
|
|
||||||
|
ask := func(ip, host string) (int, string) {
|
||||||
|
c := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DisableKeepAlives: true,
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
|
||||||
|
}}}
|
||||||
|
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
var body map[string]string
|
||||||
|
json.Unmarshal(b, &body)
|
||||||
|
return resp.StatusCode, body["backend"]
|
||||||
|
}
|
||||||
|
wait := func(ip, host, wantBackend string) {
|
||||||
|
t.Helper()
|
||||||
|
var code int
|
||||||
|
var got string
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
if code, got = ask(ip, host); code == 200 && got == wantBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("%s via %s: status %d backend %q, want %q", host, ip, code, got, wantBackend)
|
||||||
|
}
|
||||||
|
|
||||||
|
wait("127.0.0.2", "named.example.test", "named") // the named address serves its own site
|
||||||
|
wait("127.0.0.1", "wild.example.test", "wild") // the wildcard still covers the other addresses
|
||||||
|
// and the wildcard gave up the named address: its site is not served there
|
||||||
|
if code, _ := ask("127.0.0.2", "wild.example.test"); code == 200 {
|
||||||
|
t.Errorf("the wildcard website still answers on the address another website names")
|
||||||
|
}
|
||||||
|
if code, _ := ask("127.0.0.1", "named.example.test"); code == 200 {
|
||||||
|
t.Errorf("a website answers on an address it was not given")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
|
|||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.veillax.com/Veillax/bamboo v0.1.0
|
git.veillax.com/Veillax/bamboo v0.1.5
|
||||||
git.veillax.com/Veillax/blacktip v0.1.0
|
git.veillax.com/Veillax/blacktip v0.1.9
|
||||||
git.veillax.com/Veillax/reef v0.1.0
|
git.veillax.com/Veillax/reef v0.1.10
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
modernc.org/sqlite v1.61.0
|
modernc.org/sqlite v1.61.0
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
|
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
|
||||||
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
|
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
|
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
|
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
|
||||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg=
|
||||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
git.veillax.com/Veillax/reef v0.1.10/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ type edgeView struct {
|
|||||||
TunnelAddr string `json:"tunnelAddr,omitempty"`
|
TunnelAddr string `json:"tunnelAddr,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
OrgIDs []string `json:"orgIds,omitempty"`
|
OrgIDs []string `json:"orgIds,omitempty"`
|
||||||
|
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
AutoUpdate bool `json:"autoUpdate,omitempty"`
|
||||||
|
UpdatePending bool `json:"updatePending,omitempty"`
|
||||||
|
UpdateMsg string `json:"updateMsg,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
|
func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
|
||||||
@@ -33,6 +38,7 @@ func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
|
|||||||
}
|
}
|
||||||
if admin {
|
if admin {
|
||||||
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
|
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
|
||||||
|
v.Version, v.AutoUpdate, v.UpdatePending, v.UpdateMsg = e.Version, e.AutoUpdate, e.UpdateID != "", e.UpdateMsg
|
||||||
if v.OrgIDs == nil {
|
if v.OrgIDs == nil {
|
||||||
v.OrgIDs = []string{}
|
v.OrgIDs = []string{}
|
||||||
}
|
}
|
||||||
@@ -198,6 +204,43 @@ func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
writeJSON(w, 200, st)
|
writeJSON(w, 200, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// putSite renames a site and sets its tags. The built-in local site is shared between
|
||||||
|
// organizations, so it can't be edited here.
|
||||||
|
func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Name == "" {
|
||||||
|
fail(w, 400, errors.New("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := normalizeTags(in.Tags)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.findSite(r.PathValue("id"))
|
||||||
|
if st == nil || st.OrgID != c.Org.ID {
|
||||||
|
fail(w, 404, errors.New("no such site"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Local {
|
||||||
|
fail(w, 400, errors.New("the built-in local site can't be edited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.Name, st.Tags = in.Name, tags
|
||||||
|
s.bump()
|
||||||
|
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -227,7 +270,7 @@ func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 })
|
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 || m.IsRedirect() })
|
||||||
s.bump()
|
s.bump()
|
||||||
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
|
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
|
||||||
ok(w)
|
ok(w)
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// Package secretbox encrypts small secrets (tokens, passwords, private keys)
|
|
||||||
// before they are written to the database, using AES-256-GCM.
|
|
||||||
//
|
|
||||||
// Sealed values look like "enc1:<base64>". Opening a value that isn't sealed
|
|
||||||
// returns it unchanged, so data written before encryption was turned on is
|
|
||||||
// still readable and gets sealed the next time it is saved.
|
|
||||||
package secretbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const prefix = "enc1:"
|
|
||||||
|
|
||||||
type Box struct{ aead cipher.AEAD }
|
|
||||||
|
|
||||||
// GenerateKey returns a new random key in the form New accepts.
|
|
||||||
func GenerateKey() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a Box from a key given as base64 or hex of 32 bytes. Any other
|
|
||||||
// text is treated as a passphrase and stretched with SHA-256.
|
|
||||||
func New(key string) (*Box, error) {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return nil, errors.New("empty key")
|
|
||||||
}
|
|
||||||
raw := decodeKey(key)
|
|
||||||
block, err := aes.NewCipher(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
aead, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Box{aead: aead}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeKey(key string) []byte {
|
|
||||||
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
if b, err := hex.DecodeString(key); err == nil && len(b) == 32 {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(key))
|
|
||||||
return sum[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset.
|
|
||||||
func (b *Box) Seal(s string) string {
|
|
||||||
if s == "" || strings.HasPrefix(s, prefix) {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
nonce := make([]byte, b.aead.NonceSize())
|
|
||||||
rand.Read(nonce)
|
|
||||||
return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open decrypts a sealed value. A wrong key or damaged data is an error, so a
|
|
||||||
// bad key can never silently turn secrets into garbage.
|
|
||||||
func (b *Box) Open(s string) (string, error) {
|
|
||||||
if !strings.HasPrefix(s, prefix) {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(s[len(prefix):])
|
|
||||||
if err != nil || len(raw) < b.aead.NonceSize() {
|
|
||||||
return "", errors.New("damaged secret")
|
|
||||||
}
|
|
||||||
n := b.aead.NonceSize()
|
|
||||||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?")
|
|
||||||
}
|
|
||||||
return string(plain), nil
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package secretbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSealOpenRoundTripAndKeys(t *testing.T) {
|
|
||||||
key := GenerateKey()
|
|
||||||
b, err := New(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sealed := b.Seal("hunter2")
|
|
||||||
if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") {
|
|
||||||
t.Fatalf("not sealed: %q", sealed)
|
|
||||||
}
|
|
||||||
if again := b.Seal("hunter2"); again == sealed {
|
|
||||||
t.Error("two seals of the same value must differ (random nonce)")
|
|
||||||
}
|
|
||||||
if b.Seal(sealed) != sealed {
|
|
||||||
t.Error("sealing an already sealed value must not double-encrypt")
|
|
||||||
}
|
|
||||||
if got, err := b.Open(sealed); err != nil || got != "hunter2" {
|
|
||||||
t.Errorf("Open = %q, %v", got, err)
|
|
||||||
}
|
|
||||||
if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" {
|
|
||||||
t.Errorf("unsealed legacy data should pass through, got %q", got)
|
|
||||||
}
|
|
||||||
if b.Seal("") != "" {
|
|
||||||
t.Error("empty stays empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
other, _ := New(GenerateKey())
|
|
||||||
if _, err := other.Open(sealed); err == nil {
|
|
||||||
t.Error("the wrong key must fail loudly, not return garbage")
|
|
||||||
}
|
|
||||||
if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil {
|
|
||||||
t.Error("tampered data must fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
// a passphrase works as a key, and is stable
|
|
||||||
p1, _ := New("correct horse battery staple")
|
|
||||||
p2, _ := New("correct horse battery staple")
|
|
||||||
if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" {
|
|
||||||
t.Errorf("passphrase keys must be reproducible: %q %v", got, err)
|
|
||||||
}
|
|
||||||
if _, err := New(" "); err == nil {
|
|
||||||
t.Error("an empty key must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+97
-8
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,9 +55,39 @@ func listFromCSV(in []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins)
|
||||||
|
// and keeps the list short enough to stay readable in the dashboard.
|
||||||
|
func normalizeTags(in []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range in {
|
||||||
|
t = strings.Join(strings.Fields(t), " ")
|
||||||
|
if t == "" || seen[strings.ToLower(t)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len([]rune(t)) > 32 {
|
||||||
|
return nil, errors.New("A tag can be at most 32 characters.")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(t, ",<>\"") {
|
||||||
|
return nil, errors.New("Tags can't contain commas, quotes or angle brackets.")
|
||||||
|
}
|
||||||
|
seen[strings.ToLower(t)] = true
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if len(out) > 10 {
|
||||||
|
return nil, errors.New("Use at most 10 tags.")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
||||||
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||||
m.Name = strings.TrimSpace(m.Name)
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
tags, err := normalizeTags(m.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Tags = tags
|
||||||
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
||||||
m.OrgID = c.Org.ID
|
m.OrgID = c.Org.ID
|
||||||
switch m.Proto {
|
switch m.Proto {
|
||||||
@@ -67,7 +99,16 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||||
return errors.New("choose an edge")
|
return errors.New("choose an edge")
|
||||||
}
|
}
|
||||||
if len(m.Targets) == 0 {
|
redirect := strings.TrimSpace(m.RedirectTo) != ""
|
||||||
|
if redirect {
|
||||||
|
if !model.Web(m.Proto) {
|
||||||
|
return errors.New("only websites (http or https) can redirect")
|
||||||
|
}
|
||||||
|
m.Targets = []model.Target{} // a redirect has nothing behind it
|
||||||
|
} else {
|
||||||
|
m.RedirectTo, m.RedirectPermanent, m.RedirectKeepPath = "", false, false
|
||||||
|
}
|
||||||
|
if len(m.Targets) == 0 && !redirect {
|
||||||
return errors.New("add at least one target")
|
return errors.New("add at least one target")
|
||||||
}
|
}
|
||||||
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
||||||
@@ -87,7 +128,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
|
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
|
||||||
}
|
}
|
||||||
if t.Port < 1 || t.Port > 65535 {
|
if t.Port < 1 || t.Port > 65535 {
|
||||||
return errors.New("target ports must be 1-65535")
|
return errors.New("Target ports must be between 1 and 65535.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.BindIP == "" {
|
if m.BindIP == "" {
|
||||||
@@ -99,7 +140,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
}
|
}
|
||||||
m.BindIP = ip.String()
|
m.BindIP = ip.String()
|
||||||
if m.Port < 1 || m.Port > 65535 {
|
if m.Port < 1 || m.Port > 65535 {
|
||||||
return errors.New("public port must be 1-65535")
|
return errors.New("The public port must be between 1 and 65535.")
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case m.PortEnd == 0 || m.PortEnd == m.Port:
|
case m.PortEnd == 0 || m.PortEnd == m.Port:
|
||||||
@@ -107,7 +148,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
case m.PortEnd < m.Port:
|
case m.PortEnd < m.Port:
|
||||||
return errors.New("the end of a port range can't be before its start")
|
return errors.New("the end of a port range can't be before its start")
|
||||||
case m.PortEnd > 65535:
|
case m.PortEnd > 65535:
|
||||||
return errors.New("public ports must be 1-65535")
|
return errors.New("Public ports must be between 1 and 65535.")
|
||||||
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
|
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
|
||||||
return errors.New("port ranges work for tcp and udp only")
|
return errors.New("port ranges work for tcp and udp only")
|
||||||
case m.PortEnd-m.Port+1 > 1000:
|
case m.PortEnd-m.Port+1 > 1000:
|
||||||
@@ -143,9 +184,19 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
return errors.New("PROXY protocol must be v1, v2 or off")
|
return errors.New("PROXY protocol must be v1, v2 or off")
|
||||||
}
|
}
|
||||||
// web-only options
|
// web-only options
|
||||||
if !model.Web(m.Proto) {
|
if redirect {
|
||||||
|
to, err := cleanRedirect(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.RedirectTo = to
|
||||||
|
}
|
||||||
|
if !model.Web(m.Proto) || redirect {
|
||||||
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
||||||
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
||||||
|
if redirect {
|
||||||
|
m.HealthSeconds = -1 // nothing to check
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
a := &m.Auth
|
a := &m.Auth
|
||||||
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
||||||
@@ -193,7 +244,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
|
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
|
||||||
return errors.New("health check interval must be 5-3600 seconds, or off")
|
return errors.New("The health check interval must be 5 to 3600 seconds, or off.")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, o := range s.st.Mappings {
|
for _, o := range s.st.Mappings {
|
||||||
@@ -204,7 +255,23 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
||||||
continue // same listener, different hostname
|
continue // same listener, different hostname
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s port %s on %s is already used by %q", netClass(m.Proto), portLabel(*m), o.BindIP, o.Name)
|
if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) {
|
||||||
|
continue // a website on every address steps aside for one that names an address
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
|
||||||
|
}
|
||||||
|
// an https resource with the redirect on also answers on port 80 at its address, so a raw port
|
||||||
|
// there would clash with it (and the other way round)
|
||||||
|
for _, o := range s.st.Mappings {
|
||||||
|
if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort():
|
||||||
|
return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name)
|
||||||
|
case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort():
|
||||||
|
return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if m.Name == "" {
|
if m.Name == "" {
|
||||||
if m.Hostname != "" {
|
if m.Hostname != "" {
|
||||||
@@ -216,6 +283,28 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanRedirect checks where a redirect sends visitors and refuses a destination that would
|
||||||
|
// send them straight back to the same address.
|
||||||
|
func cleanRedirect(m *model.Mapping) (string, error) {
|
||||||
|
to := strings.TrimSpace(m.RedirectTo)
|
||||||
|
u, err := url.Parse(to)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
|
||||||
|
return "", errors.New("Redirect to a full address such as https://www.example.com.")
|
||||||
|
}
|
||||||
|
if len(to) > 2048 || strings.ContainsAny(to, " \t\r\n$") {
|
||||||
|
return "", errors.New("The redirect address is too long or contains spaces or a dollar sign.")
|
||||||
|
}
|
||||||
|
port := u.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
|
||||||
|
}
|
||||||
|
same := u.Scheme == m.Proto && strings.EqualFold(u.Hostname(), m.Hostname) && port == strconv.Itoa(m.Port)
|
||||||
|
if same && (m.RedirectKeepPath || u.Path == "" || u.Path == "/") {
|
||||||
|
return "", errors.New("That redirect would send visitors back to the same address.")
|
||||||
|
}
|
||||||
|
return to, nil
|
||||||
|
}
|
||||||
|
|
||||||
// portLabel is "25565" or, for a range, "2456-2458".
|
// portLabel is "25565" or, for a range, "2456-2458".
|
||||||
func portLabel(m model.Mapping) string {
|
func portLabel(m model.Mapping) string {
|
||||||
if m.LastPort() > m.Port {
|
if m.LastPort() > m.Port {
|
||||||
@@ -226,7 +315,7 @@ func portLabel(m model.Mapping) string {
|
|||||||
|
|
||||||
func checkPIN(p string) error {
|
func checkPIN(p string) error {
|
||||||
if len(p) < 4 || len(p) > 12 {
|
if len(p) < 4 || len(p) > 12 {
|
||||||
return errors.New("a PIN is 4-12 digits")
|
return errors.New("A PIN is 4 to 12 digits.")
|
||||||
}
|
}
|
||||||
for _, c := range p {
|
for _, c := range p {
|
||||||
if c < '0' || c > '9' {
|
if c < '0' || c > '9' {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// websites share a port by hostname, and a website on every address steps aside for one that names an address
|
||||||
|
func TestWebsitesShareAPortAndWildcardsStepAside(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
var edge, site struct{ ID string }
|
||||||
|
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||||
|
json.Unmarshal(b, &edge)
|
||||||
|
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||||
|
json.Unmarshal(b, &site)
|
||||||
|
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||||
|
t.Fatalf("domain: %d %s", code, b)
|
||||||
|
}
|
||||||
|
base := "/api/orgs/" + org + "/mappings"
|
||||||
|
open := func(m map[string]any) (int, string) {
|
||||||
|
m["edgeId"] = edge.ID
|
||||||
|
m["enabled"] = true
|
||||||
|
m["targets"] = []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}
|
||||||
|
code, b := do("POST", base, m)
|
||||||
|
return code, string(b)
|
||||||
|
}
|
||||||
|
want := func(code int, got int, body, what string) {
|
||||||
|
t.Helper()
|
||||||
|
if code != got {
|
||||||
|
t.Errorf("%s: got %d, want %d (%s)", what, got, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c, body := open(map[string]any{"proto": "https", "bindIp": "0.0.0.0", "port": 443, "hostname": "a.example.test"})
|
||||||
|
want(200, c, body, "first website on every address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||||
|
want(200, c, body, "a website naming an address shares the port with the wildcard one")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.11", "port": 443, "hostname": "c.example.test"})
|
||||||
|
want(200, c, body, "a second named address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "d.example.test"})
|
||||||
|
want(200, c, body, "another hostname on a named address")
|
||||||
|
// still refused: the same hostname twice on one listener, a raw port on a website's port, and another protocol
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||||
|
want(400, c, body, "the same hostname twice on one address")
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "0.0.0.0", "port": 443})
|
||||||
|
want(400, c, body, "a raw port on top of websites")
|
||||||
|
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.10", "port": 443, "hostname": "e.example.test"})
|
||||||
|
want(400, c, body, "http on an https port")
|
||||||
|
|
||||||
|
// the HTTP to HTTPS redirect also uses port 80 at its address
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.20", "port": 80})
|
||||||
|
want(200, c, body, "a raw port 80 on its own address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test", "redirectHttp": true})
|
||||||
|
want(400, c, body, "a redirect onto a raw port 80")
|
||||||
|
if !strings.Contains(body, "port 80") {
|
||||||
|
t.Errorf("the error should name port 80: %s", body)
|
||||||
|
}
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test"})
|
||||||
|
want(200, c, body, "the same website without the redirect")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.21", "port": 443, "hostname": "g.example.test", "redirectHttp": true})
|
||||||
|
want(200, c, body, "a redirect where port 80 is free")
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.21", "port": 80})
|
||||||
|
want(400, c, body, "a raw port 80 where a redirect lives")
|
||||||
|
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.21", "port": 80, "hostname": "h.example.test"})
|
||||||
|
want(200, c, body, "an http website beside a redirect on port 80")
|
||||||
|
}
|
||||||
@@ -349,7 +349,7 @@ func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
|
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
|
||||||
fail(w, 429, errors.New("too many attempts"))
|
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h := sha(in.Token)
|
h := sha(in.Token)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// a redirect is a website with a destination instead of a target
|
||||||
|
func TestDomainRedirects(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
var edge, site struct{ ID string }
|
||||||
|
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||||
|
json.Unmarshal(b, &edge)
|
||||||
|
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||||
|
json.Unmarshal(b, &site)
|
||||||
|
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||||
|
t.Fatalf("domain: %d %s", code, b)
|
||||||
|
}
|
||||||
|
base := "/api/orgs/" + org + "/mappings"
|
||||||
|
open := func(m map[string]any) (int, []byte) {
|
||||||
|
m["edgeId"], m["enabled"], m["bindIp"] = edge.ID, true, "0.0.0.0"
|
||||||
|
return do("POST", base, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the redirect needs no target, and anything that only makes sense with one is dropped
|
||||||
|
code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "old.example.test", "redirectTo": " https://new.example.test/home ", "redirectPermanent": true, "redirectKeepPath": true,
|
||||||
|
"auth": map[string]any{"password": true}, "rewriteHost": "x", "healthPath": "/h"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
ID, RedirectTo, RewriteHost, HealthPath string
|
||||||
|
RedirectPermanent, RedirectKeepPath bool
|
||||||
|
Targets []any
|
||||||
|
HealthSeconds int
|
||||||
|
Auth struct{ Password bool }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &m)
|
||||||
|
if m.RedirectTo != "https://new.example.test/home" || !m.RedirectPermanent || !m.RedirectKeepPath || len(m.Targets) != 0 ||
|
||||||
|
m.Auth.Password || m.RewriteHost != "" || m.HealthPath != "" || m.HealthSeconds != -1 {
|
||||||
|
t.Fatalf("redirect was not normalised: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// it shares the port with other websites by hostname, like any website
|
||||||
|
if code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "app.example.test", "targets": []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}}); code != 200 {
|
||||||
|
t.Fatalf("a website beside the redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
// and its plain-HTTP twin on port 80 is its own resource
|
||||||
|
if code, raw := open(map[string]any{"proto": "http", "port": 80, "hostname": "old.example.test", "redirectTo": "https://new.example.test/home"}); code != 200 {
|
||||||
|
t.Fatalf("http side of the redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, in := range map[string]map[string]any{
|
||||||
|
"not a web port": {"proto": "tcp", "port": 9000, "redirectTo": "https://new.example.test"},
|
||||||
|
"no scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "new.example.test"},
|
||||||
|
"other scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "ftp://new.example.test"},
|
||||||
|
"credentials": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://u:p@new.example.test"},
|
||||||
|
"a dollar sign": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://new.example.test/$1"},
|
||||||
|
"back to itself": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"},
|
||||||
|
"itself with a path": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/x", "redirectKeepPath": true},
|
||||||
|
"host not in domains": {"proto": "https", "port": 443, "hostname": "a.elsewhere.test", "redirectTo": "https://new.example.test"},
|
||||||
|
} {
|
||||||
|
if code, raw := open(in); code != 400 {
|
||||||
|
t.Errorf("%s: expected 400, got %d %s", name, code, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// http to https on the same name is the normal use, and is allowed
|
||||||
|
if code, raw := open(map[string]any{"proto": "http", "port": 8080, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}); code != 200 {
|
||||||
|
t.Errorf("an http to https redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// turning a redirect into a normal website needs a target again
|
||||||
|
code, raw = do("PUT", base+"/"+m.ID, map[string]any{"proto": "https", "edgeId": edge.ID, "bindIp": "0.0.0.0", "port": 443, "hostname": "old.example.test", "enabled": true})
|
||||||
|
if code != 400 || !strings.Contains(string(raw), "target") {
|
||||||
|
t.Errorf("a website with no target and no redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleting a site must not sweep away redirects, which have no targets
|
||||||
|
if code, raw := do("DELETE", "/api/orgs/"+org+"/sites/"+site.ID, nil); code != 200 {
|
||||||
|
t.Fatalf("delete site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||||
|
var st struct {
|
||||||
|
Mappings []struct{ Hostname, RedirectTo string }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &st)
|
||||||
|
var redirects, apps int
|
||||||
|
for _, x := range st.Mappings {
|
||||||
|
if x.RedirectTo != "" {
|
||||||
|
redirects++
|
||||||
|
}
|
||||||
|
if x.Hostname == "app.example.test" {
|
||||||
|
apps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if redirects != 3 || apps != 0 {
|
||||||
|
t.Errorf("after deleting the site: %d redirects (want 3), %d apps (want 0): %s", redirects, apps, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-14
@@ -54,10 +54,10 @@ func (s *Server) protectedMapping(id string) *model.Mapping {
|
|||||||
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
|
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
return nil, errors.New("invalid return address")
|
return nil, errors.New("This sign-in link is incomplete. Open the page you wanted again and you will be brought back here.")
|
||||||
}
|
}
|
||||||
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
|
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
|
||||||
return nil, errors.New("return address uses the wrong scheme")
|
return nil, errors.New("This sign-in link is not valid. Open the page you wanted again and you will be brought back here.")
|
||||||
}
|
}
|
||||||
host := u.Hostname()
|
host := u.Hostname()
|
||||||
port := u.Port()
|
port := u.Port()
|
||||||
@@ -68,7 +68,7 @@ func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
|
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
|
||||||
return nil, errors.New("return address doesn't belong to this resource")
|
return nil, errors.New("This sign-in link points somewhere else. Open the page you wanted again and you will be brought back here.")
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
m := s.protectedMapping(r.PathValue("id"))
|
m := s.protectedMapping(r.PathValue("id"))
|
||||||
if m == nil {
|
if m == nil {
|
||||||
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist"))
|
fail(w, 404, errors.New("This page does not need a sign-in, or it no longer exists. Check the address, or ask the person who shared it."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
|
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
|
||||||
@@ -141,7 +141,7 @@ func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping,
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if key == "" {
|
if key == "" {
|
||||||
fail(w, 500, errors.New("edge is not set up for logins"))
|
fail(w, 500, errors.New("This server is not set up for sign-ins yet. Ask the administrator to finish setting it up."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
|
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
|
||||||
@@ -158,7 +158,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
|
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
|
||||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -169,7 +169,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if pm == nil {
|
if pm == nil {
|
||||||
fail(w, 404, errors.New("no such resource"))
|
fail(w, 404, errors.New("That page does not exist. Check the address."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
back, err := checkReturn(&m, body["return"])
|
back, err := checkReturn(&m, body["return"])
|
||||||
@@ -191,7 +191,7 @@ func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *mod
|
|||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
fail(w, 401, errors.New("wrong password"))
|
fail(w, 401, errors.New("That password is not right. Check it and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.grant(w, r, m, back, "password")
|
s.grant(w, r, m, back, "password")
|
||||||
@@ -207,7 +207,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
|
|||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
fail(w, 401, errors.New("wrong PIN"))
|
fail(w, 401, errors.New("That PIN is not right. Check it and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.grant(w, r, m, back, "pin")
|
s.grant(w, r, m, back, "pin")
|
||||||
@@ -216,7 +216,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
|
|||||||
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||||
p := s.principalFrom(r)
|
p := s.principalFrom(r)
|
||||||
if p == nil || p.User.ID == "" || !m.Auth.SSO {
|
if p == nil || p.User.ID == "" || !m.Auth.SSO {
|
||||||
fail(w, 401, errors.New("sign in first"))
|
fail(w, 401, errors.New("Sign in first, then try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -224,7 +224,7 @@ func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Ma
|
|||||||
allowed := u != nil && s.canAccess(u, m)
|
allowed := u != nil && s.canAccess(u, m)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !allowed {
|
if !allowed {
|
||||||
fail(w, 403, errors.New("your account doesn't have access to this resource"))
|
fail(w, 403, errors.New("Your account does not have access to this page. Ask the person who shared it to add you."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.grant(w, r, m, back, p.User.Email)
|
s.grant(w, r, m, back, p.User.Email)
|
||||||
@@ -243,7 +243,7 @@ func mailAllowed(list []string, email string) bool {
|
|||||||
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||||
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
||||||
if !m.Auth.Email || !s.mailConfigured() {
|
if !m.Auth.Email || !s.mailConfigured() {
|
||||||
fail(w, 400, errors.New("email codes are not available for this resource"))
|
fail(w, 400, errors.New("Email codes are not turned on for this page. Use another sign-in method."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// always answer the same way so addresses can't be probed
|
// always answer the same way so addresses can't be probed
|
||||||
@@ -279,7 +279,7 @@ func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !good || !mailAllowed(m.Auth.AllowedMails, email) {
|
if !good || !mailAllowed(m.Auth.AllowedMails, email) {
|
||||||
fail(w, 401, errors.New("wrong or expired code"))
|
fail(w, 401, errors.New("That code is wrong or has expired. Request a new one and try again."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.grant(w, r, m, back, email)
|
s.grant(w, r, m, back, email)
|
||||||
@@ -303,7 +303,7 @@ func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !valid {
|
if !valid {
|
||||||
fail(w, 401, errors.New("this share link is invalid, expired or used up"))
|
fail(w, 401, errors.New("This share link is invalid, expired or already used up. Ask for a new one."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
|
||||||
|
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
|
||||||
|
// token block of web/index.html. Run with: node scripts/lint-design.mjs
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { extname, join, relative, sep } from "node:path";
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
|
||||||
|
const targets = ["web", "docs", "README.md", "deploy", "scripts", "cmd", "."];
|
||||||
|
const skipDirs = new Set(["node_modules", ".git", "e2e", "internal"]);
|
||||||
|
const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".example"]);
|
||||||
|
|
||||||
|
const dash = /[–—−]/;
|
||||||
|
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
|
||||||
|
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
function* walk(p, top) {
|
||||||
|
const st = statSync(p, { throwIfNoEntry: false });
|
||||||
|
if (!st || seen.has(p)) return;
|
||||||
|
if (st.isFile()) { seen.add(p); return yield p; }
|
||||||
|
for (const name of readdirSync(p)) {
|
||||||
|
if (skipDirs.has(name) || (top && name.startsWith("."))) continue;
|
||||||
|
const full = join(p, name);
|
||||||
|
// the "." target only contributes the files at the top level
|
||||||
|
if (p === root && statSync(full).isDirectory()) continue;
|
||||||
|
yield* walk(full, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
for (const t of targets) {
|
||||||
|
for (const file of walk(join(root, t), true)) {
|
||||||
|
if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue;
|
||||||
|
const rel = relative(root, file).split(sep).join("/");
|
||||||
|
if (rel.endsWith("go.sum") || rel === "scripts/lint-design.mjs") continue;
|
||||||
|
const isPage = rel.startsWith("web/") && rel.endsWith(".html");
|
||||||
|
let inTokens = false;
|
||||||
|
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||||
|
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
|
||||||
|
if (line.includes("tokens:begin")) inTokens = true;
|
||||||
|
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
|
||||||
|
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
|
||||||
|
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
|
||||||
|
at("raw color outside the token block. Use a token.");
|
||||||
|
}
|
||||||
|
if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
|
||||||
|
if (line.includes("tokens:end")) inTokens = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
console.error(problems.join("\n"));
|
||||||
|
console.error(`\n${problems.length} style problem(s).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("Style checks passed.");
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const selfMappingID = "hammerhead-dashboard"
|
||||||
|
|
||||||
|
// ensureSelfEdge makes sure this server has an edge of its own, for a Blacktip on the same
|
||||||
|
// machine, and writes that edge's token to <data>/self-edge.env for the installer. The edge
|
||||||
|
// also carries a built-in https route for the dashboard (see selfMapping), so no separate
|
||||||
|
// reverse proxy is needed in front of Hammerhead.
|
||||||
|
func (s *Server) ensureSelfEdge() error {
|
||||||
|
o := s.opts
|
||||||
|
if o.SelfDomain == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(o.SelfTarget)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HAMMERHEAD_SELF_DOMAIN needs a listen address like 127.0.0.1:8080: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := strconv.Atoi(port); n < 1 || host == "" {
|
||||||
|
return errors.New("the dashboard's listen address needs a host and a port")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e := s.findEdge(s.st.Settings.SelfEdgeID)
|
||||||
|
if e == nil {
|
||||||
|
e = &model.Edge{ID: newID(), Name: "Hammerhead server", Token: randHex(24), AuthKey: randHex(32),
|
||||||
|
TunnelAddr: o.SelfTunnel, TunnelIP: s.allocIPLocked(), OrgIDs: []string{}, IPs: []string{}}
|
||||||
|
s.st.Edges = append(s.st.Edges, e)
|
||||||
|
s.st.Sites = append(s.st.Sites, &model.Site{ID: newID(), Name: e.Name + " (this machine)",
|
||||||
|
Local: true, EdgeID: e.ID, Connected: []string{}})
|
||||||
|
s.st.Settings.SelfEdgeID = e.ID
|
||||||
|
s.bump()
|
||||||
|
}
|
||||||
|
body := "# written by Hammerhead for the Blacktip on this machine; keep it private\nBLACKTIP_TOKEN=" + e.Token + "\n"
|
||||||
|
return os.WriteFile(filepath.Join(s.dir, "self-edge.env"), []byte(body), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfMapping is the dashboard's own route on the self edge: https on the configured name,
|
||||||
|
// forwarded to Hammerhead's listen address through the edge's local site. It is added to that
|
||||||
|
// edge's config and never stored, so it can't be edited or deleted by mistake in the UI.
|
||||||
|
// Caller holds s.mu.
|
||||||
|
func (s *Server) selfMapping(e *model.Edge) *model.Mapping {
|
||||||
|
o := s.opts
|
||||||
|
if o.SelfDomain == "" || e.ID != s.st.Settings.SelfEdgeID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(o.SelfTarget)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(port)
|
||||||
|
for _, st := range s.st.Sites {
|
||||||
|
if st.Local && st.EdgeID == e.ID {
|
||||||
|
return &model.Mapping{ID: selfMappingID, Name: "Hammerhead dashboard", Proto: model.ProtoHTTPS, EdgeID: e.ID,
|
||||||
|
BindIP: "0.0.0.0", Port: 443, Hostname: strings.ToLower(o.SelfDomain), Enabled: true, RedirectHTTP: true,
|
||||||
|
HealthSeconds: -1, Targets: []model.Target{{SiteID: st.ID, Host: host, Port: n}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
|
||||||
|
t.Helper()
|
||||||
|
ts := httptest.NewServer(s.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/edge/config?v=0", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var cfg model.EdgeConfig
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("edge config: %s", resp.Status)
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFile(t *testing.T, dir string) string {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "self-edge.env"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.SplitN(strings.SplitN(string(b), "BLACKTIP_TOKEN=", 2)[1], "\n", 2)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = secretbox.GenerateKey()
|
||||||
|
|
||||||
|
func TestSelfEdgeServesTheDashboardWithoutAProxy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
opts := Options{MasterKey: key, SelfDomain: "Hammerhead.Example.com", SelfTarget: "127.0.0.1:8080"}
|
||||||
|
s, err := New(dir, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := tokenFile(t, dir)
|
||||||
|
cfg := edgeConfigOf(t, s, token)
|
||||||
|
if len(cfg.Mappings) != 1 || len(cfg.Local) != 1 {
|
||||||
|
t.Fatalf("mappings=%d local=%d", len(cfg.Mappings), len(cfg.Local))
|
||||||
|
}
|
||||||
|
m := cfg.Mappings[0]
|
||||||
|
if m.Proto != model.ProtoHTTPS || m.Hostname != "hammerhead.example.com" || m.Port != 443 || !m.RedirectHTTP || !m.Enabled ||
|
||||||
|
len(m.Targets) != 1 || m.Targets[0].Host != "127.0.0.1" || m.Targets[0].Port != 8080 || m.Targets[0].SiteID != cfg.Local[0] {
|
||||||
|
t.Fatalf("unexpected dashboard route: %+v", m)
|
||||||
|
}
|
||||||
|
if m.Auth.Protected() {
|
||||||
|
t.Fatal("the dashboard has its own login and must not sit behind resource auth")
|
||||||
|
}
|
||||||
|
edgeID := s.st.Settings.SelfEdgeID
|
||||||
|
if info, _ := os.Stat(filepath.Join(dir, "self-edge.env")); info.Mode().Perm()&0o077 != 0 && os.PathSeparator == '/' {
|
||||||
|
t.Fatalf("token file is readable by others: %v", info.Mode())
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
// a restart keeps the same edge and token instead of making a new one
|
||||||
|
s2, err := New(dir, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
if s2.st.Settings.SelfEdgeID != edgeID || tokenFile(t, dir) != token || len(s2.st.Edges) != 1 {
|
||||||
|
t.Fatal("restart changed the self edge")
|
||||||
|
}
|
||||||
|
if len(edgeConfigOf(t, s2, token).Mappings) != 1 {
|
||||||
|
t.Fatal("route missing after restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSelfEdgeUnlessAsked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := New(dir, Options{MasterKey: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if len(s.st.Edges) != 0 {
|
||||||
|
t.Fatal("an edge appeared without HAMMERHEAD_SELF_DOMAIN")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "self-edge.env")); err == nil {
|
||||||
|
t.Fatal("token file written without HAMMERHEAD_SELF_DOMAIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavingSettingsKeepsTheSelfEdge(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, SelfDomain: "hh.example.com", SelfTarget: "127.0.0.1:8080"}
|
||||||
|
s, err := New(dir, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := tokenFile(t, dir)
|
||||||
|
do := apiClient(t, s)
|
||||||
|
if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "https://hh.example.com", "acmeEmail": "me@example.com"}); code != 200 {
|
||||||
|
t.Fatalf("settings: %d %s", code, b)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
s2, err := New(dir, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
if len(s2.st.Edges) != 1 || tokenFile(t, dir) != token {
|
||||||
|
t.Fatalf("saving settings lost the self edge: %d edges", len(s2.st.Edges))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,22 @@ func page(html []byte) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dashboardPaths are the addresses the dashboard shows pages at. They all serve the same app,
|
||||||
|
// which reads the path to decide what to show. Keep in step with TAB_PATH in web/index.html.
|
||||||
|
var dashboardPaths = map[string]bool{
|
||||||
|
"/": true, "/apps": true, "/resources": true, "/sites": true, "/domains": true, "/mail-egress": true,
|
||||||
|
"/alerts": true, "/access": true, "/logs": true, "/account": true,
|
||||||
|
"/server/edges": true, "/server/organizations": true, "/server/users": true, "/server/sso": true,
|
||||||
|
"/server/settings": true, "/server/alerts": true, "/server/audit": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDashboardPath(p string) bool { return dashboardPaths[p] }
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
index := page(indexHTML)
|
index := page(indexHTML)
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if !isDashboardPath(r.URL.Path) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,6 +97,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
||||||
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
||||||
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
||||||
|
mux.HandleFunc("GET /api/admin/update", s.withServerAdmin(s.checkUpdate))
|
||||||
|
mux.HandleFunc("POST /api/admin/update/apply", s.withServerAdmin(s.applyUpdate))
|
||||||
|
mux.HandleFunc("GET /api/admin/update/status", s.withServerAdmin(s.updateStatus))
|
||||||
|
mux.HandleFunc("GET /api/versions", s.withUser(s.latestVersions))
|
||||||
|
mux.HandleFunc("GET /api/edges/{id}/update/log", s.withServerAdmin(s.edgeUpdateLog))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/update/log", s.withOrg(true, s.siteUpdateLog))
|
||||||
|
mux.HandleFunc("POST /api/edges/{id}/update", s.withServerAdmin(s.updateEdge))
|
||||||
|
mux.HandleFunc("POST /api/orgs/{org}/sites/{id}/update", s.withOrg(true, s.updateSite))
|
||||||
mux.HandleFunc("GET /api/admin/audit", s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
mux.HandleFunc("GET /api/admin/audit", s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
|
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
|
||||||
}))
|
}))
|
||||||
@@ -108,6 +127,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
||||||
|
mux.HandleFunc("PUT /api/orgs/{org}/sites/{id}", s.withOrg(true, s.putSite))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
|
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
|
||||||
|
|||||||
+2
-1
@@ -41,6 +41,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
|
|||||||
if s.opts.DashboardURL != "" {
|
if s.opts.DashboardURL != "" {
|
||||||
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
|
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
|
||||||
}
|
}
|
||||||
|
in.SelfEdgeID = s.st.Settings.SelfEdgeID // not editable here; losing it would make the next start create a second self edge
|
||||||
s.st.Settings = in
|
s.st.Settings = in
|
||||||
s.bump() // edges receive the dashboard URL and ACME settings
|
s.bump() // edges receive the dashboard URL and ACME settings
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -68,7 +69,7 @@ func (s *Server) smtpSend(to, subject, body string) error {
|
|||||||
c := s.st.Settings.SMTP
|
c := s.st.Settings.SMTP
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if c.Host == "" {
|
if c.Host == "" {
|
||||||
return errors.New("email is not configured (Settings -> Email)")
|
return errors.New("Email is not set up yet. Set it up under Server, Settings.")
|
||||||
}
|
}
|
||||||
if c.Port == 0 {
|
if c.Port == 0 {
|
||||||
c.Port = 587
|
c.Port = 587
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Org struct {
|
type Org struct {
|
||||||
@@ -111,6 +113,8 @@ type Settings struct {
|
|||||||
AcmeEmail string `json:"acmeEmail"`
|
AcmeEmail string `json:"acmeEmail"`
|
||||||
AcmeStaging bool `json:"acmeStaging"`
|
AcmeStaging bool `json:"acmeStaging"`
|
||||||
SMTP SMTP `json:"smtp"`
|
SMTP SMTP `json:"smtp"`
|
||||||
|
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
|
||||||
|
SelfEdgeID string `json:"selfEdgeId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Secret struct {
|
type Secret struct {
|
||||||
@@ -170,6 +174,22 @@ type Options struct {
|
|||||||
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
||||||
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
||||||
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
||||||
|
|
||||||
|
// SelfDomain (HAMMERHEAD_SELF_DOMAIN) gives this server an edge of its own, with a built-in
|
||||||
|
// https route for the dashboard at that name, so no reverse proxy is needed in front of it.
|
||||||
|
// SelfTarget is the address Hammerhead listens on (e.g. 127.0.0.1:8080); SelfTunnel is the
|
||||||
|
// edge's public WireGuard endpoint (default <SelfDomain>:51820).
|
||||||
|
SelfDomain string
|
||||||
|
SelfTarget string
|
||||||
|
SelfTunnel string
|
||||||
|
|
||||||
|
// Version is the running release (e.g. v0.1.5), shown under Settings with a check for updates.
|
||||||
|
Version string
|
||||||
|
// AutoUpdate says whether this process updates itself (HAMMERHEAD_AUTO_UPDATE), for the same screen.
|
||||||
|
AutoUpdate bool
|
||||||
|
// UpdateServer and UpdateRepo say where releases are published; the defaults are this project's own.
|
||||||
|
UpdateServer string
|
||||||
|
UpdateRepo string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -191,6 +211,11 @@ type Server struct {
|
|||||||
oidc map[string]*oidcDoc
|
oidc map[string]*oidcDoc
|
||||||
|
|
||||||
alerts map[string]*alertState // active problems by key (see alerts.go)
|
alerts map[string]*alertState // active problems by key (see alerts.go)
|
||||||
|
updRuns map[string]*updRun // progress of updates running on edges and sites, by ID
|
||||||
|
selfUpd selfUpdate // progress of this server's own update
|
||||||
|
verMu sync.Mutex // guards verCache and verAt, the latest releases seen by /api/versions
|
||||||
|
verCache map[string]string
|
||||||
|
verAt time.Time
|
||||||
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
||||||
|
|
||||||
logs *logStore
|
logs *logStore
|
||||||
@@ -232,6 +257,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
oidc: map[string]*oidcDoc{},
|
oidc: map[string]*oidcDoc{},
|
||||||
alerts: map[string]*alertState{},
|
alerts: map[string]*alertState{},
|
||||||
mtaSent: map[string]time.Time{},
|
mtaSent: map[string]time.Time{},
|
||||||
|
updRuns: map[string]*updRun{},
|
||||||
}
|
}
|
||||||
s.st.Sessions = map[string]*Session{}
|
s.st.Sessions = map[string]*Session{}
|
||||||
s.st.Secrets = map[string]*Secret{}
|
s.st.Secrets = map[string]*Secret{}
|
||||||
@@ -258,6 +284,14 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opts.SelfDomain != "" && opts.SelfTunnel == "" {
|
||||||
|
s.opts.SelfTunnel = opts.SelfDomain + ":51820"
|
||||||
|
}
|
||||||
|
s.finishSelfUpdateLog()
|
||||||
|
if err := s.ensureSelfEdge(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := s.save(); err != nil {
|
if err := s.save(); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -401,8 +435,13 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
|||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fail sends an error to the browser. Messages are shown to people, so they start with a capital letter.
|
||||||
func fail(w http.ResponseWriter, code int, err error) {
|
func fail(w http.ResponseWriter, code int, err error) {
|
||||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
msg := err.Error()
|
||||||
|
if r, n := utf8.DecodeRuneInString(msg); n > 0 {
|
||||||
|
msg = string(unicode.ToUpper(r)) + msg[n:]
|
||||||
|
}
|
||||||
|
writeJSON(w, code, map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
func failf(w http.ResponseWriter, code int, format string, a ...any) {
|
func failf(w http.ResponseWriter, code int, format string, a ...any) {
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeTags(t *testing.T) {
|
||||||
|
got, err := normalizeTags([]string{" Minecraft ", "minecraft", "", "prod east", "PROD east"})
|
||||||
|
if err != nil || !reflect.DeepEqual(got, []string{"Minecraft", "prod east"}) {
|
||||||
|
t.Fatalf("got %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := normalizeTags(nil); err != nil || got != nil {
|
||||||
|
t.Fatalf("nil in should give nil out: %v, %v", got, err)
|
||||||
|
}
|
||||||
|
tooMany := make([]string, 11)
|
||||||
|
for i := range tooMany {
|
||||||
|
tooMany[i] = "t" + strings.Repeat("x", i)
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{{strings.Repeat("a", 33)}, {"a,b"}, {"<b>"}, tooMany} {
|
||||||
|
if _, err := normalizeTags(bad); err == nil {
|
||||||
|
t.Errorf("accepted %v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteTagsAndRenameThroughTheAPI(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
|
||||||
|
code, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "Home"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("add site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var site struct{ ID string }
|
||||||
|
json.Unmarshal(raw, &site)
|
||||||
|
|
||||||
|
if code, raw := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, map[string]any{"name": " Home lab ", "tags": []string{"Lab", "lab", "nas"}}); code != 200 {
|
||||||
|
t.Fatalf("edit site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||||
|
var st struct {
|
||||||
|
Sites []struct {
|
||||||
|
ID, Name string
|
||||||
|
Tags []string
|
||||||
|
Local bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &st)
|
||||||
|
found := false
|
||||||
|
for _, x := range st.Sites {
|
||||||
|
if x.ID == site.ID {
|
||||||
|
found = true
|
||||||
|
if x.Name != "Home lab" || !reflect.DeepEqual(x.Tags, []string{"Lab", "nas"}) {
|
||||||
|
t.Fatalf("site after edit: %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("site missing from state: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, body := range map[string]map[string]any{"blank name": {"name": " "}, "bad tag": {"name": "x", "tags": []string{"a,b"}}} {
|
||||||
|
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, body); code != 400 {
|
||||||
|
t.Errorf("%s: expected 400, got %d", name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/nope", map[string]any{"name": "x"}); code != 404 {
|
||||||
|
t.Errorf("unknown site: expected 404, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarKeyIsNeverSentBackAndBlankKeepsIt(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
|
||||||
|
code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels", map[string]any{"name": "pager", "kind": "sandbar", "target": "https://sandbar.example.com", "token": "sb_topsecret", "policy": "p1", "enabled": true})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create channel: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/alerts", nil)
|
||||||
|
if strings.Contains(string(raw), "sb_topsecret") || strings.Contains(string(raw), "sandbar.example.com/api") {
|
||||||
|
t.Fatalf("the overview leaks Sandbar details: %s", raw)
|
||||||
|
}
|
||||||
|
var ov struct {
|
||||||
|
Channels []struct{ ID, Kind, Policy string }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &ov)
|
||||||
|
if len(ov.Channels) != 1 || ov.Channels[0].Kind != "sandbar" || ov.Channels[0].Policy != "p1" {
|
||||||
|
t.Fatalf("overview: %s", raw)
|
||||||
|
}
|
||||||
|
// editing without re-entering the address or key keeps both
|
||||||
|
if code, raw := do("PUT", "/api/orgs/"+org+"/alerts/channels/"+ov.Channels[0].ID, map[string]any{"name": "pager 2", "kind": "sandbar", "policy": "p2", "enabled": true}); code != 200 {
|
||||||
|
t.Fatalf("edit channel: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
ch := s.st.Channels[0]
|
||||||
|
kept := ch.Token == "sb_topsecret" && ch.Target == "https://sandbar.example.com" && ch.Policy == "p2"
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !kept {
|
||||||
|
t.Fatalf("an edit lost the saved key or address: %+v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/selfupdate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) updateConfig(app, current string) selfupdate.Config {
|
||||||
|
o := s.opts
|
||||||
|
server := o.UpdateServer
|
||||||
|
if server == "" {
|
||||||
|
server = "https://git.veillax.com"
|
||||||
|
}
|
||||||
|
owner := "Veillax"
|
||||||
|
if o.UpdateRepo != "" {
|
||||||
|
owner = strings.SplitN(o.UpdateRepo, "/", 2)[0]
|
||||||
|
}
|
||||||
|
return selfupdate.Config{Server: server, Repo: owner + "/" + app, App: app, Current: current}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUpdate asks the release server whether a newer Hammerhead exists. It only looks.
|
||||||
|
func (s *Server) checkUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
o := s.opts
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out := map[string]any{"current": o.Version, "autoUpdate": o.AutoUpdate, "checked": time.Now()}
|
||||||
|
latest, newer, err := selfupdate.Check(ctx, s.updateConfig("hammerhead", o.Version))
|
||||||
|
out["latest"], out["newer"] = latest, newer
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUpdate installs the newest release of Hammerhead itself and restarts into it. Like the
|
||||||
|
// scheduled check it needs auto-update to be on, which is also what makes the binary replaceable.
|
||||||
|
func (s *Server) applyUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
if !s.opts.AutoUpdate {
|
||||||
|
fail(w, 400, errors.New("auto-update is off: set HAMMERHEAD_AUTO_UPDATE=1 and restart Hammerhead, or update it by hand"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := &s.selfUpd
|
||||||
|
u.mu.Lock()
|
||||||
|
if u.running {
|
||||||
|
u.mu.Unlock()
|
||||||
|
fail(w, 409, errors.New("an update is already running"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.running, u.stage, u.pct, u.lines = true, "starting", 0, nil
|
||||||
|
u.mu.Unlock()
|
||||||
|
os.WriteFile(s.updateLogPath(), nil, 0o600) // a new run starts a new log
|
||||||
|
cfg := s.updateConfig("hammerhead", s.opts.Version)
|
||||||
|
cfg.Progress = func(e selfupdate.Event) { s.selfNote(e.Stage, e.Pct, e.Line) }
|
||||||
|
cfg.Restart = func(exe string) error {
|
||||||
|
s.selfNote("restarting", 100, "Closing the database and restarting")
|
||||||
|
s.Close()
|
||||||
|
return selfupdate.Restart(exe)
|
||||||
|
}
|
||||||
|
s.selfNote("starting", 0, "Update requested by "+p.User.Email)
|
||||||
|
s.audit("", p.User.Email, "server.update", "")
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
selfupdate.CheckOnce(ctx, cfg) // on success the process is replaced and this never returns
|
||||||
|
u.mu.Lock()
|
||||||
|
u.running = false
|
||||||
|
u.mu.Unlock()
|
||||||
|
}()
|
||||||
|
writeJSON(w, 200, map[string]any{"started": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestVersions returns the newest release of each program, for "update available" badges. Results
|
||||||
|
// are cached for ten minutes so a busy dashboard doesn't hammer the release server.
|
||||||
|
func (s *Server) latestVersions(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.verMu.Lock()
|
||||||
|
if time.Since(s.verAt) > 10*time.Minute || s.verCache == nil {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
out := map[string]string{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, app := range []string{"hammerhead", "blacktip", "bamboo"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(app string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if tag, _, err := selfupdate.Check(ctx, s.updateConfig(app, "")); err == nil && tag != "" {
|
||||||
|
mu.Lock()
|
||||||
|
out[app] = tag
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(app)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
cancel()
|
||||||
|
s.verCache, s.verAt = out, time.Now()
|
||||||
|
}
|
||||||
|
out := s.verCache
|
||||||
|
s.verMu.Unlock()
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteUpdateReport records what an edge or site says about its version and any update that was
|
||||||
|
// requested of it. It reports whether a pending request was answered, which means the config the
|
||||||
|
// agent polls has changed (it no longer carries the request).
|
||||||
|
func noteUpdateReport(version *string, auto *bool, updateID, updateMsg *string, inVersion string, inAuto bool, inID, inMsg string) bool {
|
||||||
|
upgraded := *version != "" && inVersion != "" && inVersion != *version
|
||||||
|
if inVersion != "" {
|
||||||
|
*version = inVersion
|
||||||
|
}
|
||||||
|
*auto = inAuto
|
||||||
|
if *updateID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case inID == *updateID && inMsg != "":
|
||||||
|
*updateMsg = inMsg
|
||||||
|
case upgraded:
|
||||||
|
*updateMsg = "updated to " + inVersion
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*updateID = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateEdge(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e := s.findEdge(r.PathValue("id"))
|
||||||
|
if e == nil {
|
||||||
|
fail(w, 404, errors.New("no such edge"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requestUpdate(&e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, s.edgeOnline(e), "BLACKTIP"); err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(s.updRuns, e.ID) // a new request starts a new log
|
||||||
|
s.bump()
|
||||||
|
s.audit("", p.User.Email, "edge.update", e.Name)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.findSite(r.PathValue("id"))
|
||||||
|
if st == nil || !s.siteVisible(c, st) {
|
||||||
|
fail(w, 404, errors.New("no such site"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Local {
|
||||||
|
fail(w, 400, errors.New("this site is the edge's own machine; update the edge instead"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requestUpdate(&st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, s.siteOnline(st), "BAMBOO"); err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(s.updRuns, st.ID)
|
||||||
|
s.bump()
|
||||||
|
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestUpdate marks an update as wanted. The agent installs it only if it has auto-update on, and
|
||||||
|
// the request stays until the agent answers (or reports a new version). Asking again replaces it.
|
||||||
|
func requestUpdate(auto *bool, updateID, updateMsg *string, online bool, prefix string) error {
|
||||||
|
if !online {
|
||||||
|
return errors.New("it is offline right now")
|
||||||
|
}
|
||||||
|
if !*auto {
|
||||||
|
return errors.New("it isn't running with auto-update on, so it won't install updates from here (set " + prefix + "_AUTO_UPDATE=1 and restart it)")
|
||||||
|
}
|
||||||
|
*updateID, *updateMsg = randHex(8), ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckForUpdates(t *testing.T) {
|
||||||
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/repos/Veillax/hammerhead/releases/latest" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"tag_name":"v0.3.0","prerelease":false,"assets":[]}`)
|
||||||
|
}))
|
||||||
|
defer gitea.Close()
|
||||||
|
for _, c := range []struct {
|
||||||
|
version string
|
||||||
|
newer bool
|
||||||
|
}{{"v0.2.0", true}, {"v0.3.0", false}, {"dev", false}} {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key,
|
||||||
|
Version: c.version, UpdateServer: gitea.URL, UpdateRepo: "Veillax/hammerhead", AutoUpdate: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
do := apiClient(t, s)
|
||||||
|
code, b := do("GET", "/api/admin/update", nil)
|
||||||
|
var out struct {
|
||||||
|
Current, Latest, Error string
|
||||||
|
Newer, AutoUpdate bool
|
||||||
|
}
|
||||||
|
json.Unmarshal(b, &out)
|
||||||
|
if code != 200 || out.Current != c.version || out.Latest != "v0.3.0" || out.Newer != c.newer || !out.AutoUpdate || out.Error != "" {
|
||||||
|
t.Errorf("%s: %d %s", c.version, code, b)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// an unreachable release server is reported, not fatal
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, Version: "v0.1.0", UpdateServer: "http://127.0.0.1:1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
code, b := apiClient(t, s)("GET", "/api/admin/update", nil)
|
||||||
|
var out struct{ Error string }
|
||||||
|
json.Unmarshal(b, &out)
|
||||||
|
if code != 200 || out.Error == "" {
|
||||||
|
t.Errorf("unreachable server: %d %s", code, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelfUpdateLogSurvivesTheRestartItEndsWith(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "update.log"), []byte("12:00:00 Checking\n12:00:09 Installed v0.3.0; restarting into it\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, Version: "v0.3.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
code, b := do("GET", "/api/admin/update/status", nil)
|
||||||
|
var out struct {
|
||||||
|
Running bool
|
||||||
|
Stage string
|
||||||
|
Pct int
|
||||||
|
Lines []string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
json.Unmarshal(b, &out)
|
||||||
|
last := ""
|
||||||
|
if len(out.Lines) > 0 {
|
||||||
|
last = out.Lines[len(out.Lines)-1]
|
||||||
|
}
|
||||||
|
if code != 200 || out.Running || out.Stage != "done" || out.Pct != 100 || len(out.Lines) != 3 || !strings.Contains(last, "now running v0.3.0") {
|
||||||
|
t.Fatalf("status: %d %s", code, b)
|
||||||
|
}
|
||||||
|
// starting an update needs auto-update on
|
||||||
|
if code, b := do("POST", "/api/admin/update/apply", nil); code != 400 || !strings.Contains(string(b), "HAMMERHEAD_AUTO_UPDATE") {
|
||||||
|
t.Fatalf("apply without auto-update: %d %s", code, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUpdateLines = 200
|
||||||
|
|
||||||
|
// updRun is the progress of one update an edge or site is carrying out, as that agent reported it.
|
||||||
|
type updRun struct {
|
||||||
|
ID string // the update request it belongs to
|
||||||
|
Stage string
|
||||||
|
Pct int
|
||||||
|
Lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordUpdateProgress keeps what an agent says about the update it is running. When the agent
|
||||||
|
// reports a new version, the update is finished: its log gets a closing line and is then left alone,
|
||||||
|
// so the freshly restarted program (which starts a new, empty log) can't overwrite it.
|
||||||
|
// Caller holds s.mu.
|
||||||
|
func (s *Server) recordUpdateProgress(agentID, reqID, stage string, pct int, lines []string, oldVersion, newVersion string) {
|
||||||
|
run := s.updRuns[agentID]
|
||||||
|
if run != nil && run.Stage != "done" && oldVersion != "" && newVersion != "" && newVersion != oldVersion {
|
||||||
|
run.Lines = append(run.Lines, time.Now().Format("15:04:05")+" Now running "+newVersion)
|
||||||
|
run.Stage, run.Pct = "done", 100
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reqID == "" || (stage == "" && len(lines) == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if run != nil && run.ID == reqID && run.Stage == "done" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.updRuns[agentID] = &updRun{ID: reqID, Stage: stage, Pct: pct, Lines: lines}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateLogView(agentID string, pending bool, msg, version string) map[string]any {
|
||||||
|
out := map[string]any{"running": pending, "msg": msg, "version": version, "stage": "", "pct": 0, "lines": []string{}}
|
||||||
|
if run := s.updRuns[agentID]; run != nil {
|
||||||
|
out["stage"], out["pct"], out["lines"] = run.Stage, run.Pct, run.Lines
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) edgeUpdateLog(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e := s.findEdge(r.PathValue("id"))
|
||||||
|
if e == nil {
|
||||||
|
fail(w, 404, errors.New("no such edge"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, s.updateLogView(e.ID, e.UpdateID != "", e.UpdateMsg, e.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) siteUpdateLog(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.findSite(r.PathValue("id"))
|
||||||
|
if st == nil || !s.siteVisible(c, st) {
|
||||||
|
fail(w, 404, errors.New("no such site"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, s.updateLogView(st.ID, st.UpdateID != "", st.UpdateMsg, st.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfUpdate is the progress of this server's own update. It is kept in memory and in <data>/update.log,
|
||||||
|
// so the page can still show it after the restart that ends a successful update.
|
||||||
|
type selfUpdate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
stage string
|
||||||
|
pct int
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateLogPath() string { return filepath.Join(s.dir, "update.log") }
|
||||||
|
|
||||||
|
func (s *Server) selfNote(stage string, pct int, line string) {
|
||||||
|
u := &s.selfUpd
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
if stage != "" {
|
||||||
|
u.stage = stage
|
||||||
|
}
|
||||||
|
if pct >= 0 {
|
||||||
|
u.pct = pct
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = time.Now().Format("15:04:05") + " " + line
|
||||||
|
u.lines = append(u.lines, line)
|
||||||
|
if len(u.lines) > maxUpdateLines {
|
||||||
|
u.lines = u.lines[len(u.lines)-maxUpdateLines:]
|
||||||
|
}
|
||||||
|
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
|
||||||
|
f.WriteString(line + "\n")
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLines(path string) []string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||||
|
if len(lines) == 1 && lines[0] == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(lines) > maxUpdateLines {
|
||||||
|
lines = lines[len(lines)-maxUpdateLines:]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishSelfUpdateLog runs at startup: if the last run ended by restarting into this process, say so.
|
||||||
|
func (s *Server) finishSelfUpdateLog() {
|
||||||
|
lines := readLines(s.updateLogPath())
|
||||||
|
if n := len(lines); n > 0 && strings.Contains(lines[n-1], "restarting") && s.opts.Version != "" {
|
||||||
|
if f, err := os.OpenFile(s.updateLogPath(), os.O_APPEND|os.O_WRONLY, 0o600); err == nil {
|
||||||
|
f.WriteString(time.Now().Format("15:04:05") + " Hammerhead restarted: now running " + s.opts.Version + "\n")
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateStatus returns the progress and log of this server's own update, for the Settings page.
|
||||||
|
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
u := &s.selfUpd
|
||||||
|
u.mu.Lock()
|
||||||
|
out := map[string]any{"running": u.running, "stage": u.stage, "pct": u.pct, "lines": append([]string(nil), u.lines...), "version": s.opts.Version}
|
||||||
|
u.mu.Unlock()
|
||||||
|
if lines, _ := out["lines"].([]string); len(lines) == 0 {
|
||||||
|
// nothing in memory: this is a fresh process, so show the last update's log from disk
|
||||||
|
if saved := readLines(s.updateLogPath()); len(saved) > 0 {
|
||||||
|
stage := "done"
|
||||||
|
if strings.Contains(saved[len(saved)-1], "Failed:") {
|
||||||
|
stage = "failed"
|
||||||
|
}
|
||||||
|
out["lines"], out["stage"], out["pct"] = saved, stage, 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
+799
-258
File diff suppressed because it is too large.
Load diff
+91
-37
@@ -3,34 +3,81 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>Sign in</title>
|
<title>Sign in</title>
|
||||||
<style>
|
<style>
|
||||||
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; }
|
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||||
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } }
|
:root {
|
||||||
* { box-sizing:border-box; }
|
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
||||||
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; }
|
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
||||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; }
|
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
||||||
h1 { font-size:18px; margin:0 0 4px; }
|
--accent-text:var(--accent);
|
||||||
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; }
|
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
|
||||||
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; }
|
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
|
||||||
|
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
|
||||||
|
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
|
||||||
|
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
|
||||||
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
||||||
|
--radius:6px; --radius-lg:10px; --border-w:1px;
|
||||||
|
--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:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
|
||||||
|
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
|
||||||
|
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
|
||||||
|
color-scheme:light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
||||||
|
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
||||||
|
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
||||||
|
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
||||||
|
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
||||||
|
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
||||||
|
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
||||||
|
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
||||||
|
} }
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
||||||
|
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
||||||
|
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
||||||
|
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
||||||
|
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
||||||
|
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
||||||
|
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
||||||
|
}
|
||||||
|
/* tokens:end */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing:border-box; }
|
||||||
|
html { -webkit-text-size-adjust:100%; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
|
||||||
|
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
|
||||||
|
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
|
||||||
|
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
|
||||||
|
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
|
||||||
|
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
|
||||||
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
||||||
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--dim); margin:0; }
|
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
|
||||||
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; }
|
input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
|
||||||
input:focus { outline:2px solid var(--accent); outline-offset:-1px; }
|
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
|
||||||
button, a.btn { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:center; text-decoration:none; display:block; }
|
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
||||||
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); }
|
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
|
||||||
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; }
|
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
||||||
.ok { color:var(--dim); font-size:13px; }
|
button.ghost, a.ghost { background:var(--surface); border-color: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); font-size:var(--text-sm); margin-top:var(--s3); }
|
||||||
|
#err:empty { display:none; }
|
||||||
|
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
|
||||||
[hidden] { display:none !important; }
|
[hidden] { display:none !important; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
|
||||||
|
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<main class="card">
|
||||||
<h1 id="title">Sign in</h1>
|
<h1 id="title">Sign in</h1>
|
||||||
<p class="sub" id="sub"></p>
|
<p class="lead" id="sub"></p>
|
||||||
<div id="body"></div>
|
<div id="body"></div>
|
||||||
<div id="err"></div>
|
<div id="err" role="alert"></div>
|
||||||
</div>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const id = location.pathname.split('/')[3];
|
const id = location.pathname.split('/')[3];
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -49,6 +96,9 @@ function h(tag, props, ...kids) {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
const err = m => $('#err').textContent = m || '';
|
const err = m => $('#err').textContent = m || '';
|
||||||
|
let n = 0;
|
||||||
|
// a visible label above the control, as the design language requires
|
||||||
|
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
|
||||||
|
|
||||||
async function post(path, data) {
|
async function post(path, data) {
|
||||||
err('');
|
err('');
|
||||||
@@ -67,54 +117,57 @@ async function finish(path, data) {
|
|||||||
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
|
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
|
||||||
|
|
||||||
function passwordForm(kind, label, type) {
|
function passwordForm(kind, label, type) {
|
||||||
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
|
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
|
||||||
const go = () => finish(kind, { [kind]: i.value });
|
const go = () => finish(kind, { [kind]: i.value });
|
||||||
i.addEventListener('keydown', e => e.key === 'Enter' && go());
|
i.addEventListener('keydown', e => e.key === 'Enter' && go());
|
||||||
return section(label, i, h('button', {onclick:go}, 'Continue'));
|
return h('div', {class:'sec'}, field(label, i), h('button', {onclick:go}, 'Continue'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function emailForm() {
|
function emailForm() {
|
||||||
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
||||||
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
|
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
|
||||||
const msg = h('div', {class:'ok'});
|
const codeField = field('6-digit code', code); codeField.hidden = true;
|
||||||
|
const msg = h('p', {class:'hint'});
|
||||||
const go = h('button', {}, 'Email me a code');
|
const go = h('button', {}, 'Email me a code');
|
||||||
go.onclick = async () => {
|
go.onclick = async () => {
|
||||||
try {
|
try {
|
||||||
if (code.hidden) {
|
if (codeField.hidden) {
|
||||||
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
||||||
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
|
msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
|
||||||
} else await finish('email/verify', { email: email.value, code: code.value });
|
} else await finish('email/verify', { email: email.value, code: code.value });
|
||||||
} catch (e) { err(e.message); }
|
} catch (e) { err(e.message); }
|
||||||
};
|
};
|
||||||
return section('Email code', email, code, msg, go);
|
return section('Email code', field('Email address', email), codeField, msg, go);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ssoForm(info) {
|
function ssoForm(info) {
|
||||||
const kids = [];
|
const kids = [];
|
||||||
if (info.user) {
|
if (info.user) {
|
||||||
if (info.user.allowed) {
|
if (info.user.allowed) {
|
||||||
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
||||||
} else {
|
} else {
|
||||||
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
|
kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
|
||||||
}
|
}
|
||||||
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
|
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
|
||||||
return section('Account', ...kids);
|
return section('Account', ...kids);
|
||||||
}
|
}
|
||||||
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
|
const email = h('input', {type:'email', autocomplete:'username'});
|
||||||
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
|
const pw = h('input', {type:'password', autocomplete:'current-password'});
|
||||||
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
|
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
|
||||||
|
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
|
||||||
|
codeField.hidden = true;
|
||||||
let ticket = null;
|
let ticket = null;
|
||||||
const go = async () => {
|
const go = async () => {
|
||||||
try {
|
try {
|
||||||
let j;
|
let j;
|
||||||
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
|
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
|
||||||
else j = await post('/api/login', { email: email.value, password: pw.value });
|
else j = await post('/api/login', { email: email.value, password: pw.value });
|
||||||
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
|
if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (e) { err(e.message); }
|
} catch (e) { err(e.message); }
|
||||||
};
|
};
|
||||||
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
||||||
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
|
kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
|
||||||
for (const p of info.providers || []) {
|
for (const p of info.providers || []) {
|
||||||
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
|
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
|
||||||
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
|
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
|
||||||
@@ -125,19 +178,20 @@ function ssoForm(info) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
// share links skip the form
|
// share links skip the form
|
||||||
const share = params.get('share');
|
const share = params.get('share');
|
||||||
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
|
if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
|
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
|
||||||
const info = await r.json();
|
const info = await r.json();
|
||||||
if (!r.ok) throw new Error(info.error);
|
if (!r.ok) throw new Error(info.error);
|
||||||
$('#title').textContent = info.name;
|
$('#title').textContent = info.name;
|
||||||
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
|
document.title = 'Sign in to ' + info.name;
|
||||||
|
$('#sub').textContent = `Sign in to open ${info.host}.`;
|
||||||
if (info.sso) body.append(ssoForm(info));
|
if (info.sso) body.append(ssoForm(info));
|
||||||
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
|
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
|
||||||
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
|
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
|
||||||
if (info.email) body.append(emailForm());
|
if (info.email) body.append(emailForm());
|
||||||
if (!body.children.length) err('No sign-in method is available.');
|
if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
|
||||||
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
|
} catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
|
||||||
}
|
}
|
||||||
main();
|
main();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in new issue
Block a user