Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,40 @@
|
||||
# Copy this file to .env and fill it in. .env is read by hammerhead, blacktip and
|
||||
# bamboo from their working directory (or pass -env /path/to/file). Real
|
||||
# environment variables always win over the file. NEVER commit .env.
|
||||
|
||||
# ---------------------------------------------------------------- hammerhead
|
||||
|
||||
# Encrypts secrets (tokens, SMTP/OIDC/relay passwords, DKIM private keys) inside the
|
||||
# database. Generate one with: hammerhead -genkey
|
||||
# Keep it apart from the data folder and back it up: without it those secrets cannot
|
||||
# be read. If you leave it unset, a key is generated into <data>/master.key instead.
|
||||
HAMMERHEAD_MASTER_KEY=
|
||||
|
||||
# Creates the first administrator at startup (otherwise use the setup page in the browser).
|
||||
#HAMMERHEAD_ADMIN_EMAIL=you@example.com
|
||||
#HAMMERHEAD_ADMIN_PASSWORD=
|
||||
|
||||
# Public address people use to reach the dashboard (login redirects and SSO need it).
|
||||
#HAMMERHEAD_URL=https://hammerhead.example.com
|
||||
|
||||
# Serves Prometheus metrics at /metrics for a scraper that sends this as a bearer token.
|
||||
# Leave empty and /metrics does not exist.
|
||||
#HAMMERHEAD_METRICS_TOKEN=
|
||||
|
||||
# How long access logs are kept (days). Default 30.
|
||||
#HAMMERHEAD_LOG_DAYS=30
|
||||
|
||||
# ------------------------------------------------------------------ blacktip
|
||||
# Token from Server -> Edges -> Show command.
|
||||
#BLACKTIP_HAMMERHEAD=https://hammerhead.example.com
|
||||
#BLACKTIP_TOKEN=
|
||||
|
||||
# For wildcard certificates with Traefik (-traefik ... -acme-dns cloudflare): the
|
||||
# provider's credentials, named exactly as Traefik documents for that provider.
|
||||
#BLACKTIP_ACME_DNS=cloudflare
|
||||
#CF_DNS_API_TOKEN=
|
||||
|
||||
# ------------------------------------------------------------------- bamboo
|
||||
# Token from Sites -> Show command.
|
||||
#BAMBOO_HAMMERHEAD=https://hammerhead.example.com
|
||||
#BAMBOO_TOKEN=
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
*-data/
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
master.key
|
||||
@@ -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.
|
||||
Self-hosted control plane for tunnelled ports and websites: a web dashboard, accounts and SSO, alerts, and a database, with no config files.
|
||||
|
||||
Hammerhead is the hub of the sharks stack. It runs the dashboard and API, and keeps your ports, domains, organizations, users, alerts and logs in an encrypted SQLite database. Edges and site agents connect to it and do whatever you set up in the UI.
|
||||
|
||||
| Repo | Role | Runs on |
|
||||
|---|---|---|
|
||||
| **[Hammerhead](https://git.veillax.com/Veillax/hammerhead)** (this repo) | Control plane: dashboard, API, accounts, database | anywhere the other two can reach |
|
||||
| [Blacktip](https://git.veillax.com/Veillax/blacktip) | Edge: owns public IPs, opens ports, WireGuard endpoint, enforces logins (optionally drives Traefik) | public server(s) |
|
||||
| [Bamboo](https://git.veillax.com/Veillax/bamboo) | Site agent: WireGuard peer inside a private network, outbound only | each private network |
|
||||
| [Reef](https://git.veillax.com/Veillax/reef) | Shared library imported by all three | build time only |
|
||||
|
||||
Every Blacktip also gets a built-in **"this machine"** site, so services on the same box
|
||||
(e.g. several Minecraft proxies) work without running Bamboo at all.
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
go env -w GOPRIVATE=git.veillax.com # once, so Go fetches straight from this server
|
||||
|
||||
# 1. control plane (this repo); open http://localhost:8080 and create the first admin
|
||||
git clone https://git.veillax.com/Veillax/hammerhead && cd hammerhead
|
||||
go build -o bin/ ./cmd/hammerhead
|
||||
cp .env.example .env
|
||||
bin/hammerhead -genkey # paste the output into .env as HAMMERHEAD_MASTER_KEY=
|
||||
bin/hammerhead -listen :8080 -url https://hammerhead.example.com
|
||||
|
||||
# 2. Server -> Edges -> add an edge (name + public host:UDP-port, e.g. 203.0.113.10:51820);
|
||||
# the UI shows the command to run on the public server
|
||||
go install git.veillax.com/Veillax/blacktip/cmd/blacktip@latest
|
||||
blacktip -hammerhead https://hammerhead.example.com -token <edge token> -tunnel :51820 -docker
|
||||
|
||||
# 3. optional, for another network: Sites -> add a site, run on a machine there
|
||||
go install git.veillax.com/Veillax/bamboo/cmd/bamboo@latest
|
||||
bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
|
||||
```
|
||||
|
||||
Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound
|
||||
ports. Then open ports on the **Resources** page.
|
||||
|
||||
## Configuration and secrets
|
||||
|
||||
All three programs read a `.env` file from their working directory before they start (or the file
|
||||
named by `-env path`). Real environment variables always win over the file. Copy `.env.example`,
|
||||
which lists every setting, and keep `.env` out of version control (`.gitignore` already does).
|
||||
|
||||
What belongs in it: `HAMMERHEAD_MASTER_KEY`, the first admin's credentials, the edge and site
|
||||
`..._TOKEN` values, `HAMMERHEAD_METRICS_TOKEN`, and DNS-provider credentials for wildcard certificates.
|
||||
|
||||
**Secrets at rest.** Hammerhead keeps everything in one SQLite file (`hammerhead.db` in its data
|
||||
folder) and encrypts the secrets inside it with AES-256-GCM: edge and site tokens, 2FA secrets,
|
||||
OIDC client secrets, SMTP and relay passwords, DKIM private keys, webhook URLs. Passwords are bcrypt
|
||||
hashes; PINs and resource passwords likewise. With the wrong key Hammerhead refuses to start rather
|
||||
than reading garbage. If you don't set `HAMMERHEAD_MASTER_KEY`, a key is generated into
|
||||
`<data>/master.key` so it works out of the box, but then a backup of the folder carries the key too;
|
||||
copy the file's contents into `.env` and delete it. **Back the key up separately: without it the
|
||||
encrypted secrets cannot be recovered.**
|
||||
|
||||
## Database
|
||||
|
||||
SQLite (pure Go, no C compiler needed), in WAL mode. Configuration is written incrementally in one
|
||||
transaction per change, so it survives crashes and power loss. Logs, traffic history and alerts are
|
||||
proper indexed tables: access logs are searchable and paginated, old entries are pruned
|
||||
(`HAMMERHEAD_LOG_DAYS`, default 30; audit log a year). An existing `state.json` and JSONL logs from an
|
||||
earlier version are imported automatically on first start and kept as `*.migrated`.
|
||||
|
||||
Back up by copying the data folder while Hammerhead is stopped (or use `sqlite3 .backup`), plus your
|
||||
master key.
|
||||
|
||||
## The tunnel: WireGuard
|
||||
|
||||
Blacktip and Bamboo are WireGuard peers, using [wireguard-go](https://git.zx2c4.com/wireguard-go)
|
||||
with a userspace network stack (the same approach Newt uses), so there is no root requirement, no kernel
|
||||
interface and no driver; it works on Windows, macOS and Linux alike.
|
||||
|
||||
- Hammerhead allocates each edge and site an address in `100.89.0.0/16` (it exists only inside the
|
||||
tunnel, so it can't clash with any LAN or VPN) and distributes public keys. Private keys never leave
|
||||
the machine that made them (`wg.key` in each agent's data directory).
|
||||
- Bamboo dials out to every edge it is used from and keeps the NAT mapping open (keepalive 25s),
|
||||
so sites behind NAT or CGNAT work. Each peer is limited to its own `/32`.
|
||||
- TCP goes over a TCP connection inside the tunnel; UDP uses native datagrams (no UDP-over-TCP
|
||||
head-of-line blocking, good for game servers).
|
||||
- Adding or removing a site changes peers in place; existing tunnels are not reset.
|
||||
|
||||
## Two ways to serve web resources
|
||||
|
||||
| | built-in proxy (default) | Traefik (`-traefik /path/to/traefik`) |
|
||||
|---|---|---|
|
||||
| http/https routing, HTTP/2, websockets | yes | yes |
|
||||
| Let's Encrypt | TLS-ALPN-01, per-subdomain certificates for wildcard hostnames | TLS-ALPN-01, or DNS-01 with `-acme-dns <provider>` for true wildcard certificates |
|
||||
| Logins, rules, share links | in Blacktip | same logic, called by Traefik's forward-auth |
|
||||
| Dependency | none | a Traefik v3 binary |
|
||||
|
||||
With Traefik, Blacktip supervises the process: it writes Traefik's static config, restarts it only when
|
||||
the set of web `ip:port` listeners changes (Traefik declares its ports statically), and hot-reloads
|
||||
routes, logins and targets through the file provider. Targets are reached through a loopback relay port
|
||||
per resource into the WireGuard tunnel, so health checks and failover work the same. **Raw TCP, UDP and
|
||||
TLS-passthrough ports never go through Traefik**: it can't open ports without a restart, and opening
|
||||
ports from the UI instantly is the point of this project, so Blacktip's own listeners handle those.
|
||||
|
||||
`-no-acme` makes Traefik serve its built-in self-signed certificate (useful on private networks).
|
||||
DNS-challenge credentials are passed as environment variables to Blacktip, exactly as Traefik documents
|
||||
for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the place for them.
|
||||
|
||||
## Features
|
||||
|
||||
**Ports**
|
||||
- The same port on different IPs (`x.y.z.104:51000` and `x.y.z.209:51000` go to different targets).
|
||||
Blacktip lists the IPs it finds; you can also type one.
|
||||
- **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the
|
||||
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
|
||||
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
|
||||
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
|
||||
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
|
||||
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
|
||||
`postscreen_upstream_proxy_protocol = haproxy`, Dovecot `haproxy_trusted_networks`, nginx `proxy_protocol`.
|
||||
- Many hostnames share one `ip:port`; `*.example.com` wildcards work.
|
||||
- Several targets per port: round-robin load balancing with automatic failover.
|
||||
- Health checks (TCP connect or an HTTP path); unhealthy targets are skipped and shown in the UI.
|
||||
- Conflict detection (same ip/port/protocol, overlapping ranges, or `0.0.0.0` overlapping a specific IP).
|
||||
- Traffic totals and a 24-hour sparkline per port.
|
||||
|
||||
**HTTPS**: automatic Let's Encrypt certificates, optional port-80 redirect, `X-Forwarded-*`
|
||||
headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
||||
|
||||
**Login in front of any web port** (like Pangolin's resource auth)
|
||||
- Sign in with a Hammerhead account (limited to roles or everyone in the org), a shared
|
||||
password, a PIN, or an emailed one-time code.
|
||||
- Expiring, use-limited share links that skip the login.
|
||||
- Path and IP/CIDR rules that skip the login or block requests.
|
||||
- The backend gets `X-Hammerhead-User` (client-supplied copies and header aliases are removed) and
|
||||
never sees the session cookie.
|
||||
- **Sessions can be revoked.** Visitor cookies are stateless tokens carrying a per-resource epoch.
|
||||
Hammerhead bumps it, and edges refuse older tokens within seconds, when a member is removed, a role
|
||||
changes or is deleted, a user is deleted, the password/PIN or allowed methods change, a share link is
|
||||
revoked, or you press **Sign out visitors**.
|
||||
|
||||
**Accounts**
|
||||
- Organizations, roles (admin and custom), invitations (by link or email), member management.
|
||||
- Two-factor authentication (TOTP) with backup codes. Password changes sign out other sessions.
|
||||
- **Signed-in devices** on the Account page: see where you're signed in and end any session, or all the
|
||||
others; admins can sign a user out everywhere.
|
||||
- OpenID Connect single sign-on (Google, Authentik, Keycloak, Entra ID…) with auto-provisioning.
|
||||
- Org-scoped API keys, a "My apps" page for members, brute-force throttling.
|
||||
- Domains are owned per org and checked against DNS; hostnames must sit under your own domains.
|
||||
|
||||
**Alerts**
|
||||
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a
|
||||
target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS,
|
||||
HELO, port 25, DKIM record missing).
|
||||
- A problem must last the **debounce time** (default 2 minutes, per organization) before you hear about
|
||||
it, so restarts and blips stay quiet. One message when it starts, one when it clears, optional
|
||||
reminders. Active alerts are stored, so restarting Hammerhead doesn't repeat them, and an alert is
|
||||
only marked resolved once the thing it describes has been re-checked.
|
||||
- A dead edge silences its own ports' alerts (the edge alert already says it). Channels are per
|
||||
organization; **server channels** get edge alerts.
|
||||
- Webhooks can't be pointed at internal addresses (no metadata service, no LAN) unless a server admin
|
||||
allows it for the organization, and redirects are not followed.
|
||||
- **`/metrics`** (Prometheus) when `HAMMERHEAD_METRICS_TOKEN` is set: edge/site/port/target/mail-check
|
||||
gauges, traffic counters, alerts firing. Off (404) otherwise.
|
||||
|
||||
**Mail egress (send your mail server's outgoing mail from the edge's IP)**
|
||||
- Receiving servers judge mail by the IP it comes from: its reverse DNS, your HELO name and your SPF
|
||||
record all have to agree. A mail server at home usually can't satisfy that (residential IP, port 25
|
||||
blocked). Mail egress lets it send from a VPS IP you control instead.
|
||||
- Bamboo opens a small SMTP relay on the mail server's network (default `127.0.0.1:2525`). Point your
|
||||
MTA at it (Postfix: `relayhost = [127.0.0.1]:2525`). Each message crosses the WireGuard tunnel and
|
||||
Blacktip delivers it straight to the recipient's server (MX lookup, MX fallback rules, opportunistic
|
||||
STARTTLS) **from the source IP you choose**, announcing the HELO name you set. The recipient's replies
|
||||
go straight back, so your server's queue, retries and bounces behave normally. Or set a smarthost to
|
||||
relay through a provider that allows your edge IP.
|
||||
- **DKIM signing at the edge.** Create a key per sending domain on the Mail egress page (RSA 2048,
|
||||
generated by Hammerhead, private half encrypted at rest and held only by the edge); publish the TXT
|
||||
record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message
|
||||
your mail server already signed for that domain is left alone, and a signing problem sends the mail
|
||||
unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
|
||||
- **MTA-STS enforcement.** When a recipient domain publishes an `enforce` policy, mail only goes to the
|
||||
servers it lists, over TLS with a verified certificate; otherwise it's a temporary failure so your
|
||||
server retries. (`testing` mode doesn't block.) Policies are cached for their `max_age`. The policy
|
||||
fetch can't reach internal addresses or follow redirects.
|
||||
- The page shows an edge-side deliverability checklist: reverse DNS matches the HELO name, the HELO name
|
||||
resolves back to the IP, outbound port 25 is open, plus the SPF record to publish.
|
||||
- Guard rails: only the owning site can use an egress; recipients' servers on internal address ranges
|
||||
are refused; a per-minute send limit (default 60) protects the IP's reputation if the mail server is
|
||||
compromised; the relay requires a password if it listens beyond `127.0.0.1`; optional "require
|
||||
verified TLS"; every delivery and refusal is in the access log (recipient domain, server used, SMTP
|
||||
status; never message content).
|
||||
- One transaction goes to one recipient domain; extra domains get `452` and your MTA resends them
|
||||
separately, as MTAs expect. 50 MB limit.
|
||||
- **Deliberately no queue at the edge.** Delivery is synchronous, so your mail server's own queue does
|
||||
the retrying. Accepting mail at the edge and bouncing it later would create backscatter (bounces to
|
||||
forged senders, which gets IPs blacklisted) and leave stored mail on a public server.
|
||||
- Not included: smarthost authentication (smarthosts must allow the edge IP), DANE, and receiving
|
||||
mail (use a normal TCP port with PROXY protocol for that).
|
||||
|
||||
**Operations**
|
||||
- Searchable, paginated access log (per request for web ports, per connection for TCP, per message for
|
||||
mail) and audit log.
|
||||
- Docker container discovery on any site or edge (`-docker`): pick a container as a target.
|
||||
- Server admins decide which orgs may use each edge, and whether an org may target the edge
|
||||
machine itself (off by default for extra orgs, since that includes Hammerhead).
|
||||
|
||||
## How logins work
|
||||
|
||||
Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammerhead signs a short-lived
|
||||
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
|
||||
decision function runs in both proxy modes.
|
||||
|
||||
## Limits and notes
|
||||
|
||||
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
|
||||
- Hammerhead serves plain HTTP; put it behind HTTPS (set `-url`) or keep it on a private network.
|
||||
- SQLite means one Hammerhead instance (no clustering); fine for personal and small-team use.
|
||||
- PROXY protocol covers tcp and tls ports, not udp.
|
||||
- A Blacktip can only bind IPs that exist on its machine. Cloud elastic IPs that are NATed must be
|
||||
bound by their private address.
|
||||
- Let's Encrypt needs port 443 on the edge reachable from the internet and DNS pointing at it.
|
||||
- Traefik's access log is imported into Hammerhead; the file is truncated when it passes 64 MB.
|
||||
- Tests: `go test ./...`. The `e2e/` folder runs Hammerhead, Blacktip and Bamboo together (it pulls the other two in as test dependencies); the Traefik test runs when `TRAEFIK_BIN` points at a Traefik binary.
|
||||
+565
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/netguard"
|
||||
)
|
||||
|
||||
// Alerts: every few seconds Hammerhead derives the set of current problems from
|
||||
// what the edges and sites report (an edge that went quiet, a port that can't
|
||||
// bind, a target failing its health check, a certificate error, a mail relay
|
||||
// that isn't deliverable). A problem has to persist for the debounce time before
|
||||
// anyone is told, which keeps a restart or a blip from paging you. You get one
|
||||
// message when it starts and one when it clears, and optionally a reminder.
|
||||
// Active alerts are kept in the database so a restart doesn't repeat them.
|
||||
|
||||
const (
|
||||
defaultDebounce = 2 * time.Minute
|
||||
defaultOffline = 90 * time.Second
|
||||
)
|
||||
|
||||
// AlertChannel is somewhere alerts are sent: an email address or a webhook.
|
||||
type AlertChannel struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "email" or "webhook"
|
||||
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens)
|
||||
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
||||
AllowPrivate bool `json:"allowPrivate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type alertState struct {
|
||||
Key, OrgID, Kind, Name, Message string
|
||||
Since time.Time
|
||||
Fired bool
|
||||
Notified time.Time
|
||||
}
|
||||
|
||||
type problem struct{ org, kind, name, msg string }
|
||||
|
||||
type alertNote struct {
|
||||
Status string `json:"status"` // "firing", "resolved" or "test"
|
||||
Org string `json:"org"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Since time.Time `json:"since"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
func (s *Server) offlineAfter() time.Duration {
|
||||
if s.opts.OfflineAfter > 0 {
|
||||
return s.opts.OfflineAfter
|
||||
}
|
||||
return defaultOffline
|
||||
}
|
||||
|
||||
// problemsLocked derives every current problem. Caller holds s.mu.
|
||||
func (s *Server) problemsLocked(now time.Time) map[string]problem {
|
||||
out := map[string]problem{}
|
||||
add := func(key, org, kind, name, msg string) { out[key] = problem{org, kind, name, msg} }
|
||||
quiet := func(t time.Time) (bool, string) {
|
||||
if t.IsZero() {
|
||||
return false, "" // never connected: not set up yet, nothing to alert about
|
||||
}
|
||||
d := now.Sub(t)
|
||||
return d > s.offlineAfter(), d.Round(time.Second).String()
|
||||
}
|
||||
|
||||
edgeDown, siteDown := map[string]bool{}, map[string]bool{}
|
||||
for _, e := range s.st.Edges {
|
||||
if down, d := quiet(e.LastSeen); down {
|
||||
edgeDown[e.ID] = true
|
||||
msg := fmt.Sprintf("Edge %s has not contacted Hammerhead for %s", e.Name, d)
|
||||
add("edge:"+e.ID+"@", "", "edge", e.Name, msg) // server-level
|
||||
for _, org := range s.orgsUsingEdgeLocked(e.ID) {
|
||||
add("edge:"+e.ID+"@"+org, org, "edge", e.Name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, st := range s.st.Sites {
|
||||
if st.Local {
|
||||
continue
|
||||
}
|
||||
if down, d := quiet(st.LastSeen); down {
|
||||
siteDown[st.ID] = true
|
||||
add("site:"+st.ID, st.OrgID, "site", st.Name, fmt.Sprintf("Site %s has not contacted Hammerhead for %s", st.Name, d))
|
||||
}
|
||||
}
|
||||
for _, m := range s.st.Mappings {
|
||||
if !m.Enabled || edgeDown[m.EdgeID] {
|
||||
continue // a dead edge's last report is stale: the edge alert already says it
|
||||
}
|
||||
st, known := s.status[m.ID]
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
if st.Error != "" {
|
||||
add("port:"+m.ID, m.OrgID, "port", m.Name, fmt.Sprintf("%s can't listen: %s", m.Name, st.Error))
|
||||
}
|
||||
if strings.HasPrefix(st.Cert, "error") {
|
||||
add("cert:"+m.ID, m.OrgID, "certificate", m.Name, fmt.Sprintf("%s has a certificate problem: %s", m.Name, st.Cert))
|
||||
}
|
||||
if m.HealthSeconds != -1 {
|
||||
for i, th := range st.Targets {
|
||||
if i >= len(m.Targets) || th.Healthy || siteDown[m.Targets[i].SiteID] {
|
||||
continue
|
||||
}
|
||||
t := m.Targets[i]
|
||||
add(fmt.Sprintf("target:%s:%d", m.ID, i), m.OrgID, "target", m.Name,
|
||||
fmt.Sprintf("%s: the target %s:%d isn't answering (%s)", m.Name, t.Host, t.Port, th.Detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, x := range s.st.MailEgress {
|
||||
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
|
||||
continue
|
||||
}
|
||||
if msg := s.mailStatus[x.ID]; msg != "" {
|
||||
add("mail:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("Mail relay %s can't run: %s", x.Name, msg))
|
||||
}
|
||||
if chk, ok := s.mailChecks[x.ID]; ok {
|
||||
if !chk.PTRMatchesHelo {
|
||||
add("mailptr:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: reverse DNS of %s doesn't match %s, so receiving servers may reject its mail", x.Name, x.SourceIP, x.HeloName))
|
||||
}
|
||||
if !chk.HeloResolves {
|
||||
add("mailhelo:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: %s doesn't resolve to %s", x.Name, x.HeloName, x.SourceIP))
|
||||
}
|
||||
if chk.Port25 != "" && chk.Port25 != "open" {
|
||||
add("mail25:"+x.ID, x.OrgID, "mail", x.Name, fmt.Sprintf("%s: can't connect out on port 25 from %s", x.Name, x.SourceIP))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range s.st.DKIM {
|
||||
for _, x := range s.st.MailEgress {
|
||||
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
|
||||
continue
|
||||
}
|
||||
if st := s.mailChecks[x.ID].DKIM[k.ID]; st == "missing" || st == "mismatch" {
|
||||
add("dkim:"+k.ID, k.OrgID, "mail", k.Domain, fmt.Sprintf("DKIM key %s._domainkey.%s is %s in DNS, so signed mail will fail verification", k.Selector, k.Domain, st))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// judgeableLocked reports whether we currently have the data to decide an alert's
|
||||
// problem is gone. After a restart the live statuses are empty until edges and
|
||||
// sites report again; resolving then would announce a recovery that never happened.
|
||||
func (s *Server) judgeableLocked(st *alertState) bool {
|
||||
kind, rest, _ := strings.Cut(st.Key, ":")
|
||||
switch kind {
|
||||
case "port", "cert":
|
||||
if s.findMapping(rest) == nil {
|
||||
return true // the thing was deleted: nothing left to be wrong
|
||||
}
|
||||
_, known := s.status[rest]
|
||||
return known
|
||||
case "target":
|
||||
id, _, _ := strings.Cut(rest, ":")
|
||||
if s.findMapping(id) == nil {
|
||||
return true
|
||||
}
|
||||
_, known := s.status[id]
|
||||
return known
|
||||
case "mail":
|
||||
if s.findMailEgress(rest) == nil {
|
||||
return true
|
||||
}
|
||||
_, known := s.mailStatus[rest]
|
||||
return known
|
||||
case "mailptr", "mailhelo", "mail25":
|
||||
if s.findMailEgress(rest) == nil {
|
||||
return true
|
||||
}
|
||||
_, known := s.mailChecks[rest]
|
||||
return known
|
||||
case "dkim":
|
||||
return len(s.mailChecks) > 0
|
||||
}
|
||||
return true // edges and sites carry their own last-seen time
|
||||
}
|
||||
|
||||
func (s *Server) orgsUsingEdgeLocked(edgeID string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.EdgeID == edgeID && !seen[m.OrgID] {
|
||||
seen[m.OrgID] = true
|
||||
out = append(out, m.OrgID)
|
||||
}
|
||||
}
|
||||
for _, x := range s.st.MailEgress {
|
||||
if x.EdgeID == edgeID && !seen[x.OrgID] {
|
||||
seen[x.OrgID] = true
|
||||
out = append(out, x.OrgID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) debounceFor(orgID string) time.Duration {
|
||||
if s.opts.AlertDebounce > 0 {
|
||||
return s.opts.AlertDebounce
|
||||
}
|
||||
if o := s.findOrg(orgID); o != nil && o.AlertDebounceMin > 0 {
|
||||
return time.Duration(o.AlertDebounceMin) * time.Minute
|
||||
}
|
||||
return defaultDebounce
|
||||
}
|
||||
|
||||
func (s *Server) repeatFor(orgID string) time.Duration {
|
||||
if o := s.findOrg(orgID); o != nil && o.AlertRepeatHours > 0 {
|
||||
return time.Duration(o.AlertRepeatHours) * time.Hour
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type delivery struct {
|
||||
note alertNote
|
||||
channels []AlertChannel
|
||||
}
|
||||
|
||||
// evaluateAlerts moves alert state forward to now and sends whatever became due.
|
||||
func (s *Server) evaluateAlerts(now time.Time) {
|
||||
var out []delivery
|
||||
s.mu.Lock()
|
||||
probs := s.problemsLocked(now)
|
||||
orgName := func(id string) string {
|
||||
if o := s.findOrg(id); o != nil {
|
||||
return o.Name
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
queue := func(st *alertState, status string) {
|
||||
var chans []AlertChannel
|
||||
for _, c := range s.st.Channels {
|
||||
if c.OrgID == st.OrgID && c.Enabled {
|
||||
chans = append(chans, *c)
|
||||
}
|
||||
}
|
||||
out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
||||
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
||||
}
|
||||
keys := make([]string, 0, len(probs))
|
||||
for k := range probs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
p := probs[k]
|
||||
st := s.alerts[k]
|
||||
if st == nil {
|
||||
st = &alertState{Key: k, OrgID: p.org, Kind: p.kind, Since: now}
|
||||
s.alerts[k] = st
|
||||
}
|
||||
st.Name, st.Message = p.name, p.msg
|
||||
switch {
|
||||
case !st.Fired && now.Sub(st.Since) >= s.debounceFor(p.org):
|
||||
st.Fired, st.Notified = true, now
|
||||
queue(st, "firing")
|
||||
case st.Fired && s.repeatFor(p.org) > 0 && now.Sub(st.Notified) >= s.repeatFor(p.org):
|
||||
st.Notified = now
|
||||
queue(st, "firing")
|
||||
}
|
||||
s.db.saveAlert(st)
|
||||
}
|
||||
for k, st := range s.alerts {
|
||||
if _, still := probs[k]; still {
|
||||
continue
|
||||
}
|
||||
if !s.judgeableLocked(st) {
|
||||
continue // e.g. just restarted: the agents haven't reported yet, so we can't say it cleared
|
||||
}
|
||||
if st.Fired {
|
||||
queue(st, "resolved")
|
||||
}
|
||||
delete(s.alerts, k)
|
||||
s.db.deleteAlert(k)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, d := range out {
|
||||
go s.deliver(d)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) loadAlerts() {
|
||||
for _, a := range s.db.loadAlertRows() {
|
||||
a := a
|
||||
s.alerts[a.Key] = &a
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) alertLoop() {
|
||||
every := s.opts.AlertInterval
|
||||
if every <= 0 {
|
||||
every = 15 * time.Second
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
s.evaluateAlerts(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- delivery ----------
|
||||
|
||||
func (n alertNote) subject() string {
|
||||
switch n.Status {
|
||||
case "resolved":
|
||||
return "[Hammerhead] Resolved: " + n.Name
|
||||
case "test":
|
||||
return "[Hammerhead] Test alert"
|
||||
}
|
||||
return "[Hammerhead] Problem: " + n.Name
|
||||
}
|
||||
|
||||
func (n alertNote) text() string {
|
||||
switch n.Status {
|
||||
case "resolved":
|
||||
return "Resolved: " + n.Message
|
||||
case "test":
|
||||
return "This is a test alert from Hammerhead. If you can read this, the channel works."
|
||||
}
|
||||
return "Problem: " + n.Message
|
||||
}
|
||||
|
||||
func (s *Server) deliver(d delivery) {
|
||||
for _, ch := range d.channels {
|
||||
if err := s.send(ch, d.note); err != nil {
|
||||
log.Printf("alert channel %q: %v", ch.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) send(ch AlertChannel, n alertNote) error {
|
||||
switch ch.Kind {
|
||||
case "email":
|
||||
var errs []error
|
||||
for _, to := range strings.Split(ch.Target, ",") {
|
||||
if to = strings.TrimSpace(to); to != "" {
|
||||
if err := s.Mailer(to, n.subject(), n.text()+"\n\nOrganization: "+n.Org+"\nSince: "+n.Since.Format(time.RFC1123)+"\n"); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
case "webhook":
|
||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("the webhook answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("unknown channel kind")
|
||||
}
|
||||
|
||||
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
||||
text := n.subject() + "\n" + n.text()
|
||||
switch format {
|
||||
case "slack":
|
||||
body, _ = json.Marshal(map[string]string{"text": text})
|
||||
case "discord":
|
||||
body, _ = json.Marshal(map[string]string{"content": text})
|
||||
case "ntfy":
|
||||
prio, tags := "high", "rotating_light"
|
||||
if n.Status != "firing" {
|
||||
prio, tags = "default", "white_check_mark"
|
||||
}
|
||||
return []byte(n.text()), "text/plain", map[string]string{"Title": n.subject(), "Priority": prio, "Tags": tags}
|
||||
default:
|
||||
body, _ = json.Marshal(n)
|
||||
}
|
||||
return body, "application/json", nil
|
||||
}
|
||||
|
||||
// validateChannel normalises ch. allowPrivate may only be kept if the caller is trusted.
|
||||
func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
ch.Name = strings.TrimSpace(ch.Name)
|
||||
ch.Target = strings.TrimSpace(ch.Target)
|
||||
switch ch.Kind {
|
||||
case "email":
|
||||
for _, a := range strings.Split(ch.Target, ",") {
|
||||
if a = strings.TrimSpace(a); !strings.Contains(a, "@") || strings.ContainsAny(a, " \r\n<>") {
|
||||
return errors.New("enter one or more email addresses, separated by commas")
|
||||
}
|
||||
}
|
||||
ch.Format, ch.AllowPrivate = "", false
|
||||
case "webhook":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("the webhook must be an http:// or https:// URL")
|
||||
}
|
||||
switch ch.Format {
|
||||
case "json", "slack", "discord", "ntfy":
|
||||
case "":
|
||||
ch.Format = "json"
|
||||
default:
|
||||
return errors.New("format must be json, slack, discord or ntfy")
|
||||
}
|
||||
if ch.AllowPrivate && !trusted {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
default:
|
||||
return errors.New("a channel is an email or a webhook")
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = ch.Kind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The same handlers serve an organization's channels and the server-level ones
|
||||
// (for edges, which belong to nobody); only the scope differs.
|
||||
|
||||
type alertScope struct {
|
||||
org string // "" = server level
|
||||
trusted bool // may allow private webhook targets
|
||||
actor string
|
||||
}
|
||||
|
||||
type channelView struct {
|
||||
AlertChannel
|
||||
Preview string `json:"preview"`
|
||||
}
|
||||
|
||||
func preview(ch AlertChannel) string {
|
||||
if ch.Kind != "webhook" {
|
||||
return ch.Target
|
||||
}
|
||||
if u, err := url.Parse(ch.Target); err == nil {
|
||||
return u.Scheme + "://" + u.Host + "/…"
|
||||
}
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
type alertRow struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Since time.Time `json:"since"`
|
||||
Fired bool `json:"fired"`
|
||||
}
|
||||
|
||||
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
||||
s.mu.Lock()
|
||||
chans := []channelView{}
|
||||
for _, c := range s.st.Channels {
|
||||
if c.OrgID == sc.org {
|
||||
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
||||
v.Target = "" // webhook URLs often contain tokens
|
||||
if c.Kind == "email" {
|
||||
v.Target = c.Target
|
||||
}
|
||||
chans = append(chans, v)
|
||||
}
|
||||
}
|
||||
active := []alertRow{}
|
||||
for _, a := range s.alerts {
|
||||
if a.OrgID == sc.org {
|
||||
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
|
||||
}
|
||||
}
|
||||
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
|
||||
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
|
||||
if o := s.findOrg(sc.org); o != nil {
|
||||
if o.AlertDebounceMin > 0 {
|
||||
settings["debounceMin"] = o.AlertDebounceMin
|
||||
}
|
||||
settings["repeatHours"] = o.AlertRepeatHours
|
||||
}
|
||||
mailOK := s.st.Settings.SMTP.Host != ""
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
|
||||
}
|
||||
|
||||
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
var in AlertChannel
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
var existing *AlertChannel
|
||||
if id != "" {
|
||||
for _, c := range s.st.Channels {
|
||||
if c.ID == id && c.OrgID == sc.org {
|
||||
existing = c
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
fail(w, 404, errors.New("no such channel"))
|
||||
return
|
||||
}
|
||||
if in.Target == "" {
|
||||
in.Target = existing.Target // blank keeps the saved URL/address
|
||||
}
|
||||
}
|
||||
trusted := sc.trusted
|
||||
if err := s.validateChannel(&in, trusted); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
in.OrgID = sc.org
|
||||
if existing != nil {
|
||||
in.ID = id
|
||||
*existing = in
|
||||
} else {
|
||||
in.ID = newID()
|
||||
s.st.Channels = append(s.st.Channels, &in)
|
||||
}
|
||||
s.save()
|
||||
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
var ch *AlertChannel
|
||||
for _, c := range s.st.Channels {
|
||||
if c.ID == id && c.OrgID == sc.org {
|
||||
cp := *c
|
||||
ch = &cp
|
||||
}
|
||||
}
|
||||
name := "server"
|
||||
if o := s.findOrg(sc.org); o != nil {
|
||||
name = o.Name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ch == nil {
|
||||
fail(w, 404, errors.New("no such channel"))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
var in struct{ DebounceMin, RepeatHours int }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
|
||||
fail(w, 400, errors.New("wait 1-120 minutes before alerting; remind every 0-168 hours (0 = never)"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o := s.findOrg(sc.org)
|
||||
if o == nil {
|
||||
fail(w, 404, errors.New("organization settings only"))
|
||||
return
|
||||
}
|
||||
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
|
||||
|
||||
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
|
||||
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
|
||||
}
|
||||
|
||||
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
||||
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
|
||||
}
|
||||
|
||||
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
||||
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
|
||||
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
|
||||
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
|
||||
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
|
||||
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
|
||||
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
|
||||
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
|
||||
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
|
||||
}
|
||||
+489
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+231
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// DKIM: Hammerhead makes a signing key for a sending domain, gives the edge the
|
||||
// private half, and shows you the public half as the DNS record to publish. The
|
||||
// edge signs mail on its way out; receiving servers fetch the record to check it.
|
||||
|
||||
var selectorRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$`)
|
||||
|
||||
func (k *dkimView) record() string { return "v=DKIM1; k=rsa; p=" + k.PublicKey }
|
||||
|
||||
type dkimView struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Selector string `json:"selector"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Created time.Time `json:"created"`
|
||||
RecordName string `json:"recordName"`
|
||||
Record string `json:"record"`
|
||||
Status string `json:"status"` // published, missing, mismatch or unknown
|
||||
}
|
||||
|
||||
func (s *Server) listDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []dkimView{}
|
||||
for _, k := range s.st.DKIM {
|
||||
if k.OrgID != c.Org.ID {
|
||||
continue
|
||||
}
|
||||
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
|
||||
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
|
||||
for _, x := range s.st.MailEgress {
|
||||
if x.OrgID != c.Org.ID {
|
||||
continue
|
||||
}
|
||||
switch st := s.mailChecks[x.ID].DKIM[k.ID]; {
|
||||
case st == "published":
|
||||
v.Status = "published"
|
||||
case st != "" && v.Status != "published":
|
||||
v.Status = st
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Domain+out[i].Selector < out[j].Domain+out[j].Selector })
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) createDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct{ Domain, Selector string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Domain = strings.ToLower(strings.TrimSpace(in.Domain))
|
||||
in.Selector = strings.ToLower(strings.TrimSpace(in.Selector))
|
||||
if in.Selector == "" {
|
||||
in.Selector = "hh" + time.Now().Format("200601")
|
||||
}
|
||||
if !validHostname(in.Domain) || strings.HasPrefix(in.Domain, "*.") {
|
||||
fail(w, 400, errors.New("enter the domain you send mail from, like example.com"))
|
||||
return
|
||||
}
|
||||
if !selectorRE.MatchString(in.Selector) {
|
||||
fail(w, 400, errors.New("a selector is letters, digits and dashes, like mail2026"))
|
||||
return
|
||||
}
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048) // RSA: every receiving server can verify it
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
priv, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, k := range s.st.DKIM {
|
||||
if k.Domain == in.Domain && k.Selector == in.Selector {
|
||||
fail(w, 400, errors.New("you already have a key with that selector for this domain; pick a new selector to rotate"))
|
||||
return
|
||||
}
|
||||
}
|
||||
k := &model.DKIMKey{ID: newID(), OrgID: c.Org.ID, Domain: in.Domain, Selector: in.Selector, Created: time.Now(),
|
||||
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})), PublicKey: base64.StdEncoding.EncodeToString(pub)}
|
||||
s.st.DKIM = append(s.st.DKIM, k)
|
||||
s.bump() // edges need the new signer
|
||||
s.audit(c.Org.ID, c.Actor, "dkim.create", in.Selector+"._domainkey."+in.Domain)
|
||||
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
|
||||
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
|
||||
writeJSON(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) deleteDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var gone *model.DKIMKey
|
||||
for _, k := range s.st.DKIM {
|
||||
if k.ID == id && k.OrgID == c.Org.ID {
|
||||
gone = k
|
||||
}
|
||||
}
|
||||
if gone == nil {
|
||||
fail(w, 404, errors.New("no such key"))
|
||||
return
|
||||
}
|
||||
s.st.DKIM = filter(s.st.DKIM, func(k *model.DKIMKey) bool { return k.ID != id })
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "dkim.delete", gone.Selector+"._domainkey."+gone.Domain)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// signersLocked lists the signing keys an org's mail egress may use. Caller holds s.mu.
|
||||
func (s *Server) signersLocked(orgID string) []model.DKIMSigner {
|
||||
out := []model.DKIMSigner{}
|
||||
for _, k := range s.st.DKIM {
|
||||
if k.OrgID == orgID {
|
||||
out = append(out, model.DKIMSigner{KeyID: k.ID, Domain: k.Domain, Selector: k.Selector, PrivateKey: k.PrivateKey, PublicKey: k.PublicKey})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+941
@@ -0,0 +1,941 @@
|
||||
// Package e2e runs hammerhead, blacktip and bamboo together in-process.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/bamboo"
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
"git.veillax.com/Veillax/hammerhead"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func freeUDPPort(t *testing.T) int {
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
return pc.LocalAddr().(*net.UDPAddr).Port
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// tcpBackend answers every connection with "<tag>:<what you sent>".
|
||||
func tcpBackend(t *testing.T, tag string) int {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := c.Read(buf)
|
||||
fmt.Fprintf(c, "%s:%s", tag, buf[:n])
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func udpBackend(t *testing.T, tag string) int {
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pc.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, addr, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pc.WriteTo([]byte(tag+":"+string(buf[:n])), addr)
|
||||
}
|
||||
}()
|
||||
return pc.LocalAddr().(*net.UDPAddr).Port
|
||||
}
|
||||
|
||||
// webBackend echoes what it saw as JSON; /healthz returns healthStatus.
|
||||
func webBackend(t *testing.T, name string, healthStatus int) int {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
w.WriteHeader(healthStatus)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"backend": name, "host": r.Host, "path": r.URL.Path,
|
||||
"user": r.Header.Get("X-Hammerhead-User"), "xff": r.Header.Get("X-Forwarded-For"),
|
||||
"proto": r.Header.Get("X-Forwarded-Proto"), "cookie": r.Header.Get("Cookie"),
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.Listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
type client struct {
|
||||
t *testing.T
|
||||
c *http.Client
|
||||
u string
|
||||
key string
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, base string) *client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &client{t: t, c: &http.Client{Jar: jar, Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, u: base}
|
||||
}
|
||||
|
||||
func (a *client) do(method, path string, in, out any) (int, string) {
|
||||
a.t.Helper()
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, _ := json.Marshal(in)
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, a.u+path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+a.key)
|
||||
}
|
||||
resp, err := a.c.Do(req)
|
||||
if err != nil {
|
||||
a.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if out != nil && resp.StatusCode == 200 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
a.t.Fatalf("%s %s: bad json %q: %v", method, path, raw, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func (a *client) must(method, path string, in, out any) {
|
||||
a.t.Helper()
|
||||
if code, raw := a.do(method, path, in, out); code != 200 {
|
||||
a.t.Fatalf("%s %s -> %d %s", method, path, code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *client) expect(code int, method, path string, in any) string {
|
||||
a.t.Helper()
|
||||
got, raw := a.do(method, path, in, nil)
|
||||
if got != code {
|
||||
a.t.Fatalf("%s %s -> %d (want %d): %s", method, path, got, code, raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func tcpAsk(t *testing.T, addr, msg string) string {
|
||||
t.Helper()
|
||||
var last error
|
||||
for i := 0; i < 40; i++ {
|
||||
c, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
c.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
fmt.Fprint(c, msg)
|
||||
b, _ := io.ReadAll(c)
|
||||
c.Close()
|
||||
if len(b) > 0 {
|
||||
return string(b)
|
||||
}
|
||||
last = fmt.Errorf("empty reply")
|
||||
} else {
|
||||
last = err
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("tcp %s: %v", addr, last)
|
||||
return ""
|
||||
}
|
||||
|
||||
func udpAsk(t *testing.T, addr, msg string) string {
|
||||
t.Helper()
|
||||
var last error
|
||||
for i := 0; i < 40; i++ {
|
||||
c, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.SetDeadline(time.Now().Add(400 * time.Millisecond))
|
||||
c.Write([]byte(msg))
|
||||
buf := make([]byte, 4096)
|
||||
n, err := c.Read(buf)
|
||||
c.Close()
|
||||
if err == nil {
|
||||
return string(buf[:n])
|
||||
}
|
||||
last = err
|
||||
}
|
||||
t.Fatalf("udp %s: %v", addr, last)
|
||||
return ""
|
||||
}
|
||||
|
||||
func totpNow(secret string) string {
|
||||
key, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], uint64(time.Now().Unix()/30))
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(b[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0xf
|
||||
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(sum[off:off+4])&0x7fffffff)%1000000)
|
||||
}
|
||||
|
||||
func selfSigned(t *testing.T, host string) *tls.Certificate {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: host}, DNSNames: []string{host},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// ---------- the environment ----------
|
||||
|
||||
type world struct {
|
||||
t *testing.T
|
||||
ts *httptest.Server
|
||||
hh *hammerhead.Server
|
||||
admin *client
|
||||
org string
|
||||
edge string
|
||||
edgeTok string
|
||||
site string // remote site
|
||||
local string // built-in local site
|
||||
mails chan string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Config)) *world {
|
||||
w := &world{t: t, mails: make(chan string, 16)}
|
||||
var handler http.Handler
|
||||
w.ts = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { handler.ServeHTTP(rw, r) }))
|
||||
t.Cleanup(w.ts.Close)
|
||||
hh, err := hammerhead.New(t.TempDir(), hammerhead.Options{AdminEmail: "admin@test.local", AdminPassword: "password123", DashboardURL: w.ts.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(hh.Close)
|
||||
hh.Mailer = func(to, subject, body string) error { w.mails <- to + "|" + subject + "|" + body; return nil }
|
||||
handler = hh.Handler()
|
||||
w.hh = hh
|
||||
|
||||
w.admin = newClient(t, w.ts.URL)
|
||||
w.admin.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
|
||||
var me struct{ Orgs []struct{ ID string } }
|
||||
w.admin.must("GET", "/api/me", nil, &me)
|
||||
w.org = me.Orgs[0].ID
|
||||
|
||||
tunnelPort := freeUDPPort(t)
|
||||
var edge struct{ ID, Token string }
|
||||
w.admin.must("POST", "/api/edges", map[string]any{"name": "edge1", "tunnelAddr": "127.0.0.1:" + strconv.Itoa(tunnelPort)}, &edge)
|
||||
w.edge, w.edgeTok = edge.ID, edge.Token
|
||||
var site model.Site
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/sites", map[string]string{"name": "home"}, &site)
|
||||
w.site = site.ID
|
||||
w.refreshLocal()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
cert := selfSigned(t, "secure.example.test")
|
||||
btCfg := blacktip.Config{Hammerhead: w.ts.URL, Token: edge.Token, TunnelAddr: "127.0.0.1:" + strconv.Itoa(tunnelPort), DataDir: t.TempDir(),
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return cert, nil }, Traefik: tr}
|
||||
for _, o := range opts {
|
||||
o(&btCfg)
|
||||
}
|
||||
bt, err := blacktip.New(btCfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go bt.Run(ctx)
|
||||
bb, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go bb.Run(ctx)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *world) refreshLocal() {
|
||||
var st struct{ Sites []model.Site }
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
|
||||
for _, s := range st.Sites {
|
||||
if s.Local {
|
||||
w.local = s.ID
|
||||
}
|
||||
}
|
||||
if w.local == "" {
|
||||
w.t.Fatal("edge did not get a built-in local site")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *world) open(m model.Mapping) model.Mapping {
|
||||
w.t.Helper()
|
||||
m.EdgeID, m.Enabled = w.edge, true
|
||||
if m.BindIP == "" {
|
||||
m.BindIP = "127.0.0.1"
|
||||
}
|
||||
var out model.Mapping
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings", map[string]any{
|
||||
"proto": m.Proto, "edgeId": m.EdgeID, "bindIp": m.BindIP, "port": m.Port, "hostname": m.Hostname, "targets": m.Targets,
|
||||
"enabled": true, "auth": m.Auth, "rules": m.Rules, "healthPath": m.HealthPath, "healthSeconds": m.HealthSeconds,
|
||||
"name": m.Name,
|
||||
}, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func tgt(site string, port int) []model.Target {
|
||||
return []model.Target{{SiteID: site, Host: "127.0.0.1", Port: port}}
|
||||
}
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
func TestPortsMultiIPFailoverAndValidation(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
remoteA, remoteB, localSvc := tcpBackend(t, "A"), tcpBackend(t, "B"), tcpBackend(t, "LOCAL")
|
||||
udpSvc := udpBackend(t, "UDP")
|
||||
port := freePort(t) // the SAME public port on different IPs
|
||||
|
||||
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, remoteA)})
|
||||
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.2", Port: port, Targets: tgt(w.site, remoteB)})
|
||||
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, localSvc)})
|
||||
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, udpSvc)})
|
||||
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, udpSvc)})
|
||||
|
||||
// failover: first target is dead, second is alive
|
||||
dead := freePort(t)
|
||||
fo := freePort(t)
|
||||
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.4", Port: fo,
|
||||
Targets: []model.Target{{SiteID: w.local, Host: "127.0.0.1", Port: dead}, {SiteID: w.site, Host: "127.0.0.1", Port: remoteA}}})
|
||||
|
||||
check := func(got, want string) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
pa := strconv.Itoa(port)
|
||||
check(tcpAsk(t, "127.0.0.1:"+pa, "hi"), "A:hi")
|
||||
check(tcpAsk(t, "127.0.0.2:"+pa, "hi"), "B:hi")
|
||||
check(tcpAsk(t, "127.0.0.3:"+pa, "hi"), "LOCAL:hi")
|
||||
check(udpAsk(t, "127.0.0.1:"+pa, "ping"), "UDP:ping")
|
||||
check(udpAsk(t, "127.0.0.3:"+pa, "ping"), "UDP:ping")
|
||||
for i := 0; i < 4; i++ { // round robin must keep landing on the live target
|
||||
check(tcpAsk(t, "127.0.0.4:"+strconv.Itoa(fo), "x"), "A:x")
|
||||
}
|
||||
|
||||
base := "/api/orgs/" + w.org + "/mappings"
|
||||
for name, m := range map[string]map[string]any{
|
||||
"duplicate": {"proto": "tcp", "bindIp": "127.0.0.1", "port": port},
|
||||
"wildcard": {"proto": "tcp", "bindIp": "0.0.0.0", "port": port},
|
||||
"no targets": {"proto": "tcp", "bindIp": "127.0.0.9", "port": port, "targets": []any{}},
|
||||
"bad udp": {"proto": "udp", "bindIp": "127.0.0.9", "port": port, "targets": append(tgt(w.site, 1), tgt(w.site, 2)...)},
|
||||
"no hostname": {"proto": "http", "bindIp": "127.0.0.9", "port": port},
|
||||
} {
|
||||
m["edgeId"] = w.edge
|
||||
if _, ok := m["targets"]; !ok {
|
||||
m["targets"] = tgt(w.site, remoteA)
|
||||
}
|
||||
w.admin.expect(400, "POST", base, m)
|
||||
_ = name
|
||||
}
|
||||
|
||||
// dead target gets flagged unhealthy in the UI state
|
||||
var st struct {
|
||||
Mappings []struct {
|
||||
ID, BindIP string
|
||||
Status model.MappingStatus
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
|
||||
good := false
|
||||
for _, m := range st.Mappings {
|
||||
if m.BindIP == "127.0.0.4" && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
|
||||
good = true
|
||||
}
|
||||
}
|
||||
if good {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("health state never showed the dead target: %+v", st.Mappings)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
// closing a port frees it
|
||||
for _, m := range st.Mappings {
|
||||
if m.BindIP == "127.0.0.2" {
|
||||
w.admin.must("DELETE", base+"/"+m.ID, nil, nil)
|
||||
}
|
||||
}
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
if c, err := net.DialTimeout("tcp", "127.0.0.2:"+pa, time.Second); err == nil {
|
||||
c.Close()
|
||||
t.Error("port still open after closing it")
|
||||
}
|
||||
check(tcpAsk(t, "127.0.0.1:"+pa, "again"), "A:again")
|
||||
}
|
||||
|
||||
type webResp struct {
|
||||
status int
|
||||
location string
|
||||
body map[string]string
|
||||
raw string
|
||||
}
|
||||
|
||||
// edgeClient talks to the edge's listener while presenting a chosen hostname.
|
||||
func edgeClient(scheme string, port int) (*http.Client, func(method, host, path string) webResp) {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
tr := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
c := &http.Client{Jar: jar, Transport: tr, Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
return c, func(method, host, path string) webResp {
|
||||
resp, err := c.Do(mustReq(method, scheme+"://"+host+":"+strconv.Itoa(port)+path))
|
||||
if err != nil {
|
||||
return webResp{status: -1, raw: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
out := webResp{status: resp.StatusCode, location: resp.Header.Get("Location"), raw: string(raw)}
|
||||
json.Unmarshal(raw, &out.body)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func mustReq(method, u string) *http.Request {
|
||||
r, _ := http.NewRequest(method, u, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestWebProxyAndResourceAuth runs the web suite on Blacktip's built-in proxy.
|
||||
func TestWebProxyAndResourceAuth(t *testing.T) {
|
||||
webSuite(t, setup(t, blacktip.TraefikConfig{}))
|
||||
}
|
||||
|
||||
// TestWebViaTraefik runs the identical suite with Traefik serving http/https.
|
||||
// It needs a Traefik binary: set TRAEFIK_BIN to its path.
|
||||
func TestWebViaTraefik(t *testing.T) {
|
||||
bin := os.Getenv("TRAEFIK_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("set TRAEFIK_BIN to a traefik binary to run this test")
|
||||
}
|
||||
webSuite(t, setup(t, blacktip.TraefikConfig{Path: bin, NoACME: true}))
|
||||
}
|
||||
|
||||
func webSuite(t *testing.T, w *world) {
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
be1, be2 := webBackend(t, "one", 200), webBackend(t, "two", 500)
|
||||
|
||||
port := freePort(t)
|
||||
pub := w.open(model.Mapping{Proto: "http", Port: port, Hostname: "open.example.test", Targets: tgt(w.site, be1)})
|
||||
|
||||
_, get := edgeClient("http", port)
|
||||
// wait for the listener and tunnel
|
||||
var r webResp
|
||||
for i := 0; i < 60; i++ {
|
||||
if r = get("GET", "open.example.test", "/hello"); r.status == 200 {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if r.status != 200 || r.body["backend"] != "one" || r.body["path"] != "/hello" {
|
||||
t.Fatalf("unprotected proxy: %+v", r)
|
||||
}
|
||||
if r.body["host"] != "open.example.test:"+strconv.Itoa(port) || !strings.HasPrefix(r.body["xff"], "127.0.0.1") {
|
||||
t.Errorf("forwarded headers wrong: %+v", r.body)
|
||||
}
|
||||
if got := get("GET", "nothing.example.test", "/"); got.status != 404 {
|
||||
t.Errorf("unknown host -> %d", got.status)
|
||||
}
|
||||
_ = pub
|
||||
|
||||
// ----- password + rules + share link on a protected mapping -----
|
||||
port2 := freePort(t)
|
||||
prot := w.open(model.Mapping{Proto: "http", Port: port2, Hostname: "app.example.test", Targets: tgt(w.local, be1),
|
||||
Auth: model.Auth{Password: true, SessionHours: 1},
|
||||
Rules: []model.Rule{{Action: "deny", Match: "path", Value: "/admin"}, {Action: "allow", Match: "path", Value: "/public"}}})
|
||||
// the password itself is write-only: set it through a PUT carrying newPassword
|
||||
w.admin.must("PUT", "/api/orgs/"+w.org+"/mappings/"+prot.ID, map[string]any{
|
||||
"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "app.example.test", "targets": tgt(w.local, be1),
|
||||
"enabled": true, "auth": model.Auth{Password: true, SessionHours: 1}, "rules": prot.Rules, "newPassword": "hunter22",
|
||||
}, nil)
|
||||
|
||||
c2, get2 := edgeClient("http", port2)
|
||||
var login webResp
|
||||
for i := 0; i < 60; i++ {
|
||||
if login = get2("GET", "app.example.test", "/dash?x=1"); login.status == 302 {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
wantPrefix := w.ts.URL + "/auth/r/" + prot.ID + "?return="
|
||||
if login.status != 302 || !strings.HasPrefix(login.location, wantPrefix) {
|
||||
t.Fatalf("expected redirect to login, got %+v", login)
|
||||
}
|
||||
if got := get2("GET", "app.example.test", "/admin"); got.status != 403 {
|
||||
t.Errorf("deny rule -> %d", got.status)
|
||||
}
|
||||
if got := get2("GET", "app.example.test", "/public/x"); got.status != 200 || got.body["user"] != "" {
|
||||
t.Errorf("allow rule -> %+v", got)
|
||||
}
|
||||
if got := get2("POST", "app.example.test", "/dash"); got.status != 401 {
|
||||
t.Errorf("unauthenticated POST -> %d", got.status)
|
||||
}
|
||||
|
||||
visitor := newClient(t, w.ts.URL)
|
||||
back := "http://app.example.test:" + strconv.Itoa(port2) + "/dash?x=1"
|
||||
visitor.expect(401, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "wrong", "return": back})
|
||||
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://evil.test/dash"})
|
||||
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://app.example.test:1/dash"})
|
||||
var grant struct{ Redirect string }
|
||||
visitor.must("POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": back}, &grant)
|
||||
u, _ := url.Parse(grant.Redirect)
|
||||
if u.Host != "app.example.test:"+strconv.Itoa(port2) || u.Path != "/.hammerhead/callback" {
|
||||
t.Fatalf("bad grant redirect %q", grant.Redirect)
|
||||
}
|
||||
// a forged token must be rejected
|
||||
if got := get2("GET", "app.example.test", "/.hammerhead/callback?t=abc.def&r=/"); got.status != 403 {
|
||||
t.Errorf("forged token -> %d", got.status)
|
||||
}
|
||||
cb := get2("GET", "app.example.test", u.RequestURI())
|
||||
if cb.status != 302 || cb.location != "/dash?x=1" {
|
||||
t.Fatalf("callback: %+v", cb)
|
||||
}
|
||||
got := get2("GET", "app.example.test", "/dash")
|
||||
if got.status != 200 || got.body["user"] != "password" || strings.Contains(got.body["cookie"], "hh_auth") {
|
||||
t.Fatalf("authenticated request: %+v", got)
|
||||
}
|
||||
_ = c2
|
||||
|
||||
// ----- share link -----
|
||||
var share struct{ URL string }
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings/"+prot.ID+"/shares", map[string]any{"name": "friend", "maxUses": 1}, &share)
|
||||
su, _ := url.Parse(share.URL)
|
||||
if su.Host != "app.example.test" { // default port 80 isn't used in tests: rebuild the URL against the test port
|
||||
t.Logf("share url host %s", su.Host)
|
||||
}
|
||||
tok := su.Query().Get("hh_share")
|
||||
_, get3 := edgeClient("http", port2)
|
||||
sh := get3("GET", "app.example.test", "/?hh_share="+tok)
|
||||
if sh.status != 302 || !strings.Contains(sh.location, "&share="+tok) {
|
||||
t.Fatalf("share redirect: %+v", sh)
|
||||
}
|
||||
v2 := newClient(t, w.ts.URL)
|
||||
v2.must("POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": "http://app.example.test:" + strconv.Itoa(port2) + "/"}, &grant)
|
||||
v2.expect(401, "POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": back}) // single use
|
||||
|
||||
// ----- SSO with roles -----
|
||||
port3 := freePort(t)
|
||||
var roles []struct {
|
||||
ID, Name string
|
||||
Admin bool
|
||||
}
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
|
||||
var memberRole, adminRole string
|
||||
for _, r := range roles {
|
||||
if r.Name == "Member" {
|
||||
memberRole = r.ID
|
||||
}
|
||||
if r.Admin {
|
||||
adminRole = r.ID
|
||||
}
|
||||
}
|
||||
var staffRole struct{ ID string }
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/roles", map[string]any{"name": "Staff"}, nil)
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
|
||||
for _, r := range roles {
|
||||
if r.Name == "Staff" {
|
||||
staffRole.ID = r.ID
|
||||
}
|
||||
}
|
||||
sso := w.open(model.Mapping{Proto: "http", Port: port3, Hostname: "sso.example.test", Targets: tgt(w.site, be1),
|
||||
Auth: model.Auth{SSO: true, AllowedRoles: []string{staffRole.ID}}})
|
||||
invite := func(email, role string) *client {
|
||||
var inv struct{ Link string }
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
|
||||
tok := inv.Link[strings.Index(inv.Link, "#invite=")+len("#invite="):]
|
||||
c := newClient(t, w.ts.URL)
|
||||
c.must("POST", "/api/invites/accept", map[string]string{"token": tok, "name": email, "password": "password123"}, nil)
|
||||
return c
|
||||
}
|
||||
staff := invite("staff@test.local", staffRole.ID)
|
||||
plain := invite("plain@test.local", memberRole)
|
||||
retSSO := "http://sso.example.test:" + strconv.Itoa(port3) + "/"
|
||||
plain.expect(403, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
|
||||
staff.must("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO}, &grant)
|
||||
newClient(t, w.ts.URL).expect(401, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
|
||||
_ = adminRole
|
||||
|
||||
// ----- email code -----
|
||||
w.admin.must("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": w.ts.URL, "smtp": map[string]any{"host": "mail.invalid", "port": 25, "from": "x@y.z"}}, nil)
|
||||
port4 := freePort(t)
|
||||
em := w.open(model.Mapping{Proto: "http", Port: port4, Hostname: "mail.example.test", Targets: tgt(w.site, be1),
|
||||
Auth: model.Auth{Email: true, AllowedMails: []string{"*@corp.test"}}})
|
||||
retEm := "http://mail.example.test:" + strconv.Itoa(port4) + "/"
|
||||
v3 := newClient(t, w.ts.URL)
|
||||
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "bob@corp.test", "return": retEm}, nil)
|
||||
var code string
|
||||
select {
|
||||
case m := <-w.mails:
|
||||
parts := strings.SplitN(m, "|", 3)
|
||||
code = strings.TrimSpace(strings.Split(strings.SplitN(parts[2], "Your code is ", 2)[1], "\n")[0])
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no email sent")
|
||||
}
|
||||
v3.expect(401, "POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": "000000", "return": retEm})
|
||||
v3.must("POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": code, "return": retEm}, &grant)
|
||||
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "eve@evil.test", "return": retEm}, nil) // same answer, no mail
|
||||
select {
|
||||
case m := <-w.mails:
|
||||
t.Errorf("mail sent to a non-allowed address: %s", m)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
// ----- health check marks the 500 backend unhealthy; traffic avoids it -----
|
||||
port5 := freePort(t)
|
||||
hc := w.open(model.Mapping{Proto: "http", Port: port5, Hostname: "hc.example.test", HealthPath: "/healthz", HealthSeconds: 5,
|
||||
Targets: []model.Target{{SiteID: w.site, Host: "127.0.0.1", Port: be2}, {SiteID: w.local, Host: "127.0.0.1", Port: be1}}})
|
||||
_, get5 := edgeClient("http", port5)
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
var st struct {
|
||||
Mappings []struct {
|
||||
ID string
|
||||
Status model.MappingStatus
|
||||
}
|
||||
}
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
|
||||
done := false
|
||||
for _, m := range st.Mappings {
|
||||
if m.ID == hc.ID && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("health check never flagged the bad backend")
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if got := get5("GET", "hc.example.test", "/x"); got.status != 200 || got.body["backend"] != "one" {
|
||||
t.Fatalf("traffic reached the unhealthy backend: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- https with a certificate -----
|
||||
port6 := freePort(t)
|
||||
w.open(model.Mapping{Proto: "https", Port: port6, Hostname: "secure.example.test", Targets: tgt(w.site, be1)})
|
||||
_, get6 := edgeClient("https", port6)
|
||||
var sec webResp
|
||||
for i := 0; i < 60; i++ {
|
||||
if sec = get6("GET", "secure.example.test", "/tls"); sec.status == 200 {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if sec.status != 200 || sec.body["proto"] != "https" {
|
||||
t.Fatalf("https: %+v", sec)
|
||||
}
|
||||
|
||||
// ----- access log and traffic counters reach hammerhead -----
|
||||
deadline = time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
var logs []model.LogEntry
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/logs?kind=access&limit=500", nil, &logs)
|
||||
sawAuth, sawDeny := false, false
|
||||
for _, l := range logs {
|
||||
if l.Mapping == prot.ID && l.Result == "auth-required" {
|
||||
sawAuth = true
|
||||
}
|
||||
if l.Mapping == prot.ID && l.Result == "denied" {
|
||||
sawDeny = true
|
||||
}
|
||||
}
|
||||
if sawAuth && sawDeny {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("access log incomplete (%d entries)", len(logs))
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
deadline = time.Now().Add(25 * time.Second)
|
||||
for {
|
||||
var stats map[string]struct{ Conns, BytesOut int64 }
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/stats", nil, &stats)
|
||||
if stats[pub.ID].Conns > 0 && stats[pub.ID].BytesOut > 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("no traffic stats: %+v", stats)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountsOrgsAndTOTP(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
base := "/api/orgs/" + w.org
|
||||
|
||||
// a second org the first can't see into
|
||||
var other struct{ ID string }
|
||||
w.admin.must("POST", "/api/orgs", map[string]any{"name": "Other"}, &other)
|
||||
var roles []struct {
|
||||
ID, Name string
|
||||
Admin bool
|
||||
}
|
||||
w.admin.must("GET", base+"/roles", nil, &roles)
|
||||
var member, adminRole string
|
||||
for _, r := range roles {
|
||||
if r.Name == "Member" {
|
||||
member = r.ID
|
||||
}
|
||||
if r.Admin {
|
||||
adminRole = r.ID
|
||||
}
|
||||
}
|
||||
invite := func(org, email, role string) *client {
|
||||
var inv struct{ Link string }
|
||||
w.admin.must("POST", "/api/orgs/"+org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
|
||||
c := newClient(t, w.ts.URL)
|
||||
c.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "N", "password": "password123"}, nil)
|
||||
return c
|
||||
}
|
||||
m := invite(w.org, "m@test.local", member)
|
||||
m.must("GET", base+"/state", nil, nil)
|
||||
m.expect(403, "POST", base+"/mappings", map[string]any{})
|
||||
m.expect(403, "POST", base+"/sites", map[string]string{"name": "x"})
|
||||
m.expect(403, "GET", base+"/members", nil)
|
||||
m.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
|
||||
m.expect(403, "POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "1.2.3.4:5"})
|
||||
m.expect(403, "GET", "/api/admin/users", nil)
|
||||
|
||||
// domains can't be claimed twice across orgs
|
||||
w.admin.must("POST", base+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
for _, d := range []string{"example.test", "api.example.test", "test"} {
|
||||
code, _ := w.admin.do("POST", "/api/orgs/"+other.ID+"/domains", map[string]any{"name": d}, nil)
|
||||
if code != 400 && d != "test" {
|
||||
t.Errorf("org 2 claimed %s (status %d)", d, code)
|
||||
}
|
||||
}
|
||||
// hostnames must be under the org's own domains
|
||||
w.admin.expect(400, "POST", base+"/mappings", map[string]any{"proto": "http", "edgeId": w.edge, "port": 8081,
|
||||
"hostname": "x.notmine.test", "targets": tgt(w.site, 9)})
|
||||
|
||||
// the other org can't use the edge's local site (AllowLocal is off for it)
|
||||
w.admin.must("GET", "/api/orgs/"+other.ID+"/state", nil, nil)
|
||||
w.admin.expect(400, "POST", "/api/orgs/"+other.ID+"/mappings", map[string]any{"proto": "tcp", "edgeId": w.edge, "port": 25565,
|
||||
"targets": tgt(w.local, 25565)})
|
||||
|
||||
// API keys act inside one org only
|
||||
var key struct{ Key string }
|
||||
w.admin.must("POST", base+"/apikeys", map[string]string{"name": "ci"}, &key)
|
||||
kc := newClient(t, w.ts.URL)
|
||||
kc.key = key.Key
|
||||
kc.must("POST", base+"/domains", map[string]any{"name": "keyed.test", "edgeId": w.edge}, nil)
|
||||
kc.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
|
||||
kc.expect(401, "GET", "/api/me", nil)
|
||||
bad := newClient(t, w.ts.URL)
|
||||
bad.key = "hhk_deadbeef_nope"
|
||||
bad.expect(401, "GET", base+"/state", nil)
|
||||
|
||||
// removing the last admin is refused
|
||||
var members []struct{ UserID, Email string }
|
||||
w.admin.must("GET", base+"/members", nil, &members)
|
||||
for _, mm := range members {
|
||||
if mm.Email == "admin@test.local" {
|
||||
w.admin.expect(400, "PUT", base+"/members/"+mm.UserID, map[string]string{"roleId": member})
|
||||
w.admin.expect(400, "DELETE", base+"/members/"+mm.UserID, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// two-factor: enable, then log in with a code and with a backup code
|
||||
u := invite(w.org, "otp@test.local", adminRole)
|
||||
var setup struct{ Secret string }
|
||||
u.must("POST", "/api/me/2fa/setup", nil, &setup)
|
||||
u.expect(400, "POST", "/api/me/2fa/enable", map[string]string{"code": "000000"})
|
||||
var en struct{ BackupCodes []string }
|
||||
u.must("POST", "/api/me/2fa/enable", map[string]string{"code": totpNow(setup.Secret)}, &en)
|
||||
if len(en.BackupCodes) != 8 {
|
||||
t.Fatalf("backup codes: %v", en.BackupCodes)
|
||||
}
|
||||
fresh := newClient(t, w.ts.URL)
|
||||
var step struct {
|
||||
NeedTotp bool
|
||||
Ticket string
|
||||
}
|
||||
fresh.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
|
||||
if !step.NeedTotp {
|
||||
t.Fatal("2fa not requested")
|
||||
}
|
||||
fresh.expect(401, "GET", "/api/me", nil) // not signed in until the code is given
|
||||
fresh.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": "123456"})
|
||||
fresh.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": totpNow(setup.Secret)}, nil)
|
||||
fresh.must("GET", "/api/me", nil, nil)
|
||||
f2 := newClient(t, w.ts.URL)
|
||||
f2.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
|
||||
f2.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}, nil)
|
||||
f3 := newClient(t, w.ts.URL)
|
||||
f3.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
|
||||
f3.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}) // backup codes are single use
|
||||
|
||||
// wrong passwords and brute force are throttled per address+email
|
||||
bf := newClient(t, w.ts.URL)
|
||||
for i := 0; i < 8; i++ {
|
||||
bf.expect(401, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "nope-nope"})
|
||||
}
|
||||
bf.expect(429, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "password123"})
|
||||
|
||||
// changing the password signs out the user's other sessions
|
||||
m2 := newClient(t, w.ts.URL)
|
||||
m2.must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "password123"}, nil)
|
||||
m2.must("GET", "/api/me", nil, nil)
|
||||
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "wrong", "new": "newpassword1"})
|
||||
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "password123", "new": "short"})
|
||||
m.must("POST", "/api/me/password", map[string]string{"current": "password123", "new": "newpassword1"}, nil)
|
||||
m2.expect(401, "GET", "/api/me", nil)
|
||||
m.must("GET", "/api/me", nil, nil)
|
||||
newClient(t, w.ts.URL).must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "newpassword1"}, nil)
|
||||
}
|
||||
|
||||
func TestOIDCLogin(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
// a tiny identity provider
|
||||
var idp *httptest.Server
|
||||
idp = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
json.NewEncoder(rw).Encode(map[string]string{"authorization_endpoint": idp.URL + "/authorize", "token_endpoint": idp.URL + "/token", "userinfo_endpoint": idp.URL + "/userinfo"})
|
||||
case "/authorize":
|
||||
q := r.URL.Query()
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("client_id") != "cid" {
|
||||
http.Error(rw, "bad authorize request", 400)
|
||||
return
|
||||
}
|
||||
http.Redirect(rw, r, q.Get("redirect_uri")+"?code=thecode&state="+url.QueryEscape(q.Get("state")), 302)
|
||||
case "/token":
|
||||
r.ParseForm()
|
||||
if r.Form.Get("code") != "thecode" || r.Form.Get("client_secret") != "shh" || r.Form.Get("code_verifier") == "" {
|
||||
http.Error(rw, "bad token request", 400)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(rw).Encode(map[string]string{"access_token": "at"})
|
||||
case "/userinfo":
|
||||
if r.Header.Get("Authorization") != "Bearer at" {
|
||||
http.Error(rw, "no", 401)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(rw).Encode(map[string]any{"sub": "u-1", "email": "Sso.User@Corp.Test", "email_verified": true, "name": "Sso User"})
|
||||
}
|
||||
}))
|
||||
defer idp.Close()
|
||||
|
||||
var roles []struct {
|
||||
ID, Name string
|
||||
}
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
|
||||
member := ""
|
||||
for _, r := range roles {
|
||||
if r.Name == "Member" {
|
||||
member = r.ID
|
||||
}
|
||||
}
|
||||
w.admin.must("POST", "/api/admin/idps", map[string]any{"name": "Corp", "issuer": idp.URL, "clientId": "cid", "clientSecret": "shh",
|
||||
"autoProvision": true, "orgId": w.org, "roleId": member, "allowedDomains": []string{"corp.test"}}, nil)
|
||||
|
||||
var pub struct{ Providers []struct{ ID, Name string } }
|
||||
newClient(t, w.ts.URL).must("GET", "/api/public", nil, &pub)
|
||||
if len(pub.Providers) != 1 {
|
||||
t.Fatalf("providers: %+v", pub)
|
||||
}
|
||||
|
||||
c := newClient(t, w.ts.URL)
|
||||
resp, err := c.c.Get(w.ts.URL + "/auth/oidc/" + pub.Providers[0].ID + "/start?return=/after")
|
||||
if err != nil || resp.StatusCode != 302 {
|
||||
t.Fatalf("start: %v %v", err, resp)
|
||||
}
|
||||
authz := resp.Header.Get("Location")
|
||||
resp, err = c.c.Get(authz)
|
||||
if err != nil || resp.StatusCode != 302 {
|
||||
t.Fatalf("authorize: %v %v", err, resp)
|
||||
}
|
||||
cb := resp.Header.Get("Location")
|
||||
resp, err = c.c.Get(cb)
|
||||
if err != nil || resp.StatusCode != 302 || resp.Header.Get("Location") != "/after" {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("callback: %v %d %s", err, resp.StatusCode, b)
|
||||
}
|
||||
var me struct {
|
||||
Email string
|
||||
Orgs []struct{ Name, Role string }
|
||||
}
|
||||
c.must("GET", "/api/me", nil, &me)
|
||||
if me.Email != "sso.user@corp.test" || len(me.Orgs) != 1 || me.Orgs[0].Role != "Member" {
|
||||
t.Fatalf("provisioned user: %+v", me)
|
||||
}
|
||||
// replaying the same state is refused
|
||||
resp, _ = c.c.Get(cb)
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("state replay -> %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
)
|
||||
|
||||
// proxyBackend is a TCP server that understands the PROXY protocol the way a
|
||||
// game proxy or mail server would. It replies with what it learned, or
|
||||
// "plain:<first line>" when the connection did not start with a header.
|
||||
func proxyBackend(t *testing.T, seen chan<- string) int {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
first, _ := br.Peek(1)
|
||||
var head []byte
|
||||
switch {
|
||||
case len(first) == 1 && first[0] == 0x0d:
|
||||
head, _ = br.Peek(12) // a v2 header starts with a 12-byte signature
|
||||
case len(first) == 1 && first[0] == 'P':
|
||||
head, _ = br.Peek(6) // "PROXY "
|
||||
}
|
||||
var desc string
|
||||
switch {
|
||||
case bytes.HasPrefix(head, []byte("PROXY ")):
|
||||
line, _ := br.ReadString('\n')
|
||||
f := strings.Fields(strings.TrimSpace(line))
|
||||
if len(f) == 6 {
|
||||
desc = fmt.Sprintf("v1 src=%s:%s dst=%s:%s fam=%s", f[2], f[4], f[3], f[5], f[1])
|
||||
} else {
|
||||
desc = "v1 odd:" + line
|
||||
}
|
||||
case bytes.Equal(head, []byte("\r\n\r\n\x00\r\nQUIT\n")):
|
||||
fixed := make([]byte, 16)
|
||||
io.ReadFull(br, fixed)
|
||||
body := make([]byte, binary.BigEndian.Uint16(fixed[14:16]))
|
||||
io.ReadFull(br, body)
|
||||
switch fixed[13] {
|
||||
case 0x11:
|
||||
desc = fmt.Sprintf("v2 src=%s:%d dst=%s:%d fam=TCP4", net.IP(body[0:4]), binary.BigEndian.Uint16(body[8:10]), net.IP(body[4:8]), binary.BigEndian.Uint16(body[10:12]))
|
||||
case 0x21:
|
||||
desc = fmt.Sprintf("v2 src=[%s]:%d fam=TCP6", net.IP(body[0:16]), binary.BigEndian.Uint16(body[32:34]))
|
||||
default:
|
||||
desc = fmt.Sprintf("v2 fam=%#x", fixed[13])
|
||||
}
|
||||
if fixed[12] != 0x21 {
|
||||
desc += " cmd=" + strconv.Itoa(int(fixed[12]))
|
||||
}
|
||||
default:
|
||||
line, _ := br.ReadString('\n')
|
||||
desc = "plain:" + strings.TrimSpace(line)
|
||||
}
|
||||
// what comes next shows the header was exactly the first thing and nothing was lost
|
||||
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond)) // don't wait forever for bytes that may never come
|
||||
if b, err := br.Peek(1); err == nil && b[0] == 0x16 {
|
||||
desc += " then=tls-hello"
|
||||
}
|
||||
if seen != nil {
|
||||
seen <- desc
|
||||
}
|
||||
fmt.Fprintf(c, "%s\n", desc)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func askFrom(t *testing.T, from, addr, send string) string {
|
||||
t.Helper()
|
||||
var last error
|
||||
for i := 0; i < 50; i++ {
|
||||
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}, Timeout: time.Second}
|
||||
c, err := d.Dial("tcp", addr)
|
||||
if err == nil {
|
||||
c.SetDeadline(time.Now().Add(4 * time.Second))
|
||||
fmt.Fprint(c, send)
|
||||
line, rerr := bufio.NewReader(c).ReadString('\n')
|
||||
c.Close()
|
||||
if rerr == nil {
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatalf("%s accepted the connection but never answered: %v", addr, rerr) // a real hang, not a startup race
|
||||
}
|
||||
last = rerr
|
||||
} else {
|
||||
last = err
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no answer from %s: %v", addr, last)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestProxyProtocolCarriesTheRealClientAddress(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
be := proxyBackend(t, nil)
|
||||
base := "/api/orgs/" + w.org + "/mappings"
|
||||
open := func(ip string, site string, proxy string) string {
|
||||
port := freePort(t)
|
||||
w.admin.must("POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": ip, "port": port, "targets": tgt(site, be),
|
||||
"enabled": true, "proxyProtocol": proxy}, nil)
|
||||
return ip + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// through the WireGuard tunnel and straight to the edge's own machine, in both versions
|
||||
for _, tc := range []struct{ name, site, proxy string }{
|
||||
{"v1 via tunnel", w.site, "v1"}, {"v2 via tunnel", w.site, "v2"}, {"v1 local", w.local, "v1"}, {"v2 local", w.local, "v2"},
|
||||
} {
|
||||
addr := open("127.0.0.1", tc.site, tc.proxy)
|
||||
t.Logf("case %s on %s", tc.name, addr)
|
||||
got := askFrom(t, "127.0.0.9", addr, "hello\n")
|
||||
if !strings.HasPrefix(got, tc.proxy) || !strings.Contains(got, "src=127.0.0.9:") || !strings.Contains(got, "dst="+addr) || !strings.Contains(got, "fam=TCP4") {
|
||||
t.Errorf("%s: the target learned %q, want the real client 127.0.0.9 and the public address %s", tc.name, got, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// off by default: the target gets exactly what the client sent, no header
|
||||
addr := open("127.0.0.1", w.site, "")
|
||||
if got := askFrom(t, "127.0.0.9", addr, "hello\n"); got != "plain:hello" {
|
||||
t.Errorf("without PROXY protocol the target saw %q", got)
|
||||
}
|
||||
|
||||
// the header comes before the client's own bytes, even when they are a TLS hello
|
||||
seen := make(chan string, 4)
|
||||
tbe := proxyBackend(t, seen)
|
||||
port := freePort(t)
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
w.admin.must("POST", base, map[string]any{"proto": "tls", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "pass.example.test",
|
||||
"targets": tgt(w.site, tbe), "enabled": true, "proxyProtocol": "v2"}, nil)
|
||||
for i := 0; i < 50; i++ {
|
||||
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.9")}, Timeout: time.Second}
|
||||
c, err := d.Dial("tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
if err == nil {
|
||||
tls.Client(c, &tls.Config{ServerName: "pass.example.test", InsecureSkipVerify: true}).Handshake() // fails: the backend isn't TLS
|
||||
c.Close()
|
||||
}
|
||||
select {
|
||||
case got := <-seen:
|
||||
if !strings.Contains(got, "v2 src=127.0.0.9:") || !strings.Contains(got, "then=tls-hello") {
|
||||
t.Errorf("tls passthrough: %q", got)
|
||||
}
|
||||
goto validated
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
t.Fatal("tls passthrough never reached the target")
|
||||
validated:
|
||||
|
||||
// only tcp and tls ports can carry it
|
||||
w.admin.expect(400, "POST", base, map[string]any{"proto": "udp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v1"})
|
||||
w.admin.expect(400, "POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "hostname": "w.example.test", "targets": tgt(w.site, be), "proxyProtocol": "v2"})
|
||||
w.admin.expect(400, "POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v3"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package secretbox encrypts small secrets (tokens, passwords, private keys)
|
||||
// before they are written to the database, using AES-256-GCM.
|
||||
//
|
||||
// Sealed values look like "enc1:<base64>". Opening a value that isn't sealed
|
||||
// returns it unchanged, so data written before encryption was turned on is
|
||||
// still readable and gets sealed the next time it is saved.
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "enc1:"
|
||||
|
||||
type Box struct{ aead cipher.AEAD }
|
||||
|
||||
// GenerateKey returns a new random key in the form New accepts.
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// New builds a Box from a key given as base64 or hex of 32 bytes. Any other
|
||||
// text is treated as a passphrase and stretched with SHA-256.
|
||||
func New(key string) (*Box, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, errors.New("empty key")
|
||||
}
|
||||
raw := decodeKey(key)
|
||||
block, err := aes.NewCipher(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
func decodeKey(key string) []byte {
|
||||
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
if b, err := hex.DecodeString(key); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset.
|
||||
func (b *Box) Seal(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, prefix) {
|
||||
return s
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
rand.Read(nonce)
|
||||
return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil))
|
||||
}
|
||||
|
||||
// Open decrypts a sealed value. A wrong key or damaged data is an error, so a
|
||||
// bad key can never silently turn secrets into garbage.
|
||||
func (b *Box) Open(s string) (string, error) {
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return s, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(s[len(prefix):])
|
||||
if err != nil || len(raw) < b.aead.NonceSize() {
|
||||
return "", errors.New("damaged secret")
|
||||
}
|
||||
n := b.aead.NonceSize()
|
||||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// logStore is a thin wrapper so the handlers don't care where logs live.
|
||||
type logStore struct{ db *DB }
|
||||
|
||||
func (l *logStore) addAccess(es []model.LogEntry) { l.db.addAccess(es) }
|
||||
func (l *logStore) addAudit(e AuditEntry) { l.db.addAudit(e) }
|
||||
|
||||
func (l *logStore) queryAccess(f accessFilter) []model.LogEntry { return l.db.queryAccess(f) }
|
||||
|
||||
// queryAudit returns newest-first entries; org "" selects server-level events.
|
||||
func (l *logStore) queryAudit(org string, limit int, before int64) []AuditEntry {
|
||||
return l.db.queryAudit(org, limit, before)
|
||||
}
|
||||
|
||||
func (s *Server) audit(orgID, actor, action, detail string) {
|
||||
s.logs.addAudit(AuditEntry{Time: time.Now(), OrgID: orgID, Actor: actor, Action: action, Detail: detail})
|
||||
}
|
||||
|
||||
// importLegacyLogs moves logs from the old JSONL files into the database, once.
|
||||
func (s *Server) importLegacyLogs() {
|
||||
dir := filepath.Join(s.dir, "logs")
|
||||
read := func(name string, each func([]byte)) {
|
||||
path := filepath.Join(dir, name)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
for sc.Scan() {
|
||||
each(append([]byte(nil), sc.Bytes()...))
|
||||
}
|
||||
f.Close()
|
||||
os.Rename(path, path+".migrated")
|
||||
}
|
||||
var batch []model.LogEntry
|
||||
read("access.jsonl", func(b []byte) {
|
||||
var e model.LogEntry
|
||||
if json.Unmarshal(b, &e) == nil {
|
||||
batch = append(batch, e)
|
||||
}
|
||||
})
|
||||
s.db.addAccess(batch)
|
||||
read("audit.jsonl", func(b []byte) {
|
||||
var e AuditEntry
|
||||
if json.Unmarshal(b, &e) == nil {
|
||||
s.db.addAudit(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
const defaultMailListen = "127.0.0.1:2525"
|
||||
|
||||
func (s *Server) findMailEgress(id string) *model.MailEgress {
|
||||
for _, e := range s.st.MailEgress {
|
||||
if e.ID == id {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackListen(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// validateMailEgress checks e against org c and normalises it. Caller holds s.mu.
|
||||
func (s *Server) validateMailEgress(c *orgCtx, e *model.MailEgress) error {
|
||||
e.OrgID = c.Org.ID
|
||||
e.Name = strings.TrimSpace(e.Name)
|
||||
site := s.findSite(e.SiteID)
|
||||
if site == nil || site.Local || site.OrgID != c.Org.ID {
|
||||
return errors.New("choose one of your sites: the mail server's network (the built-in local site already sends from the edge itself)")
|
||||
}
|
||||
edge := s.findEdge(e.EdgeID)
|
||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||
return errors.New("choose an edge")
|
||||
}
|
||||
e.Listen = strings.TrimSpace(e.Listen)
|
||||
if e.Listen == "" {
|
||||
e.Listen = defaultMailListen
|
||||
}
|
||||
if !validAddr(e.Listen) {
|
||||
return errors.New("listen address must look like 127.0.0.1:2525 (an address on the site's machine)")
|
||||
}
|
||||
for _, o := range s.st.MailEgress {
|
||||
if o.ID != e.ID && o.SiteID == e.SiteID && o.Listen == e.Listen {
|
||||
return errors.New("another mail egress on that site already listens on " + e.Listen)
|
||||
}
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(e.SourceIP))
|
||||
if ip == nil || ip.IsUnspecified() {
|
||||
return errors.New("choose the edge IP address your mail should leave from")
|
||||
}
|
||||
e.SourceIP = ip.String()
|
||||
e.HeloName = strings.ToLower(strings.TrimSpace(e.HeloName))
|
||||
if !validHostname(e.HeloName) || strings.HasPrefix(e.HeloName, "*.") {
|
||||
return errors.New("enter the hostname your mail server announces, like mail.example.com (its reverse DNS should match)")
|
||||
}
|
||||
e.Smarthost = strings.TrimSpace(e.Smarthost)
|
||||
if e.Smarthost != "" && !validAddr(e.Smarthost) {
|
||||
return errors.New("smarthost must look like smtp.example.com:587")
|
||||
}
|
||||
if e.RatePerMinute < 0 || e.RatePerMinute > 6000 {
|
||||
return errors.New("messages per minute must be between 1 and 6000")
|
||||
}
|
||||
if e.Username != "" && e.Password == "" {
|
||||
return errors.New("set a password too, or clear the username")
|
||||
}
|
||||
if e.Username == "" && !isLoopbackListen(e.Listen) {
|
||||
return errors.New("listening beyond this machine without a password would let anything on your network send mail from your IP; set a username and password, or listen on 127.0.0.1")
|
||||
}
|
||||
if e.Name == "" {
|
||||
e.Name = "mail via " + e.HeloName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) saveMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in model.MailEgress
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
idx := -1
|
||||
if id != "" {
|
||||
for i, e := range s.st.MailEgress {
|
||||
if e.ID == id && e.OrgID == c.Org.ID {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
fail(w, 404, errors.New("no such mail egress"))
|
||||
return
|
||||
}
|
||||
in.ID = id
|
||||
if in.Password == "" && in.Username != "" && in.Username == s.st.MailEgress[idx].Username {
|
||||
in.Password = s.st.MailEgress[idx].Password // blank keeps the saved password
|
||||
}
|
||||
}
|
||||
if err := s.validateMailEgress(c, &in); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
if idx >= 0 {
|
||||
s.st.MailEgress[idx] = &in
|
||||
delete(s.mailStatus, id)
|
||||
} else {
|
||||
in.ID = newID()
|
||||
s.st.MailEgress = append(s.st.MailEgress, &in)
|
||||
}
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mailegress.save", in.Name)
|
||||
resp := in // the stored record keeps its password; the response never carries it
|
||||
resp.Password = ""
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
|
||||
func (s *Server) deleteMailEgress(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e := s.findMailEgress(id)
|
||||
if e == nil || e.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such mail egress"))
|
||||
return
|
||||
}
|
||||
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.ID != id })
|
||||
delete(s.mailStatus, id)
|
||||
delete(s.mailChecks, id)
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mailegress.delete", e.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
type mailEgressView struct {
|
||||
model.MailEgress
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
Status string `json:"status"`
|
||||
StatusKnown bool `json:"statusKnown"`
|
||||
Check *model.MailCheck `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
||||
func (s *Server) mailEgressViewsLocked(orgID string) []mailEgressView {
|
||||
out := []mailEgressView{}
|
||||
for _, e := range s.st.MailEgress {
|
||||
if e.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
v := mailEgressView{MailEgress: *e, PasswordSet: e.Password != ""}
|
||||
v.Password = ""
|
||||
v.Status, v.StatusKnown = s.mailStatus[e.ID]
|
||||
if chk, ok := s.mailChecks[e.ID]; ok {
|
||||
c := chk
|
||||
v.Check = &c
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type mappingInput struct {
|
||||
model.Mapping
|
||||
NewPassword *string `json:"newPassword"` // nil = keep, "" = remove
|
||||
NewPin *string `json:"newPin"`
|
||||
}
|
||||
|
||||
type mappingView struct {
|
||||
model.Mapping
|
||||
Status model.MappingStatus `json:"status"`
|
||||
Known bool `json:"statusKnown"`
|
||||
Total Total `json:"total"`
|
||||
}
|
||||
|
||||
func isWild(ip string) bool {
|
||||
p := net.ParseIP(ip)
|
||||
return p != nil && p.IsUnspecified()
|
||||
}
|
||||
|
||||
func overlap(a, b string) bool { return a == b || isWild(a) || isWild(b) }
|
||||
|
||||
func netClass(proto string) string {
|
||||
if proto == model.ProtoUDP {
|
||||
return "udp"
|
||||
}
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
func listFromCSV(in []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, v := range in {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v != "" && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
||||
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
||||
m.OrgID = c.Org.ID
|
||||
switch m.Proto {
|
||||
case model.ProtoTCP, model.ProtoUDP, model.ProtoHTTP, model.ProtoHTTPS, model.ProtoTLS:
|
||||
default:
|
||||
return errors.New("protocol must be tcp, udp, http, https or tls")
|
||||
}
|
||||
edge := s.findEdge(m.EdgeID)
|
||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||
return errors.New("choose an edge")
|
||||
}
|
||||
if len(m.Targets) == 0 {
|
||||
return errors.New("add at least one target")
|
||||
}
|
||||
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
||||
return errors.New("udp ports support one target")
|
||||
}
|
||||
for i := range m.Targets {
|
||||
t := &m.Targets[i]
|
||||
t.Host = strings.TrimSpace(t.Host)
|
||||
site := s.findSite(t.SiteID)
|
||||
if site == nil || !s.siteVisible(c, site) {
|
||||
return errors.New("choose a site for every target")
|
||||
}
|
||||
if site.Local && site.EdgeID != m.EdgeID {
|
||||
return errors.New("a local site can only be used with its own edge")
|
||||
}
|
||||
if t.Host == "" {
|
||||
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
|
||||
}
|
||||
if t.Port < 1 || t.Port > 65535 {
|
||||
return errors.New("target ports must be 1-65535")
|
||||
}
|
||||
}
|
||||
if m.BindIP == "" {
|
||||
m.BindIP = "0.0.0.0"
|
||||
}
|
||||
ip := net.ParseIP(m.BindIP)
|
||||
if ip == nil {
|
||||
return errors.New("bind IP is not a valid address")
|
||||
}
|
||||
m.BindIP = ip.String()
|
||||
if m.Port < 1 || m.Port > 65535 {
|
||||
return errors.New("public port must be 1-65535")
|
||||
}
|
||||
switch {
|
||||
case m.PortEnd == 0 || m.PortEnd == m.Port:
|
||||
m.PortEnd = 0
|
||||
case m.PortEnd < m.Port:
|
||||
return errors.New("the end of a port range can't be before its start")
|
||||
case m.PortEnd > 65535:
|
||||
return errors.New("public ports must be 1-65535")
|
||||
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
|
||||
return errors.New("port ranges work for tcp and udp only")
|
||||
case m.PortEnd-m.Port+1 > 1000:
|
||||
return errors.New("a range can open at most 1000 ports")
|
||||
}
|
||||
for _, t := range m.Targets {
|
||||
if t.Port+m.LastPort()-m.Port > 65535 {
|
||||
return errors.New("the target ports would run past 65535: each public port maps to the target port plus the same offset")
|
||||
}
|
||||
}
|
||||
|
||||
if model.HostBased(m.Proto) {
|
||||
if m.Hostname == "" {
|
||||
return errors.New("a hostname is required for http, https and tls ports")
|
||||
}
|
||||
if !validHostname(m.Hostname) {
|
||||
return errors.New("hostname looks invalid (use something like app.example.com or *.example.com)")
|
||||
}
|
||||
if !s.hostnameAllowed(c.Org.ID, m.Hostname) {
|
||||
return fmt.Errorf("%s isn't under one of your domains; add the domain on the Domains tab first", m.Hostname)
|
||||
}
|
||||
} else {
|
||||
m.Hostname = ""
|
||||
}
|
||||
|
||||
switch m.ProxyProtocol {
|
||||
case "":
|
||||
case "v1", "v2":
|
||||
if m.Proto != model.ProtoTCP && m.Proto != model.ProtoTLS {
|
||||
return errors.New("the PROXY protocol is for tcp and tls ports (web ports already pass the client address in X-Forwarded-For)")
|
||||
}
|
||||
default:
|
||||
return errors.New("PROXY protocol must be v1, v2 or off")
|
||||
}
|
||||
// web-only options
|
||||
if !model.Web(m.Proto) {
|
||||
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
||||
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
||||
} else {
|
||||
a := &m.Auth
|
||||
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
||||
for _, id := range a.AllowedRoles {
|
||||
if r := s.findRole(id); r == nil || r.OrgID != c.Org.ID {
|
||||
return errors.New("an allowed role doesn't belong to this organization")
|
||||
}
|
||||
}
|
||||
for _, id := range a.AllowedUsers {
|
||||
if s.member(id, c.Org.ID) == nil {
|
||||
return errors.New("an allowed user isn't a member of this organization")
|
||||
}
|
||||
}
|
||||
if a.Email && len(a.AllowedMails) == 0 {
|
||||
return errors.New("email codes need at least one allowed address (or *@example.com)")
|
||||
}
|
||||
if a.SessionHours < 0 || a.SessionHours > 24*90 {
|
||||
return errors.New("session length must be between 1 hour and 90 days")
|
||||
}
|
||||
for _, r := range m.Rules {
|
||||
if r.Action != "allow" && r.Action != "deny" {
|
||||
return errors.New("rule action must be allow or deny")
|
||||
}
|
||||
switch r.Match {
|
||||
case "path":
|
||||
if !strings.HasPrefix(r.Value, "/") {
|
||||
return errors.New("path rules must start with /")
|
||||
}
|
||||
case "ip":
|
||||
if net.ParseIP(r.Value) == nil {
|
||||
if _, _, err := net.ParseCIDR(r.Value); err != nil {
|
||||
return fmt.Errorf("%q is not an IP address or CIDR range", r.Value)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("rule match must be path or ip")
|
||||
}
|
||||
}
|
||||
m.RewriteHost = strings.TrimSpace(m.RewriteHost)
|
||||
if m.Proto != model.ProtoHTTPS {
|
||||
m.RedirectHTTP = false
|
||||
}
|
||||
if m.HealthPath != "" && !strings.HasPrefix(m.HealthPath, "/") {
|
||||
return errors.New("health check path must start with /")
|
||||
}
|
||||
}
|
||||
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
|
||||
return errors.New("health check interval must be 5-3600 seconds, or off")
|
||||
}
|
||||
|
||||
for _, o := range s.st.Mappings {
|
||||
if o.ID == m.ID || o.EdgeID != m.EdgeID || netClass(o.Proto) != netClass(m.Proto) || !overlap(o.BindIP, m.BindIP) ||
|
||||
o.Port > m.LastPort() || m.Port > o.LastPort() {
|
||||
continue
|
||||
}
|
||||
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
||||
continue // same listener, different hostname
|
||||
}
|
||||
return fmt.Errorf("%s port %s on %s is already used by %q", netClass(m.Proto), portLabel(*m), o.BindIP, o.Name)
|
||||
}
|
||||
if m.Name == "" {
|
||||
if m.Hostname != "" {
|
||||
m.Name = m.Hostname
|
||||
} else {
|
||||
m.Name = fmt.Sprintf("%s %s:%s", m.Proto, m.BindIP, portLabel(*m))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// portLabel is "25565" or, for a range, "2456-2458".
|
||||
func portLabel(m model.Mapping) string {
|
||||
if m.LastPort() > m.Port {
|
||||
return fmt.Sprintf("%d-%d", m.Port, m.LastPort())
|
||||
}
|
||||
return fmt.Sprint(m.Port)
|
||||
}
|
||||
|
||||
func checkPIN(p string) error {
|
||||
if len(p) < 4 || len(p) > 12 {
|
||||
return errors.New("a PIN is 4-12 digits")
|
||||
}
|
||||
for _, c := range p {
|
||||
if c < '0' || c > '9' {
|
||||
return errors.New("a PIN is digits only")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySecrets updates the password/PIN for mapping id and syncs the Auth flags. Caller holds s.mu.
|
||||
func (s *Server) applySecrets(m *model.Mapping, pwHash, pinHash *string) {
|
||||
sec := s.st.Secrets[m.ID]
|
||||
if sec == nil {
|
||||
sec = &Secret{}
|
||||
}
|
||||
if pwHash != nil {
|
||||
sec.PasswordHash = *pwHash
|
||||
}
|
||||
if pinHash != nil {
|
||||
sec.PinHash = *pinHash
|
||||
}
|
||||
if !m.Auth.Password {
|
||||
sec.PasswordHash = ""
|
||||
}
|
||||
if !m.Auth.Pin {
|
||||
sec.PinHash = ""
|
||||
}
|
||||
m.Auth.Password, m.Auth.Pin = sec.PasswordHash != "", sec.PinHash != ""
|
||||
if sec.PasswordHash == "" && sec.PinHash == "" {
|
||||
delete(s.st.Secrets, m.ID)
|
||||
} else {
|
||||
s.st.Secrets[m.ID] = sec
|
||||
}
|
||||
}
|
||||
|
||||
// hashSecrets prepares optional new secrets outside the lock (bcrypt is slow).
|
||||
func hashSecrets(in *mappingInput) (pw, pin *string, err error) {
|
||||
one := func(v *string, check func(string) error) (*string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *v == "" {
|
||||
e := ""
|
||||
return &e, nil
|
||||
}
|
||||
if err := check(*v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(*v), 10)
|
||||
h := string(b)
|
||||
return &h, err
|
||||
}
|
||||
if pw, err = one(in.NewPassword, func(p string) error {
|
||||
if len(p) < 6 {
|
||||
return errors.New("a resource password needs at least 6 characters")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
pin, err = one(in.NewPin, checkPIN)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) addMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in mappingInput
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
pw, pin, err := hashSecrets(&in)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := in.Mapping
|
||||
m.ID, m.AuthEpoch = "", 0
|
||||
if err := s.validateMapping(c, &m); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
m.ID = newID()
|
||||
s.applySecrets(&m, pw, pin)
|
||||
s.st.Mappings = append(s.st.Mappings, &m)
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mapping.create", m.Name)
|
||||
writeJSON(w, 200, m)
|
||||
}
|
||||
|
||||
// authChanged reports whether an edit changes who may open a resource, which
|
||||
// must sign existing visitors out (their old sessions were granted under the old rules).
|
||||
func authChanged(o, n model.Auth, pwChanged, pinChanged bool) bool {
|
||||
o.SessionHours, n.SessionHours = 0, 0
|
||||
ob, _ := json.Marshal(o)
|
||||
nb, _ := json.Marshal(n)
|
||||
return pwChanged || pinChanged || string(ob) != string(nb)
|
||||
}
|
||||
|
||||
// revokeOrgVisitorsLocked signs out every visitor of the org's protected resources.
|
||||
// The caller must then call s.bump() so the edges learn the new epoch.
|
||||
func (s *Server) revokeOrgVisitorsLocked(orgID string) {
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID == orgID && m.Auth.Protected() {
|
||||
m.AuthEpoch++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revokeVisitors signs everyone out of one resource.
|
||||
func (s *Server) revokeVisitors(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.findMapping(r.PathValue("id"))
|
||||
if m == nil || m.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such port"))
|
||||
return
|
||||
}
|
||||
m.AuthEpoch++
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mapping.signout-visitors", m.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) putMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in mappingInput
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
pw, pin, err := hashSecrets(&in)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
for i, old := range s.st.Mappings {
|
||||
if old.ID != id || old.OrgID != c.Org.ID {
|
||||
continue
|
||||
}
|
||||
m := in.Mapping
|
||||
m.ID = id
|
||||
if err := s.validateMapping(c, &m); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.applySecrets(&m, pw, pin)
|
||||
m.AuthEpoch = old.AuthEpoch // only hammerhead moves this, never the request
|
||||
if authChanged(old.Auth, m.Auth, pw != nil, pin != nil) {
|
||||
m.AuthEpoch++
|
||||
}
|
||||
s.st.Mappings[i] = &m
|
||||
delete(s.status, id)
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mapping.update", m.Name)
|
||||
writeJSON(w, 200, m)
|
||||
return
|
||||
}
|
||||
fail(w, 404, errors.New("no such port"))
|
||||
}
|
||||
|
||||
func (s *Server) deleteMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.findMapping(id)
|
||||
if m == nil || m.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such port"))
|
||||
return
|
||||
}
|
||||
s.dropMappingLocked(id)
|
||||
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.ID != id })
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "mapping.delete", m.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- share links ----------
|
||||
|
||||
func (s *Server) listShares(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []ShareLink{}
|
||||
for _, l := range s.st.Shares {
|
||||
if l.OrgID == c.Org.ID && l.MappingID == r.PathValue("id") {
|
||||
cp := *l
|
||||
cp.TokenHash = ""
|
||||
out = append(out, cp)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// publicURL is how visitors reach a web mapping, or "" for wildcard hostnames.
|
||||
func publicURL(m *model.Mapping) string {
|
||||
if !model.Web(m.Proto) || m.Hostname == "" || strings.HasPrefix(m.Hostname, "*") {
|
||||
return ""
|
||||
}
|
||||
if m.Proto == model.ProtoHTTPS {
|
||||
if m.Port == 443 {
|
||||
return "https://" + m.Hostname
|
||||
}
|
||||
return fmt.Sprintf("https://%s:%d", m.Hostname, m.Port)
|
||||
}
|
||||
if m.Port == 80 {
|
||||
return "http://" + m.Hostname
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d", m.Hostname, m.Port)
|
||||
}
|
||||
|
||||
func (s *Server) createShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct {
|
||||
Name string
|
||||
Hours int
|
||||
MaxUses int
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.findMapping(r.PathValue("id"))
|
||||
if m == nil || m.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such port"))
|
||||
return
|
||||
}
|
||||
base := publicURL(m)
|
||||
if base == "" {
|
||||
fail(w, 400, errors.New("share links need a web port with one concrete hostname"))
|
||||
return
|
||||
}
|
||||
if !m.Auth.Protected() {
|
||||
fail(w, 400, errors.New("this port has no login in front of it, so there is nothing to share past"))
|
||||
return
|
||||
}
|
||||
tok := randHex(20)
|
||||
l := &ShareLink{ID: newID(), MappingID: m.ID, OrgID: c.Org.ID, Name: strings.TrimSpace(in.Name),
|
||||
TokenHash: sha(tok), MaxUses: in.MaxUses, Created: time.Now()}
|
||||
if in.Hours > 0 {
|
||||
l.Expires = time.Now().Add(time.Duration(in.Hours) * time.Hour)
|
||||
}
|
||||
s.st.Shares = append(s.st.Shares, l)
|
||||
s.save()
|
||||
s.audit(c.Org.ID, c.Actor, "share.create", m.Name)
|
||||
writeJSON(w, 200, map[string]string{"url": base + "/?hh_share=" + tok})
|
||||
}
|
||||
|
||||
func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("share")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, l := range s.st.Shares {
|
||||
if l.ID == id && l.OrgID == c.Org.ID {
|
||||
// sessions started from a share link can't be told apart from others, so
|
||||
// revoking one signs out everyone on that resource
|
||||
if m := s.findMapping(l.MappingID); m != nil {
|
||||
m.AuthEpoch++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return !(l.ID == id && l.OrgID == c.Org.ID) })
|
||||
s.bump()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- combined state for the UI ----------
|
||||
|
||||
func (s *Server) orgState(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
edges := []edgeView{}
|
||||
for _, e := range s.st.Edges {
|
||||
if s.edgeAllowedFor(e, c.Org.ID) {
|
||||
edges = append(edges, s.viewEdge(e, false))
|
||||
}
|
||||
}
|
||||
sites := []model.Site{}
|
||||
online := map[string]bool{}
|
||||
for _, st := range s.st.Sites {
|
||||
if !s.siteVisible(c, st) {
|
||||
continue
|
||||
}
|
||||
cp := *st
|
||||
if !c.Admin || st.Local {
|
||||
cp.Token = ""
|
||||
}
|
||||
sites = append(sites, cp)
|
||||
online[st.ID] = s.siteOnline(st)
|
||||
}
|
||||
domains := []model.Domain{}
|
||||
for _, d := range s.st.Domains {
|
||||
if d.OrgID == c.Org.ID {
|
||||
domains = append(domains, *d)
|
||||
}
|
||||
}
|
||||
maps := []mappingView{}
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID != c.Org.ID {
|
||||
continue
|
||||
}
|
||||
v := mappingView{Mapping: *m}
|
||||
v.Status, v.Known = s.status[m.ID]
|
||||
if t := s.st.Totals[m.ID]; t != nil {
|
||||
v.Total = *t
|
||||
}
|
||||
maps = append(maps, v)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"org": c.Org, "admin": c.Admin, "edges": edges, "sites": sites, "online": online,
|
||||
"domains": domains, "mappings": maps, "roles": s.rolesLocked(c.Org.ID), "mailEgress": s.mailEgressViewsLocked(c.Org.ID),
|
||||
"mailConfigured": s.st.Settings.SMTP.Host != "",
|
||||
"dashboardUrl": s.st.Settings.DashboardURL,
|
||||
})
|
||||
}
|
||||
+112
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// ---------- organizations ----------
|
||||
|
||||
func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct {
|
||||
Name string
|
||||
AllowLocal bool
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
|
||||
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
||||
s.save()
|
||||
o := *org
|
||||
s.mu.Unlock()
|
||||
s.audit(o.ID, p.User.Email, "org.create", o.Name)
|
||||
writeJSON(w, 200, o)
|
||||
}
|
||||
|
||||
func (s *Server) updateOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct {
|
||||
Name string
|
||||
AllowLocal *bool
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o := s.findOrg(c.Org.ID)
|
||||
if n := strings.TrimSpace(in.Name); n != "" {
|
||||
o.Name = n
|
||||
}
|
||||
if in.AllowLocal != nil {
|
||||
if !c.User.ServerAdmin {
|
||||
fail(w, 403, errors.New("only a server administrator can change this"))
|
||||
return
|
||||
}
|
||||
o.AllowLocal = *in.AllowLocal
|
||||
}
|
||||
s.bump()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) deleteOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
if !c.User.ServerAdmin {
|
||||
fail(w, 403, errors.New("server administrators only"))
|
||||
return
|
||||
}
|
||||
id := c.Org.ID
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.st.Orgs = filter(s.st.Orgs, func(o *Org) bool { return o.ID != id })
|
||||
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.OrgID != id })
|
||||
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.OrgID != id })
|
||||
s.st.Invites = filter(s.st.Invites, func(o *Invite) bool { return o.OrgID != id })
|
||||
s.st.APIKeys = filter(s.st.APIKeys, func(o *APIKey) bool { return o.OrgID != id })
|
||||
s.st.Sites = filter(s.st.Sites, func(o *model.Site) bool { return o.OrgID != id })
|
||||
s.st.Domains = filter(s.st.Domains, func(o *model.Domain) bool { return o.OrgID != id })
|
||||
s.st.Shares = filter(s.st.Shares, func(o *ShareLink) bool { return o.OrgID != id })
|
||||
s.st.MailEgress = filter(s.st.MailEgress, func(o *model.MailEgress) bool { return o.OrgID != id })
|
||||
s.st.DKIM = filter(s.st.DKIM, func(o *model.DKIMKey) bool { return o.OrgID != id })
|
||||
s.st.Channels = filter(s.st.Channels, func(o *AlertChannel) bool { return o.OrgID != id })
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID == id {
|
||||
delete(s.st.Secrets, m.ID)
|
||||
delete(s.st.Totals, m.ID)
|
||||
delete(s.status, m.ID)
|
||||
}
|
||||
}
|
||||
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.OrgID != id })
|
||||
for _, e := range s.st.Edges {
|
||||
e.OrgIDs = removeStr(e.OrgIDs, id)
|
||||
}
|
||||
s.bump()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func removeStr(in []string, v string) []string {
|
||||
out := []string{}
|
||||
for _, x := range in {
|
||||
if x != v {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- roles ----------
|
||||
|
||||
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
writeJSON(w, 200, s.rolesLocked(c.Org.ID))
|
||||
}
|
||||
|
||||
func (s *Server) rolesLocked(orgID string) []Role {
|
||||
out := []Role{}
|
||||
for _, r := range s.st.Roles {
|
||||
if r.OrgID == orgID {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) saveRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in Role
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, o := range s.st.Roles {
|
||||
if o.OrgID == c.Org.ID && strings.EqualFold(o.Name, in.Name) && o.ID != r.PathValue("role") {
|
||||
fail(w, 400, errors.New("a role with that name exists"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if id := r.PathValue("role"); id != "" {
|
||||
role := s.findRole(id)
|
||||
if role == nil || role.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such role"))
|
||||
return
|
||||
}
|
||||
if role.Builtin {
|
||||
fail(w, 400, errors.New("built-in roles can't be edited"))
|
||||
return
|
||||
}
|
||||
role.Name, role.Admin = in.Name, in.Admin
|
||||
} else {
|
||||
s.st.Roles = append(s.st.Roles, &Role{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Admin: in.Admin})
|
||||
}
|
||||
s.save()
|
||||
s.audit(c.Org.ID, c.Actor, "role.save", in.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("role")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
role := s.findRole(id)
|
||||
if role == nil || role.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such role"))
|
||||
return
|
||||
}
|
||||
if role.Builtin {
|
||||
fail(w, 400, errors.New("built-in roles can't be deleted"))
|
||||
return
|
||||
}
|
||||
for _, m := range s.st.Members {
|
||||
if m.RoleID == id {
|
||||
fail(w, 400, errors.New("that role still has members; move them first"))
|
||||
return
|
||||
}
|
||||
}
|
||||
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.ID != id })
|
||||
for _, m := range s.st.Mappings {
|
||||
m.Auth.AllowedRoles = removeStr(m.Auth.AllowedRoles, id)
|
||||
}
|
||||
s.revokeOrgVisitorsLocked(c.Org.ID)
|
||||
s.bump()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- members ----------
|
||||
|
||||
func (s *Server) listMembers(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []map[string]any{}
|
||||
for _, m := range s.st.Members {
|
||||
if m.OrgID != c.Org.ID {
|
||||
continue
|
||||
}
|
||||
u := s.findUser(m.UserID)
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{"userId": u.ID, "email": u.Email, "name": u.Name, "roleId": m.RoleID,
|
||||
"totp": u.TOTPEnabled, "sso": u.IdP != ""})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i]["email"].(string) < out[j]["email"].(string) })
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// adminCountLocked counts members with an admin role, excluding skip.
|
||||
func (s *Server) adminCountLocked(orgID, skip string) int {
|
||||
n := 0
|
||||
for _, m := range s.st.Members {
|
||||
if m.OrgID == orgID && m.UserID != skip {
|
||||
if role := s.findRole(m.RoleID); role != nil && role.Admin {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Server) setMemberRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct{ RoleID string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
uid := r.PathValue("user")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(uid, c.Org.ID)
|
||||
role := s.findRole(in.RoleID)
|
||||
if m == nil || role == nil || role.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such member or role"))
|
||||
return
|
||||
}
|
||||
if !role.Admin && s.adminCountLocked(c.Org.ID, uid) == 0 {
|
||||
fail(w, 400, errors.New("an organization needs at least one admin"))
|
||||
return
|
||||
}
|
||||
m.RoleID = in.RoleID
|
||||
s.revokeOrgVisitorsLocked(c.Org.ID) // what they may open just changed
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "member.role", uid+" -> "+role.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) removeMember(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
uid := r.PathValue("user")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.member(uid, c.Org.ID) == nil {
|
||||
fail(w, 404, errors.New("no such member"))
|
||||
return
|
||||
}
|
||||
if s.adminCountLocked(c.Org.ID, uid) == 0 {
|
||||
fail(w, 400, errors.New("an organization needs at least one admin"))
|
||||
return
|
||||
}
|
||||
s.st.Members = filter(s.st.Members, func(m *Member) bool { return !(m.UserID == uid && m.OrgID == c.Org.ID) })
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID == c.Org.ID {
|
||||
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, uid)
|
||||
}
|
||||
}
|
||||
s.revokeOrgVisitorsLocked(c.Org.ID) // the removed member's open sessions end now
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "member.remove", uid)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- invites ----------
|
||||
|
||||
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []Invite{}
|
||||
for _, i := range s.st.Invites {
|
||||
if i.OrgID == c.Org.ID && time.Now().Before(i.Expires) {
|
||||
cp := *i
|
||||
cp.TokenHash = ""
|
||||
out = append(out, cp)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct{ Email, RoleID string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||
if !strings.Contains(in.Email, "@") {
|
||||
fail(w, 400, errors.New("enter a valid email address"))
|
||||
return
|
||||
}
|
||||
tok := randHex(20)
|
||||
s.mu.Lock()
|
||||
role := s.findRole(in.RoleID)
|
||||
if role == nil || role.OrgID != c.Org.ID {
|
||||
s.mu.Unlock()
|
||||
fail(w, 400, errors.New("choose a role"))
|
||||
return
|
||||
}
|
||||
s.st.Invites = append(s.st.Invites, &Invite{ID: newID(), OrgID: c.Org.ID, Email: in.Email, RoleID: role.ID,
|
||||
TokenHash: sha(tok), Expires: time.Now().Add(7 * 24 * time.Hour), Created: time.Now()})
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
link := s.baseURL(r) + "/#invite=" + tok
|
||||
emailed := false
|
||||
if s.mailConfigured() {
|
||||
body := fmt.Sprintf("%s invited you to join %q on Hammerhead.\n\nAccept: %s\n\nThe link works for 7 days.", c.Actor, c.Org.Name, link)
|
||||
emailed = s.Mailer(in.Email, "You're invited to "+c.Org.Name, body) == nil
|
||||
}
|
||||
s.audit(c.Org.ID, c.Actor, "invite.create", in.Email)
|
||||
writeJSON(w, 200, map[string]any{"link": link, "emailed": emailed})
|
||||
}
|
||||
|
||||
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return !(i.ID == id && i.OrgID == c.Org.ID) })
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) inviteInfo(w http.ResponseWriter, r *http.Request) {
|
||||
h := sha(r.PathValue("token"))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, i := range s.st.Invites {
|
||||
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
||||
org := s.findOrg(i.OrgID)
|
||||
writeJSON(w, 200, map[string]any{"email": i.Email, "org": org.Name, "existing": s.userByEmail(i.Email) != nil})
|
||||
return
|
||||
}
|
||||
}
|
||||
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
||||
}
|
||||
|
||||
func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Token, Name, Password string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
|
||||
fail(w, 429, errors.New("too many attempts"))
|
||||
return
|
||||
}
|
||||
h := sha(in.Token)
|
||||
p := s.principalFrom(r)
|
||||
var hash string
|
||||
s.mu.Lock()
|
||||
var inv *Invite
|
||||
for _, i := range s.st.Invites {
|
||||
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
||||
inv = i
|
||||
}
|
||||
}
|
||||
existing := (*User)(nil)
|
||||
if inv != nil {
|
||||
existing = s.userByEmail(inv.Email)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if inv == nil {
|
||||
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
var err error
|
||||
if hash, err = hashPassword(in.Password); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
} else if p == nil || p.User.ID != existing.ID {
|
||||
fail(w, 400, errors.New("an account with this email already exists: sign in first, then open the link again"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
uid := ""
|
||||
if existing != nil {
|
||||
uid = existing.ID
|
||||
} else {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
name = inv.Email
|
||||
}
|
||||
u := &User{ID: newID(), Email: inv.Email, Name: name, PassHash: hash, Created: time.Now()}
|
||||
s.st.Users = append(s.st.Users, u)
|
||||
uid = u.ID
|
||||
}
|
||||
if s.member(uid, inv.OrgID) == nil {
|
||||
s.st.Members = append(s.st.Members, &Member{UserID: uid, OrgID: inv.OrgID, RoleID: inv.RoleID})
|
||||
}
|
||||
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return i.ID != inv.ID })
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
if existing == nil {
|
||||
s.startSession(w, r, uid)
|
||||
}
|
||||
s.audit(inv.OrgID, inv.Email, "invite.accept", "")
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- API keys ----------
|
||||
|
||||
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []APIKey{}
|
||||
for _, k := range s.st.APIKeys {
|
||||
if k.OrgID == c.Org.ID {
|
||||
cp := *k
|
||||
cp.Hash = ""
|
||||
out = append(out, cp)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct{ Name string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
id, secret := randHex(4), randHex(24)
|
||||
s.mu.Lock()
|
||||
s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()})
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name)
|
||||
writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret})
|
||||
}
|
||||
|
||||
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == c.Org.ID) })
|
||||
s.save()
|
||||
s.audit(c.Org.ID, c.Actor, "apikey.delete", id)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- logs & stats ----------
|
||||
|
||||
func (s *Server) orgLogs(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
|
||||
if q.Get("kind") == "audit" {
|
||||
writeJSON(w, 200, s.logs.queryAudit(c.Org.ID, limit, before))
|
||||
return
|
||||
}
|
||||
class, _ := strconv.Atoi(q.Get("status"))
|
||||
writeJSON(w, 200, s.logs.queryAccess(accessFilter{Org: c.Org.ID, Mapping: q.Get("mapping"), Q: strings.TrimSpace(q.Get("q")),
|
||||
Result: q.Get("result"), StatusClass: class, Before: before, Limit: limit}))
|
||||
}
|
||||
|
||||
// orgTraffic returns bytes per hour for each of the org's ports, oldest first.
|
||||
func (s *Server) orgTraffic(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||
s.mu.Lock()
|
||||
var ids []string
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID == c.Org.ID {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, 200, s.db.trafficHistory(ids, hours))
|
||||
}
|
||||
|
||||
func (s *Server) orgStats(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string]Total{}
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.OrgID == c.Org.ID {
|
||||
if t := s.st.Totals[m.ID]; t != nil {
|
||||
out[m.ID] = *t
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// ---------- server admin: users ----------
|
||||
|
||||
func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []map[string]any{}
|
||||
for _, u := range s.st.Users {
|
||||
orgs := []string{}
|
||||
for _, m := range s.st.Members {
|
||||
if m.UserID == u.ID {
|
||||
if o := s.findOrg(m.OrgID); o != nil {
|
||||
orgs = append(orgs, o.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
|
||||
"totp": u.TOTPEnabled, "sso": u.IdP != "", "orgs": orgs})
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct {
|
||||
Email, Name, Password string
|
||||
ServerAdmin bool
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.Password)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||
if !strings.Contains(in.Email, "@") {
|
||||
fail(w, 400, errors.New("enter a valid email address"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.userByEmail(in.Email) != nil {
|
||||
fail(w, 400, errors.New("that email already has an account"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
name = in.Email
|
||||
}
|
||||
s.st.Users = append(s.st.Users, &User{ID: newID(), Email: in.Email, Name: name, PassHash: hash, ServerAdmin: in.ServerAdmin, Created: time.Now()})
|
||||
s.save()
|
||||
s.audit("", p.User.Email, "user.create", in.Email)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) adminUpdateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct {
|
||||
ServerAdmin *bool
|
||||
ResetTOTP bool
|
||||
Password string
|
||||
SignOut bool
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
var hash string
|
||||
if in.Password != "" {
|
||||
var err error
|
||||
if hash, err = hashPassword(in.Password); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(r.PathValue("id"))
|
||||
if u == nil {
|
||||
fail(w, 404, errors.New("no such user"))
|
||||
return
|
||||
}
|
||||
if in.ServerAdmin != nil {
|
||||
if !*in.ServerAdmin && u.ServerAdmin {
|
||||
n := 0
|
||||
for _, o := range s.st.Users {
|
||||
if o.ServerAdmin {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n <= 1 {
|
||||
fail(w, 400, errors.New("there must be at least one server administrator"))
|
||||
return
|
||||
}
|
||||
}
|
||||
u.ServerAdmin = *in.ServerAdmin
|
||||
}
|
||||
if in.ResetTOTP {
|
||||
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
||||
}
|
||||
if hash != "" || in.SignOut {
|
||||
if hash != "" {
|
||||
u.PassHash = hash
|
||||
}
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == u.ID {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.save()
|
||||
s.audit("", p.User.Email, "user.update", u.Email)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
id := r.PathValue("id")
|
||||
if id == p.User.ID {
|
||||
fail(w, 400, errors.New("you can't delete yourself"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(id)
|
||||
if u == nil {
|
||||
fail(w, 404, errors.New("no such user"))
|
||||
return
|
||||
}
|
||||
for _, mem := range s.st.Members {
|
||||
if mem.UserID == id {
|
||||
s.revokeOrgVisitorsLocked(mem.OrgID) // their open sessions on protected resources end now
|
||||
}
|
||||
}
|
||||
s.st.Users = filter(s.st.Users, func(o *User) bool { return o.ID != id })
|
||||
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.UserID != id })
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == id {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
for _, m := range s.st.Mappings {
|
||||
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, id)
|
||||
}
|
||||
s.bump()
|
||||
s.audit("", p.User.Email, "user.delete", u.Email)
|
||||
ok(w)
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/authtoken"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed web/resource.html
|
||||
var resourceHTML []byte
|
||||
|
||||
// The visitor-facing login flow for protected web ports.
|
||||
//
|
||||
// 1. blacktip sees an unauthenticated request and redirects to
|
||||
// <dashboard>/auth/r/<mapping>?return=<original url>
|
||||
// 2. the visitor proves who they are here (account, password, PIN, emailed
|
||||
// code or share link)
|
||||
// 3. we redirect back to <original host>/.hammerhead/callback?t=<signed token>
|
||||
// and blacktip turns that into a cookie.
|
||||
|
||||
type otpEntry struct {
|
||||
hash string
|
||||
expires time.Time
|
||||
tries int
|
||||
}
|
||||
|
||||
func (s *Server) resourcePage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Write(resourceHTML)
|
||||
}
|
||||
|
||||
// protectedMapping loads a mapping that has a login in front of it. Caller holds s.mu.
|
||||
func (s *Server) protectedMapping(id string) *model.Mapping {
|
||||
m := s.findMapping(id)
|
||||
if m == nil || !model.Web(m.Proto) || !m.Auth.Protected() || !m.Enabled {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// checkReturn makes sure the return URL points at this mapping, so the login can't be used as an open redirect.
|
||||
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("invalid return address")
|
||||
}
|
||||
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
|
||||
return nil, errors.New("return address uses the wrong scheme")
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
if u.Scheme == "https" {
|
||||
port = "443"
|
||||
}
|
||||
}
|
||||
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
|
||||
return nil, errors.New("return address doesn't belong to this resource")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// canAccess applies the SSO policy. Caller holds s.mu.
|
||||
func (s *Server) canAccess(u *User, m *model.Mapping) bool {
|
||||
if u.ServerAdmin {
|
||||
return true
|
||||
}
|
||||
mem := s.member(u.ID, m.OrgID)
|
||||
if mem == nil {
|
||||
return false
|
||||
}
|
||||
if role := s.findRole(mem.RoleID); role != nil && role.Admin {
|
||||
return true
|
||||
}
|
||||
a := m.Auth
|
||||
if len(a.AllowedRoles) == 0 && len(a.AllowedUsers) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, id := range a.AllowedRoles {
|
||||
if id == mem.RoleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, id := range a.AllowedUsers {
|
||||
if id == u.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
p := s.principalFrom(r)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.protectedMapping(r.PathValue("id"))
|
||||
if m == nil {
|
||||
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist"))
|
||||
return
|
||||
}
|
||||
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
info := map[string]any{
|
||||
"name": m.Name, "host": m.Hostname,
|
||||
"sso": m.Auth.SSO, "password": m.Auth.Password, "pin": m.Auth.Pin,
|
||||
"email": m.Auth.Email && s.st.Settings.SMTP.Host != "",
|
||||
"providers": []map[string]string{},
|
||||
}
|
||||
if m.Auth.SSO {
|
||||
info["providers"] = s.providerList()
|
||||
}
|
||||
if p != nil && p.User.ID != "" {
|
||||
u := s.findUser(p.User.ID)
|
||||
info["user"] = map[string]any{"email": u.Email, "name": u.Name, "allowed": s.canAccess(u, m)}
|
||||
}
|
||||
writeJSON(w, 200, info)
|
||||
}
|
||||
|
||||
// grant mints the callback token and tells the browser where to go.
|
||||
func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, sub string) {
|
||||
s.mu.Lock()
|
||||
e := s.findEdge(m.EdgeID)
|
||||
var key string
|
||||
if e != nil {
|
||||
key = e.AuthKey
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if key == "" {
|
||||
fail(w, 500, errors.New("edge is not set up for logins"))
|
||||
return
|
||||
}
|
||||
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
|
||||
dest := fmt.Sprintf("%s://%s/.hammerhead/callback?t=%s&r=%s", back.Scheme, back.Host, tok, url.QueryEscape(back.RequestURI()))
|
||||
s.audit(m.OrgID, sub, "resource.login", m.Name)
|
||||
writeJSON(w, 200, map[string]string{"redirect": dest})
|
||||
}
|
||||
|
||||
// resourceAuth wraps a method handler: it loads the mapping, checks the return URL and rate-limits.
|
||||
func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
|
||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
pm := s.protectedMapping(r.PathValue("id"))
|
||||
var m model.Mapping
|
||||
if pm != nil {
|
||||
m = *pm
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if pm == nil {
|
||||
fail(w, 404, errors.New("no such resource"))
|
||||
return
|
||||
}
|
||||
back, err := checkReturn(&m, body["return"])
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
fn(w, r, &m, back, body)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
s.mu.Lock()
|
||||
sec := s.st.Secrets[m.ID]
|
||||
s.mu.Unlock()
|
||||
hash := string(dummyHash)
|
||||
if sec != nil && sec.PasswordHash != "" && m.Auth.Password {
|
||||
hash = sec.PasswordHash
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fail(w, 401, errors.New("wrong password"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, "password")
|
||||
}
|
||||
|
||||
func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
s.mu.Lock()
|
||||
sec := s.st.Secrets[m.ID]
|
||||
s.mu.Unlock()
|
||||
hash := string(dummyHash)
|
||||
if sec != nil && sec.PinHash != "" && m.Auth.Pin {
|
||||
hash = sec.PinHash
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fail(w, 401, errors.New("wrong PIN"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, "pin")
|
||||
}
|
||||
|
||||
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
p := s.principalFrom(r)
|
||||
if p == nil || p.User.ID == "" || !m.Auth.SSO {
|
||||
fail(w, 401, errors.New("sign in first"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
u := s.findUser(p.User.ID)
|
||||
allowed := u != nil && s.canAccess(u, m)
|
||||
s.mu.Unlock()
|
||||
if !allowed {
|
||||
fail(w, 403, errors.New("your account doesn't have access to this resource"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, p.User.Email)
|
||||
}
|
||||
|
||||
func mailAllowed(list []string, email string) bool {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
for _, a := range list {
|
||||
if a == email || (strings.HasPrefix(a, "*@") && strings.HasSuffix(email, a[1:])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
||||
if !m.Auth.Email || !s.mailConfigured() {
|
||||
fail(w, 400, errors.New("email codes are not available for this resource"))
|
||||
return
|
||||
}
|
||||
// always answer the same way so addresses can't be probed
|
||||
reply := func() {
|
||||
writeJSON(w, 200, map[string]string{"status": "If that address has access, a code is on its way."})
|
||||
}
|
||||
if !mailAllowed(m.Auth.AllowedMails, email) || !s.allow("otp|"+email, 3, 10*time.Minute) {
|
||||
reply()
|
||||
return
|
||||
}
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
code := fmt.Sprintf("%06d", n.Int64())
|
||||
s.mu.Lock()
|
||||
s.otps[m.ID+"|"+email] = otpEntry{hash: sha(code), expires: time.Now().Add(10 * time.Minute)}
|
||||
s.mu.Unlock()
|
||||
go s.Mailer(email, "Your sign-in code for "+m.Name, fmt.Sprintf("Your code is %s\n\nIt works for 10 minutes. If you didn't ask for it, ignore this email.", code))
|
||||
reply()
|
||||
}
|
||||
|
||||
func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
email := strings.ToLower(strings.TrimSpace(body["email"]))
|
||||
key := m.ID + "|" + email
|
||||
s.mu.Lock()
|
||||
e, found := s.otps[key]
|
||||
good := false
|
||||
if found && time.Now().Before(e.expires) && e.tries < 5 {
|
||||
e.tries++
|
||||
s.otps[key] = e
|
||||
good = e.hash == sha(strings.TrimSpace(body["code"]))
|
||||
if good {
|
||||
delete(s.otps, key)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !good || !mailAllowed(m.Auth.AllowedMails, email) {
|
||||
fail(w, 401, errors.New("wrong or expired code"))
|
||||
return
|
||||
}
|
||||
s.grant(w, r, m, back, email)
|
||||
}
|
||||
|
||||
func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
|
||||
h := sha(body["token"])
|
||||
s.mu.Lock()
|
||||
var link *ShareLink
|
||||
for _, l := range s.st.Shares {
|
||||
if l.MappingID == m.ID && l.TokenHash == h {
|
||||
link = l
|
||||
}
|
||||
}
|
||||
valid := link != nil && (link.Expires.IsZero() || time.Now().Before(link.Expires)) && (link.MaxUses == 0 || link.Uses < link.MaxUses)
|
||||
name := ""
|
||||
if valid {
|
||||
link.Uses++
|
||||
name = link.Name
|
||||
s.save()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !valid {
|
||||
fail(w, 401, errors.New("this share link is invalid, expired or used up"))
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "link"
|
||||
}
|
||||
s.grant(w, r, m, back, "share:"+name)
|
||||
}
|
||||
|
||||
// myResources lists the web ports the signed-in user may open.
|
||||
func (s *Server) myResources(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(p.User.ID)
|
||||
out := []map[string]string{}
|
||||
for _, m := range s.st.Mappings {
|
||||
if !m.Enabled || !model.Web(m.Proto) {
|
||||
continue
|
||||
}
|
||||
if m.Auth.Protected() && !(m.Auth.SSO && s.canAccess(u, m)) {
|
||||
continue
|
||||
}
|
||||
if !m.Auth.Protected() && s.member(u.ID, m.OrgID) == nil && !u.ServerAdmin {
|
||||
continue
|
||||
}
|
||||
if link := publicURL(m); link != "" {
|
||||
org := ""
|
||||
if o := s.findOrg(m.OrgID); o != nil {
|
||||
org = o.Name
|
||||
}
|
||||
out = append(out, map[string]string{"name": m.Name, "url": link, "org": org})
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
func page(html []byte) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
|
||||
w.Write(html)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
index := page(indexHTML)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
index(w, r)
|
||||
})
|
||||
|
||||
s.registerAlertRoutes(mux)
|
||||
mux.HandleFunc("GET /metrics", s.metrics)
|
||||
|
||||
// public
|
||||
mux.HandleFunc("GET /api/public", s.publicInfo)
|
||||
mux.HandleFunc("POST /api/setup", s.setup)
|
||||
mux.HandleFunc("POST /api/login", s.login)
|
||||
mux.HandleFunc("POST /api/login/totp", s.loginTOTP)
|
||||
mux.HandleFunc("GET /api/invites/{token}", s.inviteInfo)
|
||||
mux.HandleFunc("POST /api/invites/accept", s.acceptInvite)
|
||||
mux.HandleFunc("GET /auth/oidc/{id}/start", s.oidcStart)
|
||||
mux.HandleFunc("GET /auth/oidc/callback", s.oidcCallback)
|
||||
|
||||
// visitor login for protected ports
|
||||
res := page(resourceHTML)
|
||||
mux.HandleFunc("GET /auth/r/{id}", res)
|
||||
mux.HandleFunc("GET /auth/r/{id}/info", s.resourceInfo)
|
||||
mux.HandleFunc("POST /auth/r/{id}/password", s.resourceAuth("password", s.resourcePassword))
|
||||
mux.HandleFunc("POST /auth/r/{id}/pin", s.resourceAuth("pin", s.resourcePin))
|
||||
mux.HandleFunc("POST /auth/r/{id}/sso", s.resourceAuth("sso", s.resourceSSO))
|
||||
mux.HandleFunc("POST /auth/r/{id}/share", s.resourceAuth("share", s.resourceShare))
|
||||
mux.HandleFunc("POST /auth/r/{id}/email/start", s.resourceAuth("emailstart", s.resourceEmailStart))
|
||||
mux.HandleFunc("POST /auth/r/{id}/email/verify", s.resourceAuth("emailverify", s.resourceEmailVerify))
|
||||
|
||||
// any signed-in user
|
||||
mux.HandleFunc("POST /api/logout", s.withUser(s.logout))
|
||||
mux.HandleFunc("GET /api/me", s.withUser(s.me))
|
||||
mux.HandleFunc("PUT /api/me", s.withUser(s.updateMe))
|
||||
mux.HandleFunc("POST /api/me/password", s.withUser(s.changePassword))
|
||||
mux.HandleFunc("POST /api/me/2fa/setup", s.withUser(s.totpSetup))
|
||||
mux.HandleFunc("POST /api/me/2fa/enable", s.withUser(s.totpEnable))
|
||||
mux.HandleFunc("POST /api/me/2fa/disable", s.withUser(s.totpDisable))
|
||||
mux.HandleFunc("GET /api/me/resources", s.withUser(s.myResources))
|
||||
mux.HandleFunc("GET /api/me/sessions", s.withUser(s.mySessions))
|
||||
mux.HandleFunc("DELETE /api/me/sessions/{id}", s.withUser(s.endSession))
|
||||
mux.HandleFunc("POST /api/me/sessions/revoke-others", s.withUser(s.endOtherSessions))
|
||||
mux.HandleFunc("GET /api/edges", s.withUser(s.listEdges))
|
||||
|
||||
// server administrators
|
||||
mux.HandleFunc("POST /api/edges", s.withServerAdmin(s.saveEdge))
|
||||
mux.HandleFunc("PUT /api/edges/{id}", s.withServerAdmin(s.saveEdge))
|
||||
mux.HandleFunc("DELETE /api/edges/{id}", s.withServerAdmin(s.deleteEdge))
|
||||
mux.HandleFunc("POST /api/orgs", s.withServerAdmin(s.createOrg))
|
||||
mux.HandleFunc("GET /api/admin/users", s.withServerAdmin(s.adminUsers))
|
||||
mux.HandleFunc("POST /api/admin/users", s.withServerAdmin(s.adminCreateUser))
|
||||
mux.HandleFunc("PUT /api/admin/users/{id}", s.withServerAdmin(s.adminUpdateUser))
|
||||
mux.HandleFunc("DELETE /api/admin/users/{id}", s.withServerAdmin(s.adminDeleteUser))
|
||||
mux.HandleFunc("GET /api/admin/idps", s.withServerAdmin(s.listIdPs))
|
||||
mux.HandleFunc("POST /api/admin/idps", s.withServerAdmin(s.saveIdP))
|
||||
mux.HandleFunc("PUT /api/admin/idps/{id}", s.withServerAdmin(s.saveIdP))
|
||||
mux.HandleFunc("DELETE /api/admin/idps/{id}", s.withServerAdmin(s.deleteIdP))
|
||||
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
||||
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
||||
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
||||
mux.HandleFunc("GET /api/admin/audit", s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
|
||||
}))
|
||||
|
||||
// organization scope: members read, admins write
|
||||
mux.HandleFunc("GET /api/orgs/{org}/state", s.withOrg(false, s.orgState))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}", s.withOrg(true, s.updateOrg))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}", s.withOrg(true, s.deleteOrg))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/roles", s.withOrg(false, s.listRoles))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/roles", s.withOrg(true, s.saveRole))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/roles/{role}", s.withOrg(true, s.saveRole))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/roles/{role}", s.withOrg(true, s.deleteRole))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/members", s.withOrg(true, s.listMembers))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/members/{user}", s.withOrg(true, s.setMemberRole))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/members/{user}", s.withOrg(true, s.removeMember))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/invites", s.withOrg(true, s.listInvites))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/invites", s.withOrg(true, s.createInvite))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/invites/{id}", s.withOrg(true, s.deleteInvite))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/apikeys", s.withOrg(true, s.listKeys))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/domains/{id}", s.withOrg(true, s.deleteDomain))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/domains/{id}/check", s.withOrg(true, s.checkDomain))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/mappings", s.withOrg(true, s.addMapping))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/mappings/{id}", s.withOrg(true, s.putMapping))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/mappings/{id}", s.withOrg(true, s.deleteMapping))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.listShares))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.createShare))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/revoke", s.withOrg(true, s.revokeVisitors))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/mappings/{id}/shares/{share}", s.withOrg(true, s.deleteShare))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/mail-egress", s.withOrg(true, s.saveMailEgress))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.saveMailEgress))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.deleteMailEgress))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/dkim", s.withOrg(true, s.listDKIM))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/dkim", s.withOrg(true, s.createDKIM))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/dkim/{id}", s.withOrg(true, s.deleteDKIM))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/logs", s.withOrg(true, s.orgLogs))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/stats", s.withOrg(false, s.orgStats))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
|
||||
|
||||
// agents
|
||||
mux.HandleFunc("GET /edge/config", s.edgeConfig)
|
||||
mux.HandleFunc("POST /edge/report", s.edgeReport)
|
||||
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
|
||||
mux.HandleFunc("GET /site/config", s.siteConfig)
|
||||
mux.HandleFunc("POST /site/report", s.siteReport)
|
||||
return mux
|
||||
}
|
||||
+130
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
// Package hammerhead is the control plane: JSON API, web UI, accounts and state store.
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
type Org struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AllowLocal bool `json:"allowLocal"` // may target an edge's built-in local site (the edge machine itself)
|
||||
|
||||
AlertDebounceMin int `json:"alertDebounceMin,omitempty"` // minutes a problem must last before alerting; 0 = 2
|
||||
AlertRepeatHours int `json:"alertRepeatHours,omitempty"` // remind about an unresolved problem this often; 0 = never
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Admin bool `json:"admin"`
|
||||
Builtin bool `json:"builtin"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
UserID string `json:"userId"`
|
||||
OrgID string `json:"orgId"`
|
||||
RoleID string `json:"roleId"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PassHash string `json:"passHash,omitempty"`
|
||||
ServerAdmin bool `json:"serverAdmin"`
|
||||
TOTPSecret string `json:"totpSecret,omitempty"`
|
||||
TOTPEnabled bool `json:"totpEnabled"`
|
||||
BackupCodes []string `json:"backupCodes,omitempty"` // sha256 hashes
|
||||
IdP string `json:"idp,omitempty"`
|
||||
IdPSub string `json:"idpSub,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UA string `json:"ua,omitempty"`
|
||||
UserID string `json:"userId"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Email string `json:"email"`
|
||||
RoleID string `json:"roleId"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
type IdP struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
Scopes string `json:"scopes"` // default "openid email profile"
|
||||
AutoProvision bool `json:"autoProvision"`
|
||||
OrgID string `json:"orgId"` // org new users are added to
|
||||
RoleID string `json:"roleId"` // ...with this role
|
||||
AllowedDomains []string `json:"allowedDomains"`
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass,omitempty"`
|
||||
From string `json:"from"`
|
||||
Mode string `json:"mode"` // starttls, tls, none
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
DashboardURL string `json:"dashboardUrl"`
|
||||
AcmeEmail string `json:"acmeEmail"`
|
||||
AcmeStaging bool `json:"acmeStaging"`
|
||||
SMTP SMTP `json:"smtp"`
|
||||
}
|
||||
|
||||
type Secret struct {
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
PinHash string `json:"pinHash,omitempty"`
|
||||
}
|
||||
|
||||
type ShareLink struct {
|
||||
ID string `json:"id"`
|
||||
MappingID string `json:"mappingId"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
Expires time.Time `json:"expires"` // zero = never
|
||||
MaxUses int `json:"maxUses"` // 0 = unlimited
|
||||
Uses int `json:"uses"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type Total struct {
|
||||
Conns int64 `json:"conns"`
|
||||
BytesIn int64 `json:"bytesIn"`
|
||||
BytesOut int64 `json:"bytesOut"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
Version int64 `json:"version"`
|
||||
Settings Settings `json:"settings"`
|
||||
Users []*User `json:"users"`
|
||||
Sessions map[string]*Session `json:"sessions"` // by sha256(token)
|
||||
Orgs []*Org `json:"orgs"`
|
||||
Roles []*Role `json:"roles"`
|
||||
Members []*Member `json:"members"`
|
||||
Invites []*Invite `json:"invites"`
|
||||
APIKeys []*APIKey `json:"apiKeys"`
|
||||
IdPs []*IdP `json:"idps"`
|
||||
Edges []*model.Edge `json:"edges"`
|
||||
Sites []*model.Site `json:"sites"`
|
||||
Domains []*model.Domain `json:"domains"`
|
||||
Mappings []*model.Mapping `json:"mappings"`
|
||||
MailEgress []*model.MailEgress `json:"mailEgress"`
|
||||
DKIM []*model.DKIMKey `json:"dkim"`
|
||||
Channels []*AlertChannel `json:"channels"`
|
||||
Secrets map[string]*Secret `json:"secrets"` // by mapping ID
|
||||
Shares []*ShareLink `json:"shares"`
|
||||
Totals map[string]*Total `json:"totals"` // by mapping ID
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
AdminEmail string // optional: create this server admin on first run
|
||||
AdminPassword string
|
||||
DashboardURL string // optional: overrides the saved setting
|
||||
|
||||
MasterKey string // encrypts secrets in the database (HAMMERHEAD_MASTER_KEY); generated into <data>/master.key if empty
|
||||
MetricsToken string // enables GET /metrics for a bearer token (HAMMERHEAD_METRICS_TOKEN)
|
||||
LogRetentionDays int // access logs are kept this long; 0 = 30
|
||||
AlertInterval time.Duration // how often alerts are evaluated; 0 = 15s
|
||||
AlertDebounce time.Duration // overrides the per-organization delay before alerting (tests)
|
||||
OfflineAfter time.Duration // how long an edge or site may stay silent before it counts as down; 0 = 90s
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
st state
|
||||
opts Options
|
||||
|
||||
status map[string]model.MappingStatus
|
||||
mailStatus map[string]string // mail egress ID -> "" (listening) or an error
|
||||
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
||||
containers map[string][]model.Container // by site ID
|
||||
changed chan struct{}
|
||||
|
||||
tickets map[string]ticket
|
||||
oauth map[string]oauthState
|
||||
otps map[string]otpEntry
|
||||
limits map[string][]time.Time
|
||||
oidc map[string]*oidcDoc
|
||||
|
||||
alerts map[string]*alertState // active problems by key (see alerts.go)
|
||||
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
||||
|
||||
logs *logStore
|
||||
db *DB
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
|
||||
// Mailer sends email; tests replace it.
|
||||
Mailer func(to, subject, body string) error
|
||||
}
|
||||
|
||||
func New(dir string, opts Options) (*Server, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := masterBox(dir, opts.MasterKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := openDB(filepath.Join(dir, "hammerhead.db"), box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
dir: dir,
|
||||
opts: opts,
|
||||
db: db,
|
||||
logs: &logStore{db: db},
|
||||
stop: make(chan struct{}),
|
||||
status: map[string]model.MappingStatus{},
|
||||
mailStatus: map[string]string{},
|
||||
mailChecks: map[string]model.MailCheck{},
|
||||
containers: map[string][]model.Container{},
|
||||
changed: make(chan struct{}),
|
||||
tickets: map[string]ticket{},
|
||||
oauth: map[string]oauthState{},
|
||||
otps: map[string]otpEntry{},
|
||||
limits: map[string][]time.Time{},
|
||||
oidc: map[string]*oidcDoc{},
|
||||
alerts: map[string]*alertState{},
|
||||
mtaSent: map[string]time.Time{},
|
||||
}
|
||||
s.st.Sessions = map[string]*Session{}
|
||||
s.st.Secrets = map[string]*Secret{}
|
||||
s.st.Totals = map[string]*Total{}
|
||||
had, err := db.load(&s.st)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !had {
|
||||
if err := s.migrateLegacy(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if opts.DashboardURL != "" {
|
||||
s.st.Settings.DashboardURL = strings.TrimRight(opts.DashboardURL, "/")
|
||||
}
|
||||
s.backfillTunnelIPs()
|
||||
s.Mailer = s.smtpSend
|
||||
if len(s.st.Users) == 0 && opts.AdminEmail != "" && opts.AdminPassword != "" {
|
||||
if err := s.createFirstAdmin(opts.AdminEmail, "Admin", opts.AdminPassword, "Default"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.save(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.loadAlerts()
|
||||
go s.housekeeping()
|
||||
go s.alertLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// masterBox builds the encryption box for secrets at rest. The key normally
|
||||
// comes from HAMMERHEAD_MASTER_KEY (e.g. in .env). If none is given one is
|
||||
// generated into <data>/master.key so the server works out of the box, but that
|
||||
// file sits next to the database, so a backup of the folder carries both: keep
|
||||
// the key somewhere else for real protection.
|
||||
func masterBox(dir, key string) (*secretbox.Box, error) {
|
||||
if key != "" {
|
||||
return secretbox.New(key)
|
||||
}
|
||||
path := filepath.Join(dir, "master.key")
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
return secretbox.New(string(b))
|
||||
}
|
||||
k := secretbox.GenerateKey()
|
||||
if err := os.WriteFile(path, []byte(k+"\n"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("no HAMMERHEAD_MASTER_KEY set: generated an encryption key in %s. For real protection copy its contents into your .env as HAMMERHEAD_MASTER_KEY=... and delete the file, so a backup of the data folder doesn't carry the key too", path)
|
||||
return secretbox.New(k)
|
||||
}
|
||||
|
||||
// migrateLegacy imports a state.json (and JSONL logs) from before the database existed.
|
||||
func (s *Server) migrateLegacy() error {
|
||||
path := filepath.Join(s.dir, "state.json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil // a fresh install
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.st); err != nil {
|
||||
return fmt.Errorf("import %s: %w", path, err)
|
||||
}
|
||||
if s.st.Sessions == nil {
|
||||
s.st.Sessions = map[string]*Session{}
|
||||
}
|
||||
if s.st.Secrets == nil {
|
||||
s.st.Secrets = map[string]*Secret{}
|
||||
}
|
||||
if s.st.Totals == nil {
|
||||
s.st.Totals = map[string]*Total{}
|
||||
}
|
||||
if err := s.db.persist(&s.st); err != nil {
|
||||
return err
|
||||
}
|
||||
s.importLegacyLogs()
|
||||
os.Rename(path, path+".migrated")
|
||||
log.Printf("imported %s into the database (kept as %s.migrated)", path, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops background work and closes the database.
|
||||
func (s *Server) Close() {
|
||||
s.once.Do(func() {
|
||||
close(s.stop)
|
||||
s.db.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// housekeeping prunes old logs.
|
||||
func (s *Server) housekeeping() {
|
||||
days := s.opts.LogRetentionDays
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
s.db.purge(days, 365)
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save must be called with s.mu held (or before serving).
|
||||
func (s *Server) save() error {
|
||||
err := s.db.persist(&s.st)
|
||||
if err != nil {
|
||||
log.Printf("save: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// bump records a change that agents care about and wakes every long-poller. Caller holds s.mu.
|
||||
func (s *Server) bump() {
|
||||
s.st.Version++
|
||||
s.save()
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
|
||||
func (s *Server) wait(r *http.Request, have int64) bool {
|
||||
s.mu.Lock()
|
||||
if s.st.Version > have {
|
||||
s.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
ch := s.changed
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
case <-time.After(25 * time.Second):
|
||||
return false
|
||||
case <-r.Context().Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- small helpers ----------
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newID() string { return randHex(6) }
|
||||
|
||||
func sha(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, code int, err error) {
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func failf(w http.ResponseWriter, code int, format string, a ...any) {
|
||||
fail(w, code, fmt.Errorf(format, a...))
|
||||
}
|
||||
|
||||
func ok(w http.ResponseWriter) { writeJSON(w, 200, map[string]bool{"ok": true}) }
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
|
||||
fail(w, 400, errors.New("invalid request body"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func filter[T any](in []*T, keep func(*T) bool) []*T {
|
||||
out := in[:0:0]
|
||||
for _, v := range in {
|
||||
if keep(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) dashboardURL() string { return s.st.Settings.DashboardURL }
|
||||
|
||||
// allow is a sliding-window rate limiter. Caller must NOT hold s.mu.
|
||||
func (s *Server) allow(key string, max int, window time.Duration) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
kept := s.limits[key][:0]
|
||||
for _, t := range s.limits[key] {
|
||||
if now.Sub(t) < window {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= max {
|
||||
s.limits[key] = kept
|
||||
return false
|
||||
}
|
||||
s.limits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- lookups (caller holds s.mu) ----------
|
||||
|
||||
func (s *Server) findOrg(id string) *Org {
|
||||
for _, o := range s.st.Orgs {
|
||||
if o.ID == id {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) findRole(id string) *Role {
|
||||
for _, r := range s.st.Roles {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) findUser(id string) *User {
|
||||
for _, u := range s.st.Users {
|
||||
if u.ID == id {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) userByEmail(email string) *User {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
for _, u := range s.st.Users {
|
||||
if strings.ToLower(u.Email) == email {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) findEdge(id string) *model.Edge {
|
||||
for _, e := range s.st.Edges {
|
||||
if e.ID == id {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) findSite(id string) *model.Site {
|
||||
for _, e := range s.st.Sites {
|
||||
if e.ID == id {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) findMapping(id string) *model.Mapping {
|
||||
for _, m := range s.st.Mappings {
|
||||
if m.ID == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) member(userID, orgID string) *Member {
|
||||
for _, m := range s.st.Members {
|
||||
if m.UserID == userID && m.OrgID == orgID {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allocIPLocked picks the next free address in the tunnel network (100.89.0.0/16).
|
||||
// The network only exists inside WireGuard's userspace stack, so it can never
|
||||
// clash with a LAN or VPN on the machines involved. Caller holds s.mu.
|
||||
func (s *Server) allocIPLocked() string {
|
||||
used := map[string]bool{}
|
||||
for _, e := range s.st.Edges {
|
||||
used[e.TunnelIP] = true
|
||||
}
|
||||
for _, st := range s.st.Sites {
|
||||
used[st.TunnelIP] = true
|
||||
}
|
||||
for i := 1; i < 65535; i++ {
|
||||
ip := fmt.Sprintf("100.89.%d.%d", i>>8, i&0xff)
|
||||
if i&0xff != 0 && i&0xff != 255 && !used[ip] {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// backfillTunnelIPs gives addresses to records created before WireGuard existed.
|
||||
func (s *Server) backfillTunnelIPs() {
|
||||
for _, e := range s.st.Edges {
|
||||
if e.TunnelIP == "" {
|
||||
e.TunnelIP = s.allocIPLocked()
|
||||
}
|
||||
}
|
||||
for _, st := range s.st.Sites {
|
||||
if !st.Local && st.TunnelIP == "" {
|
||||
st.TunnelIP = s.allocIPLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) edgeAllowedFor(e *model.Edge, orgID string) bool {
|
||||
if len(e.OrgIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, id := range e.OrgIDs {
|
||||
if id == orgID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+894
@@ -0,0 +1,894 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hammerhead</title>
|
||||
<style>
|
||||
:root { --bg:#0a0a0a; --side:#0f0f10; --panel:#141415; --line:#262628; --text:#fafafa; --dim:#a1a1aa; --muted:#1c1c1e; --accent:#06b6d4; --accent-fg:#fff; --ok:#22c55e; --bad:#ef4444; --warn:#eab308; --shade:rgba(0,0,0,.65); --radius:10px; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#fafafa; --side:#fff; --panel:#fff; --line:#e4e4e7; --text:#18181b; --dim:#71717a; --muted:#f4f4f5; --accent:#0891b2; --shade:rgba(24,24,27,.5); } }
|
||||
* { box-sizing:border-box; }
|
||||
html, body { height:100%; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 Inter,system-ui,-apple-system,Segoe UI,sans-serif; -webkit-font-smoothing:antialiased; }
|
||||
a { color:var(--accent); }
|
||||
.app { display:flex; min-height:100vh; }
|
||||
.side { width:248px; flex:none; background:var(--side); border-right:1px solid var(--line); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:14px 12px; gap:4px; overflow-y:auto; }
|
||||
.brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; }
|
||||
.brand svg { color:var(--accent); }
|
||||
.orgsel { margin:0 0 10px; }
|
||||
.orgsel select { background:var(--muted); font-weight:600; }
|
||||
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; }
|
||||
.navitem { display:flex; align-items:center; gap:10px; width:100%; background:none; border:0; color:var(--dim); padding:8px 10px; border-radius:8px; cursor:pointer; font:inherit; font-weight:500; text-align:left; }
|
||||
.navitem:hover { background:var(--muted); color:var(--text); }
|
||||
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
|
||||
.navitem svg { flex:none; }
|
||||
.grow { flex:1; }
|
||||
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; }
|
||||
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.content { flex:1; min-width:0; }
|
||||
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; }
|
||||
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:22px; flex-wrap:wrap; }
|
||||
.page-h h1 { font-size:24px; font-weight:700; margin:0; letter-spacing:-.01em; }
|
||||
.page-h p { margin:4px 0 0; color:var(--dim); }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:18px; box-shadow:0 1px 2px rgba(0,0,0,.18); }
|
||||
.card.flush { padding:0; overflow:hidden; }
|
||||
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; }
|
||||
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
|
||||
.row-h h2 { display:none; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; align-items:end; }
|
||||
.stack { display:flex; flex-direction:column; gap:12px; }
|
||||
label { display:flex; flex-direction:column; gap:5px; font-size:12.5px; font-weight:500; color:var(--dim); }
|
||||
label.chk { flex-direction:row; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:400; }
|
||||
input, select, textarea { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; min-width:0; width:100%; }
|
||||
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); }
|
||||
input:focus, select:focus, textarea:focus { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:0; border-color:var(--accent); }
|
||||
input:disabled { opacity:.6; }
|
||||
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; }
|
||||
button.btn:hover { filter:brightness(1.08); }
|
||||
button.ghost { background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:5px 11px; cursor:pointer; font:inherit; font-weight:500; }
|
||||
button.ghost:hover { background:var(--muted); }
|
||||
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { text-align:left; font-size:12px; color:var(--dim); font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; background:color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); }
|
||||
tr:last-child td { border-bottom:0; }
|
||||
.card > table, .card > .scroll > table { margin:-4px 0; }
|
||||
.tag { display:inline-flex; align-items:center; padding:1px 8px; border-radius:6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; background:var(--muted); border:1px solid var(--line); color:var(--dim); }
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--dim); margin-right:7px; flex:none; }
|
||||
.dot.ok { background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } .dot.bad { background:var(--bad); } .dot.warn { background:var(--warn); }
|
||||
code, .mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12.5px; }
|
||||
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:12px; overflow:auto; margin:8px 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
||||
.dim { color:var(--dim); } .err { color:var(--bad); } .small { font-size:12px; }
|
||||
.empty { color:var(--dim); padding:28px 12px; text-align:center; }
|
||||
.scroll { overflow-x:auto; }
|
||||
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
|
||||
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; }
|
||||
.banner { background:color-mix(in srgb, var(--warn) 12%, transparent); border:1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius:var(--radius); padding:12px 16px; margin-bottom:18px; }
|
||||
.shade { position:fixed; inset:0; background:var(--shade); backdrop-filter:blur(2px); display:flex; align-items:flex-start; justify-content:center; padding:4vh 12px; overflow:auto; z-index:10; }
|
||||
.modal { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px; width:100%; max-width:780px; box-shadow:0 20px 60px rgba(0,0,0,.45); }
|
||||
.modal h2 { margin:0 0 16px; font-size:18px; }
|
||||
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; }
|
||||
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; }
|
||||
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; }
|
||||
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; }
|
||||
.sub { color:var(--dim); font-size:12px; font-weight:400; }
|
||||
.tabs2 { display:flex; gap:4px; margin-bottom:16px; background:var(--muted); padding:4px; border-radius:10px; width:fit-content; max-width:100%; flex-wrap:wrap; }
|
||||
.tabs2 button { background:none; border:0; color:var(--dim); border-radius:7px; padding:5px 14px; cursor:pointer; font:inherit; font-weight:500; }
|
||||
.tabs2 button.on { background:var(--panel); color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,.25); }
|
||||
#toast { position:fixed; bottom:20px; right:20px; background:var(--panel); border:1px solid var(--line); padding:11px 16px; border-radius:10px; z-index:30; display:none; max-width:min(420px,90vw); box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
||||
#toast.bad { border-color:var(--bad); color:var(--bad); }
|
||||
@media (max-width:860px) {
|
||||
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; }
|
||||
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
|
||||
main { padding:18px 14px 60px; } .tgt { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
// ---------- tiny toolkit ----------
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (v === true) el.setAttribute(k, '');
|
||||
else if (v !== false && v != null) el.setAttribute(k, v);
|
||||
}
|
||||
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
return el;
|
||||
}
|
||||
const $app = document.getElementById('app');
|
||||
let toastTimer;
|
||||
function toast(msg, bad) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.className = bad ? 'bad' : ''; t.style.display = 'block';
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.display = 'none', bad ? 6000 : 2500);
|
||||
}
|
||||
async function api(method, url, body) {
|
||||
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401 && url !== '/api/login' && !url.startsWith('/api/login/') && url !== '/api/me' && url !== '/api/public') {
|
||||
// the session ended while we were signed in: go back to the login page once.
|
||||
// (Not for /api/me or /api/public: boot() calls those, and reacting to them would loop forever.)
|
||||
if (S.me) { S.me = null; boot(); }
|
||||
throw new Error('Please sign in');
|
||||
}
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
}
|
||||
// run an action; show errors as a toast instead of throwing into the void
|
||||
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } };
|
||||
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
|
||||
const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Date.now() - new Date(d)) / 1000; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
|
||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
|
||||
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
|
||||
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
|
||||
const cmdBox = t => h('pre', {class:'mono'}, t);
|
||||
function modal(title, body, opts = {}) {
|
||||
const shade = h('div', {class:'shade'});
|
||||
const close = () => shade.remove();
|
||||
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
|
||||
shade.append(h('div', {class:'modal'}, h('h2', {}, title), body));
|
||||
document.body.append(shade);
|
||||
return close;
|
||||
}
|
||||
const modalOpen = () => !!document.querySelector('.shade');
|
||||
|
||||
// ---------- icons (lucide-style, static strings) ----------
|
||||
const ICONS = {
|
||||
fin:'<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
||||
mine:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
ports:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
sites:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
|
||||
domains:'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
access:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
logs:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8"/>',
|
||||
edges:'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
|
||||
orgs:'<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01"/>',
|
||||
users:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
idps:'<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
settings:'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
audit:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
account:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
egress:'<path d="M7 17 17 7M8 7h9v9"/>',
|
||||
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
||||
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
||||
};
|
||||
function icon(name, size = 18) {
|
||||
const s = document.createElement('span');
|
||||
s.style.display = 'inline-flex';
|
||||
s.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
||||
return s;
|
||||
}
|
||||
const brand = () => h('div', {class:'brand'}, icon('fin', 24), 'Hammerhead');
|
||||
|
||||
// ---------- state ----------
|
||||
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null };
|
||||
const isAdmin = () => S.me && (S.me.serverAdmin || (S.me.orgs.find(o => o.id === S.org) || {}).admin);
|
||||
|
||||
async function boot() {
|
||||
try { S.pub = await api('GET', '/api/public'); } catch (e) { $app.replaceChildren(card('Cannot reach the server', e.message)); return; }
|
||||
const inv = (location.hash.match(/^#invite=(.+)$/) || [])[1];
|
||||
if (S.pub.needsSetup) return $app.replaceChildren(setupView());
|
||||
try { S.me = await api('GET', '/api/me'); } catch { S.me = null; }
|
||||
if (inv) return $app.replaceChildren(await inviteView(inv));
|
||||
if (!S.me) return $app.replaceChildren(loginView());
|
||||
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
|
||||
if (S.org) localStorage.setItem('org', S.org);
|
||||
await render();
|
||||
}
|
||||
|
||||
// ---------- first run, login, invites ----------
|
||||
function setupView() {
|
||||
const f = { org: h('input', {value:'Default'}), name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
|
||||
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
|
||||
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password (8+ characters)', f.pw),
|
||||
h('button', {class:'btn', onclick: act(async () => {
|
||||
await api('POST', '/api/setup', {orgName:f.org.value, name:f.name.value, email:f.email.value, password:f.pw.value});
|
||||
await api('POST', '/api/login', {email:f.email.value, password:f.pw.value}); boot();
|
||||
})}, 'Create account'))));
|
||||
}
|
||||
|
||||
function loginView() {
|
||||
const email = h('input', {type:'email', autocomplete:'username'}), pw = h('input', {type:'password', autocomplete:'current-password'});
|
||||
const code = h('input', {placeholder:'6-digit or backup code', autocomplete:'one-time-code'}); let ticket = null;
|
||||
const codeBox = h('div', {class:'stack', style:'display:none'}, field('Authenticator code', code));
|
||||
const go = act(async () => {
|
||||
const j = ticket ? await api('POST', '/api/login/totp', {ticket, code:code.value}) : await api('POST', '/api/login', {email:email.value, password:pw.value});
|
||||
if (j.needTotp) { ticket = j.ticket; codeBox.style.display = ''; pw.parentElement.style.display = email.parentElement.style.display = 'none'; code.focus(); return; }
|
||||
boot();
|
||||
});
|
||||
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
||||
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Sign in'), h('div', {class:'stack'},
|
||||
field('Email', email), field('Password', pw), codeBox, h('button', {class:'btn', onclick:go}, 'Sign in'),
|
||||
(S.pub.providers || []).map(p => h('a', {href:'/auth/oidc/' + p.id + '/start?return=/'}, h('button', {class:'ghost', style:'width:100%;padding:8px'}, 'Continue with ' + p.name))))));
|
||||
}
|
||||
|
||||
async function inviteView(tok) {
|
||||
let info;
|
||||
try { info = await api('GET', '/api/invites/' + tok); } catch (e) { return h('div', {class:'center'}, card('Invitation', e.message)); }
|
||||
const name = h('input'), pw = h('input', {type:'password'});
|
||||
const accept = act(async () => { await api('POST', '/api/invites/accept', {token:tok, name:name.value, password:pw.value}); location.hash = ''; boot(); });
|
||||
if (info.existing) return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, `${info.email} already has an account.`),
|
||||
S.me && S.me.email === info.email ? h('button', {class:'btn', onclick:accept}, 'Join ' + info.org) : h('p', {}, 'Sign in as that account first, then open this link again.'),
|
||||
S.me ? null : h('button', {class:'ghost', onclick:() => { location.hash = ''; boot(); }}, 'Go to sign in')));
|
||||
return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, 'Create your account for ' + info.email + '.'),
|
||||
h('div', {class:'stack'}, field('Your name', name), field('Password (8+ characters)', pw), h('button', {class:'btn', onclick:accept}, 'Create account'))));
|
||||
}
|
||||
|
||||
// ---------- shell ----------
|
||||
async function loadOrg() { S.st = S.org ? await api('GET', `/api/orgs/${S.org}/state`) : null; }
|
||||
const O = p => `/api/orgs/${S.org}${p}`;
|
||||
|
||||
const PAGES = {
|
||||
mine: ['My apps', 'Apps you have been given access to.'],
|
||||
ports: ['Resources', 'Open ports on your edge IPs and send them to your services, with an optional login in front.'],
|
||||
sites: ['Sites', 'Private networks (or the edge machine itself) that hold your services.'],
|
||||
domains: ['Domains', 'Domains you own. Web hostnames must live under one of them.'],
|
||||
access: ['Access control', 'Members, roles, invitations and API keys for this organization.'],
|
||||
egress: ['Mail egress', 'Send your mail server\'s outgoing mail from an edge\'s public IP, so reverse DNS, SPF and your HELO name all line up.'],
|
||||
alerts: ['Alerts', 'Get told by email or webhook when something stops working, and when it recovers.'],
|
||||
srvalerts:['Server alerts', 'Where to send alerts about edges, which belong to the whole server.'],
|
||||
logs: ['Logs', 'Who reached what, and what changed.'],
|
||||
account: ['Account', 'Your profile, password and two-factor authentication.'],
|
||||
edges: ['Edges', 'Public servers running Blacktip that own your IP addresses.'],
|
||||
srvorgs: ['Organizations', 'Separate workspaces on this server.'],
|
||||
users: ['Users', 'Everyone with an account on this server.'],
|
||||
idps: ['Single sign-on', 'Sign in with an external OpenID Connect provider.'],
|
||||
settings:['Settings', 'Dashboard URL, certificates and email.'],
|
||||
srvaudit:['Audit log', 'Server-level activity.'],
|
||||
};
|
||||
// server pages are top-level sidebar entries that map onto serverViews
|
||||
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
|
||||
|
||||
async function render() {
|
||||
const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label);
|
||||
const side = [h('div', {}, brand())];
|
||||
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
|
||||
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
|
||||
const all = [];
|
||||
if (S.org) {
|
||||
side.push(h('div', {class:'navsec'}, 'Organization'), nav('mine', 'My apps', 'mine'));
|
||||
all.push('mine');
|
||||
if (isAdmin()) {
|
||||
side.push(nav('ports', 'Resources', 'ports'), nav('sites', 'Sites', 'sites'), nav('domains', 'Domains', 'domains'), nav('egress', 'Mail egress', 'egress'), nav('alerts', 'Alerts', 'alerts'), nav('access', 'Access control', 'access'), nav('logs', 'Logs', 'logs'));
|
||||
all.push('ports', 'sites', 'domains', 'egress', 'alerts', 'access', 'logs');
|
||||
}
|
||||
}
|
||||
if (S.me.serverAdmin) {
|
||||
side.push(h('div', {class:'navsec'}, 'Server'), nav('edges', 'Edges', 'edges'), nav('srvorgs', 'Organizations', 'orgs'), nav('users', 'Users', 'users'), nav('idps', 'Single sign-on', 'idps'), nav('settings', 'Settings', 'settings'), nav('srvalerts', 'Alerts', 'alerts'), nav('srvaudit', 'Audit log', 'audit'));
|
||||
all.push(...Object.keys(SRV));
|
||||
}
|
||||
all.push('account');
|
||||
if (!all.includes(S.tab)) S.tab = all[0];
|
||||
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
||||
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
||||
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function pageHeader(tab) {
|
||||
const [t, d] = PAGES[tab] || ['', ''];
|
||||
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null));
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function refresh() {
|
||||
const my = ++seq;
|
||||
try {
|
||||
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
|
||||
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
|
||||
if (my !== seq) return;
|
||||
const v = document.getElementById('view');
|
||||
if (v) v.replaceChildren(pageHeader(S.tab), view);
|
||||
} catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
|
||||
}
|
||||
setInterval(() => {
|
||||
if (!S.me || document.hidden || modalOpen()) return;
|
||||
const a = document.activeElement;
|
||||
if (a && ['INPUT', 'SELECT', 'TEXTAREA'].includes(a.tagName)) return;
|
||||
if (['ports', 'sites', 'logs', 'mine', 'alerts', 'egress'].includes(S.tab) && !(S.tab === 'logs' && logPaged)) refresh();
|
||||
}, 4000);
|
||||
|
||||
const dashURL = () => (S.st && S.st.dashboardUrl) || location.origin;
|
||||
|
||||
// ---------- views ----------
|
||||
const views = {};
|
||||
|
||||
views.mine = async () => {
|
||||
const list = await api('GET', '/api/me/resources');
|
||||
return card('Apps you can open', list.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Organization', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, list.map(r => h('tr', {}, h('td', {}, r.name), h('td', {class:'dim'}, r.org), h('td', {}, h('a', {href:r.url, target:'_blank', rel:'noopener'}, h('button', {class:'ghost'}, 'Open'))))))) :
|
||||
h('div', {class:'empty'}, 'Nothing here yet. Ask an administrator to give you access to an app.'));
|
||||
};
|
||||
|
||||
// ----- ports -----
|
||||
const PROTOS = [['tcp','TCP'],['udp','UDP'],['both','TCP + UDP'],['http','HTTP (by hostname)'],['https','HTTPS (automatic certificate)'],['tls','TLS passthrough (by SNI)']];
|
||||
|
||||
views.ports = async () => {
|
||||
const st = S.st;
|
||||
if (!st.edges.length) return card('No edges yet', h('p', {class:'dim'}, S.me.serverAdmin ? 'Add an edge (Blacktip) on the Server tab first: it is the public server that owns your IP addresses.' : 'Ask a server administrator to add an edge for your organization.'));
|
||||
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
|
||||
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
|
||||
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
|
||||
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
||||
h('div', {class:'card flush'}, table));
|
||||
};
|
||||
|
||||
function spark(series) {
|
||||
if (!series || !series.some(v => v > 0)) return null;
|
||||
const w = 84, hgt = 22, max = Math.max(...series, 1), ns = 'http://www.w3.org/2000/svg';
|
||||
const pts = series.map((v, i) => `${(i / (series.length - 1) * w).toFixed(1)},${(hgt - 2 - v / max * (hgt - 4)).toFixed(1)}`).join(' ');
|
||||
const svg = document.createElementNS(ns, 'svg'), pl = document.createElementNS(ns, 'polyline');
|
||||
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`);
|
||||
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'var(--accent)'); pl.setAttribute('stroke-width', '1.5');
|
||||
svg.append(pl);
|
||||
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
|
||||
}
|
||||
|
||||
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
|
||||
|
||||
function mappingRow(m, st, series) {
|
||||
const edge = byId(st.edges, m.edgeId), s = m.status || {};
|
||||
let dot = 'dot', why = 'paused';
|
||||
if (m.enabled) {
|
||||
const th = s.targets || [];
|
||||
if (!m.statusKnown) { dot += ' warn'; why = 'waiting for the edge'; }
|
||||
else if (s.error) { dot += ' bad'; why = s.error; }
|
||||
else if (th.length && th.every(t => !t.healthy)) { dot += ' bad'; why = 'no healthy target'; }
|
||||
else if (th.some(t => !t.healthy)) { dot += ' warn'; why = 'some targets are down'; }
|
||||
else { dot += ' ok'; why = 'listening'; }
|
||||
}
|
||||
const web = m.proto === 'http' || m.proto === 'https';
|
||||
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
|
||||
const pubAddr = (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, h('span', {class:dot})),
|
||||
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
|
||||
s.error ? h('div', {class:'err small mono'}, s.error) : null,
|
||||
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
|
||||
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, edge ? edge.name : '?')),
|
||||
h('td', {class:'mono small'}, m.targets.map((t, i) => {
|
||||
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
||||
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
|
||||
})),
|
||||
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `↑${bytes(m.total.bytesIn)} ↓${bytes(m.total.bytesOut)}`] : '—', spark(series)),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
|
||||
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
|
||||
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: act(async () => {
|
||||
if (confirm(`Sign everyone out of ${m.name}?`)) { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); }
|
||||
})}, 'Sign out visitors') : null,
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Close ${m.name}?`)) { await api('DELETE', O('/mappings/' + m.id)); refresh(); } })}, 'Close'))));
|
||||
}
|
||||
|
||||
function portForm(existing) {
|
||||
const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] };
|
||||
const a = m.auth || {};
|
||||
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)'))));
|
||||
const ipIn = h('input', {list:'ips', value:m.bindIp}), ipList = h('datalist', {id:'ips'});
|
||||
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
|
||||
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||
const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first');
|
||||
const proxySel = h('select', {}, [['', 'Off'], ['v2', 'PROXY protocol v2 (recommended)'], ['v1', 'PROXY protocol v1']].map(([v, t]) => h('option', {value:v, selected:v === (m.proxyProtocol || '')}, t)));
|
||||
const proxyBox = field('Tell the target who the real client is', proxySel, 'Without this the target sees every visitor as coming from the tunnel. Turn it on only if your server expects it: Velocity/BungeeCord haproxy-protocol, Postfix postscreen_upstream_proxy_protocol = haproxy, Dovecot haproxy_trusted_networks, nginx proxy_protocol.');
|
||||
const tgtBox = h('div', {class:'stack'});
|
||||
let targets = m.targets.map(t => ({...t}));
|
||||
const roleChecks = st.roles.map(r => ({r, c:chk(r.name + (r.admin ? ' (admin)' : ''), (a.allowedRoles || []).includes(r.id))}));
|
||||
const pwIn = h('input', {type:'password', placeholder:existing && a.password ? 'set: leave blank to keep' : 'shared password', autocomplete:'new-password'});
|
||||
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4-12 digits', inputmode:'numeric', autocomplete:'off'});
|
||||
const ssoC = chk('Sign in with a Hammerhead account', a.sso), pwC = chk('Shared password', a.password), pinC = chk('PIN', a.pin), mailC = chk('Emailed one-time code', a.email);
|
||||
const mails = h('input', {value:(a.allowedMails || []).join(', '), placeholder:'a@x.com, *@company.com'});
|
||||
const hours = h('input', {type:'number', min:1, max:2160, value:a.sessionHours || 24});
|
||||
const ruleBox = h('div', {class:'stack'}); let rules = (m.rules || []).map(r => ({...r}));
|
||||
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz (blank = TCP connect check)'});
|
||||
const healthSec = h('input', {type:'number', value:m.healthSeconds > 0 ? m.healthSeconds : 30, min:5});
|
||||
const healthOff = chk('Turn health checks off', m.healthSeconds === -1);
|
||||
const redir = chk('Also answer on port 80 with a redirect to HTTPS', m.redirectHttp), bhttps = chk('Talk HTTPS to the targets (certificate not verified)', m.backendHttps);
|
||||
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'blank = keep the visitor\'s Host'});
|
||||
const webBox = h('div', {class:'stack'});
|
||||
const hostLbl = hostBox;
|
||||
|
||||
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipList.replaceChildren(...['0.0.0.0', ...((e && e.ips) || [])].map(ip => h('option', {value:ip}))); };
|
||||
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
|
||||
const drawTargets = () => {
|
||||
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
|
||||
tgtBox.replaceChildren(...targets.map((t, i) => {
|
||||
const site = h('select', {onchange:e => { t.siteId = e.target.value; const s = byId(st.sites, t.siteId); if (s && s.local && !t.host) t.host = '127.0.0.1'; }},
|
||||
h('option', {value:'', disabled:true, selected:!t.siteId}, 'Choose a site…'), siteOpts().map(s => h('option', {value:s.id, selected:s.id === t.siteId}, s.name)));
|
||||
const th = h('input', {value:t.host, oninput:e => t.host = e.target.value, placeholder:'127.0.0.1 or 192.168.1.20'});
|
||||
const tp = h('input', {type:'number', value:t.port, placeholder:'port', oninput:e => t.port = +e.target.value});
|
||||
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
|
||||
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, '🐳'),
|
||||
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', onclick:() => { targets.splice(i, 1); drawTargets(); }}, '✕') : h('span'));
|
||||
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, '+ Add another target (load balance / failover)') : null);
|
||||
};
|
||||
const discover = act(async (t, th, tp) => {
|
||||
if (!t.siteId) throw new Error('Choose a site first');
|
||||
const list = await api('GET', O(`/sites/${t.siteId}/containers`));
|
||||
if (!list.length) throw new Error('No containers reported. Run the agent with --docker (and make sure Docker is running).');
|
||||
const opts = [];
|
||||
for (const c of list) for (const p of c.ports) {
|
||||
if (p.public) opts.push({label:`${c.name} 127.0.0.1:${p.public} (published)`, host:'127.0.0.1', port:p.public});
|
||||
for (const [n, ip] of Object.entries(c.networks)) opts.push({label:`${c.name} ${ip}:${p.private} (${n} network)`, host:ip, port:p.private});
|
||||
}
|
||||
const sel = h('select', {size:Math.min(12, opts.length + 1)}, opts.map((o, i) => h('option', {value:i}, o.label)));
|
||||
const close = modal('Pick a container', h('div', {class:'stack'}, sel, h('button', {class:'btn', onclick:() => { const o = opts[sel.value]; if (!o) return; t.host = th.value = o.host; t.port = o.port; tp.value = o.port; close(); }}, 'Use this')));
|
||||
});
|
||||
const drawRules = () => ruleBox.replaceChildren(...rules.map((r, i) => h('div', {class:'grid', style:'grid-template-columns:110px 110px 1fr auto'},
|
||||
h('select', {onchange:e => r.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
|
||||
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
|
||||
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
|
||||
h('button', {class:'ghost danger', type:'button', onclick:() => { rules.splice(i, 1); drawRules(); }}, '✕'))),
|
||||
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, '+ Add a rule'));
|
||||
const drawWeb = () => {
|
||||
const p = protoSel.value, web = p === 'http' || p === 'https';
|
||||
hostLbl.style.display = ['http', 'https', 'tls'].includes(p) ? '' : 'none';
|
||||
webBox.style.display = web ? '' : 'none';
|
||||
redir.style.display = p === 'https' ? '' : 'none';
|
||||
proxyBox.style.display = ['tcp', 'both', 'tls'].includes(p) ? '' : 'none';
|
||||
drawTargets();
|
||||
};
|
||||
webBox.append(
|
||||
h('fieldset', {}, h('legend', {}, 'Who can open this?'), h('div', {class:'stack'},
|
||||
h('div', {class:'sub'}, 'Leave everything off for a public site. Turn on one or more methods to put a login in front.'),
|
||||
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles (none ticked = every member of the organization):'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
|
||||
pwC, field('Password', pwIn), pinC, field('PIN', pinIn), mailC, field('Allowed email addresses', mails, 'Needs email set up under Server → Settings'),
|
||||
field('Stay signed in for (hours)', hours))),
|
||||
h('fieldset', {}, h('legend', {}, 'Rules (checked before login, first match wins)'), ruleBox),
|
||||
h('fieldset', {}, h('legend', {}, 'Advanced'), h('div', {class:'stack'}, redir, bhttps, field('Host header sent to targets', rewrite))));
|
||||
const adv = h('fieldset', {}, h('legend', {}, 'Health checks'), h('div', {class:'grid'}, field('Check path (web ports)', healthPath), field('Every (seconds)', healthSec), healthOff));
|
||||
|
||||
edgeSel.onchange = () => { refreshIPs(); drawTargets(); }; protoSel.onchange = drawWeb; refreshIPs(); drawRules(); drawWeb();
|
||||
const save = act(async () => {
|
||||
const pr = (port.value || '').trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
|
||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
||||
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||
const base = {
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value.trim() || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
||||
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
|
||||
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||
redirectHttp:redir.box.checked, backendHttps:bhttps.box.checked, rewriteHost:rewrite.value, healthPath:healthPath.value,
|
||||
healthSeconds:healthOff.box.checked ? -1 : (+healthSec.value || 0),
|
||||
};
|
||||
if (pwIn.value) base.newPassword = pwIn.value; if (pinIn.value) base.newPin = pinIn.value;
|
||||
if (!pwC.box.checked) base.newPassword = ''; if (!pinC.box.checked) base.newPin = '';
|
||||
const protos = protoSel.value === 'both' ? ['tcp', 'udp'] : [protoSel.value];
|
||||
if (existing) await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, proxyProtocol:['tcp', 'tls'].includes(m.proto) ? proxySel.value : ''});
|
||||
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
|
||||
close(); toast('Saved'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit port' : 'Open a port', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
|
||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
async function shareDialog(m) {
|
||||
const body = h('div', {class:'stack'});
|
||||
const draw = async () => {
|
||||
const links = await api('GET', O(`/mappings/${m.id}/shares`));
|
||||
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'hours (blank = no expiry)'}), uses = h('input', {type:'number', placeholder:'max uses (blank = unlimited)'});
|
||||
const out = h('div');
|
||||
body.replaceChildren(
|
||||
h('p', {class:'dim'}, 'A share link lets someone in without an account or password. Anyone with the link gets access, so keep it private.'),
|
||||
h('div', {class:'grid'}, field('Label', name), field('Expires after', hrs), field('Max uses', uses), h('button', {class:'btn', onclick: act(async () => {
|
||||
const r = await api('POST', O(`/mappings/${m.id}/shares`), {name:name.value, hours:+hrs.value || 0, maxUses:+uses.value || 0});
|
||||
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
|
||||
)}, 'Create link')), out,
|
||||
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || '—'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
|
||||
};
|
||||
await draw(); modal('Share links for ' + m.name, body);
|
||||
}
|
||||
|
||||
// ----- sites -----
|
||||
views.sites = async () => {
|
||||
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
|
||||
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
|
||||
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
|
||||
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
|
||||
return h('div', {},
|
||||
card('Add a site', h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'),
|
||||
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
|
||||
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
||||
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
|
||||
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
|
||||
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
||||
const l = await api('GET', O(`/sites/${s.id}/containers`));
|
||||
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
|
||||
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
|
||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${s.name} and every port that uses only it?`)) { await api('DELETE', O('/sites/' + s.id)); refresh(); } })}, 'Delete')))))))));
|
||||
};
|
||||
|
||||
// ----- domains -----
|
||||
views.domains = async () => {
|
||||
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
|
||||
return h('div', {},
|
||||
card('Add a domain', h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
|
||||
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
|
||||
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
|
||||
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `✓ Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : h('div', {class:'empty'}, 'None yet.')));
|
||||
};
|
||||
|
||||
|
||||
// ----- mail egress -----
|
||||
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
||||
|
||||
function checklist(e) {
|
||||
const c = e.check, ip = e.sourceIp, helo = e.heloName;
|
||||
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||
h('span', {class:'dot ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), style:'margin-top:6px'}),
|
||||
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
|
||||
const body = h('div', {},
|
||||
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
|
||||
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
|
||||
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
|
||||
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
|
||||
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
|
||||
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
|
||||
c.port25 === 'open' ? null : c.port25) : null,
|
||||
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
|
||||
cmdBox(spfFor(ip)),
|
||||
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
|
||||
return body;
|
||||
}
|
||||
|
||||
views.egress = async () => {
|
||||
const st = S.st;
|
||||
const remote = st.sites.filter(s => !s.local);
|
||||
const rows = st.mailEgress || [];
|
||||
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, rows.map(e => {
|
||||
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check;
|
||||
let dot = 'dot', why = 'paused';
|
||||
if (e.enabled) {
|
||||
if (!e.statusKnown) { dot += ' warn'; why = 'waiting for the site'; }
|
||||
else if (e.status) { dot += ' bad'; why = e.status; }
|
||||
else { dot += ' ok'; why = 'listening'; }
|
||||
}
|
||||
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, h('span', {class:dot})),
|
||||
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
||||
e.username ? h('span', {class:'tag', style:'margin-left:6px'}, '🔒') : null, e.requireTls ? h('span', {class:'tag', style:'margin-left:4px'}, 'tls required') : null),
|
||||
h('td', {class:'mono'}, e.listen, h('div', {class:'dim small'}, site ? site.name : '?')),
|
||||
h('td', {class:'mono'}, e.sourceIp, h('div', {class:'dim small'}, e.heloName + (e.smarthost ? ' → ' + e.smarthost : ' → recipient MX'))),
|
||||
h('td', {}, h('button', {class:'ghost', onclick:() => modal('Deliverability for ' + e.sourceIp, checklist(e))},
|
||||
!c ? '…' : good ? '✓ looks good' : '⚠ needs attention')),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
|
||||
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
|
||||
return h('div', {},
|
||||
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')),
|
||||
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
||||
h('div', {class:'card flush'}, table),
|
||||
await dkimCard(),
|
||||
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
|
||||
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
|
||||
};
|
||||
|
||||
|
||||
async function dkimCard() {
|
||||
const keys = await api('GET', O('/dkim'));
|
||||
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'});
|
||||
const chip = k => ({published:['ok', '✓ published'], missing:['warn', 'not in DNS yet'], mismatch:['bad', 'DNS has a different key'], unknown:['', 'waiting for a check']}[k.status] || ['', k.status]);
|
||||
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
|
||||
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
|
||||
return card('DKIM signing', h('p', {class:'dim'}, 'The edge signs your outgoing mail so receiving servers can prove it came from you. Make a key for each domain you send from, then publish its record. If your mail server already signs, it is left alone.'),
|
||||
keys.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Selector', 'DNS', ''].map(x => h('th', {}, x)))), h('tbody', {}, keys.map(k => h('tr', {},
|
||||
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel),
|
||||
h('button', {class:'btn', onclick: act(async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, 'Create key')));
|
||||
}
|
||||
|
||||
function mailForm(existing) {
|
||||
const st = S.st, e = existing || {enabled:true, listen:'', ratePerMinute:60, requireTls:false};
|
||||
const sites = st.sites.filter(s => !s.local);
|
||||
const siteSel = h('select', {}, sites.map(s => h('option', {value:s.id, selected:s.id === e.siteId}, s.name)));
|
||||
const edgeSel = h('select', {}, st.edges.map(x => h('option', {value:x.id, selected:x.id === e.edgeId}, x.name + (x.online ? '' : ' (offline)'))));
|
||||
const name = h('input', {value:e.name || '', placeholder:'e.g. Mail out'});
|
||||
const src = h('input', {list:'srcips', value:e.sourceIp || '', placeholder:'an IP address of the edge'});
|
||||
const ipList = h('datalist', {id:'srcips'});
|
||||
const helo = h('input', {value:e.heloName || '', placeholder:'mail.example.com'});
|
||||
const listen = h('input', {value:e.listen || '127.0.0.1:2525'});
|
||||
const smart = h('input', {value:e.smarthost || '', placeholder:'blank = deliver to each recipient\'s own server'});
|
||||
const user = h('input', {value:e.username || '', autocomplete:'off', placeholder:'needed if you listen beyond 127.0.0.1'});
|
||||
const pass = h('input', {type:'password', autocomplete:'new-password', placeholder:e.passwordSet ? 'unchanged' : ''});
|
||||
const rate = h('input', {type:'number', min:1, max:6000, value:e.ratePerMinute || 60});
|
||||
const tls = chk('Require verified TLS (refuse to send to servers without it)', e.requireTls);
|
||||
const refreshIPs = () => { const x = byId(st.edges, edgeSel.value); ipList.replaceChildren(...((x && x.ips) || []).map(ip => h('option', {value:ip}))); };
|
||||
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', O('/mail-egress' + (existing ? '/' + existing.id : '')), {name:name.value, siteId:siteSel.value, edgeId:edgeSel.value, sourceIp:src.value,
|
||||
heloName:helo.value, listen:listen.value, smarthost:smart.value, requireTls:tls.box.checked, ratePerMinute:+rate.value || 0, username:user.value, password:pass.value, enabled:e.enabled !== false});
|
||||
close(); toast('Saved. Check the deliverability column in a moment.'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit mail egress' : 'Add mail egress', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Site (your mail server\'s network)', siteSel), field('Edge (where mail leaves)', edgeSel)),
|
||||
h('div', {class:'grid'}, field('Send from this edge IP', src, 'Its reverse DNS should be your HELO name'), field('HELO hostname', helo, 'What your server announces, e.g. mail.example.com')),
|
||||
ipList,
|
||||
h('div', {class:'grid'}, field('Relay listens on (on the site)', listen, '127.0.0.1 keeps it private to that machine'), field('Messages per minute (limit)', rate, 'Caps what a compromised server could send from your IP')),
|
||||
h('div', {class:'grid'}, field('Username', user), field('Password', pass)),
|
||||
field('Smarthost (optional)', smart, 'Use this if you relay through a provider that allows your edge IP. Leave blank for direct delivery.'), tls,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
|
||||
// ----- alerts -----
|
||||
const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], ['ntfy', 'ntfy']];
|
||||
|
||||
function channelForm(base, existing, trusted) {
|
||||
const c = existing || {kind:'email', format:'json', enabled:true};
|
||||
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
|
||||
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
|
||||
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
|
||||
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
|
||||
const en = chk('Enabled', c.enabled !== false);
|
||||
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
|
||||
const targetBox = field('Where', target);
|
||||
const redraw = () => {
|
||||
const w = kind.value === 'webhook';
|
||||
fmtBox.style.display = w ? '' : 'none';
|
||||
priv.style.display = w && trusted ? '' : 'none';
|
||||
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
};
|
||||
kind.onchange = redraw; redraw();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
close(); toast('Saved'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, 'Save'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
async function alertsView(base, scoped, trusted) {
|
||||
const d = await api('GET', base);
|
||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
||||
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
|
||||
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
|
||||
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
||||
return h('div', {},
|
||||
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
||||
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
||||
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
|
||||
card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')),
|
||||
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
|
||||
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
||||
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Saved'); })}, 'Save'))) : null,
|
||||
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, new Date(e.time).toLocaleString()),
|
||||
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : h('div', {class:'empty'}, 'Nothing has fired yet.')));
|
||||
}
|
||||
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
|
||||
|
||||
// ----- access -----
|
||||
views.access = async () => {
|
||||
const [members, invites, keys] = await Promise.all([api('GET', O('/members')), api('GET', O('/invites')), api('GET', O('/apikeys'))]);
|
||||
const roles = S.st.roles;
|
||||
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
|
||||
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
|
||||
const kname = h('input', {placeholder:'e.g. CI deploy'});
|
||||
return h('div', {},
|
||||
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
|
||||
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
|
||||
h('td', {}, mm.totp ? '✓' : '—'),
|
||||
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
|
||||
card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(async () => {
|
||||
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
|
||||
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })}, 'Create invitation')),
|
||||
invites.length ? h('table', {style:'margin-top:12px'}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null),
|
||||
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
|
||||
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
|
||||
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
|
||||
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
|
||||
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'btn', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
|
||||
};
|
||||
|
||||
// ----- logs -----
|
||||
let logKind = 'access', logMap = '', logQ = '', logStatus = '', logResult = '', logPaged = false;
|
||||
views.logs = async () => {
|
||||
const st = S.st;
|
||||
const params = extra => new URLSearchParams({kind:logKind, limit:200, mapping:logMap, q:logQ, status:logStatus, result:logResult, ...extra});
|
||||
const rows = await api('GET', O('/logs?' + params()));
|
||||
const set = fn => e => { fn(e.target.value); logPaged = false; refresh(); };
|
||||
const named = [...st.mappings, ...(st.mailEgress || [])];
|
||||
const mapSel = h('select', {style:'width:auto', onchange:set(v => logMap = v)}, h('option', {value:''}, 'All ports'), named.map(m => h('option', {value:m.id, selected:m.id === logMap}, m.name)));
|
||||
const statusSel = h('select', {style:'width:auto', onchange:set(v => logStatus = v)}, [['', 'Any status'], ['2', '2xx'], ['3', '3xx'], ['4', '4xx'], ['5', '5xx']].map(([v, t]) => h('option', {value:v, selected:v === logStatus}, t)));
|
||||
const resultSel = h('select', {style:'width:auto', onchange:set(v => logResult = v)}, [['', 'Any result'], ['denied', 'Denied'], ['auth-required', 'Login required'], ['login', 'Logins'], ['mail', 'Mail sent'], ['mail-failed', 'Mail failed'], ['mail-refused', 'Mail refused'], ['no-target', 'No target']].map(([v, t]) => h('option', {value:v, selected:v === logResult}, t)));
|
||||
const search = h('input', {placeholder:'Search host, path, address, user…', value:logQ, style:'width:260px'});
|
||||
search.addEventListener('keydown', e => { if (e.key === 'Enter') { logQ = search.value.trim(); logPaged = false; refresh(); } });
|
||||
const t = d => new Date(d).toLocaleString();
|
||||
const tbody = h('tbody');
|
||||
const addRows = list => list.forEach(r => {
|
||||
if (logKind === 'audit') { tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))); return; }
|
||||
const m = byId(st.mappings, r.mapping) || byId(st.mailEgress, r.mapping);
|
||||
tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, m ? m.name : r.mapping), h('td', {class:'mono small'}, r.remote), h('td', {class:'mono small'}, r.method ? `${r.method} ${r.host}${r.path}` : (r.host || '(connection)')),
|
||||
h('td', {}, r.status || ''), h('td', {}, r.user || ''), h('td', {class:r.result && !['allowed', 'connection', 'bypass', 'login', 'logout', 'mail'].includes(r.result) ? 'err' : 'dim'}, r.result), h('td', {class:'small dim'}, `↑${bytes(r.bytesIn)} ↓${bytes(r.bytesOut)}`), h('td', {class:'dim'}, r.ms)));
|
||||
});
|
||||
addRows(rows);
|
||||
let last = rows.length ? rows[rows.length - 1].id : 0;
|
||||
const more = h('button', {class:'ghost', style:'margin:12px auto;display:block', onclick: act(async () => {
|
||||
const next = await api('GET', O('/logs?' + params({before:last})));
|
||||
if (!next.length) { more.remove(); toast('That is everything'); return; }
|
||||
logPaged = true; addRows(next); last = next[next.length - 1].id;
|
||||
})}, 'Load older entries');
|
||||
const head = logKind === 'audit' ? ['Time', 'Who', 'What', 'Detail'] : ['Time', 'Port', 'From', 'Request', 'Status', 'User', 'Result', 'Bytes', 'ms'];
|
||||
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
|
||||
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
|
||||
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
|
||||
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : h('div', {class:'empty'}, 'Nothing matches.')));
|
||||
};
|
||||
|
||||
// ----- server administration -----
|
||||
let serverTab = 'edges';
|
||||
views.server = async () => {
|
||||
return await serverViews[serverTab]();
|
||||
};
|
||||
const serverViews = {};
|
||||
serverViews.alerts = () => alertsView('/api/admin/alerts', false, true);
|
||||
|
||||
serverViews.edges = async () => {
|
||||
const [edges, me] = [await api('GET', '/api/edges'), S.me];
|
||||
const orgs = me.orgs, name = h('input', {placeholder:'e.g. VPS Frankfurt'}), addr = h('input', {placeholder:'203.0.113.10:51820'});
|
||||
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
|
||||
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
|
||||
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
|
||||
return h('div', {},
|
||||
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
|
||||
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), h('button', {class:'btn', onclick: act(async () => {
|
||||
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); showCmd(e); refresh(); })}, 'Add edge')),
|
||||
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
|
||||
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
||||
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
|
||||
};
|
||||
|
||||
serverViews.orgs = async () => {
|
||||
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
|
||||
await boot0();
|
||||
return h('div', {},
|
||||
card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))),
|
||||
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
|
||||
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
|
||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
||||
const st = await api('GET', `/api/orgs/${o.id}/state`);
|
||||
await api('PUT', `/api/orgs/${o.id}`, {allowLocal:!st.org.allowLocal}); toast(`Local access ${st.org.allowLocal ? 'off' : 'on'} for ${o.name}`); })}, 'Toggle local access')),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${o.name} and everything in it?`)) { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); } })}, 'Delete'))))))));
|
||||
};
|
||||
async function boot0() { S.me = await api('GET', '/api/me'); }
|
||||
|
||||
serverViews.users = async () => {
|
||||
const users = await api('GET', '/api/admin/users');
|
||||
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
|
||||
return h('div', {},
|
||||
card('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'),
|
||||
h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))),
|
||||
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
|
||||
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
|
||||
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
|
||||
u.totp ? h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('2FA reset'); refresh(); })}, 'Reset 2FA') : null,
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast('Signed out everywhere'); })}, 'Sign out'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { const p = prompt('New password (8+ characters)'); if (p) { await api('PUT', '/api/admin/users/' + u.id, {password:p}); toast('Password changed'); } })}, 'Set password'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete ' + u.email + '?')) { await api('DELETE', '/api/admin/users/' + u.id); refresh(); } })}, 'Delete')))))))));
|
||||
};
|
||||
|
||||
serverViews.idps = async () => {
|
||||
const idps = await api('GET', '/api/admin/idps'), orgs = S.me.orgs;
|
||||
const form = (e = {}) => {
|
||||
const f = {name:h('input', {value:e.name || ''}), issuer:h('input', {value:e.issuer || '', placeholder:'https://accounts.google.com'}), cid:h('input', {value:e.clientId || ''}),
|
||||
sec:h('input', {type:'password', placeholder:e.id ? 'unchanged' : ''}), scopes:h('input', {value:e.scopes || 'openid email profile'}), dom:h('input', {value:(e.allowedDomains || []).join(', '), placeholder:'company.com (blank = any)'})};
|
||||
const auto = chk('Create accounts for new users automatically', e.autoProvision);
|
||||
const orgSel = h('select', {}, orgs.map(o => h('option', {value:o.id, selected:o.id === e.orgId}, o.name)));
|
||||
const close = modal(e.id ? 'Edit provider' : 'Add an OpenID Connect provider', h('div', {class:'stack'},
|
||||
h('p', {class:'dim small'}, 'Redirect URI to register at the provider: ' + (S.me.dashboardUrl || location.origin) + '/auth/oidc/callback'),
|
||||
field('Name shown on the button', f.name), field('Issuer URL', f.issuer), field('Client ID', f.cid), field('Client secret', f.sec), field('Scopes', f.scopes), field('Allowed email domains', f.dom),
|
||||
auto, field('New users join', orgSel, 'as a Member'),
|
||||
h('button', {class:'btn', onclick: act(async () => {
|
||||
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
|
||||
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
|
||||
};
|
||||
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
|
||||
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'),
|
||||
h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider'));
|
||||
};
|
||||
|
||||
serverViews.settings = async () => {
|
||||
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
|
||||
const f = {url:h('input', {value:s.dashboardUrl, placeholder:'https://hammerhead.example.com'}), acme:h('input', {type:'email', value:s.acmeEmail, placeholder:'you@example.com'}),
|
||||
host:h('input', {value:s.smtp.host}), port:h('input', {type:'number', value:s.smtp.port || ''}), user:h('input', {value:s.smtp.user}), pass:h('input', {type:'password', placeholder:smtpPassSet ? 'unchanged' : ''}), from:h('input', {value:s.smtp.from, placeholder:'Hammerhead <no-reply@example.com>'}),
|
||||
mode:h('select', {}, ['starttls', 'tls', 'none'].map(m => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, m)))};
|
||||
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
|
||||
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
|
||||
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
|
||||
return h('div', {},
|
||||
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
|
||||
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
|
||||
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
|
||||
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
|
||||
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
|
||||
h('button', {class:'btn', onclick:save}, 'Save settings'));
|
||||
};
|
||||
|
||||
serverViews.audit = async () => {
|
||||
const rows = await api('GET', '/api/admin/audit');
|
||||
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : h('div', {class:'empty'}, 'Nothing yet.'));
|
||||
};
|
||||
|
||||
// ----- account -----
|
||||
views.account = async () => {
|
||||
S.me = await api('GET', '/api/me');
|
||||
const name = h('input', {value:S.me.name}), cur = h('input', {type:'password', autocomplete:'current-password'}), nw = h('input', {type:'password', autocomplete:'new-password'});
|
||||
const twofa = h('div', {class:'stack'});
|
||||
const draw2fa = () => {
|
||||
if (S.me.totpEnabled) {
|
||||
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'});
|
||||
twofa.replaceChildren(h('p', {}, '✓ Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('POST', '/api/me/2fa/disable', {password:pw.value, code:code.value}); S.me = await api('GET', '/api/me'); draw2fa(); })}, 'Turn off')));
|
||||
} else twofa.replaceChildren(h('p', {class:'dim'}, 'Add an authenticator app (Google Authenticator, 1Password, Aegis…) for a second step at sign-in.'),
|
||||
h('button', {class:'btn', style:'align-self:flex-start', onclick: act(async () => {
|
||||
const r = await api('POST', '/api/me/2fa/setup'), code = h('input', {placeholder:'6-digit code', inputmode:'numeric'});
|
||||
const close = modal('Set up two-factor authentication', h('div', {class:'stack'}, h('p', {class:'dim'}, 'In your authenticator app choose "enter a setup key" and paste this key (or open the link on your phone):'), cmdBox(r.secret), h('a', {href:r.uri}, 'Open in authenticator app'),
|
||||
field('Then enter the code it shows', code), h('button', {class:'btn', onclick: act(async () => {
|
||||
const e = await api('POST', '/api/me/2fa/enable', {code:code.value}); close(); S.me = await api('GET', '/api/me'); draw2fa();
|
||||
modal('Save your backup codes', h('div', {}, h('p', {class:'dim'}, 'Each code works once if you lose your device. They are not shown again.'), cmdBox(e.backupCodes.join('\n')))); })}, 'Turn on')), {sticky:true});
|
||||
})}, 'Set up two-factor'));
|
||||
};
|
||||
draw2fa();
|
||||
const sessions = await api('GET', '/api/me/sessions');
|
||||
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
|
||||
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? h('span', {class:'tag'}, 'this device') : null),
|
||||
h('td', {class:'small'}, (x.ua || 'unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
|
||||
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
|
||||
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'btn', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Saved'); })}, 'Save'))),
|
||||
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password (8+ characters)', nw),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed; other sessions were signed out'); })}, 'Change password'))),
|
||||
card('Two-factor authentication', twofa), devices);
|
||||
};
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in</title>
|
||||
<style>
|
||||
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.45 system-ui,sans-serif; display:grid; place-items:center; min-height:100vh; padding:16px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; }
|
||||
h1 { font-size:18px; margin:0 0 4px; }
|
||||
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; }
|
||||
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; }
|
||||
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
||||
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--dim); margin:0; }
|
||||
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:-1px; }
|
||||
button, a.btn { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:center; text-decoration:none; display:block; }
|
||||
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); }
|
||||
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; }
|
||||
.ok { color:var(--dim); font-size:13px; }
|
||||
[hidden] { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 id="title">Sign in</h1>
|
||||
<p class="sub" id="sub"></p>
|
||||
<div id="body"></div>
|
||||
<div id="err"></div>
|
||||
</div>
|
||||
<script>
|
||||
const id = location.pathname.split('/')[3];
|
||||
const params = new URLSearchParams(location.search);
|
||||
const ret = params.get('return') || '';
|
||||
const $ = s => document.querySelector(s);
|
||||
const body = $('#body');
|
||||
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (v === true) el.setAttribute(k, '');
|
||||
else if (v != null && v !== false) el.setAttribute(k, v);
|
||||
}
|
||||
for (const k of kids.flat()) if (k != null) el.append(k.nodeType ? k : document.createTextNode(k));
|
||||
return el;
|
||||
}
|
||||
const err = m => $('#err').textContent = m || '';
|
||||
|
||||
async function post(path, data) {
|
||||
err('');
|
||||
const r = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
}
|
||||
async function finish(path, data) {
|
||||
try {
|
||||
const j = await post(`/auth/r/${id}/${path}`, { ...data, return: ret });
|
||||
if (j.redirect) location.href = j.redirect;
|
||||
} catch (e) { err(e.message); }
|
||||
}
|
||||
|
||||
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
|
||||
|
||||
function passwordForm(kind, label, type) {
|
||||
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
|
||||
const go = () => finish(kind, { [kind]: i.value });
|
||||
i.addEventListener('keydown', e => e.key === 'Enter' && go());
|
||||
return section(label, i, h('button', {onclick:go}, 'Continue'));
|
||||
}
|
||||
|
||||
function emailForm() {
|
||||
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
||||
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
|
||||
const msg = h('div', {class:'ok'});
|
||||
const go = h('button', {}, 'Email me a code');
|
||||
go.onclick = async () => {
|
||||
try {
|
||||
if (code.hidden) {
|
||||
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
||||
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
|
||||
} else await finish('email/verify', { email: email.value, code: code.value });
|
||||
} catch (e) { err(e.message); }
|
||||
};
|
||||
return section('Email code', email, code, msg, go);
|
||||
}
|
||||
|
||||
function ssoForm(info) {
|
||||
const kids = [];
|
||||
if (info.user) {
|
||||
if (info.user.allowed) {
|
||||
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
||||
} else {
|
||||
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
|
||||
}
|
||||
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
|
||||
return section('Account', ...kids);
|
||||
}
|
||||
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
|
||||
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
|
||||
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
|
||||
let ticket = null;
|
||||
const go = async () => {
|
||||
try {
|
||||
let j;
|
||||
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
|
||||
else j = await post('/api/login', { email: email.value, password: pw.value });
|
||||
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
|
||||
location.reload();
|
||||
} catch (e) { err(e.message); }
|
||||
};
|
||||
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
||||
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
|
||||
for (const p of info.providers || []) {
|
||||
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
|
||||
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
|
||||
}
|
||||
return section('Account', ...kids);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// share links skip the form
|
||||
const share = params.get('share');
|
||||
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
|
||||
try {
|
||||
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
|
||||
const info = await r.json();
|
||||
if (!r.ok) throw new Error(info.error);
|
||||
$('#title').textContent = info.name;
|
||||
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
|
||||
if (info.sso) body.append(ssoForm(info));
|
||||
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
|
||||
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
|
||||
if (info.email) body.append(emailForm());
|
||||
if (!body.children.length) err('No sign-in method is available.');
|
||||
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
|
||||
}
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user