From f521fa8668523c1c0be2f54385b812fffb882e5e Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:50:03 -0600 Subject: [PATCH] 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 --- .env.example | 40 ++ .gitignore | 7 + README.md | 219 ++++++- accounts.go | 565 ++++++++++++++++ agent.go | 265 ++++++++ alerts.go | 441 +++++++++++++ alerts_api.go | 203 ++++++ alerts_test.go | 489 ++++++++++++++ cmd/hammerhead/main.go | 61 ++ db.go | 663 +++++++++++++++++++ db_test.go | 231 +++++++ dkim.go | 145 +++++ e2e/e2e_test.go | 941 +++++++++++++++++++++++++++ e2e/mail_test.go | 504 ++++++++++++++ e2e/mailextras_test.go | 279 ++++++++ e2e/proxyproto_test.go | 177 +++++ e2e/ranges_test.go | 216 ++++++ e2e/revoke_test.go | 224 +++++++ go.mod | 32 + go.sum | 76 +++ infra.go | 367 +++++++++++ internal/secretbox/secretbox.go | 87 +++ internal/secretbox/secretbox_test.go | 51 ++ logs.go | 70 ++ mailegress.go | 171 +++++ mappings.go | 544 ++++++++++++++++ metrics.go | 112 ++++ oidc.go | 346 ++++++++++ orgs.go | 642 ++++++++++++++++++ resauth.go | 340 ++++++++++ server.go | 140 ++++ settings.go | 130 ++++ store.go | 576 ++++++++++++++++ web/index.html | 894 +++++++++++++++++++++++++ web/resource.html | 145 +++++ 35 files changed, 10392 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 accounts.go create mode 100644 agent.go create mode 100644 alerts.go create mode 100644 alerts_api.go create mode 100644 alerts_test.go create mode 100644 cmd/hammerhead/main.go create mode 100644 db.go create mode 100644 db_test.go create mode 100644 dkim.go create mode 100644 e2e/e2e_test.go create mode 100644 e2e/mail_test.go create mode 100644 e2e/mailextras_test.go create mode 100644 e2e/proxyproto_test.go create mode 100644 e2e/ranges_test.go create mode 100644 e2e/revoke_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra.go create mode 100644 internal/secretbox/secretbox.go create mode 100644 internal/secretbox/secretbox_test.go create mode 100644 logs.go create mode 100644 mailegress.go create mode 100644 mappings.go create mode 100644 metrics.go create mode 100644 oidc.go create mode 100644 orgs.go create mode 100644 resauth.go create mode 100644 server.go create mode 100644 settings.go create mode 100644 store.go create mode 100644 web/index.html create mode 100644 web/resource.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f4021c1 --- /dev/null +++ b/.env.example @@ -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 /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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cf2780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +*-data/ +.env +*.db +*.db-wal +*.db-shm +master.key diff --git a/README.md b/README.md index 48bef2a..8481c0f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,220 @@ # Hammerhead -Self-hosted control plane for tunnelled ports and websites: a web dashboard, accounts and SSO, alerts, and a database, with no config files. \ No newline at end of file +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 -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 -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 +`/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 ` 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. diff --git a/accounts.go b/accounts.go new file mode 100644 index 0000000..5396f26 --- /dev/null +++ b/accounts.go @@ -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) +} diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..a6c2b89 --- /dev/null +++ b/agent.go @@ -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) +} diff --git a/alerts.go b/alerts.go new file mode 100644 index 0000000..4ffd625 --- /dev/null +++ b/alerts.go @@ -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 +} diff --git a/alerts_api.go b/alerts_api.go new file mode 100644 index 0000000..39a09c3 --- /dev/null +++ b/alerts_api.go @@ -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)) +} diff --git a/alerts_test.go b/alerts_test.go new file mode 100644 index 0000000..94a8134 --- /dev/null +++ b/alerts_test.go @@ -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), ¬e) != 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), ¬e) != 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), ¬e) != 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), ¬e) != 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), ¬e) != 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) + } +} diff --git a/cmd/hammerhead/main.go b/cmd/hammerhead/main.go new file mode 100644 index 0000000..25b7199 --- /dev/null +++ b/cmd/hammerhead/main.go @@ -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()) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..1ac4af5 --- /dev/null +++ b/db.go @@ -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, ¬ified) != 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 +} diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..f31f375 --- /dev/null +++ b/db_test.go @@ -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"]) + } +} diff --git a/dkim.go b/dkim.go new file mode 100644 index 0000000..47e619f --- /dev/null +++ b/dkim.go @@ -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 +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..e466785 --- /dev/null +++ b/e2e/e2e_test.go @@ -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 ":". +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) + } +} diff --git a/e2e/mail_test.go b/e2e/mail_test.go new file mode 100644 index 0000000..2af4a8e --- /dev/null +++ b/e2e/mail_test.go @@ -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) + } +} diff --git a/e2e/mailextras_test.go b/e2e/mailextras_test.go new file mode 100644 index 0000000..605df85 --- /dev/null +++ b/e2e/mailextras_test.go @@ -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) + } +} diff --git a/e2e/proxyproto_test.go b/e2e/proxyproto_test.go new file mode 100644 index 0000000..c493911 --- /dev/null +++ b/e2e/proxyproto_test.go @@ -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:" 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"}) +} diff --git a/e2e/ranges_test.go b/e2e/ranges_test.go new file mode 100644 index 0000000..45c17bb --- /dev/null +++ b/e2e/ranges_test.go @@ -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) + } +} diff --git a/e2e/revoke_test.go b/e2e/revoke_test.go new file mode 100644 index 0000000..e7a5e2a --- /dev/null +++ b/e2e/revoke_test.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..55510a2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..265b9e0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/infra.go b/infra.go new file mode 100644 index 0000000..c7c9429 --- /dev/null +++ b/infra.go @@ -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 +} diff --git a/internal/secretbox/secretbox.go b/internal/secretbox/secretbox.go new file mode 100644 index 0000000..159c7df --- /dev/null +++ b/internal/secretbox/secretbox.go @@ -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:". 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 +} diff --git a/internal/secretbox/secretbox_test.go b/internal/secretbox/secretbox_test.go new file mode 100644 index 0000000..c8f2822 --- /dev/null +++ b/internal/secretbox/secretbox_test.go @@ -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") + } +} diff --git a/logs.go b/logs.go new file mode 100644 index 0000000..7bb25f7 --- /dev/null +++ b/logs.go @@ -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) + } + }) +} diff --git a/mailegress.go b/mailegress.go new file mode 100644 index 0000000..becd0dd --- /dev/null +++ b/mailegress.go @@ -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 +} diff --git a/mappings.go b/mappings.go new file mode 100644 index 0000000..510ffa6 --- /dev/null +++ b/mappings.go @@ -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, + }) +} diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..787455a --- /dev/null +++ b/metrics.go @@ -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) +} diff --git a/oidc.go b/oidc.go new file mode 100644 index 0000000..a700674 --- /dev/null +++ b/oidc.go @@ -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) +} diff --git a/orgs.go b/orgs.go new file mode 100644 index 0000000..0eef062 --- /dev/null +++ b/orgs.go @@ -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) +} diff --git a/resauth.go b/resauth.go new file mode 100644 index 0000000..5284e99 --- /dev/null +++ b/resauth.go @@ -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 +// /auth/r/?return= +// 2. the visitor proves who they are here (account, password, PIN, emailed +// code or share link) +// 3. we redirect back to /.hammerhead/callback?t= +// 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) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..083cdf5 --- /dev/null +++ b/server.go @@ -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 +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..2d7e9cf --- /dev/null +++ b/settings.go @@ -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() +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..333aaff --- /dev/null +++ b/store.go @@ -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 /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 /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 +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..550ab23 --- /dev/null +++ b/web/index.html @@ -0,0 +1,894 @@ + + + + + +Hammerhead + + + +
+
+ + + diff --git a/web/resource.html b/web/resource.html new file mode 100644 index 0000000..332bca9 --- /dev/null +++ b/web/resource.html @@ -0,0 +1,145 @@ + + + + + +Sign in + + + +
+

Sign in

+

+
+
+
+ + +