Add the Hammerhead control plane

Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC
single sign-on, API keys, revocable sessions), an encrypted SQLite database,
resource logins, alerts and /metrics, mail egress management with DKIM keys,
and the .env loader. Split out of the original single repository; depends on
Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip
and Bamboo together live in e2e/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:50:03 -06:00
1 parent 3daa368233
commit 9452a10e6c
35 files changed
+10392 -1

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# Copy this file to .env and fill it in. .env is read by hammerhead, blacktip and
# bamboo from their working directory (or pass -env /path/to/file). Real
# environment variables always win over the file. NEVER commit .env.
# ---------------------------------------------------------------- hammerhead
# Encrypts secrets (tokens, SMTP/OIDC/relay passwords, DKIM private keys) inside the
# database. Generate one with: hammerhead -genkey
# Keep it apart from the data folder and back it up: without it those secrets cannot
# be read. If you leave it unset, a key is generated into <data>/master.key instead.
HAMMERHEAD_MASTER_KEY=
# Creates the first administrator at startup (otherwise use the setup page in the browser).
#HAMMERHEAD_ADMIN_EMAIL=you@example.com
#HAMMERHEAD_ADMIN_PASSWORD=
# Public address people use to reach the dashboard (login redirects and SSO need it).
#HAMMERHEAD_URL=https://hammerhead.example.com
# Serves Prometheus metrics at /metrics for a scraper that sends this as a bearer token.
# Leave empty and /metrics does not exist.
#HAMMERHEAD_METRICS_TOKEN=
# How long access logs are kept (days). Default 30.
#HAMMERHEAD_LOG_DAYS=30
# ------------------------------------------------------------------ blacktip
# Token from Server -> Edges -> Show command.
#BLACKTIP_HAMMERHEAD=https://hammerhead.example.com
#BLACKTIP_TOKEN=
# For wildcard certificates with Traefik (-traefik ... -acme-dns cloudflare): the
# provider's credentials, named exactly as Traefik documents for that provider.
#BLACKTIP_ACME_DNS=cloudflare
#CF_DNS_API_TOKEN=
# ------------------------------------------------------------------- bamboo
# Token from Sites -> Show command.
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com
#BAMBOO_TOKEN=
+7
View File
@@ -0,0 +1,7 @@
bin/
*-data/
.env
*.db
*.db-wal
*.db-shm
master.key
+218 -1
View File
@@ -1,3 +1,220 @@
# Hammerhead # Hammerhead
Self-hosted control plane for tunnelled ports and websites: a web dashboard, accounts and SSO, alerts, and a database, with no config files. Self-hosted control plane for tunnelled ports and websites: a web dashboard, accounts and SSO, alerts, and a database, with no config files.
Hammerhead is the hub of the sharks stack. It runs the dashboard and API, and keeps your ports, domains, organizations, users, alerts and logs in an encrypted SQLite database. Edges and site agents connect to it and do whatever you set up in the UI.
| Repo | Role | Runs on |
|---|---|---|
| **[Hammerhead](https://git.veillax.com/Veillax/hammerhead)** (this repo) | Control plane: dashboard, API, accounts, database | anywhere the other two can reach |
| [Blacktip](https://git.veillax.com/Veillax/blacktip) | Edge: owns public IPs, opens ports, WireGuard endpoint, enforces logins (optionally drives Traefik) | public server(s) |
| [Bamboo](https://git.veillax.com/Veillax/bamboo) | Site agent: WireGuard peer inside a private network, outbound only | each private network |
| [Reef](https://git.veillax.com/Veillax/reef) | Shared library imported by all three | build time only |
Every Blacktip also gets a built-in **"this machine"** site, so services on the same box
(e.g. several Minecraft proxies) work without running Bamboo at all.
## Quick start
```
go env -w GOPRIVATE=git.veillax.com # once, so Go fetches straight from this server
# 1. control plane (this repo); open http://localhost:8080 and create the first admin
git clone https://git.veillax.com/Veillax/hammerhead && cd hammerhead
go build -o bin/ ./cmd/hammerhead
cp .env.example .env
bin/hammerhead -genkey # paste the output into .env as HAMMERHEAD_MASTER_KEY=
bin/hammerhead -listen :8080 -url https://hammerhead.example.com
# 2. Server -> Edges -> add an edge (name + public host:UDP-port, e.g. 203.0.113.10:51820);
# the UI shows the command to run on the public server
go install git.veillax.com/Veillax/blacktip/cmd/blacktip@latest
blacktip -hammerhead https://hammerhead.example.com -token <edge token> -tunnel :51820 -docker
# 3. optional, for another network: Sites -> add a site, run on a machine there
go install git.veillax.com/Veillax/bamboo/cmd/bamboo@latest
bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
```
Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound
ports. Then open ports on the **Resources** page.
## Configuration and secrets
All three programs read a `.env` file from their working directory before they start (or the file
named by `-env path`). Real environment variables always win over the file. Copy `.env.example`,
which lists every setting, and keep `.env` out of version control (`.gitignore` already does).
What belongs in it: `HAMMERHEAD_MASTER_KEY`, the first admin's credentials, the edge and site
`..._TOKEN` values, `HAMMERHEAD_METRICS_TOKEN`, and DNS-provider credentials for wildcard certificates.
**Secrets at rest.** Hammerhead keeps everything in one SQLite file (`hammerhead.db` in its data
folder) and encrypts the secrets inside it with AES-256-GCM: edge and site tokens, 2FA secrets,
OIDC client secrets, SMTP and relay passwords, DKIM private keys, webhook URLs. Passwords are bcrypt
hashes; PINs and resource passwords likewise. With the wrong key Hammerhead refuses to start rather
than reading garbage. If you don't set `HAMMERHEAD_MASTER_KEY`, a key is generated into
`<data>/master.key` so it works out of the box, but then a backup of the folder carries the key too;
copy the file's contents into `.env` and delete it. **Back the key up separately: without it the
encrypted secrets cannot be recovered.**
## Database
SQLite (pure Go, no C compiler needed), in WAL mode. Configuration is written incrementally in one
transaction per change, so it survives crashes and power loss. Logs, traffic history and alerts are
proper indexed tables: access logs are searchable and paginated, old entries are pruned
(`HAMMERHEAD_LOG_DAYS`, default 30; audit log a year). An existing `state.json` and JSONL logs from an
earlier version are imported automatically on first start and kept as `*.migrated`.
Back up by copying the data folder while Hammerhead is stopped (or use `sqlite3 .backup`), plus your
master key.
## The tunnel: WireGuard
Blacktip and Bamboo are WireGuard peers, using [wireguard-go](https://git.zx2c4.com/wireguard-go)
with a userspace network stack (the same approach Newt uses), so there is no root requirement, no kernel
interface and no driver; it works on Windows, macOS and Linux alike.
- Hammerhead allocates each edge and site an address in `100.89.0.0/16` (it exists only inside the
tunnel, so it can't clash with any LAN or VPN) and distributes public keys. Private keys never leave
the machine that made them (`wg.key` in each agent's data directory).
- Bamboo dials out to every edge it is used from and keeps the NAT mapping open (keepalive 25s),
so sites behind NAT or CGNAT work. Each peer is limited to its own `/32`.
- TCP goes over a TCP connection inside the tunnel; UDP uses native datagrams (no UDP-over-TCP
head-of-line blocking, good for game servers).
- Adding or removing a site changes peers in place; existing tunnels are not reset.
## Two ways to serve web resources
| | built-in proxy (default) | Traefik (`-traefik /path/to/traefik`) |
|---|---|---|
| http/https routing, HTTP/2, websockets | yes | yes |
| Let's Encrypt | TLS-ALPN-01, per-subdomain certificates for wildcard hostnames | TLS-ALPN-01, or DNS-01 with `-acme-dns <provider>` for true wildcard certificates |
| Logins, rules, share links | in Blacktip | same logic, called by Traefik's forward-auth |
| Dependency | none | a Traefik v3 binary |
With Traefik, Blacktip supervises the process: it writes Traefik's static config, restarts it only when
the set of web `ip:port` listeners changes (Traefik declares its ports statically), and hot-reloads
routes, logins and targets through the file provider. Targets are reached through a loopback relay port
per resource into the WireGuard tunnel, so health checks and failover work the same. **Raw TCP, UDP and
TLS-passthrough ports never go through Traefik**: it can't open ports without a restart, and opening
ports from the UI instantly is the point of this project, so Blacktip's own listeners handle those.
`-no-acme` makes Traefik serve its built-in self-signed certificate (useful on private networks).
DNS-challenge credentials are passed as environment variables to Blacktip, exactly as Traefik documents
for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the place for them.
## Features
**Ports**
- The same port on different IPs (`x.y.z.104:51000` and `x.y.z.209:51000` go to different targets).
Blacktip lists the IPs it finds; you can also type one.
- **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
`postscreen_upstream_proxy_protocol = haproxy`, Dovecot `haproxy_trusted_networks`, nginx `proxy_protocol`.
- Many hostnames share one `ip:port`; `*.example.com` wildcards work.
- Several targets per port: round-robin load balancing with automatic failover.
- Health checks (TCP connect or an HTTP path); unhealthy targets are skipped and shown in the UI.
- Conflict detection (same ip/port/protocol, overlapping ranges, or `0.0.0.0` overlapping a specific IP).
- Traffic totals and a 24-hour sparkline per port.
**HTTPS**: automatic Let's Encrypt certificates, optional port-80 redirect, `X-Forwarded-*`
headers, Host rewrite, HTTPS to the backend, certificate status per port.
**Login in front of any web port** (like Pangolin's resource auth)
- Sign in with a Hammerhead account (limited to roles or everyone in the org), a shared
password, a PIN, or an emailed one-time code.
- Expiring, use-limited share links that skip the login.
- Path and IP/CIDR rules that skip the login or block requests.
- The backend gets `X-Hammerhead-User` (client-supplied copies and header aliases are removed) and
never sees the session cookie.
- **Sessions can be revoked.** Visitor cookies are stateless tokens carrying a per-resource epoch.
Hammerhead bumps it, and edges refuse older tokens within seconds, when a member is removed, a role
changes or is deleted, a user is deleted, the password/PIN or allowed methods change, a share link is
revoked, or you press **Sign out visitors**.
**Accounts**
- Organizations, roles (admin and custom), invitations (by link or email), member management.
- Two-factor authentication (TOTP) with backup codes. Password changes sign out other sessions.
- **Signed-in devices** on the Account page: see where you're signed in and end any session, or all the
others; admins can sign a user out everywhere.
- OpenID Connect single sign-on (Google, Authentik, Keycloak, Entra ID…) with auto-provisioning.
- Org-scoped API keys, a "My apps" page for members, brute-force throttling.
- Domains are owned per org and checked against DNS; hostnames must sit under your own domains.
**Alerts**
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a
target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS,
HELO, port 25, DKIM record missing).
- A problem must last the **debounce time** (default 2 minutes, per organization) before you hear about
it, so restarts and blips stay quiet. One message when it starts, one when it clears, optional
reminders. Active alerts are stored, so restarting Hammerhead doesn't repeat them, and an alert is
only marked resolved once the thing it describes has been re-checked.
- A dead edge silences its own ports' alerts (the edge alert already says it). Channels are per
organization; **server channels** get edge alerts.
- Webhooks can't be pointed at internal addresses (no metadata service, no LAN) unless a server admin
allows it for the organization, and redirects are not followed.
- **`/metrics`** (Prometheus) when `HAMMERHEAD_METRICS_TOKEN` is set: edge/site/port/target/mail-check
gauges, traffic counters, alerts firing. Off (404) otherwise.
**Mail egress (send your mail server's outgoing mail from the edge's IP)**
- Receiving servers judge mail by the IP it comes from: its reverse DNS, your HELO name and your SPF
record all have to agree. A mail server at home usually can't satisfy that (residential IP, port 25
blocked). Mail egress lets it send from a VPS IP you control instead.
- Bamboo opens a small SMTP relay on the mail server's network (default `127.0.0.1:2525`). Point your
MTA at it (Postfix: `relayhost = [127.0.0.1]:2525`). Each message crosses the WireGuard tunnel and
Blacktip delivers it straight to the recipient's server (MX lookup, MX fallback rules, opportunistic
STARTTLS) **from the source IP you choose**, announcing the HELO name you set. The recipient's replies
go straight back, so your server's queue, retries and bounces behave normally. Or set a smarthost to
relay through a provider that allows your edge IP.
- **DKIM signing at the edge.** Create a key per sending domain on the Mail egress page (RSA 2048,
generated by Hammerhead, private half encrypted at rest and held only by the edge); publish the TXT
record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message
your mail server already signed for that domain is left alone, and a signing problem sends the mail
unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
- **MTA-STS enforcement.** When a recipient domain publishes an `enforce` policy, mail only goes to the
servers it lists, over TLS with a verified certificate; otherwise it's a temporary failure so your
server retries. (`testing` mode doesn't block.) Policies are cached for their `max_age`. The policy
fetch can't reach internal addresses or follow redirects.
- The page shows an edge-side deliverability checklist: reverse DNS matches the HELO name, the HELO name
resolves back to the IP, outbound port 25 is open, plus the SPF record to publish.
- Guard rails: only the owning site can use an egress; recipients' servers on internal address ranges
are refused; a per-minute send limit (default 60) protects the IP's reputation if the mail server is
compromised; the relay requires a password if it listens beyond `127.0.0.1`; optional "require
verified TLS"; every delivery and refusal is in the access log (recipient domain, server used, SMTP
status; never message content).
- One transaction goes to one recipient domain; extra domains get `452` and your MTA resends them
separately, as MTAs expect. 50 MB limit.
- **Deliberately no queue at the edge.** Delivery is synchronous, so your mail server's own queue does
the retrying. Accepting mail at the edge and bouncing it later would create backscatter (bounces to
forged senders, which gets IPs blacklisted) and leave stored mail on a public server.
- Not included: smarthost authentication (smarthosts must allow the edge IP), DANE, and receiving
mail (use a normal TCP port with PROXY protocol for that).
**Operations**
- Searchable, paginated access log (per request for web ports, per connection for TCP, per message for
mail) and audit log.
- Docker container discovery on any site or edge (`-docker`): pick a container as a target.
- Server admins decide which orgs may use each edge, and whether an org may target the edge
machine itself (off by default for extra orgs, since that includes Hammerhead).
## How logins work
Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammerhead signs a short-lived
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.
## Limits and notes
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
- Hammerhead serves plain HTTP; put it behind HTTPS (set `-url`) or keep it on a private network.
- SQLite means one Hammerhead instance (no clustering); fine for personal and small-team use.
- PROXY protocol covers tcp and tls ports, not udp.
- A Blacktip can only bind IPs that exist on its machine. Cloud elastic IPs that are NATed must be
bound by their private address.
- Let's Encrypt needs port 443 on the edge reachable from the internet and DNS pointing at it.
- Traefik's access log is imported into Hammerhead; the file is truncated when it passes 64 MB.
- Tests: `go test ./...`. The `e2e/` folder runs Hammerhead, Blacktip and Bamboo together (it pulls the other two in as test dependencies); the Traefik test runs when `TRAEFIK_BIN` points at a Traefik binary.
+565
View File
@@ -0,0 +1,565 @@
package hammerhead
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionTTL = 14 * 24 * time.Hour
type principal struct {
User User // copy; zero ID when authenticated by API key
Key *APIKey // set for API-key callers
}
type ticket struct {
userID string
expires time.Time
}
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func (s *Server) principalFrom(r *http.Request) *principal {
s.mu.Lock()
defer s.mu.Unlock()
if c, err := r.Cookie("hh_session"); err == nil {
if sess := s.st.Sessions[sha(c.Value)]; sess != nil && time.Now().Before(sess.Expires) {
if u := s.findUser(sess.UserID); u != nil {
return &principal{User: *u}
}
}
}
if tok := bearer(r); strings.HasPrefix(tok, "hhk_") {
parts := strings.SplitN(tok, "_", 3)
if len(parts) == 3 {
for _, k := range s.st.APIKeys {
if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) {
k.LastUsed = time.Now()
kc := *k
return &principal{Key: &kc}
}
}
}
}
return nil
}
func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil || p.User.ID == "" {
fail(w, 401, errors.New("not logged in"))
return
}
fn(w, r, p)
}
}
func (s *Server) withServerAdmin(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) {
if !p.User.ServerAdmin {
fail(w, 403, errors.New("server administrators only"))
return
}
fn(w, r, p)
})
}
// orgCtx is the caller's standing inside one org.
type orgCtx struct {
Org Org
Admin bool
User User
Key *APIKey
Actor string
}
func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Request, *orgCtx)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil {
fail(w, 401, errors.New("not logged in"))
return
}
id := r.PathValue("org")
s.mu.Lock()
org := s.findOrg(id)
c := &orgCtx{User: p.User, Key: p.Key}
allowed := false
if org != nil {
c.Org = *org
switch {
case p.Key != nil:
allowed = p.Key.OrgID == org.ID
c.Admin = allowed
c.Actor = "apikey:" + p.Key.Name
case p.User.ServerAdmin:
allowed, c.Admin = true, true
c.Actor = p.User.Email
default:
if m := s.member(p.User.ID, org.ID); m != nil {
allowed = true
if role := s.findRole(m.RoleID); role != nil {
c.Admin = role.Admin
}
}
c.Actor = p.User.Email
}
}
s.mu.Unlock()
if !allowed {
fail(w, 404, errors.New("no such organization"))
return
}
if needAdmin && !c.Admin {
fail(w, 403, errors.New("organization administrators only"))
return
}
fn(w, r, c)
}
}
// ---------- first run ----------
func hashPassword(pw string) (string, error) {
if len(pw) < 8 {
return "", errors.New("password must be at least 8 characters")
}
b, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
return string(b), err
}
// newOrgLocked creates an org with its two built-in roles. Caller holds s.mu.
func (s *Server) newOrgLocked(name string, allowLocal bool) (*Org, *Role) {
o := &Org{ID: newID(), Name: name, AllowLocal: allowLocal}
admin := &Role{ID: newID(), OrgID: o.ID, Name: "Admin", Admin: true, Builtin: true}
member := &Role{ID: newID(), OrgID: o.ID, Name: "Member", Builtin: true}
s.st.Orgs = append(s.st.Orgs, o)
s.st.Roles = append(s.st.Roles, admin, member)
return o, admin
}
func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
hash, err := hashPassword(password)
if err != nil {
return err
}
email = strings.ToLower(strings.TrimSpace(email))
if !strings.Contains(email, "@") {
return errors.New("enter a valid email address")
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.st.Users) != 0 {
return errors.New("setup already done")
}
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
org, adminRole := s.newOrgLocked(orgName, true)
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: org.ID, RoleID: adminRole.ID})
return s.save()
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Name, Password, OrgName string }
if !decode(w, r, &in) {
return
}
if strings.TrimSpace(in.OrgName) == "" {
in.OrgName = "Default"
}
if strings.TrimSpace(in.Name) == "" {
in.Name = "Admin"
}
if err := s.createFirstAdmin(in.Email, in.Name, in.Password, in.OrgName); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
// ---------- sessions ----------
func (s *Server) secureCookies(r *http.Request) bool {
return r.TLS != nil || strings.HasPrefix(s.st.Settings.DashboardURL, "https://")
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) {
tok := randHex(24)
now := time.Now()
s.mu.Lock()
for k, v := range s.st.Sessions {
if now.After(v.Expires) {
delete(s.st.Sessions, k)
}
}
s.st.Sessions[sha(tok)] = &Session{UserID: userID, Expires: now.Add(sessionTTL), Created: now, IP: clientIP(r), UA: trim(r.UserAgent(), 200)}
secure := s.secureCookies(r)
s.save()
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: tok, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds())})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, _ *principal) {
if c, err := r.Cookie("hh_session"); err == nil {
s.mu.Lock()
delete(s.st.Sessions, sha(c.Value))
s.save()
s.mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: "", Path: "/", MaxAge: -1})
ok(w)
}
// dummyHash lets a missing user cost the same as a wrong password.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 10)
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Password string }
if !decode(w, r, &in) {
return
}
email := strings.ToLower(strings.TrimSpace(in.Email))
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
return
}
s.mu.Lock()
u := s.userByEmail(email)
hash := string(dummyHash)
if u != nil && u.PassHash != "" {
hash = u.PassHash
}
s.mu.Unlock()
good := bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) == nil
if u == nil || !good || u.PassHash == "" {
s.audit("", email, "login.failed", clientIP(r))
time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong email or password"))
return
}
if u.TOTPEnabled {
t := randHex(16)
s.mu.Lock()
s.tickets[t] = ticket{userID: u.ID, expires: time.Now().Add(5 * time.Minute)}
s.mu.Unlock()
writeJSON(w, 200, map[string]any{"needTotp": true, "ticket": t})
return
}
s.startSession(w, r, u.ID)
s.audit("", u.Email, "login", clientIP(r))
ok(w)
}
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if !decode(w, r, &in) {
return
}
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
return
}
s.mu.Lock()
t, found := s.tickets[in.Ticket]
var u *User
if found && time.Now().Before(t.expires) {
u = s.findUser(t.userID)
}
good := false
if u != nil {
code := strings.ReplaceAll(strings.TrimSpace(in.Code), " ", "")
if totpOK(u.TOTPSecret, code) {
good = true
} else {
h := sha(strings.ToLower(code))
for i, b := range u.BackupCodes {
if b == h {
u.BackupCodes = append(append([]string{}, u.BackupCodes[:i]...), u.BackupCodes[i+1:]...)
good = true
break
}
}
}
}
if good {
delete(s.tickets, in.Ticket)
s.save()
}
s.mu.Unlock()
if !good {
fail(w, 401, errors.New("wrong code"))
return
}
s.startSession(w, r, u.ID)
s.audit("", u.Email, "login", clientIP(r)+" (2fa)")
ok(w)
}
// ---------- TOTP ----------
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func hotp(key []byte, counter uint64) string {
var b [8]byte
binary.BigEndian.PutUint64(b[:], counter)
m := hmac.New(sha1.New, key)
m.Write(b[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0xf
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
return fmt.Sprintf("%06d", v)
}
func totpOK(secretB32, code string) bool {
key, err := b32.DecodeString(secretB32)
if err != nil || len(code) != 6 {
return false
}
now := uint64(time.Now().Unix() / 30)
for _, d := range []int{-1, 0, 1} {
if hmac.Equal([]byte(hotp(key, now+uint64(d))), []byte(code)) {
return true
}
}
return false
}
// ---------- /api/me ----------
type orgBrief struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Admin bool `json:"admin"`
}
func (s *Server) publicInfo(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, 200, map[string]any{"needsSetup": len(s.st.Users) == 0, "providers": s.providerList()})
}
func (s *Server) providerList() []map[string]string {
out := []map[string]string{}
for _, p := range s.st.IdPs {
out = append(out, map[string]string{"id": p.ID, "name": p.Name})
}
return out
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
orgs := []orgBrief{}
for _, o := range s.st.Orgs {
if u.ServerAdmin {
orgs = append(orgs, orgBrief{ID: o.ID, Name: o.Name, Role: "Server admin", Admin: true})
continue
}
if m := s.member(u.ID, o.ID); m != nil {
role := s.findRole(m.RoleID)
b := orgBrief{ID: o.ID, Name: o.Name}
if role != nil {
b.Role, b.Admin = role.Name, role.Admin
}
orgs = append(orgs, b)
}
}
writeJSON(w, 200, map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
"totpEnabled": u.TOTPEnabled, "hasPassword": u.PassHash != "", "orgs": orgs,
"backupCodes": len(u.BackupCodes), "dashboardUrl": s.st.Settings.DashboardURL,
})
}
func (s *Server) updateMe(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if n := strings.TrimSpace(in.Name); n != "" {
s.findUser(p.User.ID).Name = n
}
s.save()
ok(w)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Current, New string }
if !decode(w, r, &in) {
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
fail(w, 400, errors.New("current password is wrong"))
return
}
hash, err := hashPassword(in.New)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
s.findUser(p.User.ID).PassHash = hash
// sign out every other session
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && k != cur {
delete(s.st.Sessions, k)
}
}
s.save()
s.mu.Unlock()
s.audit("", p.User.Email, "password.change", "")
ok(w)
}
func (s *Server) totpSetup(w http.ResponseWriter, r *http.Request, p *principal) {
if p.User.TOTPEnabled {
fail(w, 400, errors.New("two-factor is already on"))
return
}
raw := make([]byte, 20)
rand.Read(raw)
secret := b32.EncodeToString(raw)
s.mu.Lock()
s.findUser(p.User.ID).TOTPSecret = secret
s.save()
s.mu.Unlock()
writeJSON(w, 200, map[string]string{"secret": secret,
"uri": fmt.Sprintf("otpauth://totp/Hammerhead:%s?secret=%s&issuer=Hammerhead", p.User.Email, secret)})
}
func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Code string }
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
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"))
return
}
codes, hashes := make([]string, 8), make([]string, 8)
for i := range codes {
codes[i] = randHex(5)
hashes[i] = sha(codes[i])
}
u.TOTPEnabled, u.BackupCodes = true, hashes
s.save()
writeJSON(w, 200, map[string]any{"backupCodes": codes})
}
func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Password, Code string }
if !decode(w, r, &in) {
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
fail(w, 400, errors.New("password is wrong"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("code is wrong"))
return
}
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
s.save()
ok(w)
}
func trim(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---------- your signed-in devices ----------
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request, p *principal) {
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
s.mu.Lock()
defer s.mu.Unlock()
type row struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
IP string `json:"ip"`
UA string `json:"ua"`
Current bool `json:"current"`
}
out := []row{}
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && time.Now().Before(sess.Expires) {
out = append(out, row{k[:16], sess.Created, sess.Expires, sess.IP, sess.UA, k == cur})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
writeJSON(w, 200, out)
}
func (s *Server) endSession(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
if len(id) >= 16 {
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && strings.HasPrefix(k, id) {
delete(s.st.Sessions, k)
}
}
}
s.save()
ok(w)
}
// endOtherSessions signs the user out everywhere except the browser they are using.
func (s *Server) endOtherSessions(w http.ResponseWriter, r *http.Request, p *principal) {
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
s.mu.Lock()
defer s.mu.Unlock()
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && k != cur {
delete(s.st.Sessions, k)
}
}
s.save()
ok(w)
}
+265
View File
@@ -0,0 +1,265 @@
package hammerhead
import (
"crypto/subtle"
"errors"
"net/http"
"strconv"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Endpoints used by blacktip and bamboo. They authenticate with the edge/site token.
func (s *Server) edgeByToken(r *http.Request) *model.Edge {
t := bearer(r)
if t == "" {
return nil
}
for _, e := range s.st.Edges {
if subtle.ConstantTimeCompare([]byte(e.Token), []byte(t)) == 1 {
return e
}
}
return nil
}
func (s *Server) siteByToken(r *http.Request) *model.Site {
t := bearer(r)
if t == "" {
return nil
}
for _, e := range s.st.Sites {
if !e.Local && subtle.ConstantTimeCompare([]byte(e.Token), []byte(t)) == 1 {
return e
}
}
return nil
}
func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
e := s.edgeByToken(r)
s.mu.Unlock()
if e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) {
s.mu.Lock()
if e := s.edgeByToken(r); e != nil {
e.LastSeen = time.Now()
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.edgeByToken(r); e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
e.LastSeen = time.Now()
cfg := model.EdgeConfig{
Version: s.st.Version, EdgeID: e.ID, AuthKey: e.AuthKey, Dashboard: s.st.Settings.DashboardURL,
Mappings: []model.Mapping{}, Peers: []model.WGPeer{}, Local: []string{}, TunnelIP: e.TunnelIP,
AcmeEmail: s.st.Settings.AcmeEmail, AcmeStages: s.st.Settings.AcmeStaging,
}
for _, m := range s.st.Mappings {
if m.EdgeID == e.ID && m.Enabled {
cfg.Mappings = append(cfg.Mappings, *m)
}
}
cfg.MailEgress = []model.MailPolicy{}
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID && x.Enabled {
cfg.MailEgress = append(cfg.MailEgress, model.MailPolicy{ID: x.ID, SiteID: x.SiteID, SourceIP: x.SourceIP, HeloName: x.HeloName,
Smarthost: x.Smarthost, RequireTLS: x.RequireTLS, RatePerMinute: x.RatePerMinute, DKIM: s.signersLocked(x.OrgID)})
}
}
for _, st := range s.st.Sites {
switch {
case st.Local:
if st.EdgeID == e.ID {
cfg.Local = append(cfg.Local, st.ID)
}
case st.PublicKey != "" && st.TunnelIP != "" && s.edgeAllowedFor(e, st.OrgID):
cfg.Peers = append(cfg.Peers, model.WGPeer{SiteID: st.ID, PublicKey: st.PublicKey, IP: st.TunnelIP})
}
}
writeJSON(w, 200, cfg)
}
func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
var in model.EdgeReport
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
e := s.edgeByToken(r)
if e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
e.LastSeen = time.Now()
e.IPs = in.IPs
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID {
if chk, found := in.MailChecks[x.ID]; found {
s.mailChecks[x.ID] = chk
}
}
}
for _, m := range s.st.Mappings {
if m.EdgeID != e.ID {
continue
}
if st, found := in.Status[m.ID]; found {
s.status[m.ID] = st
} else {
delete(s.status, m.ID)
}
if d, found := in.Stats[m.ID]; found {
t := s.st.Totals[m.ID]
if t == nil {
t = &Total{}
s.st.Totals[m.ID] = t
}
t.Conns += d.Conns
t.BytesIn += d.BytesIn
t.BytesOut += d.BytesOut
s.db.addTraffic(m.ID, d)
}
}
for _, st := range s.st.Sites {
if st.Local && st.EdgeID == e.ID {
s.containers[st.ID] = in.Containers
}
}
if e.PublicKey != in.PublicKey {
e.PublicKey = in.PublicKey
s.bump() // sites need the edge's new key
} else {
s.save()
}
ok(w)
}
func (s *Server) edgeLogs(w http.ResponseWriter, r *http.Request) {
var in []model.LogEntry
if !decode(w, r, &in) {
return
}
s.mu.Lock()
e := s.edgeByToken(r)
if e == nil {
s.mu.Unlock()
fail(w, 401, errors.New("bad edge token"))
return
}
out := make([]model.LogEntry, 0, len(in))
for _, l := range in {
if m := s.findMapping(l.Mapping); m != nil && m.EdgeID == e.ID {
l.OrgID = m.OrgID
} else if x := s.findMailEgress(l.Mapping); x != nil && x.EdgeID == e.ID {
l.OrgID = x.OrgID
} else {
continue
}
out = append(out, l)
}
s.mu.Unlock()
s.logs.addAccess(out)
ok(w)
}
func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
st := s.siteByToken(r)
s.mu.Unlock()
if st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) {
s.mu.Lock()
if st := s.siteByToken(r); st != nil {
st.LastSeen = time.Now()
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if st = s.siteByToken(r); st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
st.LastSeen = time.Now()
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}}
seen := map[string]bool{}
addEdge := func(id string) {
if seen[id] {
return
}
seen[id] = true
if e := s.findEdge(id); e != nil {
cfg.Edges = append(cfg.Edges, model.EdgeRef{ID: e.ID, Name: e.Name, Endpoint: e.TunnelAddr, PublicKey: e.PublicKey, IP: e.TunnelIP})
}
}
for _, m := range s.st.Mappings {
for _, t := range m.Targets {
if t.SiteID == st.ID {
addEdge(m.EdgeID)
}
}
}
for _, x := range s.st.MailEgress {
if x.SiteID == st.ID && x.Enabled {
addEdge(x.EdgeID)
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
}
}
writeJSON(w, 200, cfg)
}
func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
var in model.SiteReport
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := s.siteByToken(r)
if st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
st.LastSeen = time.Now()
if in.Connected == nil {
in.Connected = []string{}
}
st.Connected = in.Connected
s.containers[st.ID] = in.Containers
for _, x := range s.st.MailEgress {
if x.SiteID != st.ID {
continue
}
if msg, known := in.MailEgress[x.ID]; known {
s.mailStatus[x.ID] = msg
} else {
delete(s.mailStatus, x.ID)
}
}
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
st.PublicKey = in.PublicKey
s.bump() // every edge this site talks to needs the new key
}
ok(w)
}
+441
View File
@@ -0,0 +1,441 @@
package hammerhead
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/netguard"
)
// Alerts: every few seconds Hammerhead derives the set of current problems from
// what the edges and sites report (an edge that went quiet, a port that can't
// bind, a target failing its health check, a certificate error, a mail relay
// that isn't deliverable). A problem has to persist for the debounce time before
// anyone is told, which keeps a restart or a blip from paging you. You get one
// message when it starts and one when it clears, and optionally a reminder.
// Active alerts are kept in the database so a restart doesn't repeat them.
const (
defaultDebounce = 2 * time.Minute
defaultOffline = 90 * time.Second
)
// AlertChannel is somewhere alerts are sent: an email address or a webhook.
type AlertChannel struct {
ID string `json:"id"`
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
Name string `json:"name"`
Kind string `json:"kind"` // "email" or "webhook"
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens)
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
AllowPrivate bool `json:"allowPrivate"`
Enabled bool `json:"enabled"`
}
type alertState struct {
Key, OrgID, Kind, Name, Message string
Since time.Time
Fired bool
Notified time.Time
}
type problem struct{ org, kind, name, msg string }
type alertNote struct {
Status string `json:"status"` // "firing", "resolved" or "test"
Org string `json:"org"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Time time.Time `json:"time"`
}
func (s *Server) offlineAfter() time.Duration {
if s.opts.OfflineAfter > 0 {
return s.opts.OfflineAfter
}
return defaultOffline
}
// problemsLocked derives every current problem. Caller holds s.mu.
func (s *Server) problemsLocked(now time.Time) map[string]problem {
out := map[string]problem{}
add := func(key, org, kind, name, msg string) { out[key] = problem{org, kind, name, msg} }
quiet := func(t time.Time) (bool, string) {
if t.IsZero() {
return false, "" // never connected: not set up yet, nothing to alert about
}
d := now.Sub(t)
return d > s.offlineAfter(), d.Round(time.Second).String()
}
edgeDown, siteDown := map[string]bool{}, map[string]bool{}
for _, e := range s.st.Edges {
if down, d := quiet(e.LastSeen); down {
edgeDown[e.ID] = true
msg := fmt.Sprintf("Edge %s has not contacted Hammerhead for %s", e.Name, d)
add("edge:"+e.ID+"@", "", "edge", e.Name, msg) // server-level
for _, org := range s.orgsUsingEdgeLocked(e.ID) {
add("edge:"+e.ID+"@"+org, org, "edge", e.Name, msg)
}
}
}
for _, st := range s.st.Sites {
if st.Local {
continue
}
if down, d := quiet(st.LastSeen); down {
siteDown[st.ID] = true
add("site:"+st.ID, st.OrgID, "site", st.Name, fmt.Sprintf("Site %s has not contacted Hammerhead for %s", st.Name, d))
}
}
for _, m := range s.st.Mappings {
if !m.Enabled || edgeDown[m.EdgeID] {
continue // a dead edge's last report is stale: the edge alert already says it
}
st, known := s.status[m.ID]
if !known {
continue
}
if st.Error != "" {
add("port:"+m.ID, m.OrgID, "port", m.Name, fmt.Sprintf("%s can't listen: %s", m.Name, st.Error))
}
if strings.HasPrefix(st.Cert, "error") {
add("cert:"+m.ID, m.OrgID, "certificate", m.Name, fmt.Sprintf("%s has a certificate problem: %s", m.Name, st.Cert))
}
if m.HealthSeconds != -1 {
for i, th := range st.Targets {
if i >= len(m.Targets) || th.Healthy || siteDown[m.Targets[i].SiteID] {
continue
}
t := m.Targets[i]
add(fmt.Sprintf("target:%s:%d", m.ID, i), m.OrgID, "target", m.Name,
fmt.Sprintf("%s: the target %s:%d isn't answering (%s)", m.Name, t.Host, t.Port, th.Detail))
}
}
}
for _, x := range s.st.MailEgress {
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
continue
}
if msg := s.mailStatus[x.ID]; msg != "" {
add("mail:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("Mail relay %s can't run: %s", x.Name, msg))
}
if chk, ok := s.mailChecks[x.ID]; ok {
if !chk.PTRMatchesHelo {
add("mailptr:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: reverse DNS of %s doesn't match %s, so receiving servers may reject its mail", x.Name, x.SourceIP, x.HeloName))
}
if !chk.HeloResolves {
add("mailhelo:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: %s doesn't resolve to %s", x.Name, x.HeloName, x.SourceIP))
}
if chk.Port25 != "" && chk.Port25 != "open" {
add("mail25:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: can't connect out on port 25 from %s", x.Name, x.SourceIP))
}
}
}
for _, k := range s.st.DKIM {
for _, x := range s.st.MailEgress {
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
continue
}
if st := s.mailChecks[x.ID].DKIM[k.ID]; st == "missing" || st == "mismatch" {
add("dkim:"+k.ID, k.OrgID, "mail", k.Domain, fmt.Sprintf("DKIM key %s._domainkey.%s is %s in DNS, so signed mail will fail verification", k.Selector, k.Domain, st))
break
}
}
}
return out
}
// judgeableLocked reports whether we currently have the data to decide an alert's
// problem is gone. After a restart the live statuses are empty until edges and
// sites report again; resolving then would announce a recovery that never happened.
func (s *Server) judgeableLocked(st *alertState) bool {
kind, rest, _ := strings.Cut(st.Key, ":")
switch kind {
case "port", "cert":
if s.findMapping(rest) == nil {
return true // the thing was deleted: nothing left to be wrong
}
_, known := s.status[rest]
return known
case "target":
id, _, _ := strings.Cut(rest, ":")
if s.findMapping(id) == nil {
return true
}
_, known := s.status[id]
return known
case "mail":
if s.findMailEgress(rest) == nil {
return true
}
_, known := s.mailStatus[rest]
return known
case "mailptr", "mailhelo", "mail25":
if s.findMailEgress(rest) == nil {
return true
}
_, known := s.mailChecks[rest]
return known
case "dkim":
return len(s.mailChecks) > 0
}
return true // edges and sites carry their own last-seen time
}
func (s *Server) orgsUsingEdgeLocked(edgeID string) []string {
seen := map[string]bool{}
var out []string
for _, m := range s.st.Mappings {
if m.EdgeID == edgeID && !seen[m.OrgID] {
seen[m.OrgID] = true
out = append(out, m.OrgID)
}
}
for _, x := range s.st.MailEgress {
if x.EdgeID == edgeID && !seen[x.OrgID] {
seen[x.OrgID] = true
out = append(out, x.OrgID)
}
}
return out
}
func (s *Server) debounceFor(orgID string) time.Duration {
if s.opts.AlertDebounce > 0 {
return s.opts.AlertDebounce
}
if o := s.findOrg(orgID); o != nil && o.AlertDebounceMin > 0 {
return time.Duration(o.AlertDebounceMin) * time.Minute
}
return defaultDebounce
}
func (s *Server) repeatFor(orgID string) time.Duration {
if o := s.findOrg(orgID); o != nil && o.AlertRepeatHours > 0 {
return time.Duration(o.AlertRepeatHours) * time.Hour
}
return 0
}
type delivery struct {
note alertNote
channels []AlertChannel
}
// evaluateAlerts moves alert state forward to now and sends whatever became due.
func (s *Server) evaluateAlerts(now time.Time) {
var out []delivery
s.mu.Lock()
probs := s.problemsLocked(now)
orgName := func(id string) string {
if o := s.findOrg(id); o != nil {
return o.Name
}
return "server"
}
queue := func(st *alertState, status string) {
var chans []AlertChannel
for _, c := range s.st.Channels {
if c.OrgID == st.OrgID && c.Enabled {
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})
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
}
keys := make([]string, 0, len(probs))
for k := range probs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
p := probs[k]
st := s.alerts[k]
if st == nil {
st = &alertState{Key: k, OrgID: p.org, Kind: p.kind, Since: now}
s.alerts[k] = st
}
st.Name, st.Message = p.name, p.msg
switch {
case !st.Fired && now.Sub(st.Since) >= s.debounceFor(p.org):
st.Fired, st.Notified = true, now
queue(st, "firing")
case st.Fired && s.repeatFor(p.org) > 0 && now.Sub(st.Notified) >= s.repeatFor(p.org):
st.Notified = now
queue(st, "firing")
}
s.db.saveAlert(st)
}
for k, st := range s.alerts {
if _, still := probs[k]; still {
continue
}
if !s.judgeableLocked(st) {
continue // e.g. just restarted: the agents haven't reported yet, so we can't say it cleared
}
if st.Fired {
queue(st, "resolved")
}
delete(s.alerts, k)
s.db.deleteAlert(k)
}
s.mu.Unlock()
for _, d := range out {
go s.deliver(d)
}
}
func (s *Server) loadAlerts() {
for _, a := range s.db.loadAlertRows() {
a := a
s.alerts[a.Key] = &a
}
}
func (s *Server) alertLoop() {
every := s.opts.AlertInterval
if every <= 0 {
every = 15 * time.Second
}
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-t.C:
s.evaluateAlerts(time.Now())
}
}
}
// ---------- delivery ----------
func (n alertNote) subject() string {
switch n.Status {
case "resolved":
return "[Hammerhead] Resolved: " + n.Name
case "test":
return "[Hammerhead] Test alert"
}
return "[Hammerhead] Problem: " + n.Name
}
func (n alertNote) text() string {
switch n.Status {
case "resolved":
return "Resolved: " + n.Message
case "test":
return "This is a test alert from Hammerhead. If you can read this, the channel works."
}
return "Problem: " + n.Message
}
func (s *Server) deliver(d delivery) {
for _, ch := range d.channels {
if err := s.send(ch, d.note); err != nil {
log.Printf("alert channel %q: %v", ch.Name, err)
}
}
}
func (s *Server) send(ch AlertChannel, n alertNote) error {
switch ch.Kind {
case "email":
var errs []error
for _, to := range strings.Split(ch.Target, ",") {
if to = strings.TrimSpace(to); to != "" {
if err := s.Mailer(to, n.subject(), n.text()+"\n\nOrganization: "+n.Org+"\nSince: "+n.Since.Format(time.RFC1123)+"\n"); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
case "webhook":
body, ctype, hdr := webhookBody(ch.Format, n)
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", ctype)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("the webhook answered %s", resp.Status)
}
return nil
}
return errors.New("unknown channel kind")
}
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
text := n.subject() + "\n" + n.text()
switch format {
case "slack":
body, _ = json.Marshal(map[string]string{"text": text})
case "discord":
body, _ = json.Marshal(map[string]string{"content": text})
case "ntfy":
prio, tags := "high", "rotating_light"
if n.Status != "firing" {
prio, tags = "default", "white_check_mark"
}
return []byte(n.text()), "text/plain", map[string]string{"Title": n.subject(), "Priority": prio, "Tags": tags}
default:
body, _ = json.Marshal(n)
}
return body, "application/json", nil
}
// validateChannel normalises ch. allowPrivate may only be kept if the caller is trusted.
func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
ch.Name = strings.TrimSpace(ch.Name)
ch.Target = strings.TrimSpace(ch.Target)
switch ch.Kind {
case "email":
for _, a := range strings.Split(ch.Target, ",") {
if a = strings.TrimSpace(a); !strings.Contains(a, "@") || strings.ContainsAny(a, " \r\n<>") {
return errors.New("enter one or more email addresses, separated by commas")
}
}
ch.Format, ch.AllowPrivate = "", false
case "webhook":
u, err := url.Parse(ch.Target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("the webhook must be an http:// or https:// URL")
}
switch ch.Format {
case "json", "slack", "discord", "ntfy":
case "":
ch.Format = "json"
default:
return errors.New("format must be json, slack, discord or ntfy")
}
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")
}
default:
return errors.New("a channel is an email or a webhook")
}
if ch.Name == "" {
ch.Name = ch.Kind
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
package hammerhead
import (
"errors"
"net/http"
"net/url"
"sort"
"time"
)
// The same handlers serve an organization's channels and the server-level ones
// (for edges, which belong to nobody); only the scope differs.
type alertScope struct {
org string // "" = server level
trusted bool // may allow private webhook targets
actor string
}
type channelView struct {
AlertChannel
Preview string `json:"preview"`
}
func preview(ch AlertChannel) string {
if ch.Kind != "webhook" {
return ch.Target
}
if u, err := url.Parse(ch.Target); err == nil {
return u.Scheme + "://" + u.Host + "/…"
}
return "webhook"
}
type alertRow struct {
Key string `json:"key"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Fired bool `json:"fired"`
}
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
s.mu.Lock()
chans := []channelView{}
for _, c := range s.st.Channels {
if c.OrgID == sc.org {
v := channelView{AlertChannel: *c, Preview: preview(*c)}
v.Target = "" // webhook URLs often contain tokens
if c.Kind == "email" {
v.Target = c.Target
}
chans = append(chans, v)
}
}
active := []alertRow{}
for _, a := range s.alerts {
if a.OrgID == sc.org {
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
}
}
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
if o := s.findOrg(sc.org); o != nil {
if o.AlertDebounceMin > 0 {
settings["debounceMin"] = o.AlertDebounceMin
}
settings["repeatHours"] = o.AlertRepeatHours
}
mailOK := s.st.Settings.SMTP.Host != ""
s.mu.Unlock()
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
}
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in AlertChannel
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
var existing *AlertChannel
if id != "" {
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
existing = c
}
}
if existing == nil {
fail(w, 404, errors.New("no such channel"))
return
}
if in.Target == "" {
in.Target = existing.Target // blank keeps the saved URL/address
}
}
trusted := sc.trusted
if err := s.validateChannel(&in, trusted); err != nil {
fail(w, 400, err)
return
}
in.OrgID = sc.org
if existing != nil {
in.ID = id
*existing = in
} else {
in.ID = newID()
s.st.Channels = append(s.st.Channels, &in)
}
s.save()
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
ok(w)
}
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
s.save()
ok(w)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
var ch *AlertChannel
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
cp := *c
ch = &cp
}
}
name := "server"
if o := s.findOrg(sc.org); o != nil {
name = o.Name
}
s.mu.Unlock()
if ch == nil {
fail(w, 404, errors.New("no such channel"))
return
}
now := time.Now()
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in struct{ DebounceMin, RepeatHours int }
if !decode(w, r, &in) {
return
}
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)"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
o := s.findOrg(sc.org)
if o == nil {
fail(w, 404, errors.New("organization settings only"))
return
}
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
s.save()
ok(w)
}
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
}
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
}
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
})
}
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
}
+489
View File
@@ -0,0 +1,489 @@
package hammerhead
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model"
)
type hook struct {
URL string
mu sync.Mutex
got []string // raw bodies
ch chan string
hdrs []http.Header
}
func newHook(t *testing.T) *hook {
h := &hook{ch: make(chan string, 32)}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
h.mu.Lock()
h.got = append(h.got, string(b))
h.hdrs = append(h.hdrs, r.Header.Clone())
h.mu.Unlock()
h.ch <- string(b)
}))
t.Cleanup(ts.Close)
h.URL = ts.URL + "/hook/secret-token"
return h
}
// next returns the next delivery, or "" if none arrives soon.
func (h *hook) next(wait time.Duration) string {
select {
case b := <-h.ch:
return b
case <-time.After(wait):
return ""
}
}
type fx struct {
s *Server
org string
base time.Time
hook *hook
mail chan string
}
func newFx(t *testing.T) *fx {
s, err := New(t.TempDir(), Options{MasterKey: secretbox.GenerateKey(), AlertInterval: time.Hour})
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
f := &fx{s: s, base: time.Now(), hook: newHook(t), mail: make(chan string, 16)}
s.Mailer = func(to, subject, body string) error { f.mail <- to + "|" + subject + "|" + body; return nil }
s.mu.Lock()
org, _ := s.newOrgLocked("Acme", true)
f.org = org.ID
s.st.Edges = append(s.st.Edges, &model.Edge{ID: "e1", Name: "edge-1", LastSeen: f.base, TunnelIP: "100.89.0.1"})
s.st.Sites = append(s.st.Sites, &model.Site{ID: "s1", OrgID: org.ID, Name: "home", LastSeen: f.base, TunnelIP: "100.89.0.2"})
s.st.Mappings = append(s.st.Mappings, &model.Mapping{ID: "m1", OrgID: org.ID, Name: "Minecraft", EdgeID: "e1", Proto: "tcp", Port: 25565, Enabled: true,
Targets: []model.Target{{SiteID: "s1", Host: "192.168.1.30", Port: 25565}}})
s.status["m1"] = model.MappingStatus{Targets: []model.TargetHealth{{Healthy: true}}}
s.st.Channels = append(s.st.Channels, &AlertChannel{ID: "c1", OrgID: org.ID, Name: "hook", Kind: "webhook", Target: f.hook.URL, Format: "json", AllowPrivate: true, Enabled: true})
s.mu.Unlock()
return f
}
// touch keeps the edge and site looking alive at the given fake time.
func (f *fx) touch(at time.Time) {
f.s.mu.Lock()
f.s.st.Edges[0].LastSeen, f.s.st.Sites[0].LastSeen = at, at
f.s.mu.Unlock()
}
func (f *fx) setHealthy(h bool) {
f.s.mu.Lock()
f.s.status["m1"] = model.MappingStatus{Targets: []model.TargetHealth{{Healthy: h, Detail: "connection refused"}}}
f.s.mu.Unlock()
}
func (f *fx) eval(at time.Time) {
f.touch(at)
f.s.evaluateAlerts(at)
}
func TestAlertLifecycleDebounceOnceAndResolve(t *testing.T) {
f := newFx(t)
t0 := f.base
f.eval(t0)
if b := f.hook.next(200 * time.Millisecond); b != "" {
t.Fatalf("a healthy system alerted: %s", b)
}
f.setHealthy(false)
f.eval(t0.Add(10 * time.Second))
f.eval(t0.Add(70 * time.Second))
if b := f.hook.next(200 * time.Millisecond); b != "" {
t.Fatalf("alerted before the debounce time: %s", b)
}
f.eval(t0.Add(135 * time.Second)) // 2 minutes after it began
var note alertNote
b := f.hook.next(2 * time.Second)
if b == "" || json.Unmarshal([]byte(b), &note) != nil || note.Status != "firing" || !strings.Contains(note.Message, "Minecraft") || !strings.Contains(note.Message, "192.168.1.30:25565") {
t.Fatalf("firing notification: %q", b)
}
if note.Org != "Acme" || note.Kind != "target" {
t.Errorf("notification context: %+v", note)
}
// the same problem is announced once, not every evaluation
f.eval(t0.Add(200 * time.Second))
f.eval(t0.Add(10 * time.Minute))
if b := f.hook.next(300 * time.Millisecond); b != "" {
t.Fatalf("a repeat notification arrived with no reminder configured: %s", b)
}
f.setHealthy(true)
f.eval(t0.Add(11 * time.Minute))
b = f.hook.next(2 * time.Second)
if json.Unmarshal([]byte(b), &note) != nil || note.Status != "resolved" || !strings.Contains(note.Message, "Minecraft") {
t.Fatalf("resolved notification: %q", b)
}
f.eval(t0.Add(12 * time.Minute))
if b := f.hook.next(200 * time.Millisecond); b != "" {
t.Fatalf("resolved twice: %s", b)
}
hist := f.s.db.queryAlertEvents(f.org, 10)
if len(hist) != 2 || hist[0].Status != "resolved" || hist[1].Status != "firing" {
t.Errorf("alert history: %+v", hist)
}
}
func TestShortBlipsNeverAlert(t *testing.T) {
f := newFx(t)
t0 := f.base
f.setHealthy(false)
f.eval(t0)
f.eval(t0.Add(60 * time.Second))
f.setHealthy(true) // fixed itself inside the debounce window
f.eval(t0.Add(90 * time.Second))
f.eval(t0.Add(10 * time.Minute))
if b := f.hook.next(300 * time.Millisecond); b != "" {
t.Fatalf("a blip shorter than the debounce time alerted: %s", b)
}
if len(f.s.db.queryAlertEvents(f.org, 10)) != 0 {
t.Error("a blip left history behind")
}
}
func TestReminderRepeatsWhenAsked(t *testing.T) {
f := newFx(t)
f.s.mu.Lock()
f.s.findOrg(f.org).AlertRepeatHours = 1
f.s.mu.Unlock()
t0 := f.base
f.setHealthy(false)
f.eval(t0)
f.eval(t0.Add(3 * time.Minute))
if f.hook.next(2*time.Second) == "" {
t.Fatal("no first alert")
}
f.eval(t0.Add(30 * time.Minute))
if b := f.hook.next(200 * time.Millisecond); b != "" {
t.Fatalf("reminded too early: %s", b)
}
f.eval(t0.Add(64 * time.Minute))
if f.hook.next(2*time.Second) == "" {
t.Fatal("no reminder after an hour")
}
}
func TestDeadEdgeSilencesItsPortsAndRoutesByOrganization(t *testing.T) {
f := newFx(t)
other := newHook(t)
server := newHook(t)
f.s.mu.Lock()
o2, _ := f.s.newOrgLocked("Elsewhere", true)
f.s.st.Channels = append(f.s.st.Channels,
&AlertChannel{ID: "c2", OrgID: o2.ID, Name: "other org", Kind: "webhook", Target: other.URL, Format: "json", AllowPrivate: true, Enabled: true},
&AlertChannel{ID: "c3", OrgID: "", Name: "ops", Kind: "webhook", Target: server.URL, Format: "json", AllowPrivate: true, Enabled: true},
&AlertChannel{ID: "c4", OrgID: f.org, Name: "muted", Kind: "email", Target: "off@x.test", Enabled: false})
f.s.mu.Unlock()
t0 := f.base
// the edge goes silent AND the port's target looks unhealthy: only the edge is reported
f.setHealthy(false)
f.s.evaluateAlerts(t0)
f.s.mu.Lock()
f.s.st.Edges[0].LastSeen = t0.Add(-10 * time.Minute)
f.s.mu.Unlock()
f.s.evaluateAlerts(t0.Add(3 * time.Minute)) // the edge problem starts here...
f.s.mu.Lock()
f.s.st.Sites[0].LastSeen = t0.Add(6 * time.Minute) // (the site itself is fine)
f.s.mu.Unlock()
f.s.evaluateAlerts(t0.Add(6 * time.Minute)) // ...and has lasted past the debounce here
var note alertNote
if b := f.hook.next(2 * time.Second); json.Unmarshal([]byte(b), &note) != nil || note.Kind != "edge" || !strings.Contains(note.Message, "edge-1") {
t.Fatalf("the org's channel should hear about the dead edge: %q", b)
}
if b := f.hook.next(300 * time.Millisecond); b != "" {
t.Errorf("the port's stale target problem should be hidden while its edge is down: %s", b)
}
if b := server.next(2 * time.Second); json.Unmarshal([]byte(b), &note) != nil || note.Org != "server" || note.Kind != "edge" {
t.Errorf("the server-level channel should hear about the edge: %q", b)
}
if b := other.next(300 * time.Millisecond); b != "" {
t.Errorf("another organization's channel was notified: %s", b)
}
select {
case m := <-f.mail:
t.Errorf("a disabled channel sent mail: %s", m)
case <-time.After(200 * time.Millisecond):
}
}
func TestAlertsSurviveARestartWithoutFalseRecovery(t *testing.T) {
dir := t.TempDir()
key := secretbox.GenerateKey()
s, err := New(dir, Options{MasterKey: key, AlertInterval: time.Hour})
if err != nil {
t.Fatal(err)
}
h := newHook(t)
now := time.Now()
s.mu.Lock()
org, _ := s.newOrgLocked("Acme", true)
s.st.Edges = append(s.st.Edges, &model.Edge{ID: "e1", Name: "edge-1", LastSeen: now})
s.st.Sites = append(s.st.Sites, &model.Site{ID: "s1", OrgID: org.ID, Name: "home", LastSeen: now})
s.st.Mappings = append(s.st.Mappings, &model.Mapping{ID: "m1", OrgID: org.ID, Name: "Web", EdgeID: "e1", Proto: "tcp", Port: 80, Enabled: true,
Targets: []model.Target{{SiteID: "s1", Host: "10.0.0.1", Port: 80}}})
s.st.Channels = append(s.st.Channels, &AlertChannel{ID: "c1", OrgID: org.ID, Name: "hook", Kind: "webhook", Target: h.URL, Format: "json", AllowPrivate: true, Enabled: true})
s.status["m1"] = model.MappingStatus{Targets: []model.TargetHealth{{Healthy: false, Detail: "down"}}}
s.mu.Unlock()
s.save()
s.evaluateAlerts(now)
s.mu.Lock()
s.st.Edges[0].LastSeen, s.st.Sites[0].LastSeen = now.Add(3*time.Minute), now.Add(3*time.Minute) // still alive; only the target is down
s.mu.Unlock()
s.evaluateAlerts(now.Add(3 * time.Minute))
if h.next(2*time.Second) == "" {
t.Fatal("no first alert")
}
s.Close()
s2, err := New(dir, Options{MasterKey: key, AlertInterval: time.Hour})
if err != nil {
t.Fatal(err)
}
defer s2.Close()
s2.mu.Lock()
restored := s2.alerts["target:m1:0"]
s2.mu.Unlock()
if restored == nil || !restored.Fired {
t.Fatalf("the firing alert was not restored: %+v", restored)
}
// nothing has reported since the restart: that is NOT a recovery, and not a new alert either
later := now.Add(5 * time.Minute)
s2.mu.Lock()
s2.st.Edges[0].LastSeen, s2.st.Sites[0].LastSeen = later, later
s2.mu.Unlock()
s2.evaluateAlerts(later)
if b := h.next(400 * time.Millisecond); b != "" {
t.Fatalf("restarting produced a notification: %s", b)
}
s2.mu.Lock()
_, still := s2.alerts["target:m1:0"]
s2.mu.Unlock()
if !still {
t.Fatal("the alert vanished before anything re-checked the target")
}
// once the edge reports the target healthy, it really is resolved
s2.mu.Lock()
s2.status["m1"] = model.MappingStatus{Targets: []model.TargetHealth{{Healthy: true}}}
s2.mu.Unlock()
s2.evaluateAlerts(later.Add(30 * time.Second))
var note alertNote
if b := h.next(2 * time.Second); json.Unmarshal([]byte(b), &note) != nil || note.Status != "resolved" {
t.Fatalf("expected a recovery after a real healthy report, got %q", b)
}
}
func TestDeliveryFormatsAndEmail(t *testing.T) {
f := newFx(t)
note := alertNote{Status: "firing", Org: "Acme", Kind: "target", Name: "Minecraft", Message: "Minecraft: the target is down", Since: f.base, Time: f.base}
for _, tc := range []struct {
format string
check func(body string, h http.Header) bool
}{
{"json", func(b string, _ http.Header) bool {
return strings.Contains(b, `"status":"firing"`) && strings.Contains(b, `"org":"Acme"`)
}},
{"slack", func(b string, _ http.Header) bool {
return strings.HasPrefix(b, `{"text":`) && strings.Contains(b, "Minecraft")
}},
{"discord", func(b string, _ http.Header) bool {
return strings.HasPrefix(b, `{"content":`) && strings.Contains(b, "Minecraft")
}},
{"ntfy", func(b string, h http.Header) bool {
return strings.Contains(b, "the target is down") && strings.Contains(h.Get("Title"), "Minecraft") && h.Get("Priority") == "high"
}},
} {
h := newHook(t)
ch := AlertChannel{Kind: "webhook", Target: h.URL, Format: tc.format, AllowPrivate: true}
if err := f.s.send(ch, note); err != nil {
t.Fatalf("%s: %v", tc.format, err)
}
body := h.next(2 * time.Second)
h.mu.Lock()
hdr := h.hdrs[0]
h.mu.Unlock()
if !tc.check(body, hdr) {
t.Errorf("%s body/headers wrong: %q %v", tc.format, body, hdr)
}
}
if err := f.s.send(AlertChannel{Kind: "email", Target: "a@x.test, b@y.test"}, note); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for i := 0; i < 2; i++ {
select {
case m := <-f.mail:
parts := strings.SplitN(m, "|", 3)
got[parts[0]] = strings.Contains(parts[1], "Minecraft") && strings.Contains(parts[2], "the target is down")
case <-time.After(2 * time.Second):
}
}
if !got["a@x.test"] || !got["b@y.test"] {
t.Errorf("email delivery: %v", got)
}
// a webhook answering with an error is reported, not swallowed
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "no", 500) }))
defer bad.Close()
if err := f.s.send(AlertChannel{Kind: "webhook", Target: bad.URL, Format: "json", AllowPrivate: true}, note); err == nil {
t.Error("a 500 from the webhook should be an error")
}
}
func TestWebhooksCannotBeAimedAtInternalAddresses(t *testing.T) {
f := newFx(t)
h := newHook(t) // a loopback server, standing in for an internal service
note := alertNote{Status: "test", Time: f.base, Since: f.base}
for _, target := range []string{h.URL, strings.Replace(h.URL, "127.0.0.1", "localhost", 1), "http://169.254.169.254/latest/meta-data/", "http://10.0.0.5:9/x", "http://[::1]:9/x"} {
err := f.s.send(AlertChannel{Kind: "webhook", Target: target, Format: "json", AllowPrivate: false}, note)
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Errorf("%s: %v (should be refused as internal)", target, err)
}
}
if b := h.next(200 * time.Millisecond); b != "" {
t.Errorf("the internal server received a request: %s", b)
}
// the same URL is fine once a trusted admin allowed private targets
if err := f.s.send(AlertChannel{Kind: "webhook", Target: h.URL, Format: "json", AllowPrivate: true}, note); err != nil {
t.Errorf("allowed private target: %v", err)
}
// a redirect to an internal address is not followed
redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, h.URL, http.StatusFound)
}))
defer redir.Close()
h.next(500 * time.Millisecond)
err := f.s.send(AlertChannel{Kind: "webhook", Target: redir.URL, Format: "json", AllowPrivate: true}, note)
if err == nil {
t.Error("a redirect response must not count as delivered")
}
if b := h.next(300 * time.Millisecond); b != "" {
t.Errorf("the redirect was followed: %s", b)
}
// validation: only trusted callers may set allowPrivate
ch := AlertChannel{Kind: "webhook", Target: "http://192.168.1.9/x", AllowPrivate: true}
if err := f.s.validateChannel(&ch, false); err == nil {
t.Error("an untrusted caller must not be able to allow private targets")
}
if err := f.s.validateChannel(&ch, true); err != nil {
t.Errorf("a trusted caller may: %v", err)
}
for _, bad := range []AlertChannel{{Kind: "webhook", Target: "ftp://x"}, {Kind: "webhook", Target: "not a url"}, {Kind: "email", Target: "nope"}, {Kind: "sms", Target: "1"}, {Kind: "webhook", Target: "http://x.test", Format: "xml"}} {
if err := f.s.validateChannel(&bad, true); err == nil {
t.Errorf("accepted %+v", bad)
}
}
}
func TestAlertAPIHidesWebhookURLsAndMetricsNeedAToken(t *testing.T) {
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MetricsToken: "scrape-me", 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
h := newHook(t)
code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels", map[string]any{"name": "ops", "kind": "webhook", "target": h.URL, "format": "slack", "allowPrivate": true, "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), "secret-token") {
t.Errorf("the overview leaks the webhook URL: %s", raw)
}
var ov struct {
Channels []struct{ ID, Preview string }
}
json.Unmarshal(raw, &ov)
if len(ov.Channels) != 1 || !strings.HasPrefix(ov.Channels[0].Preview, "http://127.0.0.1") {
t.Fatalf("overview: %s", raw)
}
id := ov.Channels[0].ID
// editing without re-entering the URL keeps it
if code, raw := do("PUT", "/api/orgs/"+org+"/alerts/channels/"+id, map[string]any{"name": "ops renamed", "kind": "webhook", "format": "discord", "allowPrivate": true, "enabled": true}); code != 200 {
t.Fatalf("edit: %d %s", code, raw)
}
if code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels/"+id+"/test", nil); code != 200 {
t.Fatalf("test send: %d %s", code, raw)
}
if body := h.next(2 * time.Second); !strings.HasPrefix(body, `{"content":`) {
t.Errorf("the edit kept the old format or lost the URL: %q", body)
}
if code, _ := do("PUT", "/api/orgs/"+org+"/alerts/settings", map[string]int{"debounceMin": 0, "repeatHours": 1}); code != 400 {
t.Error("a zero-minute debounce must be rejected")
}
if code, _ := do("PUT", "/api/orgs/"+org+"/alerts/settings", map[string]int{"debounceMin": 5, "repeatHours": 6}); code != 200 {
t.Error("valid settings rejected")
}
if code, _ := do("DELETE", "/api/orgs/"+org+"/alerts/channels/"+id, nil); code != 200 {
t.Error("delete")
}
// metrics
ts := httptest.NewServer(s.Handler())
defer ts.Close()
get := func(token string) (int, string) {
req, _ := http.NewRequest("GET", ts.URL+"/metrics", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
if code, _ := get(""); code != 401 {
t.Errorf("metrics without a token: %d", code)
}
if code, _ := get("wrong"); code != 401 {
t.Errorf("metrics with a wrong token: %d", code)
}
code, body := get("scrape-me")
if code != 200 || !strings.Contains(body, "# TYPE hammerhead_users gauge") || !strings.Contains(body, "hammerhead_users 1") {
t.Errorf("metrics: %d %s", code, body)
}
off, err := New(t.TempDir(), Options{AdminEmail: "a@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
if err != nil {
t.Fatal(err)
}
defer off.Close()
ts2 := httptest.NewServer(off.Handler())
defer ts2.Close()
if resp, _ := http.Get(ts2.URL + "/metrics"); resp.StatusCode != 404 {
t.Errorf("metrics should not exist unless a token is configured: %d", resp.StatusCode)
}
}
func TestPromEscape(t *testing.T) {
if got := promEscape("a\"b\\c\nd"); got != `a\"b\\c\nd` {
t.Errorf("promEscape = %q", got)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Hammerhead is the control plane: web UI + API that tells blacktip and bamboo what to do.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/dotenv"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
// secrets come from a .env file (or the real environment), read before flags so flags can default to them
envFile := dotenv.ArgPath(os.Args[1:], ".env")
if used, err := dotenv.Load(envFile); err != nil {
log.Fatalf("read %s: %v", envFile, err)
} else if used != "" {
log.Printf("loaded settings from %s", used)
}
listen := flag.String("listen", env("HAMMERHEAD_LISTEN", ":8080"), "address for the web UI and API")
data := flag.String("data", env("HAMMERHEAD_DATA", "./hammerhead-data"), "directory for the database")
url := flag.String("url", env("HAMMERHEAD_URL", ""), "public URL people reach hammerhead at (used for login redirects and SSO); can also be set in Settings")
flag.String("env", ".env", "file to read secrets and settings from")
genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit")
flag.Parse()
if *genkey {
fmt.Println(secretbox.GenerateKey())
return
}
days, _ := strconv.Atoi(os.Getenv("HAMMERHEAD_LOG_DAYS"))
srv, err := hammerhead.New(*data, hammerhead.Options{
AdminEmail: os.Getenv("HAMMERHEAD_ADMIN_EMAIL"),
AdminPassword: os.Getenv("HAMMERHEAD_ADMIN_PASSWORD"),
DashboardURL: *url,
MasterKey: os.Getenv("HAMMERHEAD_MASTER_KEY"),
MetricsToken: os.Getenv("HAMMERHEAD_METRICS_TOKEN"),
LogRetentionDays: days,
})
if err != nil {
log.Fatal(err)
}
defer srv.Close()
log.Printf("hammerhead listening on %s (open it in a browser to finish setup)", *listen)
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(hs.ListenAndServe())
}
+663
View File
@@ -0,0 +1,663 @@
package hammerhead
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model"
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
)
// The configuration is held in memory (it is small and read constantly) and
// persisted to SQLite as one row per object, written incrementally in a
// transaction: only objects that changed since the last save are touched.
// Secrets are encrypted before they reach the file. Logs, traffic history and
// alerts are real tables with indexes, so they can be searched and pruned.
const schema = `
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS entities (
kind TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL,
PRIMARY KEY (kind, id)
);
CREATE TABLE IF NOT EXISTS access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, org_id TEXT NOT NULL DEFAULT '', mapping TEXT NOT NULL DEFAULT '',
remote TEXT, method TEXT, host TEXT, path TEXT, status INTEGER, usr TEXT, result TEXT,
bytes_in INTEGER, bytes_out INTEGER, ms INTEGER
);
CREATE INDEX IF NOT EXISTS access_org ON access_log (org_id, id);
CREATE INDEX IF NOT EXISTS access_map ON access_log (org_id, mapping, id);
CREATE INDEX IF NOT EXISTS access_ts ON access_log (ts);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, org_id TEXT NOT NULL DEFAULT '',
actor TEXT, action TEXT, detail TEXT
);
CREATE INDEX IF NOT EXISTS audit_org ON audit_log (org_id, id);
CREATE TABLE IF NOT EXISTS traffic_hourly (
mapping TEXT NOT NULL, hour INTEGER NOT NULL, conns INTEGER NOT NULL DEFAULT 0,
bytes_in INTEGER NOT NULL DEFAULT 0, bytes_out INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (mapping, hour)
);
CREATE TABLE IF NOT EXISTS alerts (
key TEXT PRIMARY KEY, org_id TEXT NOT NULL DEFAULT '', kind TEXT, name TEXT, message TEXT,
since INTEGER NOT NULL, fired INTEGER NOT NULL DEFAULT 0, notified INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS alert_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, org_id TEXT NOT NULL DEFAULT '',
key TEXT, status TEXT, name TEXT, message TEXT
);
CREATE INDEX IF NOT EXISTS alert_events_org ON alert_events (org_id, id);
`
type DB struct {
sql *sql.DB
box *secretbox.Box
mu sync.Mutex // serialises persist
cache map[string]map[string]string
ver string
}
func openDB(path string, box *secretbox.Box) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)")
if err != nil {
return nil, err
}
conn.SetMaxOpenConns(8)
if _, err := conn.Exec(schema); err != nil {
conn.Close()
return nil, fmt.Errorf("prepare database: %w", err)
}
return &DB{sql: conn, box: box, cache: map[string]map[string]string{}}, nil
}
func (d *DB) Close() error { return d.sql.Close() }
func ms(t time.Time) int64 { return t.UnixMilli() }
// ---------- sealing of secret fields ----------
// sealed returns a copy of v with its secret fields encrypted.
func (d *DB) sealed(v any) any {
b := d.box
switch x := v.(type) {
case *model.Edge:
c := *x
c.Token, c.AuthKey = b.Seal(c.Token), b.Seal(c.AuthKey)
return &c
case *model.Site:
c := *x
c.Token = b.Seal(c.Token)
return &c
case *User:
c := *x
c.TOTPSecret = b.Seal(c.TOTPSecret)
return &c
case *IdP:
c := *x
c.ClientSecret = b.Seal(c.ClientSecret)
return &c
case *model.MailEgress:
c := *x
c.Password = b.Seal(c.Password)
return &c
case *model.DKIMKey:
c := *x
c.PrivateKey = b.Seal(c.PrivateKey)
return &c
case *AlertChannel:
c := *x
c.Target = b.Seal(c.Target)
return &c
case Settings:
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
return x
}
return v
}
func (d *DB) open1(s *string) error {
v, err := d.box.Open(*s)
*s = v
return err
}
// unseal decrypts the secret fields of a loaded object in place.
func (d *DB) unseal(v any) error {
switch x := v.(type) {
case *model.Edge:
return errors.Join(d.open1(&x.Token), d.open1(&x.AuthKey))
case *model.Site:
return d.open1(&x.Token)
case *User:
return d.open1(&x.TOTPSecret)
case *IdP:
return d.open1(&x.ClientSecret)
case *model.MailEgress:
return d.open1(&x.Password)
case *model.DKIMKey:
return d.open1(&x.PrivateKey)
case *AlertChannel:
return d.open1(&x.Target)
case *Settings:
return d.open1(&x.SMTP.Pass)
}
return nil
}
// ---------- configuration ----------
type ent struct {
kind, id string
v any
}
func collect(st *state) []ent {
var out []ent
add := func(kind, id string, v any) { out = append(out, ent{kind, id, v}) }
add("settings", "main", st.Settings)
for _, x := range st.Users {
add("user", x.ID, x)
}
for _, x := range st.Orgs {
add("org", x.ID, x)
}
for _, x := range st.Roles {
add("role", x.ID, x)
}
for _, x := range st.Members {
add("member", x.UserID+"|"+x.OrgID, x)
}
for _, x := range st.Invites {
add("invite", x.ID, x)
}
for _, x := range st.APIKeys {
add("apikey", x.ID, x)
}
for _, x := range st.IdPs {
add("idp", x.ID, x)
}
for _, x := range st.Edges {
add("edge", x.ID, x)
}
for _, x := range st.Sites {
add("site", x.ID, x)
}
for _, x := range st.Domains {
add("domain", x.ID, x)
}
for _, x := range st.Mappings {
add("mapping", x.ID, x)
}
for _, x := range st.MailEgress {
add("mailegress", x.ID, x)
}
for _, x := range st.DKIM {
add("dkim", x.ID, x)
}
for _, x := range st.Channels {
add("channel", x.ID, x)
}
for _, x := range st.Shares {
add("share", x.ID, x)
}
for k, x := range st.Secrets {
add("secret", k, x)
}
for k, x := range st.Totals {
add("total", k, x)
}
for k, x := range st.Sessions {
add("session", k, x)
}
return out
}
// persist writes whatever changed since the last call, in one transaction.
func (d *DB) persist(st *state) error {
d.mu.Lock()
defer d.mu.Unlock()
ents := collect(st)
type change struct {
e ent
plain string
}
var changes []change
seen := map[string]map[string]bool{}
for _, e := range ents {
b, err := json.Marshal(e.v)
if err != nil {
return err
}
if seen[e.kind] == nil {
seen[e.kind] = map[string]bool{}
}
seen[e.kind][e.id] = true
if d.cache[e.kind][e.id] != string(b) {
changes = append(changes, change{e, string(b)})
}
}
type gone struct{ kind, id string }
var removed []gone
for kind, ids := range d.cache {
for id := range ids {
if !seen[kind][id] {
removed = append(removed, gone{kind, id})
}
}
}
ver := fmt.Sprint(st.Version)
if len(changes) == 0 && len(removed) == 0 && ver == d.ver {
return nil
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, c := range changes {
sealed, err := json.Marshal(d.sealed(c.e.v))
if err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO entities (kind, id, data) VALUES (?, ?, ?) ON CONFLICT (kind, id) DO UPDATE SET data = excluded.data`,
c.e.kind, c.e.id, string(sealed)); err != nil {
return err
}
}
for _, g := range removed {
if _, err := tx.Exec(`DELETE FROM entities WHERE kind = ? AND id = ?`, g.kind, g.id); err != nil {
return err
}
}
if ver != d.ver {
if _, err := tx.Exec(`INSERT INTO meta (k, v) VALUES ('version', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v`, ver); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
for _, c := range changes {
if d.cache[c.e.kind] == nil {
d.cache[c.e.kind] = map[string]string{}
}
d.cache[c.e.kind][c.e.id] = c.plain
}
for _, g := range removed {
delete(d.cache[g.kind], g.id)
}
d.ver = ver
return nil
}
// load reads every stored object into st. It reports whether the database had any.
func (d *DB) load(st *state) (bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
rows, err := d.sql.Query(`SELECT kind, id, data FROM entities`)
if err != nil {
return false, err
}
defer rows.Close()
any1 := false
for rows.Next() {
var kind, id, data string
if err := rows.Scan(&kind, &id, &data); err != nil {
return false, err
}
any1 = true
var target any
switch kind {
case "settings":
target = &st.Settings
case "user":
target = new(User)
case "org":
target = new(Org)
case "role":
target = new(Role)
case "member":
target = new(Member)
case "invite":
target = new(Invite)
case "apikey":
target = new(APIKey)
case "idp":
target = new(IdP)
case "edge":
target = new(model.Edge)
case "site":
target = new(model.Site)
case "domain":
target = new(model.Domain)
case "mapping":
target = new(model.Mapping)
case "mailegress":
target = new(model.MailEgress)
case "dkim":
target = new(model.DKIMKey)
case "channel":
target = new(AlertChannel)
case "share":
target = new(ShareLink)
case "secret":
target = new(Secret)
case "total":
target = new(Total)
case "session":
target = new(Session)
default:
continue
}
if err := json.Unmarshal([]byte(data), target); err != nil {
return false, fmt.Errorf("read %s %s: %w", kind, id, err)
}
if err := d.unseal(target); err != nil {
return false, fmt.Errorf("%s %s: %w", kind, id, err)
}
switch x := target.(type) {
case *User:
st.Users = append(st.Users, x)
case *Org:
st.Orgs = append(st.Orgs, x)
case *Role:
st.Roles = append(st.Roles, x)
case *Member:
st.Members = append(st.Members, x)
case *Invite:
st.Invites = append(st.Invites, x)
case *APIKey:
st.APIKeys = append(st.APIKeys, x)
case *IdP:
st.IdPs = append(st.IdPs, x)
case *model.Edge:
st.Edges = append(st.Edges, x)
case *model.Site:
st.Sites = append(st.Sites, x)
case *model.Domain:
st.Domains = append(st.Domains, x)
case *model.Mapping:
st.Mappings = append(st.Mappings, x)
case *model.MailEgress:
st.MailEgress = append(st.MailEgress, x)
case *model.DKIMKey:
st.DKIM = append(st.DKIM, x)
case *AlertChannel:
st.Channels = append(st.Channels, x)
case *ShareLink:
st.Shares = append(st.Shares, x)
case *Secret:
st.Secrets[id] = x
case *Total:
st.Totals[id] = x
case *Session:
st.Sessions[id] = x
}
}
if err := rows.Err(); err != nil {
return false, err
}
var v string
if d.sql.QueryRow(`SELECT v FROM meta WHERE k = 'version'`).Scan(&v) == nil {
fmt.Sscan(v, &st.Version)
}
// remember exactly what is stored, so the next persist only writes differences
d.cache = map[string]map[string]string{}
for _, e := range collect(st) {
b, _ := json.Marshal(e.v)
if d.cache[e.kind] == nil {
d.cache[e.kind] = map[string]string{}
}
d.cache[e.kind][e.id] = string(b)
}
d.ver = fmt.Sprint(st.Version)
return any1, nil
}
// ---------- access log ----------
type accessFilter struct {
Org, Mapping, Q, Result string
StatusClass int // 2, 3, 4 or 5; 0 = any
Before int64
Limit int
}
func likeEscape(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
func (d *DB) addAccess(es []model.LogEntry) error {
if len(es) == 0 {
return nil
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
st, err := tx.Prepare(`INSERT INTO access_log (ts, org_id, mapping, remote, method, host, path, status, usr, result, bytes_in, bytes_out, ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer st.Close()
for _, e := range es {
if e.Time.IsZero() {
e.Time = time.Now()
}
if _, err := st.Exec(ms(e.Time), e.OrgID, e.Mapping, e.Remote, e.Method, e.Host, e.Path, e.Status, e.User, e.Result, e.BytesIn, e.BytesOut, e.Millis); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) queryAccess(f accessFilter) []model.LogEntry {
q := `SELECT id, ts, mapping, remote, method, host, path, status, usr, result, bytes_in, bytes_out, ms FROM access_log WHERE org_id = ?`
args := []any{f.Org}
if f.Mapping != "" {
q += ` AND mapping = ?`
args = append(args, f.Mapping)
}
if f.Q != "" {
l := "%" + likeEscape(f.Q) + "%"
q += ` AND (host LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\' OR remote LIKE ? ESCAPE '\' OR usr LIKE ? ESCAPE '\')`
args = append(args, l, l, l, l)
}
if f.StatusClass >= 2 && f.StatusClass <= 5 {
q += ` AND status >= ? AND status < ?`
args = append(args, f.StatusClass*100, f.StatusClass*100+100)
}
if f.Result != "" {
q += ` AND result LIKE ? ESCAPE '\'`
args = append(args, likeEscape(f.Result)+"%")
}
if f.Before > 0 {
q += ` AND id < ?`
args = append(args, f.Before)
}
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
}
q += ` ORDER BY id DESC LIMIT ?`
args = append(args, f.Limit)
rows, err := d.sql.Query(q, args...)
out := []model.LogEntry{}
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e model.LogEntry
var ts int64
var remote, method, host, path, usr, result sql.NullString
var status sql.NullInt64
if rows.Scan(&e.ID, &ts, &e.Mapping, &remote, &method, &host, &path, &status, &usr, &result, &e.BytesIn, &e.BytesOut, &e.Millis) != nil {
continue
}
e.Time, e.OrgID = time.UnixMilli(ts), f.Org
e.Remote, e.Method, e.Host, e.Path, e.User, e.Result = remote.String, method.String, host.String, path.String, usr.String, result.String
e.Status = int(status.Int64)
out = append(out, e)
}
return out
}
// ---------- audit log ----------
func (d *DB) addAudit(e AuditEntry) {
d.sql.Exec(`INSERT INTO audit_log (ts, org_id, actor, action, detail) VALUES (?, ?, ?, ?, ?)`, ms(e.Time), e.OrgID, e.Actor, e.Action, e.Detail)
}
func (d *DB) queryAudit(org string, limit int, before int64) []AuditEntry {
if limit <= 0 || limit > 1000 {
limit = 200
}
q, args := `SELECT id, ts, actor, action, detail FROM audit_log WHERE org_id = ?`, []any{org}
if before > 0 {
q += ` AND id < ?`
args = append(args, before)
}
rows, err := d.sql.Query(q+` ORDER BY id DESC LIMIT ?`, append(args, limit)...)
out := []AuditEntry{}
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e AuditEntry
var ts int64
var actor, action, detail sql.NullString
if rows.Scan(&e.ID, &ts, &actor, &action, &detail) != nil {
continue
}
e.Time, e.OrgID, e.Actor, e.Action, e.Detail = time.UnixMilli(ts), org, actor.String, action.String, detail.String
out = append(out, e)
}
return out
}
// purge deletes logs older than the retention periods.
func (d *DB) purge(accessDays, auditDays int) {
if accessDays > 0 {
d.sql.Exec(`DELETE FROM access_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -accessDays)))
}
if auditDays > 0 {
d.sql.Exec(`DELETE FROM audit_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -auditDays)))
}
d.sql.Exec(`DELETE FROM traffic_hourly WHERE hour < ?`, time.Now().Add(-31*24*time.Hour).Unix()/3600)
d.sql.Exec(`DELETE FROM alert_events WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -90)))
}
// ---------- traffic history ----------
func (d *DB) addTraffic(mapping string, st model.Stat) {
d.sql.Exec(`INSERT INTO traffic_hourly (mapping, hour, conns, bytes_in, bytes_out) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (mapping, hour) DO UPDATE SET conns = conns + excluded.conns, bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out`,
mapping, time.Now().Unix()/3600, st.Conns, st.BytesIn, st.BytesOut)
}
// trafficHistory returns bytes (in + out) per hour for each mapping, oldest first.
func (d *DB) trafficHistory(ids []string, hours int) map[string][]int64 {
out := map[string][]int64{}
if hours <= 0 || hours > 24*30 {
hours = 24
}
now := time.Now().Unix() / 3600
for _, id := range ids {
out[id] = make([]int64, hours)
}
rows, err := d.sql.Query(`SELECT mapping, hour, bytes_in + bytes_out FROM traffic_hourly WHERE hour > ?`, now-int64(hours))
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var id string
var h, n int64
if rows.Scan(&id, &h, &n) != nil {
continue
}
if series, ok := out[id]; ok {
if i := int(h - (now - int64(hours) + 1)); i >= 0 && i < hours {
series[i] += n
}
}
}
return out
}
// ---------- alerts ----------
func (d *DB) saveAlert(a *alertState) {
fired := 0
if a.Fired {
fired = 1
}
d.sql.Exec(`INSERT INTO alerts (key, org_id, kind, name, message, since, fired, notified) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (key) DO UPDATE SET name = excluded.name, message = excluded.message, fired = excluded.fired, notified = excluded.notified`,
a.Key, a.OrgID, a.Kind, a.Name, a.Message, ms(a.Since), fired, ms(a.Notified))
}
func (d *DB) deleteAlert(key string) { d.sql.Exec(`DELETE FROM alerts WHERE key = ?`, key) }
func (d *DB) loadAlertRows() []alertState {
rows, err := d.sql.Query(`SELECT key, org_id, kind, name, message, since, fired, notified FROM alerts`)
if err != nil {
return nil
}
defer rows.Close()
var out []alertState
for rows.Next() {
var a alertState
var since, notified, fired int64
var kind, name, msg sql.NullString
if rows.Scan(&a.Key, &a.OrgID, &kind, &name, &msg, &since, &fired, &notified) != nil {
continue
}
a.Kind, a.Name, a.Message = kind.String, name.String, msg.String
a.Since, a.Fired, a.Notified = time.UnixMilli(since), fired == 1, time.UnixMilli(notified)
out = append(out, a)
}
return out
}
type alertEvent struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Status string `json:"status"`
Name string `json:"name"`
Message string `json:"message"`
}
func (d *DB) addAlertEvent(t time.Time, org, key, status, name, msg string) {
d.sql.Exec(`INSERT INTO alert_events (ts, org_id, key, status, name, message) VALUES (?, ?, ?, ?, ?, ?)`, ms(t), org, key, status, name, msg)
}
func (d *DB) queryAlertEvents(org string, limit int) []alertEvent {
out := []alertEvent{}
rows, err := d.sql.Query(`SELECT id, ts, status, name, message FROM alert_events WHERE org_id = ? ORDER BY id DESC LIMIT ?`, org, limit)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e alertEvent
var ts int64
var status, name, msg sql.NullString
if rows.Scan(&e.ID, &ts, &status, &name, &msg) != nil {
continue
}
e.Time, e.Status, e.Name, e.Message = time.UnixMilli(ts), status.String, name.String, msg.String
out = append(out, e)
}
return out
}
+231
View File
@@ -0,0 +1,231 @@
package hammerhead
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model"
)
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar}
do = func(method, path string, in any) (int, []byte) {
var body bytes.Buffer
if in != nil {
json.NewEncoder(&body).Encode(in)
}
req, _ := http.NewRequest(method, ts.URL+path, &body)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out bytes.Buffer
out.ReadFrom(resp.Body)
return resp.StatusCode, out.Bytes()
}
if code, b := do("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}); code != 200 {
t.Fatalf("login: %d %s", code, b)
}
return do
}
func fileHas(t *testing.T, dir, needle string) bool {
found := false
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && strings.HasPrefix(filepath.Base(p), "hammerhead.db") {
if b, _ := os.ReadFile(p); bytes.Contains(b, []byte(needle)) {
found = true
}
}
return nil
})
return found
}
func TestPersistsAcrossRestartsAndEncryptsSecrets(t *testing.T) {
dir := t.TempDir()
key := secretbox.GenerateKey()
opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key}
s, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
do := apiClient(t, s)
code, b := do("POST", "/api/edges", map[string]any{"name": "edge-one", "tunnelAddr": "203.0.113.5:51820"})
if code != 200 {
t.Fatalf("edge: %d %s", code, b)
}
var edge struct{ ID, Token string }
json.Unmarshal(b, &edge)
if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "http://x.test", "smtp": map[string]any{"host": "smtp.test", "pass": "smtp-secret-pw-31337", "mode": "none"}}); code != 200 {
t.Fatalf("settings: %d %s", code, b)
}
s.Close()
// nothing sensitive is readable in the database file
for _, secret := range []string{edge.Token, "smtp-secret-pw-31337"} {
if secret == "" || fileHas(t, dir, secret) {
t.Errorf("secret %q is readable in the database file", secret)
}
}
// reopening with the same key restores everything, secrets included
s2, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
do = apiClient(t, s2)
_, b = do("GET", "/api/edges", nil)
var edges []struct{ ID, Name, Token string }
json.Unmarshal(b, &edges)
if len(edges) != 1 || edges[0].Name != "edge-one" || edges[0].Token != edge.Token {
t.Errorf("edge after restart: %+v (want token %s)", edges, edge.Token)
}
_, b = do("GET", "/api/admin/settings", nil)
if !strings.Contains(string(b), `"smtpPassSet":true`) {
t.Errorf("smtp password lost after restart: %s", b)
}
s2.mu.Lock()
pw := s2.st.Settings.SMTP.Pass
s2.mu.Unlock()
if pw != "smtp-secret-pw-31337" {
t.Errorf("decrypted smtp password = %q", pw)
}
s2.Close()
// the wrong key is a loud failure, not silent garbage
if _, err := New(dir, Options{MasterKey: secretbox.GenerateKey()}); err == nil || !strings.Contains(err.Error(), "decrypt") {
t.Errorf("opening with the wrong key: %v", err)
}
}
func TestGeneratedKeyFileKeepsWorking(t *testing.T) {
dir := t.TempDir()
s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123"})
if err != nil {
t.Fatal(err)
}
apiClient(t, s)("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
s.Close()
if _, err := os.Stat(filepath.Join(dir, "master.key")); err != nil {
t.Fatalf("no key file was generated: %v", err)
}
s2, err := New(dir, Options{})
if err != nil {
t.Fatalf("reopen with the generated key: %v", err)
}
s2.Close()
}
func TestMigratesALegacyStateFile(t *testing.T) {
dir := t.TempDir()
legacy := map[string]any{
"version": 7,
"users": []any{map[string]any{"id": "u1", "email": "old@test.local", "name": "Old", "serverAdmin": true, "created": time.Now()}},
"orgs": []any{map[string]any{"id": "o1", "name": "Legacy org", "allowLocal": true}},
"edges": []any{map[string]any{"id": "e1", "name": "old-edge", "token": "legacy-plain-token-abc", "authKey": "k", "tunnelAddr": "1.2.3.4:51820", "tunnelIp": "100.89.0.1", "ips": []string{}}},
}
b, _ := json.Marshal(legacy)
os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600)
os.MkdirAll(filepath.Join(dir, "logs"), 0o700)
os.WriteFile(filepath.Join(dir, "logs", "access.jsonl"), []byte(`{"time":"`+time.Now().UTC().Format(time.RFC3339)+`","mapping":"m1","orgId":"o1","remote":"9.9.9.9","method":"GET","host":"h","path":"/x","status":200}`+"\n"), 0o600)
s, err := New(dir, Options{MasterKey: secretbox.GenerateKey()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.mu.Lock()
ok := len(s.st.Users) == 1 && s.st.Users[0].Email == "old@test.local" && len(s.st.Orgs) == 1 && len(s.st.Edges) == 1 && s.st.Edges[0].Token == "legacy-plain-token-abc" && s.st.Version >= 7
s.mu.Unlock()
if !ok {
t.Fatal("legacy state was not imported")
}
if _, err := os.Stat(filepath.Join(dir, "state.json.migrated")); err != nil {
t.Error("the old file should be kept aside as state.json.migrated")
}
if got := s.logs.queryAccess(accessFilter{Org: "o1"}); len(got) != 1 || got[0].Remote != "9.9.9.9" {
t.Errorf("legacy access log not imported: %+v", got)
}
s.Close()
if fileHas(t, dir, "legacy-plain-token-abc") {
t.Error("the imported plaintext token was stored unencrypted")
}
}
func TestLogSearchPaginationAndRetention(t *testing.T) {
s, err := New(t.TempDir(), Options{MasterKey: secretbox.GenerateKey()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
var batch []model.LogEntry
for i := 0; i < 250; i++ {
status := 200
if i%10 == 0 {
status = 404
}
batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: fmt.Sprintf("m%d", i%2), Remote: "1.1.1.1", Method: "GET",
Host: "site.test", Path: fmt.Sprintf("/page/%d", i), Status: status, Result: "allowed", User: "bob"})
}
batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: "m0", Path: "/100%_sure", Status: 200, Result: "denied", Host: "site.test"},
model.LogEntry{Time: time.Now(), OrgID: "o2", Mapping: "m9", Path: "/other-org", Status: 200})
s.logs.addAccess(batch)
page1 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100})
if len(page1) != 100 {
t.Fatalf("page 1 has %d entries", len(page1))
}
page2 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100, Before: page1[len(page1)-1].ID})
if len(page2) != 100 || page2[0].ID >= page1[len(page1)-1].ID {
t.Errorf("pagination is wrong: page2=%d", len(page2))
}
if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, StatusClass: 4})); n != 25 {
t.Errorf("status class 4xx matched %d, want 25", n)
}
if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, Mapping: "m1"})); n != 125 {
t.Errorf("mapping filter matched %d, want 125", n)
}
if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "100%_sure"}); len(got) != 1 {
t.Errorf("a search containing LIKE wildcards must match literally, got %d", len(got))
}
if got := s.logs.queryAccess(accessFilter{Org: "o1", Result: "denied"}); len(got) != 1 {
t.Errorf("result filter: %d", len(got))
}
for _, e := range s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000}) {
if e.OrgID != "o1" || strings.Contains(e.Path, "other-org") {
t.Fatal("another organization's log entry leaked")
}
}
// retention
s.db.sql.Exec(`UPDATE access_log SET ts = ? WHERE path = '/page/3'`, time.Now().AddDate(0, 0, -60).UnixMilli())
s.db.purge(30, 365)
if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "/page/3", Limit: 1000}); len(got) != 0 && got[0].Path == "/page/3" {
t.Error("old entries should have been purged")
}
// traffic history
s.db.addTraffic("m0", model.Stat{Conns: 2, BytesIn: 100, BytesOut: 900})
s.db.addTraffic("m0", model.Stat{Conns: 1, BytesIn: 50, BytesOut: 50})
h := s.db.trafficHistory([]string{"m0", "m1"}, 24)
if len(h["m0"]) != 24 || h["m0"][23] != 1100 || h["m1"][23] != 0 {
t.Errorf("traffic history: %v", h["m0"])
}
}
+145
View File
@@ -0,0 +1,145 @@
package hammerhead
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"net/http"
"regexp"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
// DKIM: Hammerhead makes a signing key for a sending domain, gives the edge the
// private half, and shows you the public half as the DNS record to publish. The
// edge signs mail on its way out; receiving servers fetch the record to check it.
var selectorRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$`)
func (k *dkimView) record() string { return "v=DKIM1; k=rsa; p=" + k.PublicKey }
type dkimView struct {
ID string `json:"id"`
Domain string `json:"domain"`
Selector string `json:"selector"`
PublicKey string `json:"publicKey"`
Created time.Time `json:"created"`
RecordName string `json:"recordName"`
Record string `json:"record"`
Status string `json:"status"` // published, missing, mismatch or unknown
}
func (s *Server) listDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []dkimView{}
for _, k := range s.st.DKIM {
if k.OrgID != c.Org.ID {
continue
}
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
for _, x := range s.st.MailEgress {
if x.OrgID != c.Org.ID {
continue
}
switch st := s.mailChecks[x.ID].DKIM[k.ID]; {
case st == "published":
v.Status = "published"
case st != "" && v.Status != "published":
v.Status = st
}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain+out[i].Selector < out[j].Domain+out[j].Selector })
writeJSON(w, 200, out)
}
func (s *Server) createDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Domain, Selector string }
if !decode(w, r, &in) {
return
}
in.Domain = strings.ToLower(strings.TrimSpace(in.Domain))
in.Selector = strings.ToLower(strings.TrimSpace(in.Selector))
if in.Selector == "" {
in.Selector = "hh" + time.Now().Format("200601")
}
if !validHostname(in.Domain) || strings.HasPrefix(in.Domain, "*.") {
fail(w, 400, errors.New("enter the domain you send mail from, like example.com"))
return
}
if !selectorRE.MatchString(in.Selector) {
fail(w, 400, errors.New("a selector is letters, digits and dashes, like mail2026"))
return
}
key, err := rsa.GenerateKey(rand.Reader, 2048) // RSA: every receiving server can verify it
if err != nil {
fail(w, 500, err)
return
}
priv, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
fail(w, 500, err)
return
}
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
fail(w, 500, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, k := range s.st.DKIM {
if k.Domain == in.Domain && k.Selector == in.Selector {
fail(w, 400, errors.New("you already have a key with that selector for this domain; pick a new selector to rotate"))
return
}
}
k := &model.DKIMKey{ID: newID(), OrgID: c.Org.ID, Domain: in.Domain, Selector: in.Selector, Created: time.Now(),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})), PublicKey: base64.StdEncoding.EncodeToString(pub)}
s.st.DKIM = append(s.st.DKIM, k)
s.bump() // edges need the new signer
s.audit(c.Org.ID, c.Actor, "dkim.create", in.Selector+"._domainkey."+in.Domain)
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
writeJSON(w, 200, v)
}
func (s *Server) deleteDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
var gone *model.DKIMKey
for _, k := range s.st.DKIM {
if k.ID == id && k.OrgID == c.Org.ID {
gone = k
}
}
if gone == nil {
fail(w, 404, errors.New("no such key"))
return
}
s.st.DKIM = filter(s.st.DKIM, func(k *model.DKIMKey) bool { return k.ID != id })
s.bump()
s.audit(c.Org.ID, c.Actor, "dkim.delete", gone.Selector+"._domainkey."+gone.Domain)
ok(w)
}
// signersLocked lists the signing keys an org's mail egress may use. Caller holds s.mu.
func (s *Server) signersLocked(orgID string) []model.DKIMSigner {
out := []model.DKIMSigner{}
for _, k := range s.st.DKIM {
if k.OrgID == orgID {
out = append(out, model.DKIMSigner{KeyID: k.ID, Domain: k.Domain, Selector: k.Selector, PrivateKey: k.PrivateKey, PublicKey: k.PublicKey})
}
}
return out
}
+941
View File
@@ -0,0 +1,941 @@
// Package e2e runs hammerhead, blacktip and bamboo together in-process.
package e2e
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base32"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/reef/model"
)
// ---------- helpers ----------
func freeUDPPort(t *testing.T) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
return pc.LocalAddr().(*net.UDPAddr).Port
}
func freePort(t *testing.T) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// tcpBackend answers every connection with "<tag>:<what you sent>".
func tcpBackend(t *testing.T, tag string) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
buf := make([]byte, 4096)
n, _ := c.Read(buf)
fmt.Fprintf(c, "%s:%s", tag, buf[:n])
}()
}
}()
return l.Addr().(*net.TCPAddr).Port
}
func udpBackend(t *testing.T, tag string) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 4096)
for {
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return
}
pc.WriteTo([]byte(tag+":"+string(buf[:n])), addr)
}
}()
return pc.LocalAddr().(*net.UDPAddr).Port
}
// webBackend echoes what it saw as JSON; /healthz returns healthStatus.
func webBackend(t *testing.T, name string, healthStatus int) int {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(healthStatus)
return
}
json.NewEncoder(w).Encode(map[string]string{
"backend": name, "host": r.Host, "path": r.URL.Path,
"user": r.Header.Get("X-Hammerhead-User"), "xff": r.Header.Get("X-Forwarded-For"),
"proto": r.Header.Get("X-Forwarded-Proto"), "cookie": r.Header.Get("Cookie"),
})
}))
t.Cleanup(srv.Close)
return srv.Listener.Addr().(*net.TCPAddr).Port
}
type client struct {
t *testing.T
c *http.Client
u string
key string
}
func newClient(t *testing.T, base string) *client {
jar, _ := cookiejar.New(nil)
return &client{t: t, c: &http.Client{Jar: jar, Timeout: 15 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, u: base}
}
func (a *client) do(method, path string, in, out any) (int, string) {
a.t.Helper()
var body io.Reader
if in != nil {
b, _ := json.Marshal(in)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, a.u+path, body)
req.Header.Set("Content-Type", "application/json")
if a.key != "" {
req.Header.Set("Authorization", "Bearer "+a.key)
}
resp, err := a.c.Do(req)
if err != nil {
a.t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if out != nil && resp.StatusCode == 200 {
if err := json.Unmarshal(raw, out); err != nil {
a.t.Fatalf("%s %s: bad json %q: %v", method, path, raw, err)
}
}
return resp.StatusCode, string(raw)
}
func (a *client) must(method, path string, in, out any) {
a.t.Helper()
if code, raw := a.do(method, path, in, out); code != 200 {
a.t.Fatalf("%s %s -> %d %s", method, path, code, raw)
}
}
func (a *client) expect(code int, method, path string, in any) string {
a.t.Helper()
got, raw := a.do(method, path, in, nil)
if got != code {
a.t.Fatalf("%s %s -> %d (want %d): %s", method, path, got, code, raw)
}
return raw
}
func tcpAsk(t *testing.T, addr, msg string) string {
t.Helper()
var last error
for i := 0; i < 40; i++ {
c, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
c.SetDeadline(time.Now().Add(3 * time.Second))
fmt.Fprint(c, msg)
b, _ := io.ReadAll(c)
c.Close()
if len(b) > 0 {
return string(b)
}
last = fmt.Errorf("empty reply")
} else {
last = err
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("tcp %s: %v", addr, last)
return ""
}
func udpAsk(t *testing.T, addr, msg string) string {
t.Helper()
var last error
for i := 0; i < 40; i++ {
c, err := net.Dial("udp", addr)
if err != nil {
t.Fatal(err)
}
c.SetDeadline(time.Now().Add(400 * time.Millisecond))
c.Write([]byte(msg))
buf := make([]byte, 4096)
n, err := c.Read(buf)
c.Close()
if err == nil {
return string(buf[:n])
}
last = err
}
t.Fatalf("udp %s: %v", addr, last)
return ""
}
func totpNow(secret string) string {
key, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
var b [8]byte
binary.BigEndian.PutUint64(b[:], uint64(time.Now().Unix()/30))
m := hmac.New(sha1.New, key)
m.Write(b[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0xf
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(sum[off:off+4])&0x7fffffff)%1000000)
}
func selfSigned(t *testing.T, host string) *tls.Certificate {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: host}, DNSNames: []string{host},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// ---------- the environment ----------
type world struct {
t *testing.T
ts *httptest.Server
hh *hammerhead.Server
admin *client
org string
edge string
edgeTok string
site string // remote site
local string // built-in local site
mails chan string
mu sync.Mutex
}
func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Config)) *world {
w := &world{t: t, mails: make(chan string, 16)}
var handler http.Handler
w.ts = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { handler.ServeHTTP(rw, r) }))
t.Cleanup(w.ts.Close)
hh, err := hammerhead.New(t.TempDir(), hammerhead.Options{AdminEmail: "admin@test.local", AdminPassword: "password123", DashboardURL: w.ts.URL})
if err != nil {
t.Fatal(err)
}
t.Cleanup(hh.Close)
hh.Mailer = func(to, subject, body string) error { w.mails <- to + "|" + subject + "|" + body; return nil }
handler = hh.Handler()
w.hh = hh
w.admin = newClient(t, w.ts.URL)
w.admin.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
var me struct{ Orgs []struct{ ID string } }
w.admin.must("GET", "/api/me", nil, &me)
w.org = me.Orgs[0].ID
tunnelPort := freeUDPPort(t)
var edge struct{ ID, Token string }
w.admin.must("POST", "/api/edges", map[string]any{"name": "edge1", "tunnelAddr": "127.0.0.1:" + strconv.Itoa(tunnelPort)}, &edge)
w.edge, w.edgeTok = edge.ID, edge.Token
var site model.Site
w.admin.must("POST", "/api/orgs/"+w.org+"/sites", map[string]string{"name": "home"}, &site)
w.site = site.ID
w.refreshLocal()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cert := selfSigned(t, "secure.example.test")
btCfg := blacktip.Config{Hammerhead: w.ts.URL, Token: edge.Token, TunnelAddr: "127.0.0.1:" + strconv.Itoa(tunnelPort), DataDir: t.TempDir(),
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return cert, nil }, Traefik: tr}
for _, o := range opts {
o(&btCfg)
}
bt, err := blacktip.New(btCfg)
if err != nil {
t.Fatal(err)
}
go bt.Run(ctx)
bb, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
go bb.Run(ctx)
return w
}
func (w *world) refreshLocal() {
var st struct{ Sites []model.Site }
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
for _, s := range st.Sites {
if s.Local {
w.local = s.ID
}
}
if w.local == "" {
w.t.Fatal("edge did not get a built-in local site")
}
}
func (w *world) open(m model.Mapping) model.Mapping {
w.t.Helper()
m.EdgeID, m.Enabled = w.edge, true
if m.BindIP == "" {
m.BindIP = "127.0.0.1"
}
var out model.Mapping
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings", map[string]any{
"proto": m.Proto, "edgeId": m.EdgeID, "bindIp": m.BindIP, "port": m.Port, "hostname": m.Hostname, "targets": m.Targets,
"enabled": true, "auth": m.Auth, "rules": m.Rules, "healthPath": m.HealthPath, "healthSeconds": m.HealthSeconds,
"name": m.Name,
}, &out)
return out
}
func tgt(site string, port int) []model.Target {
return []model.Target{{SiteID: site, Host: "127.0.0.1", Port: port}}
}
// ---------- tests ----------
func TestPortsMultiIPFailoverAndValidation(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
remoteA, remoteB, localSvc := tcpBackend(t, "A"), tcpBackend(t, "B"), tcpBackend(t, "LOCAL")
udpSvc := udpBackend(t, "UDP")
port := freePort(t) // the SAME public port on different IPs
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, remoteA)})
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.2", Port: port, Targets: tgt(w.site, remoteB)})
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, localSvc)})
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, udpSvc)})
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, udpSvc)})
// failover: first target is dead, second is alive
dead := freePort(t)
fo := freePort(t)
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.4", Port: fo,
Targets: []model.Target{{SiteID: w.local, Host: "127.0.0.1", Port: dead}, {SiteID: w.site, Host: "127.0.0.1", Port: remoteA}}})
check := func(got, want string) {
t.Helper()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
pa := strconv.Itoa(port)
check(tcpAsk(t, "127.0.0.1:"+pa, "hi"), "A:hi")
check(tcpAsk(t, "127.0.0.2:"+pa, "hi"), "B:hi")
check(tcpAsk(t, "127.0.0.3:"+pa, "hi"), "LOCAL:hi")
check(udpAsk(t, "127.0.0.1:"+pa, "ping"), "UDP:ping")
check(udpAsk(t, "127.0.0.3:"+pa, "ping"), "UDP:ping")
for i := 0; i < 4; i++ { // round robin must keep landing on the live target
check(tcpAsk(t, "127.0.0.4:"+strconv.Itoa(fo), "x"), "A:x")
}
base := "/api/orgs/" + w.org + "/mappings"
for name, m := range map[string]map[string]any{
"duplicate": {"proto": "tcp", "bindIp": "127.0.0.1", "port": port},
"wildcard": {"proto": "tcp", "bindIp": "0.0.0.0", "port": port},
"no targets": {"proto": "tcp", "bindIp": "127.0.0.9", "port": port, "targets": []any{}},
"bad udp": {"proto": "udp", "bindIp": "127.0.0.9", "port": port, "targets": append(tgt(w.site, 1), tgt(w.site, 2)...)},
"no hostname": {"proto": "http", "bindIp": "127.0.0.9", "port": port},
} {
m["edgeId"] = w.edge
if _, ok := m["targets"]; !ok {
m["targets"] = tgt(w.site, remoteA)
}
w.admin.expect(400, "POST", base, m)
_ = name
}
// dead target gets flagged unhealthy in the UI state
var st struct {
Mappings []struct {
ID, BindIP string
Status model.MappingStatus
}
}
deadline := time.Now().Add(8 * time.Second)
for {
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
good := false
for _, m := range st.Mappings {
if m.BindIP == "127.0.0.4" && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
good = true
}
}
if good {
break
}
if time.Now().After(deadline) {
t.Fatalf("health state never showed the dead target: %+v", st.Mappings)
}
time.Sleep(250 * time.Millisecond)
}
// closing a port frees it
for _, m := range st.Mappings {
if m.BindIP == "127.0.0.2" {
w.admin.must("DELETE", base+"/"+m.ID, nil, nil)
}
}
time.Sleep(600 * time.Millisecond)
if c, err := net.DialTimeout("tcp", "127.0.0.2:"+pa, time.Second); err == nil {
c.Close()
t.Error("port still open after closing it")
}
check(tcpAsk(t, "127.0.0.1:"+pa, "again"), "A:again")
}
type webResp struct {
status int
location string
body map[string]string
raw string
}
// edgeClient talks to the edge's listener while presenting a chosen hostname.
func edgeClient(scheme string, port int) (*http.Client, func(method, host, path string) webResp) {
jar, _ := cookiejar.New(nil)
tr := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
c := &http.Client{Jar: jar, Transport: tr, Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return c, func(method, host, path string) webResp {
resp, err := c.Do(mustReq(method, scheme+"://"+host+":"+strconv.Itoa(port)+path))
if err != nil {
return webResp{status: -1, raw: err.Error()}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
out := webResp{status: resp.StatusCode, location: resp.Header.Get("Location"), raw: string(raw)}
json.Unmarshal(raw, &out.body)
return out
}
}
func mustReq(method, u string) *http.Request {
r, _ := http.NewRequest(method, u, nil)
return r
}
// TestWebProxyAndResourceAuth runs the web suite on Blacktip's built-in proxy.
func TestWebProxyAndResourceAuth(t *testing.T) {
webSuite(t, setup(t, blacktip.TraefikConfig{}))
}
// TestWebViaTraefik runs the identical suite with Traefik serving http/https.
// It needs a Traefik binary: set TRAEFIK_BIN to its path.
func TestWebViaTraefik(t *testing.T) {
bin := os.Getenv("TRAEFIK_BIN")
if bin == "" {
t.Skip("set TRAEFIK_BIN to a traefik binary to run this test")
}
webSuite(t, setup(t, blacktip.TraefikConfig{Path: bin, NoACME: true}))
}
func webSuite(t *testing.T, w *world) {
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
be1, be2 := webBackend(t, "one", 200), webBackend(t, "two", 500)
port := freePort(t)
pub := w.open(model.Mapping{Proto: "http", Port: port, Hostname: "open.example.test", Targets: tgt(w.site, be1)})
_, get := edgeClient("http", port)
// wait for the listener and tunnel
var r webResp
for i := 0; i < 60; i++ {
if r = get("GET", "open.example.test", "/hello"); r.status == 200 {
break
}
time.Sleep(150 * time.Millisecond)
}
if r.status != 200 || r.body["backend"] != "one" || r.body["path"] != "/hello" {
t.Fatalf("unprotected proxy: %+v", r)
}
if r.body["host"] != "open.example.test:"+strconv.Itoa(port) || !strings.HasPrefix(r.body["xff"], "127.0.0.1") {
t.Errorf("forwarded headers wrong: %+v", r.body)
}
if got := get("GET", "nothing.example.test", "/"); got.status != 404 {
t.Errorf("unknown host -> %d", got.status)
}
_ = pub
// ----- password + rules + share link on a protected mapping -----
port2 := freePort(t)
prot := w.open(model.Mapping{Proto: "http", Port: port2, Hostname: "app.example.test", Targets: tgt(w.local, be1),
Auth: model.Auth{Password: true, SessionHours: 1},
Rules: []model.Rule{{Action: "deny", Match: "path", Value: "/admin"}, {Action: "allow", Match: "path", Value: "/public"}}})
// the password itself is write-only: set it through a PUT carrying newPassword
w.admin.must("PUT", "/api/orgs/"+w.org+"/mappings/"+prot.ID, map[string]any{
"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "app.example.test", "targets": tgt(w.local, be1),
"enabled": true, "auth": model.Auth{Password: true, SessionHours: 1}, "rules": prot.Rules, "newPassword": "hunter22",
}, nil)
c2, get2 := edgeClient("http", port2)
var login webResp
for i := 0; i < 60; i++ {
if login = get2("GET", "app.example.test", "/dash?x=1"); login.status == 302 {
break
}
time.Sleep(150 * time.Millisecond)
}
wantPrefix := w.ts.URL + "/auth/r/" + prot.ID + "?return="
if login.status != 302 || !strings.HasPrefix(login.location, wantPrefix) {
t.Fatalf("expected redirect to login, got %+v", login)
}
if got := get2("GET", "app.example.test", "/admin"); got.status != 403 {
t.Errorf("deny rule -> %d", got.status)
}
if got := get2("GET", "app.example.test", "/public/x"); got.status != 200 || got.body["user"] != "" {
t.Errorf("allow rule -> %+v", got)
}
if got := get2("POST", "app.example.test", "/dash"); got.status != 401 {
t.Errorf("unauthenticated POST -> %d", got.status)
}
visitor := newClient(t, w.ts.URL)
back := "http://app.example.test:" + strconv.Itoa(port2) + "/dash?x=1"
visitor.expect(401, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "wrong", "return": back})
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://evil.test/dash"})
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://app.example.test:1/dash"})
var grant struct{ Redirect string }
visitor.must("POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": back}, &grant)
u, _ := url.Parse(grant.Redirect)
if u.Host != "app.example.test:"+strconv.Itoa(port2) || u.Path != "/.hammerhead/callback" {
t.Fatalf("bad grant redirect %q", grant.Redirect)
}
// a forged token must be rejected
if got := get2("GET", "app.example.test", "/.hammerhead/callback?t=abc.def&r=/"); got.status != 403 {
t.Errorf("forged token -> %d", got.status)
}
cb := get2("GET", "app.example.test", u.RequestURI())
if cb.status != 302 || cb.location != "/dash?x=1" {
t.Fatalf("callback: %+v", cb)
}
got := get2("GET", "app.example.test", "/dash")
if got.status != 200 || got.body["user"] != "password" || strings.Contains(got.body["cookie"], "hh_auth") {
t.Fatalf("authenticated request: %+v", got)
}
_ = c2
// ----- share link -----
var share struct{ URL string }
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings/"+prot.ID+"/shares", map[string]any{"name": "friend", "maxUses": 1}, &share)
su, _ := url.Parse(share.URL)
if su.Host != "app.example.test" { // default port 80 isn't used in tests: rebuild the URL against the test port
t.Logf("share url host %s", su.Host)
}
tok := su.Query().Get("hh_share")
_, get3 := edgeClient("http", port2)
sh := get3("GET", "app.example.test", "/?hh_share="+tok)
if sh.status != 302 || !strings.Contains(sh.location, "&share="+tok) {
t.Fatalf("share redirect: %+v", sh)
}
v2 := newClient(t, w.ts.URL)
v2.must("POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": "http://app.example.test:" + strconv.Itoa(port2) + "/"}, &grant)
v2.expect(401, "POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": back}) // single use
// ----- SSO with roles -----
port3 := freePort(t)
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
var memberRole, adminRole string
for _, r := range roles {
if r.Name == "Member" {
memberRole = r.ID
}
if r.Admin {
adminRole = r.ID
}
}
var staffRole struct{ ID string }
w.admin.must("POST", "/api/orgs/"+w.org+"/roles", map[string]any{"name": "Staff"}, nil)
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
for _, r := range roles {
if r.Name == "Staff" {
staffRole.ID = r.ID
}
}
sso := w.open(model.Mapping{Proto: "http", Port: port3, Hostname: "sso.example.test", Targets: tgt(w.site, be1),
Auth: model.Auth{SSO: true, AllowedRoles: []string{staffRole.ID}}})
invite := func(email, role string) *client {
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
tok := inv.Link[strings.Index(inv.Link, "#invite=")+len("#invite="):]
c := newClient(t, w.ts.URL)
c.must("POST", "/api/invites/accept", map[string]string{"token": tok, "name": email, "password": "password123"}, nil)
return c
}
staff := invite("staff@test.local", staffRole.ID)
plain := invite("plain@test.local", memberRole)
retSSO := "http://sso.example.test:" + strconv.Itoa(port3) + "/"
plain.expect(403, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
staff.must("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO}, &grant)
newClient(t, w.ts.URL).expect(401, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
_ = adminRole
// ----- email code -----
w.admin.must("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": w.ts.URL, "smtp": map[string]any{"host": "mail.invalid", "port": 25, "from": "x@y.z"}}, nil)
port4 := freePort(t)
em := w.open(model.Mapping{Proto: "http", Port: port4, Hostname: "mail.example.test", Targets: tgt(w.site, be1),
Auth: model.Auth{Email: true, AllowedMails: []string{"*@corp.test"}}})
retEm := "http://mail.example.test:" + strconv.Itoa(port4) + "/"
v3 := newClient(t, w.ts.URL)
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "bob@corp.test", "return": retEm}, nil)
var code string
select {
case m := <-w.mails:
parts := strings.SplitN(m, "|", 3)
code = strings.TrimSpace(strings.Split(strings.SplitN(parts[2], "Your code is ", 2)[1], "\n")[0])
case <-time.After(3 * time.Second):
t.Fatal("no email sent")
}
v3.expect(401, "POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": "000000", "return": retEm})
v3.must("POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": code, "return": retEm}, &grant)
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "eve@evil.test", "return": retEm}, nil) // same answer, no mail
select {
case m := <-w.mails:
t.Errorf("mail sent to a non-allowed address: %s", m)
case <-time.After(300 * time.Millisecond):
}
// ----- health check marks the 500 backend unhealthy; traffic avoids it -----
port5 := freePort(t)
hc := w.open(model.Mapping{Proto: "http", Port: port5, Hostname: "hc.example.test", HealthPath: "/healthz", HealthSeconds: 5,
Targets: []model.Target{{SiteID: w.site, Host: "127.0.0.1", Port: be2}, {SiteID: w.local, Host: "127.0.0.1", Port: be1}}})
_, get5 := edgeClient("http", port5)
deadline := time.Now().Add(8 * time.Second)
for {
var st struct {
Mappings []struct {
ID string
Status model.MappingStatus
}
}
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
done := false
for _, m := range st.Mappings {
if m.ID == hc.ID && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
done = true
}
}
if done {
break
}
if time.Now().After(deadline) {
t.Fatal("health check never flagged the bad backend")
}
time.Sleep(250 * time.Millisecond)
}
for i := 0; i < 5; i++ {
if got := get5("GET", "hc.example.test", "/x"); got.status != 200 || got.body["backend"] != "one" {
t.Fatalf("traffic reached the unhealthy backend: %+v", got)
}
}
// ----- https with a certificate -----
port6 := freePort(t)
w.open(model.Mapping{Proto: "https", Port: port6, Hostname: "secure.example.test", Targets: tgt(w.site, be1)})
_, get6 := edgeClient("https", port6)
var sec webResp
for i := 0; i < 60; i++ {
if sec = get6("GET", "secure.example.test", "/tls"); sec.status == 200 {
break
}
time.Sleep(150 * time.Millisecond)
}
if sec.status != 200 || sec.body["proto"] != "https" {
t.Fatalf("https: %+v", sec)
}
// ----- access log and traffic counters reach hammerhead -----
deadline = time.Now().Add(10 * time.Second)
for {
var logs []model.LogEntry
w.admin.must("GET", "/api/orgs/"+w.org+"/logs?kind=access&limit=500", nil, &logs)
sawAuth, sawDeny := false, false
for _, l := range logs {
if l.Mapping == prot.ID && l.Result == "auth-required" {
sawAuth = true
}
if l.Mapping == prot.ID && l.Result == "denied" {
sawDeny = true
}
}
if sawAuth && sawDeny {
break
}
if time.Now().After(deadline) {
t.Fatalf("access log incomplete (%d entries)", len(logs))
}
time.Sleep(300 * time.Millisecond)
}
deadline = time.Now().Add(25 * time.Second)
for {
var stats map[string]struct{ Conns, BytesOut int64 }
w.admin.must("GET", "/api/orgs/"+w.org+"/stats", nil, &stats)
if stats[pub.ID].Conns > 0 && stats[pub.ID].BytesOut > 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("no traffic stats: %+v", stats)
}
time.Sleep(500 * time.Millisecond)
}
}
func TestAccountsOrgsAndTOTP(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
base := "/api/orgs/" + w.org
// a second org the first can't see into
var other struct{ ID string }
w.admin.must("POST", "/api/orgs", map[string]any{"name": "Other"}, &other)
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", base+"/roles", nil, &roles)
var member, adminRole string
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
if r.Admin {
adminRole = r.ID
}
}
invite := func(org, email, role string) *client {
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
c := newClient(t, w.ts.URL)
c.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "N", "password": "password123"}, nil)
return c
}
m := invite(w.org, "m@test.local", member)
m.must("GET", base+"/state", nil, nil)
m.expect(403, "POST", base+"/mappings", map[string]any{})
m.expect(403, "POST", base+"/sites", map[string]string{"name": "x"})
m.expect(403, "GET", base+"/members", nil)
m.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
m.expect(403, "POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "1.2.3.4:5"})
m.expect(403, "GET", "/api/admin/users", nil)
// domains can't be claimed twice across orgs
w.admin.must("POST", base+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
for _, d := range []string{"example.test", "api.example.test", "test"} {
code, _ := w.admin.do("POST", "/api/orgs/"+other.ID+"/domains", map[string]any{"name": d}, nil)
if code != 400 && d != "test" {
t.Errorf("org 2 claimed %s (status %d)", d, code)
}
}
// hostnames must be under the org's own domains
w.admin.expect(400, "POST", base+"/mappings", map[string]any{"proto": "http", "edgeId": w.edge, "port": 8081,
"hostname": "x.notmine.test", "targets": tgt(w.site, 9)})
// the other org can't use the edge's local site (AllowLocal is off for it)
w.admin.must("GET", "/api/orgs/"+other.ID+"/state", nil, nil)
w.admin.expect(400, "POST", "/api/orgs/"+other.ID+"/mappings", map[string]any{"proto": "tcp", "edgeId": w.edge, "port": 25565,
"targets": tgt(w.local, 25565)})
// API keys act inside one org only
var key struct{ Key string }
w.admin.must("POST", base+"/apikeys", map[string]string{"name": "ci"}, &key)
kc := newClient(t, w.ts.URL)
kc.key = key.Key
kc.must("POST", base+"/domains", map[string]any{"name": "keyed.test", "edgeId": w.edge}, nil)
kc.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
kc.expect(401, "GET", "/api/me", nil)
bad := newClient(t, w.ts.URL)
bad.key = "hhk_deadbeef_nope"
bad.expect(401, "GET", base+"/state", nil)
// removing the last admin is refused
var members []struct{ UserID, Email string }
w.admin.must("GET", base+"/members", nil, &members)
for _, mm := range members {
if mm.Email == "admin@test.local" {
w.admin.expect(400, "PUT", base+"/members/"+mm.UserID, map[string]string{"roleId": member})
w.admin.expect(400, "DELETE", base+"/members/"+mm.UserID, nil)
}
}
// two-factor: enable, then log in with a code and with a backup code
u := invite(w.org, "otp@test.local", adminRole)
var setup struct{ Secret string }
u.must("POST", "/api/me/2fa/setup", nil, &setup)
u.expect(400, "POST", "/api/me/2fa/enable", map[string]string{"code": "000000"})
var en struct{ BackupCodes []string }
u.must("POST", "/api/me/2fa/enable", map[string]string{"code": totpNow(setup.Secret)}, &en)
if len(en.BackupCodes) != 8 {
t.Fatalf("backup codes: %v", en.BackupCodes)
}
fresh := newClient(t, w.ts.URL)
var step struct {
NeedTotp bool
Ticket string
}
fresh.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
if !step.NeedTotp {
t.Fatal("2fa not requested")
}
fresh.expect(401, "GET", "/api/me", nil) // not signed in until the code is given
fresh.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": "123456"})
fresh.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": totpNow(setup.Secret)}, nil)
fresh.must("GET", "/api/me", nil, nil)
f2 := newClient(t, w.ts.URL)
f2.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
f2.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}, nil)
f3 := newClient(t, w.ts.URL)
f3.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
f3.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}) // backup codes are single use
// wrong passwords and brute force are throttled per address+email
bf := newClient(t, w.ts.URL)
for i := 0; i < 8; i++ {
bf.expect(401, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "nope-nope"})
}
bf.expect(429, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "password123"})
// changing the password signs out the user's other sessions
m2 := newClient(t, w.ts.URL)
m2.must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "password123"}, nil)
m2.must("GET", "/api/me", nil, nil)
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "wrong", "new": "newpassword1"})
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "password123", "new": "short"})
m.must("POST", "/api/me/password", map[string]string{"current": "password123", "new": "newpassword1"}, nil)
m2.expect(401, "GET", "/api/me", nil)
m.must("GET", "/api/me", nil, nil)
newClient(t, w.ts.URL).must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "newpassword1"}, nil)
}
func TestOIDCLogin(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
// a tiny identity provider
var idp *httptest.Server
idp = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(rw).Encode(map[string]string{"authorization_endpoint": idp.URL + "/authorize", "token_endpoint": idp.URL + "/token", "userinfo_endpoint": idp.URL + "/userinfo"})
case "/authorize":
q := r.URL.Query()
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("client_id") != "cid" {
http.Error(rw, "bad authorize request", 400)
return
}
http.Redirect(rw, r, q.Get("redirect_uri")+"?code=thecode&state="+url.QueryEscape(q.Get("state")), 302)
case "/token":
r.ParseForm()
if r.Form.Get("code") != "thecode" || r.Form.Get("client_secret") != "shh" || r.Form.Get("code_verifier") == "" {
http.Error(rw, "bad token request", 400)
return
}
json.NewEncoder(rw).Encode(map[string]string{"access_token": "at"})
case "/userinfo":
if r.Header.Get("Authorization") != "Bearer at" {
http.Error(rw, "no", 401)
return
}
json.NewEncoder(rw).Encode(map[string]any{"sub": "u-1", "email": "Sso.User@Corp.Test", "email_verified": true, "name": "Sso User"})
}
}))
defer idp.Close()
var roles []struct {
ID, Name string
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
w.admin.must("POST", "/api/admin/idps", map[string]any{"name": "Corp", "issuer": idp.URL, "clientId": "cid", "clientSecret": "shh",
"autoProvision": true, "orgId": w.org, "roleId": member, "allowedDomains": []string{"corp.test"}}, nil)
var pub struct{ Providers []struct{ ID, Name string } }
newClient(t, w.ts.URL).must("GET", "/api/public", nil, &pub)
if len(pub.Providers) != 1 {
t.Fatalf("providers: %+v", pub)
}
c := newClient(t, w.ts.URL)
resp, err := c.c.Get(w.ts.URL + "/auth/oidc/" + pub.Providers[0].ID + "/start?return=/after")
if err != nil || resp.StatusCode != 302 {
t.Fatalf("start: %v %v", err, resp)
}
authz := resp.Header.Get("Location")
resp, err = c.c.Get(authz)
if err != nil || resp.StatusCode != 302 {
t.Fatalf("authorize: %v %v", err, resp)
}
cb := resp.Header.Get("Location")
resp, err = c.c.Get(cb)
if err != nil || resp.StatusCode != 302 || resp.Header.Get("Location") != "/after" {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("callback: %v %d %s", err, resp.StatusCode, b)
}
var me struct {
Email string
Orgs []struct{ Name, Role string }
}
c.must("GET", "/api/me", nil, &me)
if me.Email != "sso.user@corp.test" || len(me.Orgs) != 1 || me.Orgs[0].Role != "Member" {
t.Fatalf("provisioned user: %+v", me)
}
// replaying the same state is refused
resp, _ = c.c.Get(cb)
if resp.StatusCode != 400 {
t.Errorf("state replay -> %d", resp.StatusCode)
}
}
+504
View File
@@ -0,0 +1,504 @@
package e2e
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// ---------- a fake recipient mail server ----------
type mxMsg struct {
from, ehlo, remote, body string
rcpts []string
tls bool
}
type fakeMX struct {
addr string
mu sync.Mutex
msgs []mxMsg
rcptCode map[string]int // address -> reply code to give instead of 250
offerTLS bool
cert *tls.Certificate
}
func newFakeMX(t *testing.T, offerTLS bool) *fakeMX {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
m := &fakeMX{addr: l.Addr().String(), rcptCode: map[string]int{}, offerTLS: offerTLS}
if offerTLS {
m.cert = selfSigned(t, "mx.example.invalid")
}
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go m.serve(c)
}
}()
return m
}
func (m *fakeMX) serve(c net.Conn) {
defer c.Close()
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
tp := textproto.NewConn(c)
say := func(format string, a ...any) { fmt.Fprintf(c, format+"\r\n", a...) }
say("220 fake-mx ESMTP")
cur := mxMsg{remote: host}
usedTLS := false
for {
line, err := tp.ReadLine()
if err != nil {
return
}
verb, arg, _ := strings.Cut(line, " ")
switch strings.ToUpper(verb) {
case "EHLO", "HELO":
cur.ehlo = arg
if m.offerTLS && !usedTLS {
say("250-fake-mx")
say("250-STARTTLS")
say("250 8BITMIME")
} else {
say("250-fake-mx")
say("250 8BITMIME")
}
case "STARTTLS":
say("220 go ahead")
tc := tls.Server(c, &tls.Config{Certificates: []tls.Certificate{*m.cert}})
if tc.Handshake() != nil {
return
}
c, tp, usedTLS = tc, textproto.NewConn(tc), true
say = func(format string, a ...any) { fmt.Fprintf(tc, format+"\r\n", a...) }
case "MAIL":
if i, j := strings.IndexByte(arg, '<'), strings.IndexByte(arg, '>'); i >= 0 && j > i {
cur.from = arg[i+1 : j]
}
cur.rcpts, cur.tls = nil, usedTLS
say("250 ok")
case "RCPT":
a := strings.Trim(arg[3:], "<> ")
if code, bad := m.rcptCode[a]; bad {
say("%d no thanks", code)
continue
}
cur.rcpts = append(cur.rcpts, a)
say("250 ok")
case "DATA":
say("354 go on")
b := new(strings.Builder)
r := tp.DotReader()
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
cur.body = b.String()
m.mu.Lock()
m.msgs = append(m.msgs, cur)
m.mu.Unlock()
say("250 2.0.0 queued as FAKE%d", len(m.msgs))
case "RSET", "NOOP":
say("250 ok")
case "QUIT":
say("221 bye")
return
default:
say("502 nope")
}
}
}
func (m *fakeMX) last(t *testing.T) mxMsg {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
if len(m.msgs) == 0 {
t.Fatal("the mail server received nothing")
}
return m.msgs[len(m.msgs)-1]
}
func (m *fakeMX) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.msgs)
}
// ---------- helpers ----------
type mailEnv struct {
*world
mx *fakeMX
mu sync.Mutex
hosts map[string][]blacktip.MailHost
}
func setupMail(t *testing.T, allowPrivate bool, mx *fakeMX, extra ...func(*blacktip.Config)) *mailEnv {
e := &mailEnv{mx: mx, hosts: map[string][]blacktip.MailHost{}}
host, port, _ := net.SplitHostPort(mx.addr)
p, _ := strconv.Atoi(port)
e.hosts["dest.example"] = []blacktip.MailHost{{Host: host, Port: p}}
opts := append([]func(*blacktip.Config){func(c *blacktip.Config) {
c.MailAllowPrivate = allowPrivate
c.MailProbe = mx.addr
c.MailLookup = func(domain string) ([]blacktip.MailHost, error) {
e.mu.Lock()
defer e.mu.Unlock()
if h, ok := e.hosts[domain]; ok {
return h, nil
}
return nil, errors.New("no such domain in this test")
}
}}, extra...)
e.world = setup(t, blacktip.TraefikConfig{}, opts...)
return e
}
func (e *mailEnv) add(m map[string]any) (id, listen string) {
e.t.Helper()
listen = "127.0.0.1:" + strconv.Itoa(freePort(e.t))
defaults := map[string]any{"listen": listen, "sourceIp": "127.0.0.2", "heloName": "mail.example.invalid",
"siteId": e.site, "edgeId": e.edge, "enabled": true}
for k, v := range defaults {
if _, ok := m[k]; !ok {
m[k] = v
}
}
var out struct{ ID string }
e.admin.must("POST", "/api/orgs/"+e.org+"/mail-egress", m, &out)
return out.ID, m["listen"].(string)
}
// send delivers one message through the relay at addr, retrying while the tunnel comes up.
func send(t *testing.T, addr string, auth smtp.Auth, from string, to []string, body string) error {
t.Helper()
var err error
for i := 0; i < 50; i++ {
err = trySend(addr, auth, from, to, body)
var te *textproto.Error
// retry only while the relay or the tunnel is still coming up
transient := err != nil && (!errors.As(err, &te) || (te.Code == 451 && strings.Contains(te.Msg, "edge")))
if !transient {
return err
}
time.Sleep(200 * time.Millisecond)
}
return err
}
func trySend(addr string, auth smtp.Auth, from string, to []string, body string) error {
c, err := smtp.Dial(addr)
if err != nil {
return err
}
defer c.Close()
if err := c.Hello("client.local"); err != nil {
return err
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return err
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, r := range to {
if err := c.Rcpt(r); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write([]byte(body)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
func smtpCode(err error) int {
var te *textproto.Error
if errors.As(err, &te) {
return te.Code
}
return 0
}
const letter = "Subject: hi\r\nFrom: me@home.example\r\nTo: you@dest.example\r\n\r\nHello.\r\n.line that starts with a dot\r\nBye.\r\n"
// ---------- the tests ----------
func TestMailEgressDeliversFromTheEdgeIP(t *testing.T) {
mx := newFakeMX(t, true) // offers STARTTLS (self-signed)
e := setupMail(t, true, mx)
_, listen := e.add(map[string]any{"name": "mail out"})
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example", "other@dest.example"}, letter); err != nil {
t.Fatalf("send: %v", err)
}
got := mx.last(t)
if got.remote != "127.0.0.2" {
t.Errorf("mail left from %s, want the edge source IP 127.0.0.2", got.remote)
}
if got.ehlo != "mail.example.invalid" {
t.Errorf("announced itself as %q, want the configured HELO name", got.ehlo)
}
if got.from != "me@home.example" || strings.Join(got.rcpts, ",") != "you@dest.example,other@dest.example" {
t.Errorf("envelope wrong: from=%q rcpts=%v", got.from, got.rcpts)
}
if !got.tls {
t.Error("opportunistic STARTTLS was not used although the server offered it")
}
if !strings.Contains(got.body, "\n.line that starts with a dot\n") && !strings.Contains(got.body, "\r\n.line that starts with a dot\r\n") {
t.Errorf("a line starting with a dot was mangled: %q", got.body)
}
if !strings.Contains(got.body, "Hello.") || !strings.Contains(got.body, "Bye.") {
t.Errorf("body incomplete: %q", got.body)
}
// a 1 MB message goes through intact
big := "Subject: big\r\n\r\n" + strings.Repeat("0123456789abcdef", 64*1024) + "\r\n"
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, big); err != nil {
t.Fatalf("big message: %v", err)
}
if n := len(mx.last(t).body); n < len(big)-200 {
t.Errorf("large message truncated: got %d bytes of %d", n, len(big))
}
// the null sender (bounces) is allowed
if err := send(t, listen, nil, "", []string{"you@dest.example"}, letter); err != nil {
t.Errorf("null sender: %v", err)
}
if mx.last(t).from != "" {
t.Errorf("null sender became %q", mx.last(t).from)
}
// the delivery shows up in the access log with the recipient domain and the SMTP status
deadline := time.Now().Add(10 * time.Second)
for {
var logs []model.LogEntry
e.admin.must("GET", "/api/orgs/"+e.org+"/logs?kind=access&limit=100", nil, &logs)
found := false
for _, l := range logs {
if l.Result == "mail" && l.Host == "dest.example" && l.Status == 250 {
found = true
}
}
if found {
break
}
if time.Now().After(deadline) {
t.Fatalf("no mail entry in the access log: %+v", logs)
}
time.Sleep(300 * time.Millisecond)
}
}
func TestMailEgressReplyHandling(t *testing.T) {
mx := newFakeMX(t, false)
mx.rcptCode["nobody@dest.example"] = 550
mx.rcptCode["full@dest.example"] = 452
e := setupMail(t, true, mx)
_, listen := e.add(map[string]any{})
// the receiving server's refusal reaches the sender with its own code
err := send(t, listen, nil, "me@home.example", []string{"nobody@dest.example"}, letter)
if smtpCode(err) != 550 {
t.Errorf("a 550 from the recipient's server came back as %v", err)
}
// ...and a refused first recipient doesn't poison the next transaction
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Errorf("a good message after a refusal: %v", err)
}
// a refused extra recipient is reported for that recipient only
c, err := smtp.Dial(listen)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.Hello("client.local")
c.Mail("me@home.example")
if err := c.Rcpt("you@dest.example"); err != nil {
t.Fatalf("rcpt 1: %v", err)
}
if err := c.Rcpt("full@dest.example"); smtpCode(err) != 452 {
t.Errorf("second recipient: %v", err)
}
// recipients in a different domain are deferred to another transaction
e.mu.Lock()
e.hosts["elsewhere.example"] = e.hosts["dest.example"]
e.mu.Unlock()
if err := c.Rcpt("x@elsewhere.example"); smtpCode(err) != 452 {
t.Errorf("a second domain in one transaction: %v", err)
}
c.Reset()
if err := c.Mail("me@home.example"); err != nil {
t.Errorf("RSET then a new transaction: %v", err)
}
// a mail server that can't be reached is a temporary failure: the sender will retry
e.mu.Lock()
e.hosts["down.example"] = []blacktip.MailHost{{Host: "127.0.0.1", Port: freePort(t)}}
e.mu.Unlock()
if err := send(t, listen, nil, "me@home.example", []string{"a@down.example"}, letter); smtpCode(err) != 451 {
t.Errorf("unreachable server: %v", err)
}
// an unknown domain with no records is temporary here too (the test resolver errors)
if err := send(t, listen, nil, "me@home.example", []string{"a@nowhere.example"}, letter); smtpCode(err) < 400 {
t.Errorf("unknown domain: %v", err)
}
}
func TestMailEgressAuthTLSSmarthostAndRate(t *testing.T) {
plain := newFakeMX(t, false)
e := setupMail(t, true, plain)
// passwords
_, authAddr := e.add(map[string]any{"name": "auth", "username": "relay", "password": "hunter2!"})
if err := send(t, authAddr, smtp.PlainAuth("", "relay", "hunter2!", "127.0.0.1"), "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatalf("authenticated send: %v", err)
}
if err := trySend(authAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 530 {
t.Errorf("sending without credentials: %v", err)
}
if err := trySend(authAddr, smtp.PlainAuth("", "relay", "wrong", "127.0.0.1"), "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 535 {
t.Errorf("wrong password: %v", err)
}
// require-TLS refuses a server with no STARTTLS, and one whose certificate can't be verified
_, tlsAddr := e.add(map[string]any{"name": "strict", "requireTls": true})
err := send(t, tlsAddr, nil, "me@home.example", []string{"you@dest.example"}, letter)
if smtpCode(err) != 451 || !strings.Contains(err.Error(), "TLS") {
t.Errorf("require-TLS against a plain server: %v", err)
}
selfSignedMX := newFakeMX(t, true)
h, p, _ := net.SplitHostPort(selfSignedMX.addr)
pn, _ := strconv.Atoi(p)
e.mu.Lock()
e.hosts["tls.example"] = []blacktip.MailHost{{Host: h, Port: pn}}
e.mu.Unlock()
err = send(t, tlsAddr, nil, "me@home.example", []string{"you@tls.example"}, letter)
if smtpCode(err) != 451 || selfSignedMX.count() != 0 {
t.Errorf("require-TLS with an untrusted certificate: err=%v delivered=%d", err, selfSignedMX.count())
}
// a smarthost receives everything, whatever the domain
smart := newFakeMX(t, false)
_, smartAddr := e.add(map[string]any{"name": "smart", "smarthost": smart.addr})
if err := send(t, smartAddr, nil, "me@home.example", []string{"anyone@unknown.example"}, letter); err != nil {
t.Fatalf("smarthost: %v", err)
}
if got := smart.last(t); got.remote != "127.0.0.2" || strings.Join(got.rcpts, ",") != "anyone@unknown.example" {
t.Errorf("smarthost got %+v", got)
}
// the per-minute limit protects the IP's reputation
_, rateAddr := e.add(map[string]any{"name": "slow", "ratePerMinute": 2})
for i := 0; i < 2; i++ {
if err := send(t, rateAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatalf("message %d: %v", i+1, err)
}
}
if err := send(t, rateAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 451 || !strings.Contains(err.Error(), "rate") {
t.Errorf("third message in a minute: %v", err)
}
}
func TestMailEgressGuardsAndValidation(t *testing.T) {
mx := newFakeMX(t, false)
e := setupMail(t, false, mx) // loopback destinations are NOT allowed here
_, listen := e.add(map[string]any{})
// a mail server on an internal address is refused: the edge can't be aimed at its own network
err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter)
if smtpCode(err) != 554 || !strings.Contains(err.Error(), "not allowed") || mx.count() != 0 {
t.Errorf("internal destination: err=%v delivered=%d", err, mx.count())
}
base := "/api/orgs/" + e.org + "/mail-egress"
good := func(over map[string]any) map[string]any {
m := map[string]any{"siteId": e.site, "edgeId": e.edge, "sourceIp": "127.0.0.2", "heloName": "mail.example.invalid",
"listen": "127.0.0.1:" + strconv.Itoa(freePort(t)), "enabled": true}
for k, v := range over {
m[k] = v
}
return m
}
e.admin.expect(400, "POST", base, good(map[string]any{"siteId": e.local})) // the local site needs no relay
e.admin.expect(400, "POST", base, good(map[string]any{"sourceIp": ""})) // the exit IP must be chosen
e.admin.expect(400, "POST", base, good(map[string]any{"sourceIp": "nope"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"heloName": "not a host"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"heloName": ""})) //
e.admin.expect(400, "POST", base, good(map[string]any{"smarthost": "no-port"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"ratePerMinute": 99999})) //
e.admin.expect(400, "POST", base, good(map[string]any{"username": "x"})) // user without password
e.admin.expect(400, "POST", base, good(map[string]any{"listen": "0.0.0.0:2525"})) // open to the LAN without a password
e.admin.expect(400, "POST", base, good(map[string]any{"listen": listen})) // already taken
e.admin.must("POST", base, good(map[string]any{"listen": "0.0.0.0:" + strconv.Itoa(freePort(t)), "username": "u", "password": "p"}), nil) // LAN is fine with a password
// the state shows the egress without its password, plus the edge's deliverability report
deadline := time.Now().Add(15 * time.Second)
for {
var st struct {
MailEgress []struct {
ID, Name string
PasswordSet bool
Password string
Check *model.MailCheck
}
}
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
ready := len(st.MailEgress) > 0
for _, m := range st.MailEgress {
if m.Password != "" {
t.Fatalf("state leaks the password of %s", m.Name)
}
if m.Check == nil || m.Check.Port25 == "" {
ready = false
}
}
if ready {
for _, m := range st.MailEgress {
if m.Check.Port25 != "open" {
t.Errorf("port 25 probe = %q, want open (the probe target is the fake server)", m.Check.Port25)
}
if m.Check.PTRMatchesHelo || m.Check.HeloResolves {
t.Errorf("a made-up HELO name can't match reverse DNS: %+v", m.Check)
}
}
break
}
if time.Now().After(deadline) {
t.Fatalf("no deliverability report arrived: %+v", st.MailEgress)
}
time.Sleep(300 * time.Millisecond)
}
}
+279
View File
@@ -0,0 +1,279 @@
package e2e
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"github.com/emersion/go-msgauth/dkim"
)
// trustedMXCert makes a certificate for 127.0.0.1 and a pool that trusts it, so
// verified TLS to the fake mail server can succeed.
func trustedMXCert(t *testing.T) (*tls.Certificate, *x509.CertPool) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tpl := &x509.Certificate{SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: "127.0.0.1"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true, IsCA: true, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
leaf, _ := x509.ParseCertificate(der)
pool := x509.NewCertPool()
pool.AddCert(leaf)
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, pool
}
// asCRLF puts a message back in the form it travels in (the fake server reads it with LF).
func asCRLF(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
}
type txtTable struct {
mu sync.Mutex
m map[string][]string
}
func (t *txtTable) set(name string, vals ...string) {
t.mu.Lock()
defer t.mu.Unlock()
t.m[name] = vals
}
func (t *txtTable) lookup(name string) ([]string, error) {
t.mu.Lock()
defer t.mu.Unlock()
if v, ok := t.m[name]; ok {
return v, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func TestDKIMSignsOutgoingMailAndChecksDNS(t *testing.T) {
mx := newFakeMX(t, false)
dns := &txtTable{m: map[string][]string{}}
e := setupMail(t, true, mx, func(c *blacktip.Config) { c.MailLookupTXT = dns.lookup })
base := "/api/orgs/" + e.org + "/dkim"
// validation
for name, in := range map[string]map[string]string{
"a bad domain": {"domain": "not a domain"},
"a wildcard": {"domain": "*.home.example"},
"a bad selector": {"domain": "home.example", "selector": "Bad Selector!"},
} {
if code, _ := e.admin.do("POST", base, in, nil); code != 400 {
t.Errorf("%s was accepted", name)
}
}
var key struct {
ID, Domain, Selector, PublicKey, RecordName, Record, Status string
}
e.admin.must("POST", base, map[string]string{"domain": "Home.Example", "selector": "s1"}, &key)
if key.Domain != "home.example" || key.RecordName != "s1._domainkey.home.example" || !strings.HasPrefix(key.Record, "v=DKIM1; k=rsa; p=") || !strings.HasSuffix(key.Record, key.PublicKey) {
t.Fatalf("key: %+v", key)
}
if code, _ := e.admin.do("POST", base, map[string]string{"domain": "home.example", "selector": "s1"}, nil); code != 400 {
t.Error("the same selector twice must be refused (rotate by choosing a new one)")
}
if code, raw := e.admin.do("GET", base, nil, nil); code != 200 || strings.Contains(raw, "PRIVATE KEY") {
t.Errorf("listing must work and never reveal the private key: %d %s", code, raw)
}
_, listen := e.add(map[string]any{"name": "signed"})
verify := func(body string) []*dkim.Verification {
v, err := dkim.VerifyWithOptions(strings.NewReader(asCRLF(body)), &dkim.VerifyOptions{LookupTXT: func(string) ([]string, error) { return []string{key.Record}, nil }})
if err != nil {
t.Fatalf("verify: %v", err)
}
return v
}
// mail from the signed domain is signed, and an independent verifier accepts it
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatalf("send: %v", err)
}
got := mx.last(t).body
v := verify(got)
if len(v) != 1 || v[0].Err != nil || v[0].Domain != "home.example" {
t.Fatalf("the signature did not verify: %+v", v)
}
if !strings.Contains(got, "Hello.") || !strings.Contains(got, ".line that starts with a dot") {
t.Errorf("signing damaged the body: %q", got)
}
// mail from another domain is left alone
other := strings.ReplaceAll(letter, "me@home.example", "me@other.example")
if err := send(t, listen, nil, "me@other.example", []string{"you@dest.example"}, other); err != nil {
t.Fatal(err)
}
if b := mx.last(t).body; strings.Contains(b, "DKIM-Signature") {
t.Errorf("mail for a domain without a key was signed:\n%s", b)
}
// a message your mail server already signed for that domain is not signed twice
pre := "DKIM-Signature: v=1; a=rsa-sha256; d=home.example; s=old; h=from; bh=x; b=y\r\n" + letter
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, pre); err != nil {
t.Fatal(err)
}
if n := strings.Count(strings.ToLower(mx.last(t).body), "dkim-signature:"); n != 1 {
t.Errorf("an already-signed message now has %d signatures", n)
}
// subdomains are covered by the parent domain's key
sub := strings.ReplaceAll(letter, "me@home.example", "me@news.home.example")
if err := send(t, listen, nil, "me@news.home.example", []string{"you@dest.example"}, sub); err != nil {
t.Fatal(err)
}
if v := verify(mx.last(t).body); len(v) != 1 || v[0].Err != nil {
t.Errorf("a subdomain wasn't signed by the parent's key: %+v", v)
}
// the DNS check: missing until you publish the record
status := func() string {
var list []struct{ ID, Status string }
e.admin.must("GET", base, nil, &list)
return list[0].Status
}
waitStatus2 := func(want string) {
deadline := time.Now().Add(12 * time.Second)
for status() != want {
if time.Now().After(deadline) {
t.Fatalf("DKIM status stuck at %q, want %q", status(), want)
}
time.Sleep(250 * time.Millisecond)
}
}
waitStatus2("missing")
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; p=AAAA") // somebody else's key
e.admin.must("PUT", "/api/orgs/"+e.org+"/mail-egress/"+firstMailEgress(t, e), egressBody(e, "nudge1.example.invalid"), nil)
waitStatus2("mismatch")
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; p="+key.PublicKey[:40], "p="+key.PublicKey[40:]) // published, split over strings like long records are
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; "+"p="+key.PublicKey)
e.admin.must("PUT", "/api/orgs/"+e.org+"/mail-egress/"+firstMailEgress(t, e), egressBody(e, "nudge2.example.invalid"), nil)
waitStatus2("published")
// removing the key stops signing
e.admin.must("DELETE", base+"/"+key.ID, nil, nil)
deadline := time.Now().Add(8 * time.Second)
for {
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatal(err)
}
if !strings.Contains(mx.last(t).body, "DKIM-Signature") {
break
}
if time.Now().After(deadline) {
t.Fatal("mail is still signed after the key was deleted")
}
time.Sleep(300 * time.Millisecond)
}
}
func firstMailEgress(t *testing.T, e *mailEnv) string {
var st struct{ MailEgress []struct{ ID string } }
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
return st.MailEgress[0].ID
}
func egressBody(e *mailEnv, helo string) map[string]any {
var st struct {
MailEgress []struct {
Listen string
}
}
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
return map[string]any{"name": "signed", "siteId": e.site, "edgeId": e.edge, "sourceIp": "127.0.0.2", "heloName": helo, "listen": st.MailEgress[0].Listen, "enabled": true}
}
func TestMTASTSAndVerifiedTLS(t *testing.T) {
cert, pool := trustedMXCert(t)
good := newFakeMX(t, true) // offers STARTTLS with a certificate the edge trusts
good.cert = cert
plain := newFakeMX(t, false)
untrusted := newFakeMX(t, true) // self-signed, not trusted
var mu sync.Mutex
policies := map[string]*blacktip.STSPolicy{}
e := setupMail(t, true, good, func(c *blacktip.Config) {
c.MailRootCAs = pool
c.MailSTS = func(domain string) (*blacktip.STSPolicy, error) {
mu.Lock()
defer mu.Unlock()
return policies[domain], nil
}
})
hostOf := func(m *fakeMX) blacktip.MailHost {
h, p, _ := net.SplitHostPort(m.addr)
n, _ := strconv.Atoi(p)
return blacktip.MailHost{Host: h, Port: n}
}
e.mu.Lock()
e.hosts["tls.example"] = []blacktip.MailHost{hostOf(good)}
e.hosts["plain.example"] = []blacktip.MailHost{hostOf(plain)}
e.hosts["untrusted.example"] = []blacktip.MailHost{hostOf(untrusted)}
e.mu.Unlock()
mu.Lock()
policies["tls.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
policies["plain.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
policies["untrusted.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
policies["wrongmx.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"mail.elsewhere.example", "*.mx.elsewhere.example"}}
policies["testing.example"] = &blacktip.STSPolicy{Mode: "testing", MX: []string{"mail.elsewhere.example"}}
mu.Unlock()
e.mu.Lock()
e.hosts["wrongmx.example"] = []blacktip.MailHost{hostOf(good)}
e.hosts["testing.example"] = []blacktip.MailHost{hostOf(plain)}
e.mu.Unlock()
_, listen := e.add(map[string]any{"name": "sts"})
mail := func(domain string) error {
return send(t, listen, nil, "me@home.example", []string{"you@" + domain}, letter)
}
// enforce + a listed server + verified TLS: delivered, over TLS
if err := mail("tls.example"); err != nil {
t.Fatalf("enforce with a good server: %v", err)
}
if got := good.last(t); !got.tls {
t.Error("the message was not sent over TLS under an enforcing policy")
}
// enforce + a server the policy doesn't list: refused before any connection
if err := mail("wrongmx.example"); smtpCode(err) != 451 || !strings.Contains(err.Error(), "MTA-STS") {
t.Errorf("a server outside the policy: %v", err)
}
// enforce + no TLS offered: refused, and nothing sent in the clear
if err := mail("plain.example"); smtpCode(err) != 451 || !strings.Contains(err.Error(), "TLS") || plain.count() != 0 {
t.Errorf("enforce without TLS: err=%v delivered=%d", err, plain.count())
}
// enforce + a certificate nobody trusts: refused
if err := mail("untrusted.example"); smtpCode(err) != 451 || untrusted.count() != 0 {
t.Errorf("enforce with an untrusted certificate: err=%v delivered=%d", err, untrusted.count())
}
// testing mode only observes: delivered even in the clear
if err := mail("testing.example"); err != nil {
t.Errorf("testing mode must not block mail: %v", err)
}
// and with no policy at all, mail flows as before
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Errorf("no policy: %v", err)
}
// the egress's own "require verified TLS" now succeeds against a trusted certificate and still refuses the rest
_, strict := e.add(map[string]any{"name": "strict", "requireTls": true})
if err := send(t, strict, nil, "me@home.example", []string{"you@tls.example"}, letter); err != nil {
t.Errorf("require TLS against a trusted server: %v", err)
}
if err := send(t, strict, nil, "me@home.example", []string{"you@untrusted.example"}, letter); smtpCode(err) != 451 {
t.Errorf("require TLS against an untrusted server: %v", err)
}
}
+177
View File
@@ -0,0 +1,177 @@
package e2e
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
)
// proxyBackend is a TCP server that understands the PROXY protocol the way a
// game proxy or mail server would. It replies with what it learned, or
// "plain:<first line>" when the connection did not start with a header.
func proxyBackend(t *testing.T, seen chan<- string) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
br := bufio.NewReader(c)
first, _ := br.Peek(1)
var head []byte
switch {
case len(first) == 1 && first[0] == 0x0d:
head, _ = br.Peek(12) // a v2 header starts with a 12-byte signature
case len(first) == 1 && first[0] == 'P':
head, _ = br.Peek(6) // "PROXY "
}
var desc string
switch {
case bytes.HasPrefix(head, []byte("PROXY ")):
line, _ := br.ReadString('\n')
f := strings.Fields(strings.TrimSpace(line))
if len(f) == 6 {
desc = fmt.Sprintf("v1 src=%s:%s dst=%s:%s fam=%s", f[2], f[4], f[3], f[5], f[1])
} else {
desc = "v1 odd:" + line
}
case bytes.Equal(head, []byte("\r\n\r\n\x00\r\nQUIT\n")):
fixed := make([]byte, 16)
io.ReadFull(br, fixed)
body := make([]byte, binary.BigEndian.Uint16(fixed[14:16]))
io.ReadFull(br, body)
switch fixed[13] {
case 0x11:
desc = fmt.Sprintf("v2 src=%s:%d dst=%s:%d fam=TCP4", net.IP(body[0:4]), binary.BigEndian.Uint16(body[8:10]), net.IP(body[4:8]), binary.BigEndian.Uint16(body[10:12]))
case 0x21:
desc = fmt.Sprintf("v2 src=[%s]:%d fam=TCP6", net.IP(body[0:16]), binary.BigEndian.Uint16(body[32:34]))
default:
desc = fmt.Sprintf("v2 fam=%#x", fixed[13])
}
if fixed[12] != 0x21 {
desc += " cmd=" + strconv.Itoa(int(fixed[12]))
}
default:
line, _ := br.ReadString('\n')
desc = "plain:" + strings.TrimSpace(line)
}
// what comes next shows the header was exactly the first thing and nothing was lost
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond)) // don't wait forever for bytes that may never come
if b, err := br.Peek(1); err == nil && b[0] == 0x16 {
desc += " then=tls-hello"
}
if seen != nil {
seen <- desc
}
fmt.Fprintf(c, "%s\n", desc)
}()
}
}()
return l.Addr().(*net.TCPAddr).Port
}
func askFrom(t *testing.T, from, addr, send string) string {
t.Helper()
var last error
for i := 0; i < 50; i++ {
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}, Timeout: time.Second}
c, err := d.Dial("tcp", addr)
if err == nil {
c.SetDeadline(time.Now().Add(4 * time.Second))
fmt.Fprint(c, send)
line, rerr := bufio.NewReader(c).ReadString('\n')
c.Close()
if rerr == nil {
return strings.TrimSpace(line)
}
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
t.Fatalf("%s accepted the connection but never answered: %v", addr, rerr) // a real hang, not a startup race
}
last = rerr
} else {
last = err
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("no answer from %s: %v", addr, last)
return ""
}
func TestProxyProtocolCarriesTheRealClientAddress(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
be := proxyBackend(t, nil)
base := "/api/orgs/" + w.org + "/mappings"
open := func(ip string, site string, proxy string) string {
port := freePort(t)
w.admin.must("POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": ip, "port": port, "targets": tgt(site, be),
"enabled": true, "proxyProtocol": proxy}, nil)
return ip + ":" + strconv.Itoa(port)
}
// through the WireGuard tunnel and straight to the edge's own machine, in both versions
for _, tc := range []struct{ name, site, proxy string }{
{"v1 via tunnel", w.site, "v1"}, {"v2 via tunnel", w.site, "v2"}, {"v1 local", w.local, "v1"}, {"v2 local", w.local, "v2"},
} {
addr := open("127.0.0.1", tc.site, tc.proxy)
t.Logf("case %s on %s", tc.name, addr)
got := askFrom(t, "127.0.0.9", addr, "hello\n")
if !strings.HasPrefix(got, tc.proxy) || !strings.Contains(got, "src=127.0.0.9:") || !strings.Contains(got, "dst="+addr) || !strings.Contains(got, "fam=TCP4") {
t.Errorf("%s: the target learned %q, want the real client 127.0.0.9 and the public address %s", tc.name, got, addr)
}
}
// off by default: the target gets exactly what the client sent, no header
addr := open("127.0.0.1", w.site, "")
if got := askFrom(t, "127.0.0.9", addr, "hello\n"); got != "plain:hello" {
t.Errorf("without PROXY protocol the target saw %q", got)
}
// the header comes before the client's own bytes, even when they are a TLS hello
seen := make(chan string, 4)
tbe := proxyBackend(t, seen)
port := freePort(t)
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
w.admin.must("POST", base, map[string]any{"proto": "tls", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "pass.example.test",
"targets": tgt(w.site, tbe), "enabled": true, "proxyProtocol": "v2"}, nil)
for i := 0; i < 50; i++ {
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.9")}, Timeout: time.Second}
c, err := d.Dial("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
tls.Client(c, &tls.Config{ServerName: "pass.example.test", InsecureSkipVerify: true}).Handshake() // fails: the backend isn't TLS
c.Close()
}
select {
case got := <-seen:
if !strings.Contains(got, "v2 src=127.0.0.9:") || !strings.Contains(got, "then=tls-hello") {
t.Errorf("tls passthrough: %q", got)
}
goto validated
case <-time.After(200 * time.Millisecond):
}
}
t.Fatal("tls passthrough never reached the target")
validated:
// only tcp and tls ports can carry it
w.admin.expect(400, "POST", base, map[string]any{"proto": "udp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v1"})
w.admin.expect(400, "POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "hostname": "w.example.test", "targets": tgt(w.site, be), "proxyProtocol": "v2"})
w.admin.expect(400, "POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v3"})
}
+216
View File
@@ -0,0 +1,216 @@
package e2e
import (
"fmt"
"net"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// freeRun finds n consecutive ports that are free for both TCP and UDP.
func freeRun(t *testing.T, n int) int {
t.Helper()
for try := 0; try < 200; try++ {
base := 20000 + (time.Now().Nanosecond()/1000+try*37)%30000
var closers []func()
ok := true
for i := 0; i < n && ok; i++ {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(base+i))
if err != nil {
ok = false
break
}
closers = append(closers, func() { l.Close() })
pc, err := net.ListenPacket("udp", "127.0.0.1:"+strconv.Itoa(base+i))
if err != nil {
ok = false
break
}
closers = append(closers, func() { pc.Close() })
}
for _, c := range closers {
c()
}
if ok {
return base
}
}
t.Fatal("no free port block")
return 0
}
func tcpOn(t *testing.T, port int) {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
buf := make([]byte, 64)
n, _ := c.Read(buf)
fmt.Fprintf(c, "tcp%d:%s", port, buf[:n])
}()
}
}()
}
func udpOn(t *testing.T, port int) {
pc, err := net.ListenPacket("udp", "127.0.0.1:"+strconv.Itoa(port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 64)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
pc.WriteTo([]byte(fmt.Sprintf("udp%d:%s", port, buf[:n])), from)
}
}()
}
func TestPortRanges(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
target := freeRun(t, 3)
pub := freeRun(t, 3)
if target == pub {
pub = freeRun(t, 4) + 0
}
for i := 0; i < 3; i++ {
tcpOn(t, target+i)
udpOn(t, target+i)
}
base := "/api/orgs/" + w.org + "/mappings"
open := func(proto, ip string, site string, port, end, tport int) (model.Mapping, int, string) {
var m model.Mapping
code, raw := w.admin.do("POST", base, map[string]any{"proto": proto, "edgeId": w.edge, "bindIp": ip, "port": port, "portEnd": end,
"targets": tgt(site, tport), "enabled": true}, &m)
return m, code, raw
}
tcp, code, raw := open("tcp", "127.0.0.1", w.site, pub, pub+2, target)
if code != 200 {
t.Fatalf("open range: %d %s", code, raw)
}
if _, code, raw = open("udp", "127.0.0.1", w.site, pub, pub+2, target); code != 200 {
t.Fatalf("a udp range over the same numbers is a different protocol and must be allowed: %d %s", code, raw)
}
if _, code, raw = open("tcp", "127.0.0.2", w.local, pub, pub+2, target); code != 200 {
t.Fatalf("local range: %d %s", code, raw)
}
if tcp.PortEnd != pub+2 {
t.Errorf("portEnd = %d, want %d", tcp.PortEnd, pub+2)
}
// each public port reaches the target port at the same offset, through the tunnel and directly
for _, ip := range []string{"127.0.0.1", "127.0.0.2"} {
for i := 0; i < 3; i++ {
got := tcpAsk(t, ip+":"+strconv.Itoa(pub+i), "hi")
if want := fmt.Sprintf("tcp%d:hi", target+i); got != want {
t.Errorf("%s port %d -> %q, want %q", ip, pub+i, got, want)
}
}
}
for i := 0; i < 3; i++ {
got := udpAsk(t, "127.0.0.1:"+strconv.Itoa(pub+i), "yo")
if want := fmt.Sprintf("udp%d:yo", target+i); got != want {
t.Errorf("udp port %d -> %q, want %q", pub+i, got, want)
}
}
// shrinking the range closes the ports that fell off the end
if code, raw := w.admin.do("PUT", base+"/"+tcp.ID, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": pub, "portEnd": pub + 1,
"targets": tgt(w.site, target), "enabled": true}, nil); code != 200 {
t.Fatalf("shrink: %d %s", code, raw)
}
deadline := time.Now().Add(6 * time.Second)
for {
c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(pub+2), 500*time.Millisecond)
if err != nil {
break
}
c.Close()
if time.Now().After(deadline) {
t.Fatal("a port past the end of the range stayed open")
}
time.Sleep(200 * time.Millisecond)
}
if got := tcpAsk(t, "127.0.0.1:"+strconv.Itoa(pub+1), "x"); got != fmt.Sprintf("tcp%d:x", target+1) {
t.Errorf("a port still inside the range: %q", got)
}
// validation
bad := func(what, proto string, port, end, tport int) {
t.Helper()
if _, code, raw := open(proto, "127.0.0.9", w.site, port, end, tport); code != 400 {
t.Errorf("%s was accepted (%d %s)", what, code, raw)
}
}
bad("a range on http", "http", 8080, 8082, 80)
bad("an end before the start", "tcp", 5000, 4990, 80)
bad("a range of 1001 ports", "tcp", 10000, 11000, 80)
bad("a range past 65535", "tcp", 65530, 65540, 80)
bad("targets that would run past 65535", "tcp", 5000, 5009, 65530)
if _, code, _ := open("tcp", "127.0.0.1", w.site, pub+1, pub+1, target); code != 400 {
t.Error("a single port inside an existing range must conflict")
}
if _, code, _ := open("tcp", "127.0.0.1", w.site, pub-1, pub, target); code != 400 {
t.Error("a range overlapping the start of an existing range must conflict")
}
if _, code, raw := open("tcp", "127.0.0.1", w.site, pub+2, pub+4, target); code != 200 {
t.Errorf("a range that starts right after the existing one is fine: %d %s", code, raw)
}
// one busy port inside a range is reported, and the others still work
busyBase := freeRun(t, 3)
busy, err := net.Listen("tcp", "127.0.0.3:"+strconv.Itoa(busyBase+1))
if err != nil {
t.Fatal(err)
}
defer busy.Close()
bm, code, raw := open("tcp", "127.0.0.3", w.local, busyBase, busyBase+2, target)
if code != 200 {
t.Fatalf("open busy range: %d %s", code, raw)
}
if got := tcpAsk(t, "127.0.0.3:"+strconv.Itoa(busyBase), "a"); got != fmt.Sprintf("tcp%d:a", target) {
t.Errorf("a free port next to a busy one: %q", got)
}
deadline = time.Now().Add(8 * time.Second)
for {
var st struct {
Mappings []struct {
ID string
Status model.MappingStatus
}
}
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
msg := ""
for _, m := range st.Mappings {
if m.ID == bm.ID {
msg = m.Status.Error
}
}
if strings.Contains(msg, strconv.Itoa(busyBase+1)) {
break
}
if time.Now().After(deadline) {
t.Fatalf("the busy port isn't named in the status: %q", msg)
}
time.Sleep(250 * time.Millisecond)
}
}
+224
View File
@@ -0,0 +1,224 @@
package e2e
import (
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// signedIn logs a visitor in through hammerhead's page for mapping id and
// returns an edge client whose cookie jar now holds the session.
func signedIn(t *testing.T, w *world, id, host string, port int, how, secret string) func(method, host, path string) webResp {
t.Helper()
_, get := edgeClient("http", port)
back := "http://" + host + ":" + strconv.Itoa(port) + "/"
vis := newClient(t, w.ts.URL)
var grant struct{ Redirect string }
body := map[string]string{"return": back}
path := "/auth/r/" + id + "/" + how
if how == "password" {
body["password"] = secret
}
vis.must("POST", path, body, &grant)
u, _ := url.Parse(grant.Redirect)
if cb := get("GET", host, u.RequestURI()); cb.status != 302 {
t.Fatalf("callback -> %+v", cb)
}
return get
}
// waitStatus polls until the edge answers with want (config reaches it within seconds).
func waitStatus(t *testing.T, get func(method, host, path string) webResp, host string, want int, what string) {
t.Helper()
var last webResp
for i := 0; i < 50; i++ {
if last = get("GET", host, "/"); last.status == want {
return
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("%s: wanted %d, last answer %+v", what, want, last)
}
func TestVisitorSessionsCanBeRevoked(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)
be := webBackend(t, "app", 200)
base := "/api/orgs/" + w.org + "/mappings"
port := freePort(t)
var m model.Mapping
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "newPassword": "first-password", "authEpoch": 99}, &m)
if m.AuthEpoch != 0 {
t.Fatalf("a client set the epoch to %d", m.AuthEpoch)
}
put := func(pw string) {
body := map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "authEpoch": 1234}
if pw != "" {
body["newPassword"] = pw
}
w.admin.must("PUT", base+"/"+m.ID, body, &m)
}
get := signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
waitStatus(t, get, "rv.example.test", 200, "a signed-in visitor")
// signing everyone out on demand
w.admin.must("POST", base+"/"+m.ID+"/revoke", nil, nil)
waitStatus(t, get, "rv.example.test", 302, "after 'sign out visitors'")
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
waitStatus(t, get, "rv.example.test", 200, "signing in again after a revoke")
// editing without touching who may enter keeps visitors signed in
put("")
time.Sleep(600 * time.Millisecond)
if got := get("GET", "rv.example.test", "/"); got.status != 200 {
t.Errorf("an unrelated edit signed visitors out: %+v", got)
}
var st struct{ Mappings []model.Mapping }
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
if e := st.Mappings[0].AuthEpoch; e != 1 {
t.Errorf("epoch = %d after one revoke and one harmless edit, want 1 (a client-sent value must be ignored)", e)
}
// changing the password signs out everyone who got in with the old one
put("second-password")
waitStatus(t, get, "rv.example.test", 302, "after a password change")
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "second-password")
waitStatus(t, get, "rv.example.test", 200, "the new password")
// removing a member ends their open sessions on SSO resources
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "leaver@test.local", "roleId": member}, &inv)
leaver := newClient(t, w.ts.URL)
leaver.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "L", "password": "password123"}, nil)
var me struct{ ID string }
leaver.must("GET", "/api/me", nil, &me)
port2 := freePort(t)
var sso model.Mapping
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "sso.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{SSO: true}}, &sso)
_, get2 := edgeClient("http", port2)
var grant struct{ Redirect string }
for i := 0; i < 50; i++ { // wait for the resource to exist on the edge
if err := leaver.do2("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": "http://sso.example.test:" + strconv.Itoa(port2) + "/"}, &grant); err == nil {
break
}
time.Sleep(150 * time.Millisecond)
}
u, _ := url.Parse(grant.Redirect)
for i := 0; i < 50; i++ {
if cb := get2("GET", "sso.example.test", u.RequestURI()); cb.status == 302 {
break
}
time.Sleep(150 * time.Millisecond)
}
waitStatus(t, get2, "sso.example.test", 200, "an SSO member")
w.admin.must("DELETE", "/api/orgs/"+w.org+"/members/"+me.ID, nil, nil)
waitStatus(t, get2, "sso.example.test", 302, "after the member was removed")
}
// do2 is like do but returns an error instead of failing the test on a non-200.
func (a *client) do2(method, path string, in, out any) error {
code, raw := a.do(method, path, in, out)
if code != 200 {
return &httpErr{code, raw}
}
return nil
}
type httpErr struct {
code int
body string
}
func (e *httpErr) Error() string { return strconv.Itoa(e.code) + " " + e.body }
func TestDashboardSessionsAreListedAndEnded(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
login := func() *client {
c := newClient(t, w.ts.URL)
c.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
return c
}
a, b, c := login(), login(), login()
type row struct {
ID string
Current bool
IP string
}
var rows []row
a.must("GET", "/api/me/sessions", nil, &rows)
// the setup client (w.admin) plus the three above
if len(rows) < 4 {
t.Fatalf("expected at least 4 sessions, got %d", len(rows))
}
current := 0
for _, r := range rows {
if r.Current {
current++
}
if r.IP == "" {
t.Error("a session has no IP recorded")
}
}
if current != 1 {
t.Errorf("exactly one session should be marked current, got %d", current)
}
// end a specific one
var bRows []row
b.must("GET", "/api/me/sessions", nil, &bRows)
bID := ""
for _, r := range bRows {
if r.Current {
bID = r.ID
}
}
a.must("DELETE", "/api/me/sessions/"+bID, nil, nil)
b.expect(401, "GET", "/api/me", nil)
c.must("GET", "/api/me", nil, nil)
// "sign out everywhere else" keeps only this browser
a.must("POST", "/api/me/sessions/revoke-others", nil, nil)
c.expect(401, "GET", "/api/me", nil)
w.admin.expect(401, "GET", "/api/me", nil)
a.must("GET", "/api/me", nil, nil)
// someone else's session id can't be ended through your account
var inv struct{ Link string }
var roles []struct{ ID, Name string }
a.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
a.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "other@test.local", "roleId": member}, &inv)
other := newClient(t, w.ts.URL)
other.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "O", "password": "password123"}, nil)
var aRows []row
a.must("GET", "/api/me/sessions", nil, &aRows)
other.must("DELETE", "/api/me/sessions/"+aRows[0].ID, nil, nil)
a.must("GET", "/api/me", nil, nil)
}
+32
View File
@@ -0,0 +1,32 @@
module git.veillax.com/Veillax/hammerhead
go 1.27.3
require (
git.veillax.com/Veillax/bamboo v0.1.0
git.veillax.com/Veillax/blacktip v0.1.0
git.veillax.com/Veillax/reef v0.1.0
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
)
require (
github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+76
View File
@@ -0,0 +1,76 @@
git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+367
View File
@@ -0,0 +1,367 @@
package hammerhead
import (
"errors"
"net"
"net/http"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
const onlineWindow = 60 * time.Second
func (s *Server) edgeOnline(e *model.Edge) bool { return time.Since(e.LastSeen) < onlineWindow }
// edgeView is what browsers see. Only server admins get tokens and org lists.
type edgeView struct {
ID string `json:"id"`
Name string `json:"name"`
IPs []string `json:"ips"`
Online bool `json:"online"`
TunnelAddr string `json:"tunnelAddr,omitempty"`
Token string `json:"token,omitempty"`
OrgIDs []string `json:"orgIds,omitempty"`
}
func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
v := edgeView{ID: e.ID, Name: e.Name, IPs: e.IPs, Online: s.edgeOnline(e)}
if v.IPs == nil {
v.IPs = []string{}
}
if admin {
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
if v.OrgIDs == nil {
v.OrgIDs = []string{}
}
}
return v
}
func validAddr(a string) bool {
h, p, err := net.SplitHostPort(a)
if err != nil || h == "" {
return false
}
n := 0
for _, c := range p {
if c < '0' || c > '9' {
return false
}
n = n*10 + int(c-'0')
if n > 65535 {
return false
}
}
return n > 0
}
func (s *Server) listEdges(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []edgeView{}
for _, e := range s.st.Edges {
if p.User.ServerAdmin {
out = append(out, s.viewEdge(e, true))
continue
}
for _, m := range s.st.Members {
if m.UserID == p.User.ID && s.edgeAllowedFor(e, m.OrgID) {
out = append(out, s.viewEdge(e, false))
break
}
}
}
writeJSON(w, 200, out)
}
func (s *Server) saveEdge(w http.ResponseWriter, r *http.Request, p *principal) {
var in model.Edge
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
if !validAddr(in.TunnelAddr) {
fail(w, 400, errors.New("WireGuard endpoint must look like host:port (the edge's public address and UDP port)"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, o := range in.OrgIDs {
if s.findOrg(o) == nil {
fail(w, 400, errors.New("unknown organization in the allowed list"))
return
}
}
if id := r.PathValue("id"); id != "" {
e := s.findEdge(id)
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
e.Name, e.TunnelAddr, e.OrgIDs = in.Name, in.TunnelAddr, in.OrgIDs
for _, st := range s.st.Sites {
if st.Local && st.EdgeID == e.ID {
st.Name = e.Name + " (this machine)"
}
}
s.bump()
writeJSON(w, 200, s.viewEdge(e, true))
return
}
e := &model.Edge{ID: newID(), Name: in.Name, Token: randHex(24), AuthKey: randHex(32),
TunnelAddr: in.TunnelAddr, TunnelIP: s.allocIPLocked(), OrgIDs: in.OrgIDs, IPs: []string{}}
s.st.Edges = append(s.st.Edges, e)
// every edge gets a built-in site for services on the edge's own machine
s.st.Sites = append(s.st.Sites, &model.Site{ID: newID(), Name: in.Name + " (this machine)",
Local: true, EdgeID: e.ID, Connected: []string{}})
s.bump()
s.audit("", p.User.Email, "edge.create", e.Name)
writeJSON(w, 200, s.viewEdge(e, true))
}
func (s *Server) deleteEdge(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(id)
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
s.st.Edges = filter(s.st.Edges, func(x *model.Edge) bool { return x.ID != id })
s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !(x.Local && x.EdgeID == id) })
s.st.Domains = filter(s.st.Domains, func(d *model.Domain) bool { return d.EdgeID != id })
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.EdgeID != id })
for _, m := range s.st.Mappings {
if m.EdgeID == id {
s.dropMappingLocked(m.ID)
}
}
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return m.EdgeID != id })
s.bump()
s.audit("", p.User.Email, "edge.delete", e.Name)
ok(w)
}
// dropMappingLocked removes everything hanging off a mapping except the mapping itself.
func (s *Server) dropMappingLocked(id string) {
delete(s.st.Secrets, id)
delete(s.st.Totals, id)
delete(s.status, id)
s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return l.MappingID != id })
}
// ---------- sites ----------
func (s *Server) siteVisible(c *orgCtx, st *model.Site) bool {
if st.Local {
if !c.Org.AllowLocal {
return false
}
e := s.findEdge(st.EdgeID)
return e != nil && s.edgeAllowedFor(e, c.Org.ID)
}
return st.OrgID == c.Org.ID
}
func (s *Server) siteOnline(st *model.Site) bool {
if st.Local {
e := s.findEdge(st.EdgeID)
return e != nil && s.edgeOnline(e)
}
return time.Since(st.LastSeen) < onlineWindow
}
func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := &model.Site{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Token: randHex(24), Connected: []string{}, TunnelIP: s.allocIPLocked()}
s.st.Sites = append(s.st.Sites, st)
s.bump()
s.audit(c.Org.ID, c.Actor, "site.create", in.Name)
writeJSON(w, 200, st)
}
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(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 goes away with its edge"))
return
}
s.st.Sites = filter(s.st.Sites, func(e *model.Site) bool { return e.ID != id })
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.SiteID != id })
for _, m := range s.st.Mappings {
kept := []model.Target{}
for _, t := range m.Targets {
if t.SiteID != id {
kept = append(kept, t)
}
}
if len(kept) != len(m.Targets) {
m.Targets = kept
if len(kept) == 0 {
s.dropMappingLocked(m.ID)
}
}
}
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 })
s.bump()
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
ok(w)
}
func (s *Server) siteContainers(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
}
out := s.containers[st.ID]
if out == nil {
out = []model.Container{}
}
writeJSON(w, 200, out)
}
// ---------- domains ----------
func domainsOverlap(a, b string) bool {
return a == b || strings.HasSuffix(a, "."+b) || strings.HasSuffix(b, "."+a)
}
func validHostname(h string) bool {
h = strings.TrimPrefix(h, "*.")
if h == "" || len(h) > 253 || !strings.Contains(h, ".") {
return false
}
for _, c := range h {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.') {
return false
}
}
return !strings.HasPrefix(h, ".") && !strings.HasSuffix(h, ".") && !strings.Contains(h, "..")
}
func (s *Server) addDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in model.Domain
if !decode(w, r, &in) {
return
}
in.Name = strings.ToLower(strings.TrimSpace(in.Name))
if !validHostname(in.Name) || strings.HasPrefix(in.Name, "*.") {
fail(w, 400, errors.New("enter a domain like example.com"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
if in.EdgeID != "" {
e := s.findEdge(in.EdgeID)
if e == nil || !s.edgeAllowedFor(e, c.Org.ID) {
fail(w, 400, errors.New("choose an edge"))
return
}
}
for _, d := range s.st.Domains {
if d.OrgID != c.Org.ID && domainsOverlap(d.Name, in.Name) {
fail(w, 400, errors.New("that domain (or a parent/child of it) belongs to another organization"))
return
}
if d.OrgID == c.Org.ID && d.Name == in.Name {
fail(w, 400, errors.New("you already added that domain"))
return
}
}
in.ID, in.OrgID = newID(), c.Org.ID
s.st.Domains = append(s.st.Domains, &in)
s.save()
s.audit(c.Org.ID, c.Actor, "domain.add", in.Name)
writeJSON(w, 200, in)
}
func (s *Server) deleteDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Domains = filter(s.st.Domains, func(d *model.Domain) bool { return !(d.ID == id && d.OrgID == c.Org.ID) })
s.save()
ok(w)
}
// checkDomain resolves the domain (and a probe subdomain) and compares with the edge's IPs.
func (s *Server) checkDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
var d *model.Domain
for _, x := range s.st.Domains {
if x.ID == r.PathValue("id") && x.OrgID == c.Org.ID {
cp := *x
d = &cp
}
}
var edgeIPs []string
if d != nil {
if e := s.findEdge(d.EdgeID); e != nil {
edgeIPs = append(edgeIPs, e.IPs...)
}
}
s.mu.Unlock()
if d == nil {
fail(w, 404, errors.New("no such domain"))
return
}
want := map[string]bool{}
for _, ip := range edgeIPs {
want[ip] = true
}
if d.IP != "" {
want[d.IP] = true
}
resolved, err := net.LookupHost(d.Name)
if err != nil {
writeJSON(w, 200, map[string]any{"resolved": []string{}, "matches": false, "error": "no DNS record found"})
return
}
sort.Strings(resolved)
matches := false
for _, ip := range resolved {
if want[ip] {
matches = true
}
}
writeJSON(w, 200, map[string]any{"resolved": resolved, "matches": matches})
}
// hostnameAllowed reports whether org owns a domain that covers host.
func (s *Server) hostnameAllowed(orgID, host string) bool {
host = strings.TrimPrefix(host, "*.")
for _, d := range s.st.Domains {
if d.OrgID == orgID && (host == d.Name || strings.HasSuffix(host, "."+d.Name)) {
return true
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
// 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
}
+51
View File
@@ -0,0 +1,51 @@
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")
}
}
+70
View File
@@ -0,0 +1,70 @@
package hammerhead
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"time"
"git.veillax.com/Veillax/reef/model"
)
type AuditEntry struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
OrgID string `json:"orgId,omitempty"`
Actor string `json:"actor"`
Action string `json:"action"`
Detail string `json:"detail,omitempty"`
}
// logStore is a thin wrapper so the handlers don't care where logs live.
type logStore struct{ db *DB }
func (l *logStore) addAccess(es []model.LogEntry) { l.db.addAccess(es) }
func (l *logStore) addAudit(e AuditEntry) { l.db.addAudit(e) }
func (l *logStore) queryAccess(f accessFilter) []model.LogEntry { return l.db.queryAccess(f) }
// queryAudit returns newest-first entries; org "" selects server-level events.
func (l *logStore) queryAudit(org string, limit int, before int64) []AuditEntry {
return l.db.queryAudit(org, limit, before)
}
func (s *Server) audit(orgID, actor, action, detail string) {
s.logs.addAudit(AuditEntry{Time: time.Now(), OrgID: orgID, Actor: actor, Action: action, Detail: detail})
}
// importLegacyLogs moves logs from the old JSONL files into the database, once.
func (s *Server) importLegacyLogs() {
dir := filepath.Join(s.dir, "logs")
read := func(name string, each func([]byte)) {
path := filepath.Join(dir, name)
f, err := os.Open(path)
if err != nil {
return
}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
each(append([]byte(nil), sc.Bytes()...))
}
f.Close()
os.Rename(path, path+".migrated")
}
var batch []model.LogEntry
read("access.jsonl", func(b []byte) {
var e model.LogEntry
if json.Unmarshal(b, &e) == nil {
batch = append(batch, e)
}
})
s.db.addAccess(batch)
read("audit.jsonl", func(b []byte) {
var e AuditEntry
if json.Unmarshal(b, &e) == nil {
s.db.addAudit(e)
}
})
}
+171
View File
@@ -0,0 +1,171 @@
package hammerhead
import (
"errors"
"net"
"net/http"
"strings"
"git.veillax.com/Veillax/reef/model"
)
const defaultMailListen = "127.0.0.1:2525"
func (s *Server) findMailEgress(id string) *model.MailEgress {
for _, e := range s.st.MailEgress {
if e.ID == id {
return e
}
}
return nil
}
func isLoopbackListen(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// validateMailEgress checks e against org c and normalises it. Caller holds s.mu.
func (s *Server) validateMailEgress(c *orgCtx, e *model.MailEgress) error {
e.OrgID = c.Org.ID
e.Name = strings.TrimSpace(e.Name)
site := s.findSite(e.SiteID)
if site == nil || site.Local || site.OrgID != c.Org.ID {
return errors.New("choose one of your sites: the mail server's network (the built-in local site already sends from the edge itself)")
}
edge := s.findEdge(e.EdgeID)
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
return errors.New("choose an edge")
}
e.Listen = strings.TrimSpace(e.Listen)
if e.Listen == "" {
e.Listen = defaultMailListen
}
if !validAddr(e.Listen) {
return errors.New("listen address must look like 127.0.0.1:2525 (an address on the site's machine)")
}
for _, o := range s.st.MailEgress {
if o.ID != e.ID && o.SiteID == e.SiteID && o.Listen == e.Listen {
return errors.New("another mail egress on that site already listens on " + e.Listen)
}
}
ip := net.ParseIP(strings.TrimSpace(e.SourceIP))
if ip == nil || ip.IsUnspecified() {
return errors.New("choose the edge IP address your mail should leave from")
}
e.SourceIP = ip.String()
e.HeloName = strings.ToLower(strings.TrimSpace(e.HeloName))
if !validHostname(e.HeloName) || strings.HasPrefix(e.HeloName, "*.") {
return errors.New("enter the hostname your mail server announces, like mail.example.com (its reverse DNS should match)")
}
e.Smarthost = strings.TrimSpace(e.Smarthost)
if e.Smarthost != "" && !validAddr(e.Smarthost) {
return errors.New("smarthost must look like smtp.example.com:587")
}
if e.RatePerMinute < 0 || e.RatePerMinute > 6000 {
return errors.New("messages per minute must be between 1 and 6000")
}
if e.Username != "" && e.Password == "" {
return errors.New("set a password too, or clear the username")
}
if e.Username == "" && !isLoopbackListen(e.Listen) {
return errors.New("listening beyond this machine without a password would let anything on your network send mail from your IP; set a username and password, or listen on 127.0.0.1")
}
if e.Name == "" {
e.Name = "mail via " + e.HeloName
}
return nil
}
func (s *Server) saveMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in model.MailEgress
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
idx := -1
if id != "" {
for i, e := range s.st.MailEgress {
if e.ID == id && e.OrgID == c.Org.ID {
idx = i
}
}
if idx < 0 {
fail(w, 404, errors.New("no such mail egress"))
return
}
in.ID = id
if in.Password == "" && in.Username != "" && in.Username == s.st.MailEgress[idx].Username {
in.Password = s.st.MailEgress[idx].Password // blank keeps the saved password
}
}
if err := s.validateMailEgress(c, &in); err != nil {
fail(w, 400, err)
return
}
if idx >= 0 {
s.st.MailEgress[idx] = &in
delete(s.mailStatus, id)
} else {
in.ID = newID()
s.st.MailEgress = append(s.st.MailEgress, &in)
}
s.bump()
s.audit(c.Org.ID, c.Actor, "mailegress.save", in.Name)
resp := in // the stored record keeps its password; the response never carries it
resp.Password = ""
writeJSON(w, 200, resp)
}
func (s *Server) deleteMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
e := s.findMailEgress(id)
if e == nil || e.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such mail egress"))
return
}
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.ID != id })
delete(s.mailStatus, id)
delete(s.mailChecks, id)
s.bump()
s.audit(c.Org.ID, c.Actor, "mailegress.delete", e.Name)
ok(w)
}
type mailEgressView struct {
model.MailEgress
PasswordSet bool `json:"passwordSet"`
Status string `json:"status"`
StatusKnown bool `json:"statusKnown"`
Check *model.MailCheck `json:"check,omitempty"`
}
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
func (s *Server) mailEgressViewsLocked(orgID string) []mailEgressView {
out := []mailEgressView{}
for _, e := range s.st.MailEgress {
if e.OrgID != orgID {
continue
}
v := mailEgressView{MailEgress: *e, PasswordSet: e.Password != ""}
v.Password = ""
v.Status, v.StatusKnown = s.mailStatus[e.ID]
if chk, ok := s.mailChecks[e.ID]; ok {
c := chk
v.Check = &c
}
out = append(out, v)
}
return out
}
+544
View File
@@ -0,0 +1,544 @@
package hammerhead
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/bcrypt"
)
type mappingInput struct {
model.Mapping
NewPassword *string `json:"newPassword"` // nil = keep, "" = remove
NewPin *string `json:"newPin"`
}
type mappingView struct {
model.Mapping
Status model.MappingStatus `json:"status"`
Known bool `json:"statusKnown"`
Total Total `json:"total"`
}
func isWild(ip string) bool {
p := net.ParseIP(ip)
return p != nil && p.IsUnspecified()
}
func overlap(a, b string) bool { return a == b || isWild(a) || isWild(b) }
func netClass(proto string) string {
if proto == model.ProtoUDP {
return "udp"
}
return "tcp"
}
func listFromCSV(in []string) []string {
out := []string{}
seen := map[string]bool{}
for _, v := range in {
v = strings.ToLower(strings.TrimSpace(v))
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
m.Name = strings.TrimSpace(m.Name)
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
m.OrgID = c.Org.ID
switch m.Proto {
case model.ProtoTCP, model.ProtoUDP, model.ProtoHTTP, model.ProtoHTTPS, model.ProtoTLS:
default:
return errors.New("protocol must be tcp, udp, http, https or tls")
}
edge := s.findEdge(m.EdgeID)
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
return errors.New("choose an edge")
}
if len(m.Targets) == 0 {
return errors.New("add at least one target")
}
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
return errors.New("udp ports support one target")
}
for i := range m.Targets {
t := &m.Targets[i]
t.Host = strings.TrimSpace(t.Host)
site := s.findSite(t.SiteID)
if site == nil || !s.siteVisible(c, site) {
return errors.New("choose a site for every target")
}
if site.Local && site.EdgeID != m.EdgeID {
return errors.New("a local site can only be used with its own edge")
}
if t.Host == "" {
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 {
return errors.New("target ports must be 1-65535")
}
}
if m.BindIP == "" {
m.BindIP = "0.0.0.0"
}
ip := net.ParseIP(m.BindIP)
if ip == nil {
return errors.New("bind IP is not a valid address")
}
m.BindIP = ip.String()
if m.Port < 1 || m.Port > 65535 {
return errors.New("public port must be 1-65535")
}
switch {
case m.PortEnd == 0 || m.PortEnd == m.Port:
m.PortEnd = 0
case m.PortEnd < m.Port:
return errors.New("the end of a port range can't be before its start")
case m.PortEnd > 65535:
return errors.New("public ports must be 1-65535")
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
return errors.New("port ranges work for tcp and udp only")
case m.PortEnd-m.Port+1 > 1000:
return errors.New("a range can open at most 1000 ports")
}
for _, t := range m.Targets {
if t.Port+m.LastPort()-m.Port > 65535 {
return errors.New("the target ports would run past 65535: each public port maps to the target port plus the same offset")
}
}
if model.HostBased(m.Proto) {
if m.Hostname == "" {
return errors.New("a hostname is required for http, https and tls ports")
}
if !validHostname(m.Hostname) {
return errors.New("hostname looks invalid (use something like app.example.com or *.example.com)")
}
if !s.hostnameAllowed(c.Org.ID, m.Hostname) {
return fmt.Errorf("%s isn't under one of your domains; add the domain on the Domains tab first", m.Hostname)
}
} else {
m.Hostname = ""
}
switch m.ProxyProtocol {
case "":
case "v1", "v2":
if m.Proto != model.ProtoTCP && m.Proto != model.ProtoTLS {
return errors.New("the PROXY protocol is for tcp and tls ports (web ports already pass the client address in X-Forwarded-For)")
}
default:
return errors.New("PROXY protocol must be v1, v2 or off")
}
// web-only options
if !model.Web(m.Proto) {
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
} else {
a := &m.Auth
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
for _, id := range a.AllowedRoles {
if r := s.findRole(id); r == nil || r.OrgID != c.Org.ID {
return errors.New("an allowed role doesn't belong to this organization")
}
}
for _, id := range a.AllowedUsers {
if s.member(id, c.Org.ID) == nil {
return errors.New("an allowed user isn't a member of this organization")
}
}
if a.Email && len(a.AllowedMails) == 0 {
return errors.New("email codes need at least one allowed address (or *@example.com)")
}
if a.SessionHours < 0 || a.SessionHours > 24*90 {
return errors.New("session length must be between 1 hour and 90 days")
}
for _, r := range m.Rules {
if r.Action != "allow" && r.Action != "deny" {
return errors.New("rule action must be allow or deny")
}
switch r.Match {
case "path":
if !strings.HasPrefix(r.Value, "/") {
return errors.New("path rules must start with /")
}
case "ip":
if net.ParseIP(r.Value) == nil {
if _, _, err := net.ParseCIDR(r.Value); err != nil {
return fmt.Errorf("%q is not an IP address or CIDR range", r.Value)
}
}
default:
return errors.New("rule match must be path or ip")
}
}
m.RewriteHost = strings.TrimSpace(m.RewriteHost)
if m.Proto != model.ProtoHTTPS {
m.RedirectHTTP = false
}
if m.HealthPath != "" && !strings.HasPrefix(m.HealthPath, "/") {
return errors.New("health check path must start with /")
}
}
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")
}
for _, o := range s.st.Mappings {
if o.ID == m.ID || o.EdgeID != m.EdgeID || netClass(o.Proto) != netClass(m.Proto) || !overlap(o.BindIP, m.BindIP) ||
o.Port > m.LastPort() || m.Port > o.LastPort() {
continue
}
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.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.Name == "" {
if m.Hostname != "" {
m.Name = m.Hostname
} else {
m.Name = fmt.Sprintf("%s %s:%s", m.Proto, m.BindIP, portLabel(*m))
}
}
return nil
}
// portLabel is "25565" or, for a range, "2456-2458".
func portLabel(m model.Mapping) string {
if m.LastPort() > m.Port {
return fmt.Sprintf("%d-%d", m.Port, m.LastPort())
}
return fmt.Sprint(m.Port)
}
func checkPIN(p string) error {
if len(p) < 4 || len(p) > 12 {
return errors.New("a PIN is 4-12 digits")
}
for _, c := range p {
if c < '0' || c > '9' {
return errors.New("a PIN is digits only")
}
}
return nil
}
// applySecrets updates the password/PIN for mapping id and syncs the Auth flags. Caller holds s.mu.
func (s *Server) applySecrets(m *model.Mapping, pwHash, pinHash *string) {
sec := s.st.Secrets[m.ID]
if sec == nil {
sec = &Secret{}
}
if pwHash != nil {
sec.PasswordHash = *pwHash
}
if pinHash != nil {
sec.PinHash = *pinHash
}
if !m.Auth.Password {
sec.PasswordHash = ""
}
if !m.Auth.Pin {
sec.PinHash = ""
}
m.Auth.Password, m.Auth.Pin = sec.PasswordHash != "", sec.PinHash != ""
if sec.PasswordHash == "" && sec.PinHash == "" {
delete(s.st.Secrets, m.ID)
} else {
s.st.Secrets[m.ID] = sec
}
}
// hashSecrets prepares optional new secrets outside the lock (bcrypt is slow).
func hashSecrets(in *mappingInput) (pw, pin *string, err error) {
one := func(v *string, check func(string) error) (*string, error) {
if v == nil {
return nil, nil
}
if *v == "" {
e := ""
return &e, nil
}
if err := check(*v); err != nil {
return nil, err
}
b, err := bcrypt.GenerateFromPassword([]byte(*v), 10)
h := string(b)
return &h, err
}
if pw, err = one(in.NewPassword, func(p string) error {
if len(p) < 6 {
return errors.New("a resource password needs at least 6 characters")
}
return nil
}); err != nil {
return
}
pin, err = one(in.NewPin, checkPIN)
return
}
func (s *Server) addMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in mappingInput
if !decode(w, r, &in) {
return
}
pw, pin, err := hashSecrets(&in)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
m := in.Mapping
m.ID, m.AuthEpoch = "", 0
if err := s.validateMapping(c, &m); err != nil {
fail(w, 400, err)
return
}
m.ID = newID()
s.applySecrets(&m, pw, pin)
s.st.Mappings = append(s.st.Mappings, &m)
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.create", m.Name)
writeJSON(w, 200, m)
}
// authChanged reports whether an edit changes who may open a resource, which
// must sign existing visitors out (their old sessions were granted under the old rules).
func authChanged(o, n model.Auth, pwChanged, pinChanged bool) bool {
o.SessionHours, n.SessionHours = 0, 0
ob, _ := json.Marshal(o)
nb, _ := json.Marshal(n)
return pwChanged || pinChanged || string(ob) != string(nb)
}
// revokeOrgVisitorsLocked signs out every visitor of the org's protected resources.
// The caller must then call s.bump() so the edges learn the new epoch.
func (s *Server) revokeOrgVisitorsLocked(orgID string) {
for _, m := range s.st.Mappings {
if m.OrgID == orgID && m.Auth.Protected() {
m.AuthEpoch++
}
}
}
// revokeVisitors signs everyone out of one resource.
func (s *Server) revokeVisitors(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(r.PathValue("id"))
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
m.AuthEpoch++
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.signout-visitors", m.Name)
ok(w)
}
func (s *Server) putMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in mappingInput
if !decode(w, r, &in) {
return
}
pw, pin, err := hashSecrets(&in)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
for i, old := range s.st.Mappings {
if old.ID != id || old.OrgID != c.Org.ID {
continue
}
m := in.Mapping
m.ID = id
if err := s.validateMapping(c, &m); err != nil {
fail(w, 400, err)
return
}
s.applySecrets(&m, pw, pin)
m.AuthEpoch = old.AuthEpoch // only hammerhead moves this, never the request
if authChanged(old.Auth, m.Auth, pw != nil, pin != nil) {
m.AuthEpoch++
}
s.st.Mappings[i] = &m
delete(s.status, id)
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.update", m.Name)
writeJSON(w, 200, m)
return
}
fail(w, 404, errors.New("no such port"))
}
func (s *Server) deleteMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(id)
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
s.dropMappingLocked(id)
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.ID != id })
s.bump()
s.audit(c.Org.ID, c.Actor, "mapping.delete", m.Name)
ok(w)
}
// ---------- share links ----------
func (s *Server) listShares(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []ShareLink{}
for _, l := range s.st.Shares {
if l.OrgID == c.Org.ID && l.MappingID == r.PathValue("id") {
cp := *l
cp.TokenHash = ""
out = append(out, cp)
}
}
writeJSON(w, 200, out)
}
// publicURL is how visitors reach a web mapping, or "" for wildcard hostnames.
func publicURL(m *model.Mapping) string {
if !model.Web(m.Proto) || m.Hostname == "" || strings.HasPrefix(m.Hostname, "*") {
return ""
}
if m.Proto == model.ProtoHTTPS {
if m.Port == 443 {
return "https://" + m.Hostname
}
return fmt.Sprintf("https://%s:%d", m.Hostname, m.Port)
}
if m.Port == 80 {
return "http://" + m.Hostname
}
return fmt.Sprintf("http://%s:%d", m.Hostname, m.Port)
}
func (s *Server) createShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct {
Name string
Hours int
MaxUses int
}
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.findMapping(r.PathValue("id"))
if m == nil || m.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such port"))
return
}
base := publicURL(m)
if base == "" {
fail(w, 400, errors.New("share links need a web port with one concrete hostname"))
return
}
if !m.Auth.Protected() {
fail(w, 400, errors.New("this port has no login in front of it, so there is nothing to share past"))
return
}
tok := randHex(20)
l := &ShareLink{ID: newID(), MappingID: m.ID, OrgID: c.Org.ID, Name: strings.TrimSpace(in.Name),
TokenHash: sha(tok), MaxUses: in.MaxUses, Created: time.Now()}
if in.Hours > 0 {
l.Expires = time.Now().Add(time.Duration(in.Hours) * time.Hour)
}
s.st.Shares = append(s.st.Shares, l)
s.save()
s.audit(c.Org.ID, c.Actor, "share.create", m.Name)
writeJSON(w, 200, map[string]string{"url": base + "/?hh_share=" + tok})
}
func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("share")
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range s.st.Shares {
if l.ID == id && l.OrgID == c.Org.ID {
// sessions started from a share link can't be told apart from others, so
// revoking one signs out everyone on that resource
if m := s.findMapping(l.MappingID); m != nil {
m.AuthEpoch++
}
}
}
s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return !(l.ID == id && l.OrgID == c.Org.ID) })
s.bump()
ok(w)
}
// ---------- combined state for the UI ----------
func (s *Server) orgState(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
edges := []edgeView{}
for _, e := range s.st.Edges {
if s.edgeAllowedFor(e, c.Org.ID) {
edges = append(edges, s.viewEdge(e, false))
}
}
sites := []model.Site{}
online := map[string]bool{}
for _, st := range s.st.Sites {
if !s.siteVisible(c, st) {
continue
}
cp := *st
if !c.Admin || st.Local {
cp.Token = ""
}
sites = append(sites, cp)
online[st.ID] = s.siteOnline(st)
}
domains := []model.Domain{}
for _, d := range s.st.Domains {
if d.OrgID == c.Org.ID {
domains = append(domains, *d)
}
}
maps := []mappingView{}
for _, m := range s.st.Mappings {
if m.OrgID != c.Org.ID {
continue
}
v := mappingView{Mapping: *m}
v.Status, v.Known = s.status[m.ID]
if t := s.st.Totals[m.ID]; t != nil {
v.Total = *t
}
maps = append(maps, v)
}
writeJSON(w, 200, map[string]any{
"org": c.Org, "admin": c.Admin, "edges": edges, "sites": sites, "online": online,
"domains": domains, "mappings": maps, "roles": s.rolesLocked(c.Org.ID), "mailEgress": s.mailEgressViewsLocked(c.Org.ID),
"mailConfigured": s.st.Settings.SMTP.Host != "",
"dashboardUrl": s.st.Settings.DashboardURL,
})
}
+112
View File
@@ -0,0 +1,112 @@
package hammerhead
import (
"crypto/subtle"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
// metrics serves Prometheus text format at /metrics. It is off unless a token
// is configured (HAMMERHEAD_METRICS_TOKEN), and then requires it as a bearer token.
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
want := s.opts.MetricsToken
if want == "" {
http.NotFound(w, r)
return
}
got := bearer(r)
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var b strings.Builder
typ := map[string]bool{}
line := func(name, kind, help string, value float64, labels ...string) {
if !typ[name] {
typ[name] = true
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, kind)
}
var l []string
for i := 0; i+1 < len(labels); i += 2 {
l = append(l, fmt.Sprintf(`%s="%s"`, labels[i], promEscape(labels[i+1])))
}
if len(l) > 0 {
fmt.Fprintf(&b, "%s{%s} %v\n", name, strings.Join(l, ","), value)
} else {
fmt.Fprintf(&b, "%s %v\n", name, value)
}
}
b2f := func(v bool) float64 {
if v {
return 1
}
return 0
}
now := time.Now()
s.mu.Lock()
orgName := func(id string) string {
if o := s.findOrg(id); o != nil {
return o.Name
}
return ""
}
for _, e := range s.st.Edges {
line("hammerhead_edge_up", "gauge", "1 if the edge contacted Hammerhead recently", b2f(!e.LastSeen.IsZero() && now.Sub(e.LastSeen) <= s.offlineAfter()), "edge", e.Name)
}
for _, st := range s.st.Sites {
if !st.Local {
line("hammerhead_site_up", "gauge", "1 if the site agent contacted Hammerhead recently", b2f(!st.LastSeen.IsZero() && now.Sub(st.LastSeen) <= s.offlineAfter()), "site", st.Name, "org", orgName(st.OrgID))
}
}
for _, m := range s.st.Mappings {
org := orgName(m.OrgID)
st, known := s.status[m.ID]
line("hammerhead_port_listening", "gauge", "1 if the edge reports the port is open", b2f(m.Enabled && known && st.Error == ""), "port", m.Name, "org", org)
for i, th := range st.Targets {
if i < len(m.Targets) {
line("hammerhead_target_healthy", "gauge", "1 if the target passes its health check", b2f(th.Healthy), "port", m.Name, "org", org, "target", fmt.Sprintf("%s:%d", m.Targets[i].Host, m.Targets[i].Port))
}
}
if t := s.st.Totals[m.ID]; t != nil {
line("hammerhead_port_connections_total", "counter", "Connections or requests served", float64(t.Conns), "port", m.Name, "org", org)
line("hammerhead_port_bytes_in_total", "counter", "Bytes from clients to targets", float64(t.BytesIn), "port", m.Name, "org", org)
line("hammerhead_port_bytes_out_total", "counter", "Bytes from targets to clients", float64(t.BytesOut), "port", m.Name, "org", org)
}
}
for _, x := range s.st.MailEgress {
org := orgName(x.OrgID)
line("hammerhead_mail_relay_listening", "gauge", "1 if the mail relay is running", b2f(x.Enabled && s.mailStatus[x.ID] == ""), "relay", x.Name, "org", org)
if c, ok := s.mailChecks[x.ID]; ok {
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.PTRMatchesHelo), "relay", x.Name, "org", org, "check", "reverse_dns")
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.HeloResolves), "relay", x.Name, "org", org, "check", "helo_resolves")
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.Port25 == "open"), "relay", x.Name, "org", org, "check", "port25")
}
}
firing := map[string]int{}
for _, a := range s.alerts {
if a.Fired {
firing[orgName(a.OrgID)]++
}
}
orgs := make([]string, 0, len(firing))
for o := range firing {
orgs = append(orgs, o)
}
sort.Strings(orgs)
for _, o := range orgs {
line("hammerhead_alerts_firing", "gauge", "Alerts currently firing", float64(firing[o]), "org", o)
}
line("hammerhead_users", "gauge", "Accounts", float64(len(s.st.Users)))
line("hammerhead_orgs", "gauge", "Organizations", float64(len(s.st.Orgs)))
s.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Write([]byte(b.String()))
}
func promEscape(s string) string {
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s)
}
+346
View File
@@ -0,0 +1,346 @@
package hammerhead
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Hammerhead acts as an OpenID Connect relying party using the authorization
// code flow with PKCE. Identity comes from the provider's userinfo endpoint,
// fetched over TLS with the access token we just received from the token endpoint.
type oidcDoc struct {
auth, token, userinfo string
fetched time.Time
}
type oauthState struct {
idp, verifier, ret string
expires time.Time
}
var oidcClient = &http.Client{Timeout: 10 * time.Second}
func (s *Server) discover(idp *IdP) (*oidcDoc, error) {
s.mu.Lock()
d := s.oidc[idp.ID]
s.mu.Unlock()
if d != nil && time.Since(d.fetched) < time.Hour {
return d, nil
}
resp, err := oidcClient.Get(strings.TrimRight(idp.Issuer, "/") + "/.well-known/openid-configuration")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("discovery returned %d", resp.StatusCode)
}
var doc struct {
Auth string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
Userinfo string `json:"userinfo_endpoint"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return nil, err
}
if doc.Auth == "" || doc.Token == "" || doc.Userinfo == "" {
return nil, errors.New("provider discovery document is missing endpoints")
}
d = &oidcDoc{auth: doc.Auth, token: doc.Token, userinfo: doc.Userinfo, fetched: time.Now()}
s.mu.Lock()
s.oidc[idp.ID] = d
s.mu.Unlock()
return d, nil
}
// baseURL is where browsers reach hammerhead.
func (s *Server) baseURL(r *http.Request) string {
s.mu.Lock()
u := s.st.Settings.DashboardURL
s.mu.Unlock()
if u != "" {
return u
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host
}
func safeReturn(p string) string {
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
return "/"
}
return p
}
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
var idp *IdP
for _, p := range s.st.IdPs {
if p.ID == r.PathValue("id") {
c := *p
idp = &c
}
}
s.mu.Unlock()
if idp == nil {
http.Error(w, "unknown provider", 404)
return
}
doc, err := s.discover(idp)
if err != nil {
http.Error(w, "identity provider unreachable: "+err.Error(), 502)
return
}
state, verifier := randHex(16), randHex(32)
sum := sha256.Sum256([]byte(verifier))
s.mu.Lock()
now := time.Now()
for k, v := range s.oauth {
if now.After(v.expires) {
delete(s.oauth, k)
}
}
s.oauth[state] = oauthState{idp: idp.ID, verifier: verifier, ret: safeReturn(r.URL.Query().Get("return")), expires: now.Add(10 * time.Minute)}
s.mu.Unlock()
scopes := idp.Scopes
if scopes == "" {
scopes = "openid email profile"
}
q := url.Values{
"response_type": {"code"},
"client_id": {idp.ClientID},
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
"scope": {scopes},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])},
"code_challenge_method": {"S256"},
}
sep := "?"
if strings.Contains(doc.auth, "?") {
sep = "&"
}
http.Redirect(w, r, doc.auth+sep+q.Encode(), http.StatusFound)
}
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
s.mu.Lock()
st, found := s.oauth[q.Get("state")]
delete(s.oauth, q.Get("state"))
var idp *IdP
if found && time.Now().Before(st.expires) {
for _, p := range s.st.IdPs {
if p.ID == st.idp {
c := *p
idp = &c
}
}
}
s.mu.Unlock()
if idp == nil {
http.Error(w, "sign-in expired, please try again", 400)
return
}
if e := q.Get("error"); e != "" {
http.Error(w, "identity provider said: "+e, 400)
return
}
email, name, sub, err := s.oidcExchange(r, idp, q.Get("code"), st.verifier)
if err != nil {
http.Error(w, "sign-in failed: "+err.Error(), 400)
return
}
uid, err := s.oidcUser(idp, email, name, sub)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
s.startSession(w, r, uid)
s.audit("", email, "login", "oidc:"+idp.Name)
http.Redirect(w, r, st.ret, http.StatusFound)
}
func (s *Server) oidcExchange(r *http.Request, idp *IdP, code, verifier string) (email, name, sub string, err error) {
doc, err := s.discover(idp)
if err != nil {
return
}
resp, err := oidcClient.PostForm(doc.token, url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
"client_id": {idp.ClientID}, "client_secret": {idp.ClientSecret}, "code_verifier": {verifier},
})
if err != nil {
return
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
}
if resp.StatusCode != 200 || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok) != nil || tok.AccessToken == "" {
err = fmt.Errorf("token exchange failed (%d)", resp.StatusCode)
return
}
req, _ := http.NewRequest("GET", doc.userinfo, nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
ui, err := oidcClient.Do(req)
if err != nil {
return
}
defer ui.Body.Close()
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
if ui.StatusCode != 200 || json.NewDecoder(io.LimitReader(ui.Body, 1<<20)).Decode(&claims) != nil {
err = fmt.Errorf("userinfo failed (%d)", ui.StatusCode)
return
}
// some providers send "true" as a string, some omit it; only an explicit false is refused
if v, ok := claims.EmailVerified.(bool); ok && !v {
err = errors.New("your email address is not verified at the identity provider")
return
}
if v, ok := claims.EmailVerified.(string); ok && strings.EqualFold(v, "false") {
err = errors.New("your email address is not verified at the identity provider")
return
}
if claims.Sub == "" || claims.Email == "" {
err = errors.New("the identity provider did not return an email address")
return
}
name = claims.Name
if name == "" {
name = claims.PreferredUsername
}
return strings.ToLower(claims.Email), name, claims.Sub, nil
}
func (s *Server) oidcUser(idp *IdP, email, name, sub string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.st.Users {
if u.IdP == idp.ID && u.IdPSub == sub {
return u.ID, nil
}
}
if len(idp.AllowedDomains) > 0 {
okDomain := false
for _, d := range idp.AllowedDomains {
if strings.HasSuffix(email, "@"+strings.ToLower(strings.TrimSpace(d))) {
okDomain = true
}
}
if !okDomain {
return "", errors.New("your email domain is not allowed to sign in here")
}
}
if u := s.userByEmail(email); u != nil { // link an existing account by verified email
if u.IdP == "" {
u.IdP, u.IdPSub = idp.ID, sub
s.save()
}
return u.ID, nil
}
if !idp.AutoProvision {
return "", errors.New("no account exists for " + email + "; ask an administrator to invite you")
}
if name == "" {
name = email
}
u := &User{ID: newID(), Email: email, Name: name, IdP: idp.ID, IdPSub: sub, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
if s.findOrg(idp.OrgID) != nil {
role := s.findRole(idp.RoleID)
if role == nil || role.OrgID != idp.OrgID {
for _, r := range s.st.Roles {
if r.OrgID == idp.OrgID && r.Name == "Member" && r.Builtin {
role = r
}
}
}
if role != nil {
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: idp.OrgID, RoleID: role.ID})
}
}
s.save()
return u.ID, nil
}
// ---------- admin CRUD ----------
func (s *Server) listIdPs(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []IdP{}
for _, i := range s.st.IdPs {
c := *i
c.ClientSecret = ""
out = append(out, c)
}
writeJSON(w, 200, out)
}
func (s *Server) saveIdP(w http.ResponseWriter, r *http.Request, p *principal) {
var in IdP
if !decode(w, r, &in) {
return
}
in.Issuer = strings.TrimRight(strings.TrimSpace(in.Issuer), "/")
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || in.Issuer == "" || in.ClientID == "" {
fail(w, 400, errors.New("name, issuer URL and client ID are required"))
return
}
if !strings.HasPrefix(in.Issuer, "https://") && !strings.HasPrefix(in.Issuer, "http://") {
fail(w, 400, errors.New("issuer must be a URL"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
if id := r.PathValue("id"); id != "" {
for _, e := range s.st.IdPs {
if e.ID == id {
if in.ClientSecret == "" {
in.ClientSecret = e.ClientSecret
}
in.ID = id
*e = in
delete(s.oidc, id)
s.save()
ok(w)
return
}
}
fail(w, 404, errors.New("no such provider"))
return
}
in.ID = newID()
s.st.IdPs = append(s.st.IdPs, &in)
s.save()
ok(w)
}
func (s *Server) deleteIdP(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.IdPs = filter(s.st.IdPs, func(i *IdP) bool { return i.ID != id })
s.save()
ok(w)
}
+642
View File
@@ -0,0 +1,642 @@
package hammerhead
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
// ---------- organizations ----------
func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct {
Name string
AllowLocal bool
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
s.mu.Lock()
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
s.save()
o := *org
s.mu.Unlock()
s.audit(o.ID, p.User.Email, "org.create", o.Name)
writeJSON(w, 200, o)
}
func (s *Server) updateOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct {
Name string
AllowLocal *bool
}
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
o := s.findOrg(c.Org.ID)
if n := strings.TrimSpace(in.Name); n != "" {
o.Name = n
}
if in.AllowLocal != nil {
if !c.User.ServerAdmin {
fail(w, 403, errors.New("only a server administrator can change this"))
return
}
o.AllowLocal = *in.AllowLocal
}
s.bump()
ok(w)
}
func (s *Server) deleteOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
if !c.User.ServerAdmin {
fail(w, 403, errors.New("server administrators only"))
return
}
id := c.Org.ID
s.mu.Lock()
defer s.mu.Unlock()
s.st.Orgs = filter(s.st.Orgs, func(o *Org) bool { return o.ID != id })
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.OrgID != id })
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.OrgID != id })
s.st.Invites = filter(s.st.Invites, func(o *Invite) bool { return o.OrgID != id })
s.st.APIKeys = filter(s.st.APIKeys, func(o *APIKey) bool { return o.OrgID != id })
s.st.Sites = filter(s.st.Sites, func(o *model.Site) bool { return o.OrgID != id })
s.st.Domains = filter(s.st.Domains, func(o *model.Domain) bool { return o.OrgID != id })
s.st.Shares = filter(s.st.Shares, func(o *ShareLink) bool { return o.OrgID != id })
s.st.MailEgress = filter(s.st.MailEgress, func(o *model.MailEgress) bool { return o.OrgID != id })
s.st.DKIM = filter(s.st.DKIM, func(o *model.DKIMKey) bool { return o.OrgID != id })
s.st.Channels = filter(s.st.Channels, func(o *AlertChannel) bool { return o.OrgID != id })
for _, m := range s.st.Mappings {
if m.OrgID == id {
delete(s.st.Secrets, m.ID)
delete(s.st.Totals, m.ID)
delete(s.status, m.ID)
}
}
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.OrgID != id })
for _, e := range s.st.Edges {
e.OrgIDs = removeStr(e.OrgIDs, id)
}
s.bump()
ok(w)
}
func removeStr(in []string, v string) []string {
out := []string{}
for _, x := range in {
if x != v {
out = append(out, x)
}
}
return out
}
// ---------- roles ----------
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, 200, s.rolesLocked(c.Org.ID))
}
func (s *Server) rolesLocked(orgID string) []Role {
out := []Role{}
for _, r := range s.st.Roles {
if r.OrgID == orgID {
out = append(out, *r)
}
}
return out
}
func (s *Server) saveRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in Role
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, o := range s.st.Roles {
if o.OrgID == c.Org.ID && strings.EqualFold(o.Name, in.Name) && o.ID != r.PathValue("role") {
fail(w, 400, errors.New("a role with that name exists"))
return
}
}
if id := r.PathValue("role"); id != "" {
role := s.findRole(id)
if role == nil || role.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such role"))
return
}
if role.Builtin {
fail(w, 400, errors.New("built-in roles can't be edited"))
return
}
role.Name, role.Admin = in.Name, in.Admin
} else {
s.st.Roles = append(s.st.Roles, &Role{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Admin: in.Admin})
}
s.save()
s.audit(c.Org.ID, c.Actor, "role.save", in.Name)
ok(w)
}
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("role")
s.mu.Lock()
defer s.mu.Unlock()
role := s.findRole(id)
if role == nil || role.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such role"))
return
}
if role.Builtin {
fail(w, 400, errors.New("built-in roles can't be deleted"))
return
}
for _, m := range s.st.Members {
if m.RoleID == id {
fail(w, 400, errors.New("that role still has members; move them first"))
return
}
}
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.ID != id })
for _, m := range s.st.Mappings {
m.Auth.AllowedRoles = removeStr(m.Auth.AllowedRoles, id)
}
s.revokeOrgVisitorsLocked(c.Org.ID)
s.bump()
ok(w)
}
// ---------- members ----------
func (s *Server) listMembers(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []map[string]any{}
for _, m := range s.st.Members {
if m.OrgID != c.Org.ID {
continue
}
u := s.findUser(m.UserID)
if u == nil {
continue
}
out = append(out, map[string]any{"userId": u.ID, "email": u.Email, "name": u.Name, "roleId": m.RoleID,
"totp": u.TOTPEnabled, "sso": u.IdP != ""})
}
sort.Slice(out, func(i, j int) bool { return out[i]["email"].(string) < out[j]["email"].(string) })
writeJSON(w, 200, out)
}
// adminCountLocked counts members with an admin role, excluding skip.
func (s *Server) adminCountLocked(orgID, skip string) int {
n := 0
for _, m := range s.st.Members {
if m.OrgID == orgID && m.UserID != skip {
if role := s.findRole(m.RoleID); role != nil && role.Admin {
n++
}
}
}
return n
}
func (s *Server) setMemberRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ RoleID string }
if !decode(w, r, &in) {
return
}
uid := r.PathValue("user")
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(uid, c.Org.ID)
role := s.findRole(in.RoleID)
if m == nil || role == nil || role.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such member or role"))
return
}
if !role.Admin && s.adminCountLocked(c.Org.ID, uid) == 0 {
fail(w, 400, errors.New("an organization needs at least one admin"))
return
}
m.RoleID = in.RoleID
s.revokeOrgVisitorsLocked(c.Org.ID) // what they may open just changed
s.bump()
s.audit(c.Org.ID, c.Actor, "member.role", uid+" -> "+role.Name)
ok(w)
}
func (s *Server) removeMember(w http.ResponseWriter, r *http.Request, c *orgCtx) {
uid := r.PathValue("user")
s.mu.Lock()
defer s.mu.Unlock()
if s.member(uid, c.Org.ID) == nil {
fail(w, 404, errors.New("no such member"))
return
}
if s.adminCountLocked(c.Org.ID, uid) == 0 {
fail(w, 400, errors.New("an organization needs at least one admin"))
return
}
s.st.Members = filter(s.st.Members, func(m *Member) bool { return !(m.UserID == uid && m.OrgID == c.Org.ID) })
for _, m := range s.st.Mappings {
if m.OrgID == c.Org.ID {
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, uid)
}
}
s.revokeOrgVisitorsLocked(c.Org.ID) // the removed member's open sessions end now
s.bump()
s.audit(c.Org.ID, c.Actor, "member.remove", uid)
ok(w)
}
// ---------- invites ----------
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []Invite{}
for _, i := range s.st.Invites {
if i.OrgID == c.Org.ID && time.Now().Before(i.Expires) {
cp := *i
cp.TokenHash = ""
out = append(out, cp)
}
}
writeJSON(w, 200, out)
}
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Email, RoleID string }
if !decode(w, r, &in) {
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
if !strings.Contains(in.Email, "@") {
fail(w, 400, errors.New("enter a valid email address"))
return
}
tok := randHex(20)
s.mu.Lock()
role := s.findRole(in.RoleID)
if role == nil || role.OrgID != c.Org.ID {
s.mu.Unlock()
fail(w, 400, errors.New("choose a role"))
return
}
s.st.Invites = append(s.st.Invites, &Invite{ID: newID(), OrgID: c.Org.ID, Email: in.Email, RoleID: role.ID,
TokenHash: sha(tok), Expires: time.Now().Add(7 * 24 * time.Hour), Created: time.Now()})
s.save()
s.mu.Unlock()
link := s.baseURL(r) + "/#invite=" + tok
emailed := false
if s.mailConfigured() {
body := fmt.Sprintf("%s invited you to join %q on Hammerhead.\n\nAccept: %s\n\nThe link works for 7 days.", c.Actor, c.Org.Name, link)
emailed = s.Mailer(in.Email, "You're invited to "+c.Org.Name, body) == nil
}
s.audit(c.Org.ID, c.Actor, "invite.create", in.Email)
writeJSON(w, 200, map[string]any{"link": link, "emailed": emailed})
}
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return !(i.ID == id && i.OrgID == c.Org.ID) })
s.save()
ok(w)
}
func (s *Server) inviteInfo(w http.ResponseWriter, r *http.Request) {
h := sha(r.PathValue("token"))
s.mu.Lock()
defer s.mu.Unlock()
for _, i := range s.st.Invites {
if i.TokenHash == h && time.Now().Before(i.Expires) {
org := s.findOrg(i.OrgID)
writeJSON(w, 200, map[string]any{"email": i.Email, "org": org.Name, "existing": s.userByEmail(i.Email) != nil})
return
}
}
fail(w, 404, errors.New("this invitation is invalid or has expired"))
}
func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
var in struct{ Token, Name, Password string }
if !decode(w, r, &in) {
return
}
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
fail(w, 429, errors.New("too many attempts"))
return
}
h := sha(in.Token)
p := s.principalFrom(r)
var hash string
s.mu.Lock()
var inv *Invite
for _, i := range s.st.Invites {
if i.TokenHash == h && time.Now().Before(i.Expires) {
inv = i
}
}
existing := (*User)(nil)
if inv != nil {
existing = s.userByEmail(inv.Email)
}
s.mu.Unlock()
if inv == nil {
fail(w, 404, errors.New("this invitation is invalid or has expired"))
return
}
if existing == nil {
var err error
if hash, err = hashPassword(in.Password); err != nil {
fail(w, 400, err)
return
}
} else if p == nil || p.User.ID != existing.ID {
fail(w, 400, errors.New("an account with this email already exists: sign in first, then open the link again"))
return
}
s.mu.Lock()
uid := ""
if existing != nil {
uid = existing.ID
} else {
name := strings.TrimSpace(in.Name)
if name == "" {
name = inv.Email
}
u := &User{ID: newID(), Email: inv.Email, Name: name, PassHash: hash, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
uid = u.ID
}
if s.member(uid, inv.OrgID) == nil {
s.st.Members = append(s.st.Members, &Member{UserID: uid, OrgID: inv.OrgID, RoleID: inv.RoleID})
}
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return i.ID != inv.ID })
s.save()
s.mu.Unlock()
if existing == nil {
s.startSession(w, r, uid)
}
s.audit(inv.OrgID, inv.Email, "invite.accept", "")
ok(w)
}
// ---------- API keys ----------
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []APIKey{}
for _, k := range s.st.APIKeys {
if k.OrgID == c.Org.ID {
cp := *k
cp.Hash = ""
out = append(out, cp)
}
}
writeJSON(w, 200, out)
}
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
id, secret := randHex(4), randHex(24)
s.mu.Lock()
s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()})
s.save()
s.mu.Unlock()
s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name)
writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret})
}
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == c.Org.ID) })
s.save()
s.audit(c.Org.ID, c.Actor, "apikey.delete", id)
ok(w)
}
// ---------- logs & stats ----------
func (s *Server) orgLogs(w http.ResponseWriter, r *http.Request, c *orgCtx) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
if q.Get("kind") == "audit" {
writeJSON(w, 200, s.logs.queryAudit(c.Org.ID, limit, before))
return
}
class, _ := strconv.Atoi(q.Get("status"))
writeJSON(w, 200, s.logs.queryAccess(accessFilter{Org: c.Org.ID, Mapping: q.Get("mapping"), Q: strings.TrimSpace(q.Get("q")),
Result: q.Get("result"), StatusClass: class, Before: before, Limit: limit}))
}
// orgTraffic returns bytes per hour for each of the org's ports, oldest first.
func (s *Server) orgTraffic(w http.ResponseWriter, r *http.Request, c *orgCtx) {
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
s.mu.Lock()
var ids []string
for _, m := range s.st.Mappings {
if m.OrgID == c.Org.ID {
ids = append(ids, m.ID)
}
}
s.mu.Unlock()
writeJSON(w, 200, s.db.trafficHistory(ids, hours))
}
func (s *Server) orgStats(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string]Total{}
for _, m := range s.st.Mappings {
if m.OrgID == c.Org.ID {
if t := s.st.Totals[m.ID]; t != nil {
out[m.ID] = *t
}
}
}
writeJSON(w, 200, out)
}
// ---------- server admin: users ----------
func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []map[string]any{}
for _, u := range s.st.Users {
orgs := []string{}
for _, m := range s.st.Members {
if m.UserID == u.ID {
if o := s.findOrg(m.OrgID); o != nil {
orgs = append(orgs, o.Name)
}
}
}
out = append(out, map[string]any{"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
"totp": u.TOTPEnabled, "sso": u.IdP != "", "orgs": orgs})
}
writeJSON(w, 200, out)
}
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct {
Email, Name, Password string
ServerAdmin bool
}
if !decode(w, r, &in) {
return
}
hash, err := hashPassword(in.Password)
if err != nil {
fail(w, 400, err)
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
if !strings.Contains(in.Email, "@") {
fail(w, 400, errors.New("enter a valid email address"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.userByEmail(in.Email) != nil {
fail(w, 400, errors.New("that email already has an account"))
return
}
name := strings.TrimSpace(in.Name)
if name == "" {
name = in.Email
}
s.st.Users = append(s.st.Users, &User{ID: newID(), Email: in.Email, Name: name, PassHash: hash, ServerAdmin: in.ServerAdmin, Created: time.Now()})
s.save()
s.audit("", p.User.Email, "user.create", in.Email)
ok(w)
}
func (s *Server) adminUpdateUser(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct {
ServerAdmin *bool
ResetTOTP bool
Password string
SignOut bool
}
if !decode(w, r, &in) {
return
}
var hash string
if in.Password != "" {
var err error
if hash, err = hashPassword(in.Password); err != nil {
fail(w, 400, err)
return
}
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(r.PathValue("id"))
if u == nil {
fail(w, 404, errors.New("no such user"))
return
}
if in.ServerAdmin != nil {
if !*in.ServerAdmin && u.ServerAdmin {
n := 0
for _, o := range s.st.Users {
if o.ServerAdmin {
n++
}
}
if n <= 1 {
fail(w, 400, errors.New("there must be at least one server administrator"))
return
}
}
u.ServerAdmin = *in.ServerAdmin
}
if in.ResetTOTP {
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
}
if hash != "" || in.SignOut {
if hash != "" {
u.PassHash = hash
}
for k, sess := range s.st.Sessions {
if sess.UserID == u.ID {
delete(s.st.Sessions, k)
}
}
}
s.save()
s.audit("", p.User.Email, "user.update", u.Email)
ok(w)
}
func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
if id == p.User.ID {
fail(w, 400, errors.New("you can't delete yourself"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(id)
if u == nil {
fail(w, 404, errors.New("no such user"))
return
}
for _, mem := range s.st.Members {
if mem.UserID == id {
s.revokeOrgVisitorsLocked(mem.OrgID) // their open sessions on protected resources end now
}
}
s.st.Users = filter(s.st.Users, func(o *User) bool { return o.ID != id })
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.UserID != id })
for k, sess := range s.st.Sessions {
if sess.UserID == id {
delete(s.st.Sessions, k)
}
}
for _, m := range s.st.Mappings {
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, id)
}
s.bump()
s.audit("", p.User.Email, "user.delete", u.Email)
ok(w)
}
+340
View File
@@ -0,0 +1,340 @@
package hammerhead
import (
"crypto/rand"
_ "embed"
"errors"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"git.veillax.com/Veillax/reef/authtoken"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/bcrypt"
)
//go:embed web/resource.html
var resourceHTML []byte
// The visitor-facing login flow for protected web ports.
//
// 1. blacktip sees an unauthenticated request and redirects to
// <dashboard>/auth/r/<mapping>?return=<original url>
// 2. the visitor proves who they are here (account, password, PIN, emailed
// code or share link)
// 3. we redirect back to <original host>/.hammerhead/callback?t=<signed token>
// and blacktip turns that into a cookie.
type otpEntry struct {
hash string
expires time.Time
tries int
}
func (s *Server) resourcePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Write(resourceHTML)
}
// protectedMapping loads a mapping that has a login in front of it. Caller holds s.mu.
func (s *Server) protectedMapping(id string) *model.Mapping {
m := s.findMapping(id)
if m == nil || !model.Web(m.Proto) || !m.Auth.Protected() || !m.Enabled {
return nil
}
return m
}
// checkReturn makes sure the return URL points at this mapping, so the login can't be used as an open redirect.
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("invalid return address")
}
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
return nil, errors.New("return address uses the wrong scheme")
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "80"
if u.Scheme == "https" {
port = "443"
}
}
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 u, nil
}
// canAccess applies the SSO policy. Caller holds s.mu.
func (s *Server) canAccess(u *User, m *model.Mapping) bool {
if u.ServerAdmin {
return true
}
mem := s.member(u.ID, m.OrgID)
if mem == nil {
return false
}
if role := s.findRole(mem.RoleID); role != nil && role.Admin {
return true
}
a := m.Auth
if len(a.AllowedRoles) == 0 && len(a.AllowedUsers) == 0 {
return true
}
for _, id := range a.AllowedRoles {
if id == mem.RoleID {
return true
}
}
for _, id := range a.AllowedUsers {
if id == u.ID {
return true
}
}
return false
}
func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
s.mu.Lock()
defer s.mu.Unlock()
m := s.protectedMapping(r.PathValue("id"))
if m == nil {
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist"))
return
}
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
fail(w, 400, err)
return
}
info := map[string]any{
"name": m.Name, "host": m.Hostname,
"sso": m.Auth.SSO, "password": m.Auth.Password, "pin": m.Auth.Pin,
"email": m.Auth.Email && s.st.Settings.SMTP.Host != "",
"providers": []map[string]string{},
}
if m.Auth.SSO {
info["providers"] = s.providerList()
}
if p != nil && p.User.ID != "" {
u := s.findUser(p.User.ID)
info["user"] = map[string]any{"email": u.Email, "name": u.Name, "allowed": s.canAccess(u, m)}
}
writeJSON(w, 200, info)
}
// grant mints the callback token and tells the browser where to go.
func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, sub string) {
s.mu.Lock()
e := s.findEdge(m.EdgeID)
var key string
if e != nil {
key = e.AuthKey
}
s.mu.Unlock()
if key == "" {
fail(w, 500, errors.New("edge is not set up for logins"))
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()})
dest := fmt.Sprintf("%s://%s/.hammerhead/callback?t=%s&r=%s", back.Scheme, back.Host, tok, url.QueryEscape(back.RequestURI()))
s.audit(m.OrgID, sub, "resource.login", m.Name)
writeJSON(w, 200, map[string]string{"redirect": dest})
}
// resourceAuth wraps a method handler: it loads the mapping, checks the return URL and rate-limits.
func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if !decode(w, r, &body) {
return
}
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"))
return
}
s.mu.Lock()
pm := s.protectedMapping(r.PathValue("id"))
var m model.Mapping
if pm != nil {
m = *pm
}
s.mu.Unlock()
if pm == nil {
fail(w, 404, errors.New("no such resource"))
return
}
back, err := checkReturn(&m, body["return"])
if err != nil {
fail(w, 400, err)
return
}
fn(w, r, &m, back, body)
}
}
func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
s.mu.Lock()
sec := s.st.Secrets[m.ID]
s.mu.Unlock()
hash := string(dummyHash)
if sec != nil && sec.PasswordHash != "" && m.Auth.Password {
hash = sec.PasswordHash
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong password"))
return
}
s.grant(w, r, m, back, "password")
}
func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
s.mu.Lock()
sec := s.st.Secrets[m.ID]
s.mu.Unlock()
hash := string(dummyHash)
if sec != nil && sec.PinHash != "" && m.Auth.Pin {
hash = sec.PinHash
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong PIN"))
return
}
s.grant(w, r, m, back, "pin")
}
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
p := s.principalFrom(r)
if p == nil || p.User.ID == "" || !m.Auth.SSO {
fail(w, 401, errors.New("sign in first"))
return
}
s.mu.Lock()
u := s.findUser(p.User.ID)
allowed := u != nil && s.canAccess(u, m)
s.mu.Unlock()
if !allowed {
fail(w, 403, errors.New("your account doesn't have access to this resource"))
return
}
s.grant(w, r, m, back, p.User.Email)
}
func mailAllowed(list []string, email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
for _, a := range list {
if a == email || (strings.HasPrefix(a, "*@") && strings.HasSuffix(email, a[1:])) {
return true
}
}
return false
}
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"]))
if !m.Auth.Email || !s.mailConfigured() {
fail(w, 400, errors.New("email codes are not available for this resource"))
return
}
// always answer the same way so addresses can't be probed
reply := func() {
writeJSON(w, 200, map[string]string{"status": "If that address has access, a code is on its way."})
}
if !mailAllowed(m.Auth.AllowedMails, email) || !s.allow("otp|"+email, 3, 10*time.Minute) {
reply()
return
}
n, _ := rand.Int(rand.Reader, big.NewInt(1000000))
code := fmt.Sprintf("%06d", n.Int64())
s.mu.Lock()
s.otps[m.ID+"|"+email] = otpEntry{hash: sha(code), expires: time.Now().Add(10 * time.Minute)}
s.mu.Unlock()
go s.Mailer(email, "Your sign-in code for "+m.Name, fmt.Sprintf("Your code is %s\n\nIt works for 10 minutes. If you didn't ask for it, ignore this email.", code))
reply()
}
func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
email := strings.ToLower(strings.TrimSpace(body["email"]))
key := m.ID + "|" + email
s.mu.Lock()
e, found := s.otps[key]
good := false
if found && time.Now().Before(e.expires) && e.tries < 5 {
e.tries++
s.otps[key] = e
good = e.hash == sha(strings.TrimSpace(body["code"]))
if good {
delete(s.otps, key)
}
}
s.mu.Unlock()
if !good || !mailAllowed(m.Auth.AllowedMails, email) {
fail(w, 401, errors.New("wrong or expired code"))
return
}
s.grant(w, r, m, back, email)
}
func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
h := sha(body["token"])
s.mu.Lock()
var link *ShareLink
for _, l := range s.st.Shares {
if l.MappingID == m.ID && l.TokenHash == h {
link = l
}
}
valid := link != nil && (link.Expires.IsZero() || time.Now().Before(link.Expires)) && (link.MaxUses == 0 || link.Uses < link.MaxUses)
name := ""
if valid {
link.Uses++
name = link.Name
s.save()
}
s.mu.Unlock()
if !valid {
fail(w, 401, errors.New("this share link is invalid, expired or used up"))
return
}
if name == "" {
name = "link"
}
s.grant(w, r, m, back, "share:"+name)
}
// myResources lists the web ports the signed-in user may open.
func (s *Server) myResources(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
out := []map[string]string{}
for _, m := range s.st.Mappings {
if !m.Enabled || !model.Web(m.Proto) {
continue
}
if m.Auth.Protected() && !(m.Auth.SSO && s.canAccess(u, m)) {
continue
}
if !m.Auth.Protected() && s.member(u.ID, m.OrgID) == nil && !u.ServerAdmin {
continue
}
if link := publicURL(m); link != "" {
org := ""
if o := s.findOrg(m.OrgID); o != nil {
org = o.Name
}
out = append(out, map[string]string{"name": m.Name, "url": link, "org": org})
}
}
writeJSON(w, 200, out)
}
+140
View File
@@ -0,0 +1,140 @@
package hammerhead
import (
_ "embed"
"net/http"
)
//go:embed web/index.html
var indexHTML []byte
func page(html []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store")
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
w.Write(html)
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
index := page(indexHTML)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
index(w, r)
})
s.registerAlertRoutes(mux)
mux.HandleFunc("GET /metrics", s.metrics)
// public
mux.HandleFunc("GET /api/public", s.publicInfo)
mux.HandleFunc("POST /api/setup", s.setup)
mux.HandleFunc("POST /api/login", s.login)
mux.HandleFunc("POST /api/login/totp", s.loginTOTP)
mux.HandleFunc("GET /api/invites/{token}", s.inviteInfo)
mux.HandleFunc("POST /api/invites/accept", s.acceptInvite)
mux.HandleFunc("GET /auth/oidc/{id}/start", s.oidcStart)
mux.HandleFunc("GET /auth/oidc/callback", s.oidcCallback)
// visitor login for protected ports
res := page(resourceHTML)
mux.HandleFunc("GET /auth/r/{id}", res)
mux.HandleFunc("GET /auth/r/{id}/info", s.resourceInfo)
mux.HandleFunc("POST /auth/r/{id}/password", s.resourceAuth("password", s.resourcePassword))
mux.HandleFunc("POST /auth/r/{id}/pin", s.resourceAuth("pin", s.resourcePin))
mux.HandleFunc("POST /auth/r/{id}/sso", s.resourceAuth("sso", s.resourceSSO))
mux.HandleFunc("POST /auth/r/{id}/share", s.resourceAuth("share", s.resourceShare))
mux.HandleFunc("POST /auth/r/{id}/email/start", s.resourceAuth("emailstart", s.resourceEmailStart))
mux.HandleFunc("POST /auth/r/{id}/email/verify", s.resourceAuth("emailverify", s.resourceEmailVerify))
// any signed-in user
mux.HandleFunc("POST /api/logout", s.withUser(s.logout))
mux.HandleFunc("GET /api/me", s.withUser(s.me))
mux.HandleFunc("PUT /api/me", s.withUser(s.updateMe))
mux.HandleFunc("POST /api/me/password", s.withUser(s.changePassword))
mux.HandleFunc("POST /api/me/2fa/setup", s.withUser(s.totpSetup))
mux.HandleFunc("POST /api/me/2fa/enable", s.withUser(s.totpEnable))
mux.HandleFunc("POST /api/me/2fa/disable", s.withUser(s.totpDisable))
mux.HandleFunc("GET /api/me/resources", s.withUser(s.myResources))
mux.HandleFunc("GET /api/me/sessions", s.withUser(s.mySessions))
mux.HandleFunc("DELETE /api/me/sessions/{id}", s.withUser(s.endSession))
mux.HandleFunc("POST /api/me/sessions/revoke-others", s.withUser(s.endOtherSessions))
mux.HandleFunc("GET /api/edges", s.withUser(s.listEdges))
// server administrators
mux.HandleFunc("POST /api/edges", s.withServerAdmin(s.saveEdge))
mux.HandleFunc("PUT /api/edges/{id}", s.withServerAdmin(s.saveEdge))
mux.HandleFunc("DELETE /api/edges/{id}", s.withServerAdmin(s.deleteEdge))
mux.HandleFunc("POST /api/orgs", s.withServerAdmin(s.createOrg))
mux.HandleFunc("GET /api/admin/users", s.withServerAdmin(s.adminUsers))
mux.HandleFunc("POST /api/admin/users", s.withServerAdmin(s.adminCreateUser))
mux.HandleFunc("PUT /api/admin/users/{id}", s.withServerAdmin(s.adminUpdateUser))
mux.HandleFunc("DELETE /api/admin/users/{id}", s.withServerAdmin(s.adminDeleteUser))
mux.HandleFunc("GET /api/admin/idps", s.withServerAdmin(s.listIdPs))
mux.HandleFunc("POST /api/admin/idps", s.withServerAdmin(s.saveIdP))
mux.HandleFunc("PUT /api/admin/idps/{id}", s.withServerAdmin(s.saveIdP))
mux.HandleFunc("DELETE /api/admin/idps/{id}", s.withServerAdmin(s.deleteIdP))
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
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))
}))
// organization scope: members read, admins write
mux.HandleFunc("GET /api/orgs/{org}/state", s.withOrg(false, s.orgState))
mux.HandleFunc("PUT /api/orgs/{org}", s.withOrg(true, s.updateOrg))
mux.HandleFunc("DELETE /api/orgs/{org}", s.withOrg(true, s.deleteOrg))
mux.HandleFunc("GET /api/orgs/{org}/roles", s.withOrg(false, s.listRoles))
mux.HandleFunc("POST /api/orgs/{org}/roles", s.withOrg(true, s.saveRole))
mux.HandleFunc("PUT /api/orgs/{org}/roles/{role}", s.withOrg(true, s.saveRole))
mux.HandleFunc("DELETE /api/orgs/{org}/roles/{role}", s.withOrg(true, s.deleteRole))
mux.HandleFunc("GET /api/orgs/{org}/members", s.withOrg(true, s.listMembers))
mux.HandleFunc("PUT /api/orgs/{org}/members/{user}", s.withOrg(true, s.setMemberRole))
mux.HandleFunc("DELETE /api/orgs/{org}/members/{user}", s.withOrg(true, s.removeMember))
mux.HandleFunc("GET /api/orgs/{org}/invites", s.withOrg(true, s.listInvites))
mux.HandleFunc("POST /api/orgs/{org}/invites", s.withOrg(true, s.createInvite))
mux.HandleFunc("DELETE /api/orgs/{org}/invites/{id}", s.withOrg(true, s.deleteInvite))
mux.HandleFunc("GET /api/orgs/{org}/apikeys", s.withOrg(true, s.listKeys))
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("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
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("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
mux.HandleFunc("DELETE /api/orgs/{org}/domains/{id}", s.withOrg(true, s.deleteDomain))
mux.HandleFunc("POST /api/orgs/{org}/domains/{id}/check", s.withOrg(true, s.checkDomain))
mux.HandleFunc("POST /api/orgs/{org}/mappings", s.withOrg(true, s.addMapping))
mux.HandleFunc("PUT /api/orgs/{org}/mappings/{id}", s.withOrg(true, s.putMapping))
mux.HandleFunc("DELETE /api/orgs/{org}/mappings/{id}", s.withOrg(true, s.deleteMapping))
mux.HandleFunc("GET /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.listShares))
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.createShare))
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/revoke", s.withOrg(true, s.revokeVisitors))
mux.HandleFunc("DELETE /api/orgs/{org}/mappings/{id}/shares/{share}", s.withOrg(true, s.deleteShare))
mux.HandleFunc("POST /api/orgs/{org}/mail-egress", s.withOrg(true, s.saveMailEgress))
mux.HandleFunc("PUT /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.saveMailEgress))
mux.HandleFunc("DELETE /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.deleteMailEgress))
mux.HandleFunc("GET /api/orgs/{org}/dkim", s.withOrg(true, s.listDKIM))
mux.HandleFunc("POST /api/orgs/{org}/dkim", s.withOrg(true, s.createDKIM))
mux.HandleFunc("DELETE /api/orgs/{org}/dkim/{id}", s.withOrg(true, s.deleteDKIM))
mux.HandleFunc("GET /api/orgs/{org}/logs", s.withOrg(true, s.orgLogs))
mux.HandleFunc("GET /api/orgs/{org}/stats", s.withOrg(false, s.orgStats))
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
// agents
mux.HandleFunc("GET /edge/config", s.edgeConfig)
mux.HandleFunc("POST /edge/report", s.edgeReport)
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
mux.HandleFunc("GET /site/config", s.siteConfig)
mux.HandleFunc("POST /site/report", s.siteReport)
return mux
}
+130
View File
@@ -0,0 +1,130 @@
package hammerhead
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/smtp"
"strconv"
"strings"
"time"
)
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.st.Settings
st.SMTP.Pass = "" // write-only
writeJSON(w, 200, map[string]any{"settings": st, "smtpPassSet": s.st.Settings.SMTP.Pass != ""})
}
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principal) {
var in Settings
if !decode(w, r, &in) {
return
}
in.DashboardURL = strings.TrimRight(strings.TrimSpace(in.DashboardURL), "/")
if in.DashboardURL != "" && !strings.HasPrefix(in.DashboardURL, "http://") && !strings.HasPrefix(in.DashboardURL, "https://") {
fail(w, 400, errors.New("dashboard URL must start with http:// or https://"))
return
}
if m := in.SMTP.Mode; m != "" && m != "starttls" && m != "tls" && m != "none" {
fail(w, 400, errors.New("smtp mode must be starttls, tls or none"))
return
}
s.mu.Lock()
if in.SMTP.Pass == "" {
in.SMTP.Pass = s.st.Settings.SMTP.Pass // blank keeps the saved password
}
if s.opts.DashboardURL != "" {
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
}
s.st.Settings = in
s.bump() // edges receive the dashboard URL and ACME settings
s.mu.Unlock()
s.audit("", p.User.Email, "settings.update", "")
ok(w)
}
func (s *Server) testMail(w http.ResponseWriter, r *http.Request, p *principal) {
if err := s.Mailer(p.User.Email, "Hammerhead test email", "If you can read this, outgoing email works."); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
func (s *Server) mailConfigured() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.st.Settings.SMTP.Host != ""
}
// smtpSend is the default Mailer.
func (s *Server) smtpSend(to, subject, body string) error {
s.mu.Lock()
c := s.st.Settings.SMTP
s.mu.Unlock()
if c.Host == "" {
return errors.New("email is not configured (Settings -> Email)")
}
if c.Port == 0 {
c.Port = 587
if c.Mode == "tls" {
c.Port = 465
}
}
if c.From == "" {
c.From = c.User
}
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
c.From, to, strings.NewReplacer("\r", "", "\n", " ").Replace(subject), body)
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.Dial("tcp", addr)
if err != nil {
return err
}
if c.Mode == "tls" {
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
}
conn.SetDeadline(time.Now().Add(30 * time.Second))
cl, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return err
}
defer cl.Close()
if c.Mode == "" || c.Mode == "starttls" {
if ok, _ := cl.Extension("STARTTLS"); ok {
if err := cl.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return err
}
}
}
if c.User != "" {
if err := cl.Auth(smtp.PlainAuth("", c.User, c.Pass, c.Host)); err != nil {
return err
}
}
if err := cl.Mail(c.From); err != nil {
return err
}
if err := cl.Rcpt(to); err != nil {
return err
}
wc, err := cl.Data()
if err != nil {
return err
}
if _, err := wc.Write([]byte(msg)); err != nil {
return err
}
if err := wc.Close(); err != nil {
return err
}
return cl.Quit()
}
+576
View File
@@ -0,0 +1,576 @@
// Package hammerhead is the control plane: JSON API, web UI, accounts and state store.
package hammerhead
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model"
)
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
AllowLocal bool `json:"allowLocal"` // may target an edge's built-in local site (the edge machine itself)
AlertDebounceMin int `json:"alertDebounceMin,omitempty"` // minutes a problem must last before alerting; 0 = 2
AlertRepeatHours int `json:"alertRepeatHours,omitempty"` // remind about an unresolved problem this often; 0 = never
}
type Role struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Admin bool `json:"admin"`
Builtin bool `json:"builtin"`
}
type Member struct {
UserID string `json:"userId"`
OrgID string `json:"orgId"`
RoleID string `json:"roleId"`
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
PassHash string `json:"passHash,omitempty"`
ServerAdmin bool `json:"serverAdmin"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPEnabled bool `json:"totpEnabled"`
BackupCodes []string `json:"backupCodes,omitempty"` // sha256 hashes
IdP string `json:"idp,omitempty"`
IdPSub string `json:"idpSub,omitempty"`
Created time.Time `json:"created"`
}
type Session struct {
UA string `json:"ua,omitempty"`
UserID string `json:"userId"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
IP string `json:"ip"`
}
type Invite struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Email string `json:"email"`
RoleID string `json:"roleId"`
TokenHash string `json:"tokenHash"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
}
type APIKey struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
LastUsed time.Time `json:"lastUsed"`
}
type IdP struct {
ID string `json:"id"`
Name string `json:"name"`
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret,omitempty"`
Scopes string `json:"scopes"` // default "openid email profile"
AutoProvision bool `json:"autoProvision"`
OrgID string `json:"orgId"` // org new users are added to
RoleID string `json:"roleId"` // ...with this role
AllowedDomains []string `json:"allowedDomains"`
}
type SMTP struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Pass string `json:"pass,omitempty"`
From string `json:"from"`
Mode string `json:"mode"` // starttls, tls, none
}
type Settings struct {
DashboardURL string `json:"dashboardUrl"`
AcmeEmail string `json:"acmeEmail"`
AcmeStaging bool `json:"acmeStaging"`
SMTP SMTP `json:"smtp"`
}
type Secret struct {
PasswordHash string `json:"passwordHash,omitempty"`
PinHash string `json:"pinHash,omitempty"`
}
type ShareLink struct {
ID string `json:"id"`
MappingID string `json:"mappingId"`
OrgID string `json:"orgId"`
Name string `json:"name"`
TokenHash string `json:"tokenHash"`
Expires time.Time `json:"expires"` // zero = never
MaxUses int `json:"maxUses"` // 0 = unlimited
Uses int `json:"uses"`
Created time.Time `json:"created"`
}
type Total struct {
Conns int64 `json:"conns"`
BytesIn int64 `json:"bytesIn"`
BytesOut int64 `json:"bytesOut"`
}
type state struct {
Version int64 `json:"version"`
Settings Settings `json:"settings"`
Users []*User `json:"users"`
Sessions map[string]*Session `json:"sessions"` // by sha256(token)
Orgs []*Org `json:"orgs"`
Roles []*Role `json:"roles"`
Members []*Member `json:"members"`
Invites []*Invite `json:"invites"`
APIKeys []*APIKey `json:"apiKeys"`
IdPs []*IdP `json:"idps"`
Edges []*model.Edge `json:"edges"`
Sites []*model.Site `json:"sites"`
Domains []*model.Domain `json:"domains"`
Mappings []*model.Mapping `json:"mappings"`
MailEgress []*model.MailEgress `json:"mailEgress"`
DKIM []*model.DKIMKey `json:"dkim"`
Channels []*AlertChannel `json:"channels"`
Secrets map[string]*Secret `json:"secrets"` // by mapping ID
Shares []*ShareLink `json:"shares"`
Totals map[string]*Total `json:"totals"` // by mapping ID
}
type Options struct {
AdminEmail string // optional: create this server admin on first run
AdminPassword string
DashboardURL string // optional: overrides the saved setting
MasterKey string // encrypts secrets in the database (HAMMERHEAD_MASTER_KEY); generated into <data>/master.key if empty
MetricsToken string // enables GET /metrics for a bearer token (HAMMERHEAD_METRICS_TOKEN)
LogRetentionDays int // access logs are kept this long; 0 = 30
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
}
type Server struct {
mu sync.Mutex
dir string
st state
opts Options
status map[string]model.MappingStatus
mailStatus map[string]string // mail egress ID -> "" (listening) or an error
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
containers map[string][]model.Container // by site ID
changed chan struct{}
tickets map[string]ticket
oauth map[string]oauthState
otps map[string]otpEntry
limits map[string][]time.Time
oidc map[string]*oidcDoc
alerts map[string]*alertState // active problems by key (see alerts.go)
mtaSent map[string]time.Time // reminder throttle for repeat notifications
logs *logStore
db *DB
stop chan struct{}
once sync.Once
// Mailer sends email; tests replace it.
Mailer func(to, subject, body string) error
}
func New(dir string, opts Options) (*Server, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
box, err := masterBox(dir, opts.MasterKey)
if err != nil {
return nil, err
}
db, err := openDB(filepath.Join(dir, "hammerhead.db"), box)
if err != nil {
return nil, err
}
s := &Server{
dir: dir,
opts: opts,
db: db,
logs: &logStore{db: db},
stop: make(chan struct{}),
status: map[string]model.MappingStatus{},
mailStatus: map[string]string{},
mailChecks: map[string]model.MailCheck{},
containers: map[string][]model.Container{},
changed: make(chan struct{}),
tickets: map[string]ticket{},
oauth: map[string]oauthState{},
otps: map[string]otpEntry{},
limits: map[string][]time.Time{},
oidc: map[string]*oidcDoc{},
alerts: map[string]*alertState{},
mtaSent: map[string]time.Time{},
}
s.st.Sessions = map[string]*Session{}
s.st.Secrets = map[string]*Secret{}
s.st.Totals = map[string]*Total{}
had, err := db.load(&s.st)
if err != nil {
db.Close()
return nil, err
}
if !had {
if err := s.migrateLegacy(); err != nil {
db.Close()
return nil, err
}
}
if opts.DashboardURL != "" {
s.st.Settings.DashboardURL = strings.TrimRight(opts.DashboardURL, "/")
}
s.backfillTunnelIPs()
s.Mailer = s.smtpSend
if len(s.st.Users) == 0 && opts.AdminEmail != "" && opts.AdminPassword != "" {
if err := s.createFirstAdmin(opts.AdminEmail, "Admin", opts.AdminPassword, "Default"); err != nil {
db.Close()
return nil, err
}
}
if err := s.save(); err != nil {
db.Close()
return nil, err
}
s.loadAlerts()
go s.housekeeping()
go s.alertLoop()
return s, nil
}
// masterBox builds the encryption box for secrets at rest. The key normally
// comes from HAMMERHEAD_MASTER_KEY (e.g. in .env). If none is given one is
// generated into <data>/master.key so the server works out of the box, but that
// file sits next to the database, so a backup of the folder carries both: keep
// the key somewhere else for real protection.
func masterBox(dir, key string) (*secretbox.Box, error) {
if key != "" {
return secretbox.New(key)
}
path := filepath.Join(dir, "master.key")
if b, err := os.ReadFile(path); err == nil {
return secretbox.New(string(b))
}
k := secretbox.GenerateKey()
if err := os.WriteFile(path, []byte(k+"\n"), 0o600); err != nil {
return nil, err
}
log.Printf("no HAMMERHEAD_MASTER_KEY set: generated an encryption key in %s. For real protection copy its contents into your .env as HAMMERHEAD_MASTER_KEY=... and delete the file, so a backup of the data folder doesn't carry the key too", path)
return secretbox.New(k)
}
// migrateLegacy imports a state.json (and JSONL logs) from before the database existed.
func (s *Server) migrateLegacy() error {
path := filepath.Join(s.dir, "state.json")
b, err := os.ReadFile(path)
if err != nil {
return nil // a fresh install
}
if err := json.Unmarshal(b, &s.st); err != nil {
return fmt.Errorf("import %s: %w", path, err)
}
if s.st.Sessions == nil {
s.st.Sessions = map[string]*Session{}
}
if s.st.Secrets == nil {
s.st.Secrets = map[string]*Secret{}
}
if s.st.Totals == nil {
s.st.Totals = map[string]*Total{}
}
if err := s.db.persist(&s.st); err != nil {
return err
}
s.importLegacyLogs()
os.Rename(path, path+".migrated")
log.Printf("imported %s into the database (kept as %s.migrated)", path, path)
return nil
}
// Close stops background work and closes the database.
func (s *Server) Close() {
s.once.Do(func() {
close(s.stop)
s.db.Close()
})
}
// housekeeping prunes old logs.
func (s *Server) housekeeping() {
days := s.opts.LogRetentionDays
if days <= 0 {
days = 30
}
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
s.db.purge(days, 365)
select {
case <-s.stop:
return
case <-t.C:
}
}
}
// save must be called with s.mu held (or before serving).
func (s *Server) save() error {
err := s.db.persist(&s.st)
if err != nil {
log.Printf("save: %v", err)
}
return err
}
// bump records a change that agents care about and wakes every long-poller. Caller holds s.mu.
func (s *Server) bump() {
s.st.Version++
s.save()
close(s.changed)
s.changed = make(chan struct{})
}
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
func (s *Server) wait(r *http.Request, have int64) bool {
s.mu.Lock()
if s.st.Version > have {
s.mu.Unlock()
return true
}
ch := s.changed
s.mu.Unlock()
select {
case <-ch:
return true
case <-time.After(25 * time.Second):
return false
case <-r.Context().Done():
return false
}
}
// ---------- small helpers ----------
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
func newID() string { return randHex(6) }
func sha(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}
func failf(w http.ResponseWriter, code int, format string, a ...any) {
fail(w, code, fmt.Errorf(format, a...))
}
func ok(w http.ResponseWriter) { writeJSON(w, 200, map[string]bool{"ok": true}) }
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
fail(w, 400, errors.New("invalid request body"))
return false
}
return true
}
func filter[T any](in []*T, keep func(*T) bool) []*T {
out := in[:0:0]
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
func bearer(r *http.Request) string {
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
func (s *Server) dashboardURL() string { return s.st.Settings.DashboardURL }
// allow is a sliding-window rate limiter. Caller must NOT hold s.mu.
func (s *Server) allow(key string, max int, window time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
kept := s.limits[key][:0]
for _, t := range s.limits[key] {
if now.Sub(t) < window {
kept = append(kept, t)
}
}
if len(kept) >= max {
s.limits[key] = kept
return false
}
s.limits[key] = append(kept, now)
return true
}
// ---------- lookups (caller holds s.mu) ----------
func (s *Server) findOrg(id string) *Org {
for _, o := range s.st.Orgs {
if o.ID == id {
return o
}
}
return nil
}
func (s *Server) findRole(id string) *Role {
for _, r := range s.st.Roles {
if r.ID == id {
return r
}
}
return nil
}
func (s *Server) findUser(id string) *User {
for _, u := range s.st.Users {
if u.ID == id {
return u
}
}
return nil
}
func (s *Server) userByEmail(email string) *User {
email = strings.ToLower(strings.TrimSpace(email))
for _, u := range s.st.Users {
if strings.ToLower(u.Email) == email {
return u
}
}
return nil
}
func (s *Server) findEdge(id string) *model.Edge {
for _, e := range s.st.Edges {
if e.ID == id {
return e
}
}
return nil
}
func (s *Server) findSite(id string) *model.Site {
for _, e := range s.st.Sites {
if e.ID == id {
return e
}
}
return nil
}
func (s *Server) findMapping(id string) *model.Mapping {
for _, m := range s.st.Mappings {
if m.ID == id {
return m
}
}
return nil
}
func (s *Server) member(userID, orgID string) *Member {
for _, m := range s.st.Members {
if m.UserID == userID && m.OrgID == orgID {
return m
}
}
return nil
}
// allocIPLocked picks the next free address in the tunnel network (100.89.0.0/16).
// The network only exists inside WireGuard's userspace stack, so it can never
// clash with a LAN or VPN on the machines involved. Caller holds s.mu.
func (s *Server) allocIPLocked() string {
used := map[string]bool{}
for _, e := range s.st.Edges {
used[e.TunnelIP] = true
}
for _, st := range s.st.Sites {
used[st.TunnelIP] = true
}
for i := 1; i < 65535; i++ {
ip := fmt.Sprintf("100.89.%d.%d", i>>8, i&0xff)
if i&0xff != 0 && i&0xff != 255 && !used[ip] {
return ip
}
}
return ""
}
// backfillTunnelIPs gives addresses to records created before WireGuard existed.
func (s *Server) backfillTunnelIPs() {
for _, e := range s.st.Edges {
if e.TunnelIP == "" {
e.TunnelIP = s.allocIPLocked()
}
}
for _, st := range s.st.Sites {
if !st.Local && st.TunnelIP == "" {
st.TunnelIP = s.allocIPLocked()
}
}
}
func (s *Server) edgeAllowedFor(e *model.Edge, orgID string) bool {
if len(e.OrgIDs) == 0 {
return true
}
for _, id := range e.OrgIDs {
if id == orgID {
return true
}
}
return false
}
+894
View File
@@ -0,0 +1,894 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hammerhead</title>
<style>
:root { --bg:#0a0a0a; --side:#0f0f10; --panel:#141415; --line:#262628; --text:#fafafa; --dim:#a1a1aa; --muted:#1c1c1e; --accent:#06b6d4; --accent-fg:#fff; --ok:#22c55e; --bad:#ef4444; --warn:#eab308; --shade:rgba(0,0,0,.65); --radius:10px; }
@media (prefers-color-scheme: light) { :root { --bg:#fafafa; --side:#fff; --panel:#fff; --line:#e4e4e7; --text:#18181b; --dim:#71717a; --muted:#f4f4f5; --accent:#0891b2; --shade:rgba(24,24,27,.5); } }
* { box-sizing:border-box; }
html, body { height:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 Inter,system-ui,-apple-system,Segoe UI,sans-serif; -webkit-font-smoothing:antialiased; }
a { color:var(--accent); }
.app { display:flex; min-height:100vh; }
.side { width:248px; flex:none; background:var(--side); border-right:1px solid var(--line); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:14px 12px; gap:4px; overflow-y:auto; }
.brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; }
.brand svg { color:var(--accent); }
.orgsel { margin:0 0 10px; }
.orgsel select { background:var(--muted); font-weight:600; }
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; }
.navitem { display:flex; align-items:center; gap:10px; width:100%; background:none; border:0; color:var(--dim); padding:8px 10px; border-radius:8px; cursor:pointer; font:inherit; font-weight:500; text-align:left; }
.navitem:hover { background:var(--muted); color:var(--text); }
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
.navitem svg { flex:none; }
.grow { flex:1; }
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; }
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { flex:1; min-width:0; }
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:22px; flex-wrap:wrap; }
.page-h h1 { font-size:24px; font-weight:700; margin:0; letter-spacing:-.01em; }
.page-h p { margin:4px 0 0; color:var(--dim); }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:18px; box-shadow:0 1px 2px rgba(0,0,0,.18); }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; align-items:end; }
.stack { display:flex; flex-direction:column; gap:12px; }
label { display:flex; flex-direction:column; gap:5px; font-size:12.5px; font-weight:500; color:var(--dim); }
label.chk { flex-direction:row; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:400; }
input, select, textarea { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; min-width:0; width:100%; }
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:0; border-color:var(--accent); }
input:disabled { opacity:.6; }
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; }
button.btn:hover { filter:brightness(1.08); }
button.ghost { background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:5px 11px; cursor:pointer; font:inherit; font-weight:500; }
button.ghost:hover { background:var(--muted); }
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:12px; color:var(--dim); font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; background:color-mix(in srgb, var(--muted) 55%, transparent); }
td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); }
tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:-4px 0; }
.tag { display:inline-flex; align-items:center; padding:1px 8px; border-radius:6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; background:var(--muted); border:1px solid var(--line); color:var(--dim); }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--dim); margin-right:7px; flex:none; }
.dot.ok { background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } .dot.bad { background:var(--bad); } .dot.warn { background:var(--warn); }
code, .mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12.5px; }
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:12px; overflow:auto; margin:8px 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
.dim { color:var(--dim); } .err { color:var(--bad); } .small { font-size:12px; }
.empty { color:var(--dim); padding:28px 12px; text-align:center; }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; }
.banner { background:color-mix(in srgb, var(--warn) 12%, transparent); border:1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius:var(--radius); padding:12px 16px; margin-bottom:18px; }
.shade { position:fixed; inset:0; background:var(--shade); backdrop-filter:blur(2px); display:flex; align-items:flex-start; justify-content:center; padding:4vh 12px; overflow:auto; z-index:10; }
.modal { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px; width:100%; max-width:780px; box-shadow:0 20px 60px rgba(0,0,0,.45); }
.modal h2 { margin:0 0 16px; font-size:18px; }
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; }
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; }
.sub { color:var(--dim); font-size:12px; font-weight:400; }
.tabs2 { display:flex; gap:4px; margin-bottom:16px; background:var(--muted); padding:4px; border-radius:10px; width:fit-content; max-width:100%; flex-wrap:wrap; }
.tabs2 button { background:none; border:0; color:var(--dim); border-radius:7px; padding:5px 14px; cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button.on { background:var(--panel); color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,.25); }
#toast { position:fixed; bottom:20px; right:20px; background:var(--panel); border:1px solid var(--line); padding:11px 16px; border-radius:10px; z-index:30; display:none; max-width:min(420px,90vw); box-shadow:0 10px 30px rgba(0,0,0,.4); }
#toast.bad { border-color:var(--bad); color:var(--bad); }
@media (max-width:860px) {
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; }
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
main { padding:18px 14px 60px; } .tgt { grid-template-columns:1fr 1fr; }
}
</style>
</head>
<body>
<div id="app"></div>
<div id="toast"></div>
<script>
// ---------- tiny toolkit ----------
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (v === true) el.setAttribute(k, '');
else if (v !== false && v != null) el.setAttribute(k, v);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
return el;
}
const $app = document.getElementById('app');
let toastTimer;
function toast(msg, bad) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = bad ? 'bad' : ''; t.style.display = 'block';
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.display = 'none', bad ? 6000 : 2500);
}
async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
if (r.status === 401 && url !== '/api/login' && !url.startsWith('/api/login/') && url !== '/api/me' && url !== '/api/public') {
// the session ended while we were signed in: go back to the login page once.
// (Not for /api/me or /api/public: boot() calls those, and reacting to them would loop forever.)
if (S.me) { S.me = null; boot(); }
throw new Error('Please sign in');
}
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } };
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Date.now() - new Date(d)) / 1000; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
const byId = (list, id) => (list || []).find(x => x.id === id);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
const cmdBox = t => h('pre', {class:'mono'}, t);
function modal(title, body, opts = {}) {
const shade = h('div', {class:'shade'});
const close = () => shade.remove();
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(h('div', {class:'modal'}, h('h2', {}, title), body));
document.body.append(shade);
return close;
}
const modalOpen = () => !!document.querySelector('.shade');
// ---------- icons (lucide-style, static strings) ----------
const ICONS = {
fin:'<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
mine:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
ports:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
sites:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
domains:'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
access:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
logs:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8"/>',
edges:'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
orgs:'<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01"/>',
users:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
idps:'<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
audit:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
account:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
egress:'<path d="M7 17 17 7M8 7h9v9"/>',
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
};
function icon(name, size = 18) {
const s = document.createElement('span');
s.style.display = 'inline-flex';
s.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', {class:'brand'}, icon('fin', 24), 'Hammerhead');
// ---------- state ----------
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null };
const isAdmin = () => S.me && (S.me.serverAdmin || (S.me.orgs.find(o => o.id === S.org) || {}).admin);
async function boot() {
try { S.pub = await api('GET', '/api/public'); } catch (e) { $app.replaceChildren(card('Cannot reach the server', e.message)); return; }
const inv = (location.hash.match(/^#invite=(.+)$/) || [])[1];
if (S.pub.needsSetup) return $app.replaceChildren(setupView());
try { S.me = await api('GET', '/api/me'); } catch { S.me = null; }
if (inv) return $app.replaceChildren(await inviteView(inv));
if (!S.me) return $app.replaceChildren(loginView());
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
if (S.org) localStorage.setItem('org', S.org);
await render();
}
// ---------- first run, login, invites ----------
function setupView() {
const f = { org: h('input', {value:'Default'}), name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password (8+ characters)', f.pw),
h('button', {class:'btn', onclick: act(async () => {
await api('POST', '/api/setup', {orgName:f.org.value, name:f.name.value, email:f.email.value, password:f.pw.value});
await api('POST', '/api/login', {email:f.email.value, password:f.pw.value}); boot();
})}, 'Create account'))));
}
function loginView() {
const email = h('input', {type:'email', autocomplete:'username'}), pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {placeholder:'6-digit or backup code', autocomplete:'one-time-code'}); let ticket = null;
const codeBox = h('div', {class:'stack', style:'display:none'}, field('Authenticator code', code));
const go = act(async () => {
const j = ticket ? await api('POST', '/api/login/totp', {ticket, code:code.value}) : await api('POST', '/api/login', {email:email.value, password:pw.value});
if (j.needTotp) { ticket = j.ticket; codeBox.style.display = ''; pw.parentElement.style.display = email.parentElement.style.display = 'none'; code.focus(); return; }
boot();
});
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Sign in'), h('div', {class:'stack'},
field('Email', email), field('Password', pw), codeBox, h('button', {class:'btn', onclick:go}, 'Sign in'),
(S.pub.providers || []).map(p => h('a', {href:'/auth/oidc/' + p.id + '/start?return=/'}, h('button', {class:'ghost', style:'width:100%;padding:8px'}, 'Continue with ' + p.name))))));
}
async function inviteView(tok) {
let info;
try { info = await api('GET', '/api/invites/' + tok); } catch (e) { return h('div', {class:'center'}, card('Invitation', e.message)); }
const name = h('input'), pw = h('input', {type:'password'});
const accept = act(async () => { await api('POST', '/api/invites/accept', {token:tok, name:name.value, password:pw.value}); location.hash = ''; boot(); });
if (info.existing) return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, `${info.email} already has an account.`),
S.me && S.me.email === info.email ? h('button', {class:'btn', onclick:accept}, 'Join ' + info.org) : h('p', {}, 'Sign in as that account first, then open this link again.'),
S.me ? null : h('button', {class:'ghost', onclick:() => { location.hash = ''; boot(); }}, 'Go to sign in')));
return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, 'Create your account for ' + info.email + '.'),
h('div', {class:'stack'}, field('Your name', name), field('Password (8+ characters)', pw), h('button', {class:'btn', onclick:accept}, 'Create account'))));
}
// ---------- shell ----------
async function loadOrg() { S.st = S.org ? await api('GET', `/api/orgs/${S.org}/state`) : null; }
const O = p => `/api/orgs/${S.org}${p}`;
const PAGES = {
mine: ['My apps', 'Apps you have been given access to.'],
ports: ['Resources', 'Open ports on your edge IPs and send them to your services, with an optional login in front.'],
sites: ['Sites', 'Private networks (or the edge machine itself) that hold your services.'],
domains: ['Domains', 'Domains you own. Web hostnames must live under one of them.'],
access: ['Access control', 'Members, roles, invitations and API keys for this organization.'],
egress: ['Mail egress', 'Send your mail server\'s outgoing mail from an edge\'s public IP, so reverse DNS, SPF and your HELO name all line up.'],
alerts: ['Alerts', 'Get told by email or webhook when something stops working, and when it recovers.'],
srvalerts:['Server alerts', 'Where to send alerts about edges, which belong to the whole server.'],
logs: ['Logs', 'Who reached what, and what changed.'],
account: ['Account', 'Your profile, password and two-factor authentication.'],
edges: ['Edges', 'Public servers running Blacktip that own your IP addresses.'],
srvorgs: ['Organizations', 'Separate workspaces on this server.'],
users: ['Users', 'Everyone with an account on this server.'],
idps: ['Single sign-on', 'Sign in with an external OpenID Connect provider.'],
settings:['Settings', 'Dashboard URL, certificates and email.'],
srvaudit:['Audit log', 'Server-level activity.'],
};
// server pages are top-level sidebar entries that map onto serverViews
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
async function render() {
const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label);
const side = [h('div', {}, brand())];
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
const all = [];
if (S.org) {
side.push(h('div', {class:'navsec'}, 'Organization'), nav('mine', 'My apps', 'mine'));
all.push('mine');
if (isAdmin()) {
side.push(nav('ports', 'Resources', 'ports'), nav('sites', 'Sites', 'sites'), nav('domains', 'Domains', 'domains'), nav('egress', 'Mail egress', 'egress'), nav('alerts', 'Alerts', 'alerts'), nav('access', 'Access control', 'access'), nav('logs', 'Logs', 'logs'));
all.push('ports', 'sites', 'domains', 'egress', 'alerts', 'access', 'logs');
}
}
if (S.me.serverAdmin) {
side.push(h('div', {class:'navsec'}, 'Server'), nav('edges', 'Edges', 'edges'), nav('srvorgs', 'Organizations', 'orgs'), nav('users', 'Users', 'users'), nav('idps', 'Single sign-on', 'idps'), nav('settings', 'Settings', 'settings'), nav('srvalerts', 'Alerts', 'alerts'), nav('srvaudit', 'Audit log', 'audit'));
all.push(...Object.keys(SRV));
}
all.push('account');
if (!all.includes(S.tab)) S.tab = all[0];
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
await refresh();
}
function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', ''];
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null));
}
let seq = 0;
async function refresh() {
const my = ++seq;
try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return;
const v = document.getElementById('view');
if (v) v.replaceChildren(pageHeader(S.tab), view);
} catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
}
setInterval(() => {
if (!S.me || document.hidden || modalOpen()) return;
const a = document.activeElement;
if (a && ['INPUT', 'SELECT', 'TEXTAREA'].includes(a.tagName)) return;
if (['ports', 'sites', 'logs', 'mine', 'alerts', 'egress'].includes(S.tab) && !(S.tab === 'logs' && logPaged)) refresh();
}, 4000);
const dashURL = () => (S.st && S.st.dashboardUrl) || location.origin;
// ---------- views ----------
const views = {};
views.mine = async () => {
const list = await api('GET', '/api/me/resources');
return card('Apps you can open', list.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Organization', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(r => h('tr', {}, h('td', {}, r.name), h('td', {class:'dim'}, r.org), h('td', {}, h('a', {href:r.url, target:'_blank', rel:'noopener'}, h('button', {class:'ghost'}, 'Open'))))))) :
h('div', {class:'empty'}, 'Nothing here yet. Ask an administrator to give you access to an app.'));
};
// ----- ports -----
const PROTOS = [['tcp','TCP'],['udp','UDP'],['both','TCP + UDP'],['http','HTTP (by hostname)'],['https','HTTPS (automatic certificate)'],['tls','TLS passthrough (by SNI)']];
views.ports = async () => {
const st = S.st;
if (!st.edges.length) return card('No edges yet', h('p', {class:'dim'}, S.me.serverAdmin ? 'Add an edge (Blacktip) on the Server tab first: it is the public server that owns your IP addresses.' : 'Ask a server administrator to add an edge for your organization.'));
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
h('div', {class:'card flush'}, table));
};
function spark(series) {
if (!series || !series.some(v => v > 0)) return null;
const w = 84, hgt = 22, max = Math.max(...series, 1), ns = 'http://www.w3.org/2000/svg';
const pts = series.map((v, i) => `${(i / (series.length - 1) * w).toFixed(1)},${(hgt - 2 - v / max * (hgt - 4)).toFixed(1)}`).join(' ');
const svg = document.createElementNS(ns, 'svg'), pl = document.createElementNS(ns, 'polyline');
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`);
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'var(--accent)'); pl.setAttribute('stroke-width', '1.5');
svg.append(pl);
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
}
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
function mappingRow(m, st, series) {
const edge = byId(st.edges, m.edgeId), s = m.status || {};
let dot = 'dot', why = 'paused';
if (m.enabled) {
const th = s.targets || [];
if (!m.statusKnown) { dot += ' warn'; why = 'waiting for the edge'; }
else if (s.error) { dot += ' bad'; why = s.error; }
else if (th.length && th.every(t => !t.healthy)) { dot += ' bad'; why = 'no healthy target'; }
else if (th.some(t => !t.healthy)) { dot += ' warn'; why = 'some targets are down'; }
else { dot += ' ok'; why = 'listening'; }
}
const web = m.proto === 'http' || m.proto === 'https';
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
const pubAddr = (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
return h('tr', {},
h('td', {title:why}, h('span', {class:dot})),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
s.error ? h('div', {class:'err small mono'}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, edge ? edge.name : '?')),
h('td', {class:'mono small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
})),
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `↑${bytes(m.total.bytesIn)} ↓${bytes(m.total.bytesOut)}`] : '—', spark(series)),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: act(async () => {
if (confirm(`Sign everyone out of ${m.name}?`)) { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); }
})}, 'Sign out visitors') : null,
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Close ${m.name}?`)) { await api('DELETE', O('/mappings/' + m.id)); refresh(); } })}, 'Close'))));
}
function portForm(existing) {
const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] };
const a = m.auth || {};
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)'))));
const ipIn = h('input', {list:'ips', value:m.bindIp}), ipList = h('datalist', {id:'ips'});
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first');
const proxySel = h('select', {}, [['', 'Off'], ['v2', 'PROXY protocol v2 (recommended)'], ['v1', 'PROXY protocol v1']].map(([v, t]) => h('option', {value:v, selected:v === (m.proxyProtocol || '')}, t)));
const proxyBox = field('Tell the target who the real client is', proxySel, 'Without this the target sees every visitor as coming from the tunnel. Turn it on only if your server expects it: Velocity/BungeeCord haproxy-protocol, Postfix postscreen_upstream_proxy_protocol = haproxy, Dovecot haproxy_trusted_networks, nginx proxy_protocol.');
const tgtBox = h('div', {class:'stack'});
let targets = m.targets.map(t => ({...t}));
const roleChecks = st.roles.map(r => ({r, c:chk(r.name + (r.admin ? ' (admin)' : ''), (a.allowedRoles || []).includes(r.id))}));
const pwIn = h('input', {type:'password', placeholder:existing && a.password ? 'set: leave blank to keep' : 'shared password', autocomplete:'new-password'});
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4-12 digits', inputmode:'numeric', autocomplete:'off'});
const ssoC = chk('Sign in with a Hammerhead account', a.sso), pwC = chk('Shared password', a.password), pinC = chk('PIN', a.pin), mailC = chk('Emailed one-time code', a.email);
const mails = h('input', {value:(a.allowedMails || []).join(', '), placeholder:'a@x.com, *@company.com'});
const hours = h('input', {type:'number', min:1, max:2160, value:a.sessionHours || 24});
const ruleBox = h('div', {class:'stack'}); let rules = (m.rules || []).map(r => ({...r}));
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz (blank = TCP connect check)'});
const healthSec = h('input', {type:'number', value:m.healthSeconds > 0 ? m.healthSeconds : 30, min:5});
const healthOff = chk('Turn health checks off', m.healthSeconds === -1);
const redir = chk('Also answer on port 80 with a redirect to HTTPS', m.redirectHttp), bhttps = chk('Talk HTTPS to the targets (certificate not verified)', m.backendHttps);
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'blank = keep the visitor\'s Host'});
const webBox = h('div', {class:'stack'});
const hostLbl = hostBox;
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipList.replaceChildren(...['0.0.0.0', ...((e && e.ips) || [])].map(ip => h('option', {value:ip}))); };
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
const drawTargets = () => {
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
tgtBox.replaceChildren(...targets.map((t, i) => {
const site = h('select', {onchange:e => { t.siteId = e.target.value; const s = byId(st.sites, t.siteId); if (s && s.local && !t.host) t.host = '127.0.0.1'; }},
h('option', {value:'', disabled:true, selected:!t.siteId}, 'Choose a site…'), siteOpts().map(s => h('option', {value:s.id, selected:s.id === t.siteId}, s.name)));
const th = h('input', {value:t.host, oninput:e => t.host = e.target.value, placeholder:'127.0.0.1 or 192.168.1.20'});
const tp = h('input', {type:'number', value:t.port, placeholder:'port', oninput:e => t.port = +e.target.value});
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, '🐳'),
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', onclick:() => { targets.splice(i, 1); drawTargets(); }}, '✕') : h('span'));
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, '+ Add another target (load balance / failover)') : null);
};
const discover = act(async (t, th, tp) => {
if (!t.siteId) throw new Error('Choose a site first');
const list = await api('GET', O(`/sites/${t.siteId}/containers`));
if (!list.length) throw new Error('No containers reported. Run the agent with --docker (and make sure Docker is running).');
const opts = [];
for (const c of list) for (const p of c.ports) {
if (p.public) opts.push({label:`${c.name} 127.0.0.1:${p.public} (published)`, host:'127.0.0.1', port:p.public});
for (const [n, ip] of Object.entries(c.networks)) opts.push({label:`${c.name} ${ip}:${p.private} (${n} network)`, host:ip, port:p.private});
}
const sel = h('select', {size:Math.min(12, opts.length + 1)}, opts.map((o, i) => h('option', {value:i}, o.label)));
const close = modal('Pick a container', h('div', {class:'stack'}, sel, h('button', {class:'btn', onclick:() => { const o = opts[sel.value]; if (!o) return; t.host = th.value = o.host; t.port = o.port; tp.value = o.port; close(); }}, 'Use this')));
});
const drawRules = () => ruleBox.replaceChildren(...rules.map((r, i) => h('div', {class:'grid', style:'grid-template-columns:110px 110px 1fr auto'},
h('select', {onchange:e => r.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
h('button', {class:'ghost danger', type:'button', onclick:() => { rules.splice(i, 1); drawRules(); }}, '✕'))),
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, '+ Add a rule'));
const drawWeb = () => {
const p = protoSel.value, web = p === 'http' || p === 'https';
hostLbl.style.display = ['http', 'https', 'tls'].includes(p) ? '' : 'none';
webBox.style.display = web ? '' : 'none';
redir.style.display = p === 'https' ? '' : 'none';
proxyBox.style.display = ['tcp', 'both', 'tls'].includes(p) ? '' : 'none';
drawTargets();
};
webBox.append(
h('fieldset', {}, h('legend', {}, 'Who can open this?'), h('div', {class:'stack'},
h('div', {class:'sub'}, 'Leave everything off for a public site. Turn on one or more methods to put a login in front.'),
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles (none ticked = every member of the organization):'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
pwC, field('Password', pwIn), pinC, field('PIN', pinIn), mailC, field('Allowed email addresses', mails, 'Needs email set up under Server → Settings'),
field('Stay signed in for (hours)', hours))),
h('fieldset', {}, h('legend', {}, 'Rules (checked before login, first match wins)'), ruleBox),
h('fieldset', {}, h('legend', {}, 'Advanced'), h('div', {class:'stack'}, redir, bhttps, field('Host header sent to targets', rewrite))));
const adv = h('fieldset', {}, h('legend', {}, 'Health checks'), h('div', {class:'grid'}, field('Check path (web ports)', healthPath), field('Every (seconds)', healthSec), healthOff));
edgeSel.onchange = () => { refreshIPs(); drawTargets(); }; protoSel.onchange = drawWeb; refreshIPs(); drawRules(); drawWeb();
const save = act(async () => {
const pr = (port.value || '').trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
const base = {
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value.trim() || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
redirectHttp:redir.box.checked, backendHttps:bhttps.box.checked, rewriteHost:rewrite.value, healthPath:healthPath.value,
healthSeconds:healthOff.box.checked ? -1 : (+healthSec.value || 0),
};
if (pwIn.value) base.newPassword = pwIn.value; if (pinIn.value) base.newPin = pinIn.value;
if (!pwC.box.checked) base.newPassword = ''; if (!pinC.box.checked) base.newPin = '';
const protos = protoSel.value === 'both' ? ['tcp', 'udp'] : [protoSel.value];
if (existing) await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, proxyProtocol:['tcp', 'tls'].includes(m.proto) ? proxySel.value : ''});
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
close(); toast('Saved'); refresh();
});
const close = modal(existing ? 'Edit port' : 'Open a port', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
}
async function shareDialog(m) {
const body = h('div', {class:'stack'});
const draw = async () => {
const links = await api('GET', O(`/mappings/${m.id}/shares`));
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'hours (blank = no expiry)'}), uses = h('input', {type:'number', placeholder:'max uses (blank = unlimited)'});
const out = h('div');
body.replaceChildren(
h('p', {class:'dim'}, 'A share link lets someone in without an account or password. Anyone with the link gets access, so keep it private.'),
h('div', {class:'grid'}, field('Label', name), field('Expires after', hrs), field('Max uses', uses), h('button', {class:'btn', onclick: act(async () => {
const r = await api('POST', O(`/mappings/${m.id}/shares`), {name:name.value, hours:+hrs.value || 0, maxUses:+uses.value || 0});
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
)}, 'Create link')), out,
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || '—'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
};
await draw(); modal('Share links for ' + m.name, body);
}
// ----- sites -----
views.sites = async () => {
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
return h('div', {},
card('Add a site', h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'),
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${s.name} and every port that uses only it?`)) { await api('DELETE', O('/sites/' + s.id)); refresh(); } })}, 'Delete')))))))));
};
// ----- domains -----
views.domains = async () => {
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
return h('div', {},
card('Add a domain', h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `✓ Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : h('div', {class:'empty'}, 'None yet.')));
};
// ----- mail egress -----
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
function checklist(e) {
const c = e.check, ip = e.sourceIp, helo = e.heloName;
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
h('span', {class:'dot ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), style:'margin-top:6px'}),
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
const body = h('div', {},
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
c.port25 === 'open' ? null : c.port25) : null,
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
cmdBox(spfFor(ip)),
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
return body;
}
views.egress = async () => {
const st = S.st;
const remote = st.sites.filter(s => !s.local);
const rows = st.mailEgress || [];
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(e => {
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check;
let dot = 'dot', why = 'paused';
if (e.enabled) {
if (!e.statusKnown) { dot += ' warn'; why = 'waiting for the site'; }
else if (e.status) { dot += ' bad'; why = e.status; }
else { dot += ' ok'; why = 'listening'; }
}
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
return h('tr', {},
h('td', {title:why}, h('span', {class:dot})),
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
e.username ? h('span', {class:'tag', style:'margin-left:6px'}, '🔒') : null, e.requireTls ? h('span', {class:'tag', style:'margin-left:4px'}, 'tls required') : null),
h('td', {class:'mono'}, e.listen, h('div', {class:'dim small'}, site ? site.name : '?')),
h('td', {class:'mono'}, e.sourceIp, h('div', {class:'dim small'}, e.heloName + (e.smarthost ? ' → ' + e.smarthost : ' → recipient MX'))),
h('td', {}, h('button', {class:'ghost', onclick:() => modal('Deliverability for ' + e.sourceIp, checklist(e))},
!c ? '…' : good ? '✓ looks good' : '⚠ needs attention')),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
return h('div', {},
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')),
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
h('div', {class:'card flush'}, table),
await dkimCard(),
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
};
async function dkimCard() {
const keys = await api('GET', O('/dkim'));
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'});
const chip = k => ({published:['ok', '✓ published'], missing:['warn', 'not in DNS yet'], mismatch:['bad', 'DNS has a different key'], unknown:['', 'waiting for a check']}[k.status] || ['', k.status]);
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
return card('DKIM signing', h('p', {class:'dim'}, 'The edge signs your outgoing mail so receiving servers can prove it came from you. Make a key for each domain you send from, then publish its record. If your mail server already signs, it is left alone.'),
keys.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Selector', 'DNS', ''].map(x => h('th', {}, x)))), h('tbody', {}, keys.map(k => h('tr', {},
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel),
h('button', {class:'btn', onclick: act(async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, 'Create key')));
}
function mailForm(existing) {
const st = S.st, e = existing || {enabled:true, listen:'', ratePerMinute:60, requireTls:false};
const sites = st.sites.filter(s => !s.local);
const siteSel = h('select', {}, sites.map(s => h('option', {value:s.id, selected:s.id === e.siteId}, s.name)));
const edgeSel = h('select', {}, st.edges.map(x => h('option', {value:x.id, selected:x.id === e.edgeId}, x.name + (x.online ? '' : ' (offline)'))));
const name = h('input', {value:e.name || '', placeholder:'e.g. Mail out'});
const src = h('input', {list:'srcips', value:e.sourceIp || '', placeholder:'an IP address of the edge'});
const ipList = h('datalist', {id:'srcips'});
const helo = h('input', {value:e.heloName || '', placeholder:'mail.example.com'});
const listen = h('input', {value:e.listen || '127.0.0.1:2525'});
const smart = h('input', {value:e.smarthost || '', placeholder:'blank = deliver to each recipient\'s own server'});
const user = h('input', {value:e.username || '', autocomplete:'off', placeholder:'needed if you listen beyond 127.0.0.1'});
const pass = h('input', {type:'password', autocomplete:'new-password', placeholder:e.passwordSet ? 'unchanged' : ''});
const rate = h('input', {type:'number', min:1, max:6000, value:e.ratePerMinute || 60});
const tls = chk('Require verified TLS (refuse to send to servers without it)', e.requireTls);
const refreshIPs = () => { const x = byId(st.edges, edgeSel.value); ipList.replaceChildren(...((x && x.ips) || []).map(ip => h('option', {value:ip}))); };
edgeSel.onchange = refreshIPs; refreshIPs();
const save = act(async () => {
await api(existing ? 'PUT' : 'POST', O('/mail-egress' + (existing ? '/' + existing.id : '')), {name:name.value, siteId:siteSel.value, edgeId:edgeSel.value, sourceIp:src.value,
heloName:helo.value, listen:listen.value, smarthost:smart.value, requireTls:tls.box.checked, ratePerMinute:+rate.value || 0, username:user.value, password:pass.value, enabled:e.enabled !== false});
close(); toast('Saved. Check the deliverability column in a moment.'); refresh();
});
const close = modal(existing ? 'Edit mail egress' : 'Add mail egress', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Site (your mail server\'s network)', siteSel), field('Edge (where mail leaves)', edgeSel)),
h('div', {class:'grid'}, field('Send from this edge IP', src, 'Its reverse DNS should be your HELO name'), field('HELO hostname', helo, 'What your server announces, e.g. mail.example.com')),
ipList,
h('div', {class:'grid'}, field('Relay listens on (on the site)', listen, '127.0.0.1 keeps it private to that machine'), field('Messages per minute (limit)', rate, 'Caps what a compromised server could send from your IP')),
h('div', {class:'grid'}, field('Username', user), field('Password', pass)),
field('Smarthost (optional)', smart, 'Use this if you relay through a provider that allows your edge IP. Leave blank for direct delivery.'), tls,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
}
// ----- alerts -----
const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], ['ntfy', 'ntfy']];
function channelForm(base, existing, trusted) {
const c = existing || {kind:'email', format:'json', enabled:true};
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
const en = chk('Enabled', c.enabled !== false);
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
const targetBox = field('Where', target);
const redraw = () => {
const w = kind.value === 'webhook';
fmtBox.style.display = w ? '' : 'none';
priv.style.display = w && trusted ? '' : 'none';
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
};
kind.onchange = redraw; redraw();
const save = act(async () => {
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
close(); toast('Saved'); refresh();
});
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, 'Save'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
}
async function alertsView(base, scoped, trusted) {
const d = await api('GET', base);
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
return h('div', {},
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')),
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
h('button', {class:'btn', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Saved'); })}, 'Save'))) : null,
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, new Date(e.time).toLocaleString()),
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : h('div', {class:'empty'}, 'Nothing has fired yet.')));
}
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
// ----- access -----
views.access = async () => {
const [members, invites, keys] = await Promise.all([api('GET', O('/members')), api('GET', O('/invites')), api('GET', O('/apikeys'))]);
const roles = S.st.roles;
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
const kname = h('input', {placeholder:'e.g. CI deploy'});
return h('div', {},
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
h('td', {}, mm.totp ? '✓' : '—'),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(async () => {
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })}, 'Create invitation')),
invites.length ? h('table', {style:'margin-top:12px'}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null),
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'btn', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
};
// ----- logs -----
let logKind = 'access', logMap = '', logQ = '', logStatus = '', logResult = '', logPaged = false;
views.logs = async () => {
const st = S.st;
const params = extra => new URLSearchParams({kind:logKind, limit:200, mapping:logMap, q:logQ, status:logStatus, result:logResult, ...extra});
const rows = await api('GET', O('/logs?' + params()));
const set = fn => e => { fn(e.target.value); logPaged = false; refresh(); };
const named = [...st.mappings, ...(st.mailEgress || [])];
const mapSel = h('select', {style:'width:auto', onchange:set(v => logMap = v)}, h('option', {value:''}, 'All ports'), named.map(m => h('option', {value:m.id, selected:m.id === logMap}, m.name)));
const statusSel = h('select', {style:'width:auto', onchange:set(v => logStatus = v)}, [['', 'Any status'], ['2', '2xx'], ['3', '3xx'], ['4', '4xx'], ['5', '5xx']].map(([v, t]) => h('option', {value:v, selected:v === logStatus}, t)));
const resultSel = h('select', {style:'width:auto', onchange:set(v => logResult = v)}, [['', 'Any result'], ['denied', 'Denied'], ['auth-required', 'Login required'], ['login', 'Logins'], ['mail', 'Mail sent'], ['mail-failed', 'Mail failed'], ['mail-refused', 'Mail refused'], ['no-target', 'No target']].map(([v, t]) => h('option', {value:v, selected:v === logResult}, t)));
const search = h('input', {placeholder:'Search host, path, address, user…', value:logQ, style:'width:260px'});
search.addEventListener('keydown', e => { if (e.key === 'Enter') { logQ = search.value.trim(); logPaged = false; refresh(); } });
const t = d => new Date(d).toLocaleString();
const tbody = h('tbody');
const addRows = list => list.forEach(r => {
if (logKind === 'audit') { tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))); return; }
const m = byId(st.mappings, r.mapping) || byId(st.mailEgress, r.mapping);
tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, m ? m.name : r.mapping), h('td', {class:'mono small'}, r.remote), h('td', {class:'mono small'}, r.method ? `${r.method} ${r.host}${r.path}` : (r.host || '(connection)')),
h('td', {}, r.status || ''), h('td', {}, r.user || ''), h('td', {class:r.result && !['allowed', 'connection', 'bypass', 'login', 'logout', 'mail'].includes(r.result) ? 'err' : 'dim'}, r.result), h('td', {class:'small dim'}, `↑${bytes(r.bytesIn)} ↓${bytes(r.bytesOut)}`), h('td', {class:'dim'}, r.ms)));
});
addRows(rows);
let last = rows.length ? rows[rows.length - 1].id : 0;
const more = h('button', {class:'ghost', style:'margin:12px auto;display:block', onclick: act(async () => {
const next = await api('GET', O('/logs?' + params({before:last})));
if (!next.length) { more.remove(); toast('That is everything'); return; }
logPaged = true; addRows(next); last = next[next.length - 1].id;
})}, 'Load older entries');
const head = logKind === 'audit' ? ['Time', 'Who', 'What', 'Detail'] : ['Time', 'Port', 'From', 'Request', 'Status', 'User', 'Result', 'Bytes', 'ms'];
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : h('div', {class:'empty'}, 'Nothing matches.')));
};
// ----- server administration -----
let serverTab = 'edges';
views.server = async () => {
return await serverViews[serverTab]();
};
const serverViews = {};
serverViews.alerts = () => alertsView('/api/admin/alerts', false, true);
serverViews.edges = async () => {
const [edges, me] = [await api('GET', '/api/edges'), S.me];
const orgs = me.orgs, name = h('input', {placeholder:'e.g. VPS Frankfurt'}), addr = h('input', {placeholder:'203.0.113.10:51820'});
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
return h('div', {},
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); showCmd(e); refresh(); })}, 'Add edge')),
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
};
serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0();
return h('div', {},
card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))),
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const st = await api('GET', `/api/orgs/${o.id}/state`);
await api('PUT', `/api/orgs/${o.id}`, {allowLocal:!st.org.allowLocal}); toast(`Local access ${st.org.allowLocal ? 'off' : 'on'} for ${o.name}`); })}, 'Toggle local access')),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${o.name} and everything in it?`)) { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); } })}, 'Delete'))))))));
};
async function boot0() { S.me = await api('GET', '/api/me'); }
serverViews.users = async () => {
const users = await api('GET', '/api/admin/users');
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
return h('div', {},
card('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'),
h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))),
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
u.totp ? h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('2FA reset'); refresh(); })}, 'Reset 2FA') : null,
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast('Signed out everywhere'); })}, 'Sign out'),
h('button', {class:'ghost', onclick: act(async () => { const p = prompt('New password (8+ characters)'); if (p) { await api('PUT', '/api/admin/users/' + u.id, {password:p}); toast('Password changed'); } })}, 'Set password'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete ' + u.email + '?')) { await api('DELETE', '/api/admin/users/' + u.id); refresh(); } })}, 'Delete')))))))));
};
serverViews.idps = async () => {
const idps = await api('GET', '/api/admin/idps'), orgs = S.me.orgs;
const form = (e = {}) => {
const f = {name:h('input', {value:e.name || ''}), issuer:h('input', {value:e.issuer || '', placeholder:'https://accounts.google.com'}), cid:h('input', {value:e.clientId || ''}),
sec:h('input', {type:'password', placeholder:e.id ? 'unchanged' : ''}), scopes:h('input', {value:e.scopes || 'openid email profile'}), dom:h('input', {value:(e.allowedDomains || []).join(', '), placeholder:'company.com (blank = any)'})};
const auto = chk('Create accounts for new users automatically', e.autoProvision);
const orgSel = h('select', {}, orgs.map(o => h('option', {value:o.id, selected:o.id === e.orgId}, o.name)));
const close = modal(e.id ? 'Edit provider' : 'Add an OpenID Connect provider', h('div', {class:'stack'},
h('p', {class:'dim small'}, 'Redirect URI to register at the provider: ' + (S.me.dashboardUrl || location.origin) + '/auth/oidc/callback'),
field('Name shown on the button', f.name), field('Issuer URL', f.issuer), field('Client ID', f.cid), field('Client secret', f.sec), field('Scopes', f.scopes), field('Allowed email domains', f.dom),
auto, field('New users join', orgSel, 'as a Member'),
h('button', {class:'btn', onclick: act(async () => {
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
};
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'),
h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider'));
};
serverViews.settings = async () => {
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
const f = {url:h('input', {value:s.dashboardUrl, placeholder:'https://hammerhead.example.com'}), acme:h('input', {type:'email', value:s.acmeEmail, placeholder:'you@example.com'}),
host:h('input', {value:s.smtp.host}), port:h('input', {type:'number', value:s.smtp.port || ''}), user:h('input', {value:s.smtp.user}), pass:h('input', {type:'password', placeholder:smtpPassSet ? 'unchanged' : ''}), from:h('input', {value:s.smtp.from, placeholder:'Hammerhead <no-reply@example.com>'}),
mode:h('select', {}, ['starttls', 'tls', 'none'].map(m => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, m)))};
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
return h('div', {},
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
h('button', {class:'btn', onclick:save}, 'Save settings'));
};
serverViews.audit = async () => {
const rows = await api('GET', '/api/admin/audit');
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : h('div', {class:'empty'}, 'Nothing yet.'));
};
// ----- account -----
views.account = async () => {
S.me = await api('GET', '/api/me');
const name = h('input', {value:S.me.name}), cur = h('input', {type:'password', autocomplete:'current-password'}), nw = h('input', {type:'password', autocomplete:'new-password'});
const twofa = h('div', {class:'stack'});
const draw2fa = () => {
if (S.me.totpEnabled) {
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'});
twofa.replaceChildren(h('p', {}, '✓ Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
h('button', {class:'ghost danger', onclick: act(async () => { await api('POST', '/api/me/2fa/disable', {password:pw.value, code:code.value}); S.me = await api('GET', '/api/me'); draw2fa(); })}, 'Turn off')));
} else twofa.replaceChildren(h('p', {class:'dim'}, 'Add an authenticator app (Google Authenticator, 1Password, Aegis…) for a second step at sign-in.'),
h('button', {class:'btn', style:'align-self:flex-start', onclick: act(async () => {
const r = await api('POST', '/api/me/2fa/setup'), code = h('input', {placeholder:'6-digit code', inputmode:'numeric'});
const close = modal('Set up two-factor authentication', h('div', {class:'stack'}, h('p', {class:'dim'}, 'In your authenticator app choose "enter a setup key" and paste this key (or open the link on your phone):'), cmdBox(r.secret), h('a', {href:r.uri}, 'Open in authenticator app'),
field('Then enter the code it shows', code), h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/me/2fa/enable', {code:code.value}); close(); S.me = await api('GET', '/api/me'); draw2fa();
modal('Save your backup codes', h('div', {}, h('p', {class:'dim'}, 'Each code works once if you lose your device. They are not shown again.'), cmdBox(e.backupCodes.join('\n')))); })}, 'Turn on')), {sticky:true});
})}, 'Set up two-factor'));
};
draw2fa();
const sessions = await api('GET', '/api/me/sessions');
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? h('span', {class:'tag'}, 'this device') : null),
h('td', {class:'small'}, (x.ua || 'unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'btn', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Saved'); })}, 'Save'))),
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password (8+ characters)', nw),
h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed; other sessions were signed out'); })}, 'Change password'))),
card('Two-factor authentication', twofa), devices);
};
boot();
</script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<style>
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; }
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.45 system-ui,sans-serif; display:grid; place-items:center; min-height:100vh; padding:16px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; }
h1 { font-size:18px; margin:0 0 4px; }
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; }
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--dim); margin:0; }
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; }
input:focus { outline:2px solid var(--accent); outline-offset:-1px; }
button, a.btn { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:center; text-decoration:none; display:block; }
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); }
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; }
.ok { color:var(--dim); font-size:13px; }
[hidden] { display:none !important; }
</style>
</head>
<body>
<div class="card">
<h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p>
<div id="body"></div>
<div id="err"></div>
</div>
<script>
const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search);
const ret = params.get('return') || '';
const $ = s => document.querySelector(s);
const body = $('#body');
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (v === true) el.setAttribute(k, '');
else if (v != null && v !== false) el.setAttribute(k, v);
}
for (const k of kids.flat()) if (k != null) el.append(k.nodeType ? k : document.createTextNode(k));
return el;
}
const err = m => $('#err').textContent = m || '';
async function post(path, data) {
err('');
const r = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
async function finish(path, data) {
try {
const j = await post(`/auth/r/${id}/${path}`, { ...data, return: ret });
if (j.redirect) location.href = j.redirect;
} catch (e) { err(e.message); }
}
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) {
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, i, h('button', {onclick:go}, 'Continue'));
}
function emailForm() {
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 msg = h('div', {class:'ok'});
const go = h('button', {}, 'Email me a code');
go.onclick = async () => {
try {
if (code.hidden) {
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();
} else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); }
};
return section('Email code', email, code, msg, go);
}
function ssoForm(info) {
const kids = [];
if (info.user) {
if (info.user.allowed) {
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
} else {
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
}
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids);
}
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
let ticket = null;
const go = async () => {
try {
let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.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; }
location.reload();
} catch (e) { err(e.message); }
};
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
for (const p of info.providers || []) {
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}`));
}
return section('Account', ...kids);
}
async function main() {
// share links skip the form
const share = params.get('share');
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json();
if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name;
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
if (info.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.');
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
}
main();
</script>
</body>
</html>