Compare commits
20
Commits
v0.1.10
...
0f17659962
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f17659962 | ||
|
|
5fdeff012c | ||
|
|
54bd151ddd | ||
|
|
1281d6cd50 | ||
|
|
a9a0372d25 | ||
|
|
863274dad5 | ||
|
|
6d912ddd22 | ||
|
|
f7ec3e431a | ||
|
|
654cf6d0fa | ||
|
|
7e429a7be0 | ||
|
|
bce44e21ce | ||
|
|
617742e2b8 | ||
|
|
8f3d303d7b | ||
|
|
1c2a5b71bd | ||
|
|
39659f0144 | ||
|
|
227c890409 | ||
|
|
38ab4f7db2 | ||
|
|
f93698f63f | ||
|
|
c2845d8630 | ||
|
|
42eb6410b4 |
No files matched your search
@@ -171,7 +171,9 @@ for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the plac
|
|||||||
- Several targets per port: round-robin load balancing with automatic failover.
|
- 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.
|
- 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).
|
- 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.
|
- Traffic totals and a 24-hour sparkline per port, plus a **Traffic** chart per resource (and a
|
||||||
|
**Traffic overview** for the whole organization) with bytes in and out, connections and the
|
||||||
|
busiest visitor addresses. See *Traffic analytics* below.
|
||||||
|
|
||||||
**HTTPS**: automatic Let's Encrypt certificates, optional port-80 redirect, `X-Forwarded-*`
|
**HTTPS**: automatic Let's Encrypt certificates, optional port-80 redirect, `X-Forwarded-*`
|
||||||
headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
||||||
@@ -200,11 +202,26 @@ headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
|||||||
**Alerts**
|
**Alerts**
|
||||||
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a
|
- 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,
|
target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS,
|
||||||
HELO, port 25, DKIM record missing).
|
HELO, port 25, DKIM record missing), a sending domain's SPF doesn't authorize the relay or its
|
||||||
|
DMARC record is missing, the relay's IP lands on a DNS blocklist, too much of its mail is being
|
||||||
|
refused, or too little of the mail it sends passes DMARC in the reports you upload. Each alert says
|
||||||
|
what to change.
|
||||||
- A problem must last the **debounce time** (default 2 minutes, per organization) before you hear about
|
- 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
|
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
|
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.
|
only marked resolved once the thing it describes has been re-checked.
|
||||||
|
- **Sandbar** ([self-hosted notifications, monitors and on-call](https://git.veillax.com/Veillax/sandbar)) can
|
||||||
|
receive alerts in two ways. A *Sandbar* channel raises the alert through its API (and resolves it when the
|
||||||
|
problem clears), so the escalation policy you name pages whoever is on call. A *Sandbar topic* channel
|
||||||
|
publishes a plain message to a topic, which anyone subscribed (the web page, `curl`, the ntfy phone app) sees;
|
||||||
|
the API key is optional there, for servers that don't ask for one. Keys are sealed at rest and never sent back
|
||||||
|
to the browser.
|
||||||
|
- **Sandbar monitors** (Server, Settings): turn this on with Sandbar's address and an administrator's API key,
|
||||||
|
and Hammerhead creates a Sandbar monitor for every enabled resource that has health checks: a website is
|
||||||
|
fetched at its public address (a sign-in page counts as up), a TCP port is connected to on the edge's public
|
||||||
|
IP. Each gets uptime history, an optional place on the status page, and the escalation policy you pick.
|
||||||
|
Redirects, UDP, wildcard hostnames and resources with health checks off get none. Hammerhead re-syncs every
|
||||||
|
minute, renames and removes the monitors it created, and never touches ones you made in Sandbar by hand.
|
||||||
- A dead edge silences its own ports' alerts (the edge alert already says it). Channels are per
|
- A dead edge silences its own ports' alerts (the edge alert already says it). Channels are per
|
||||||
organization; **server channels** get edge alerts.
|
organization; **server channels** get edge alerts.
|
||||||
- Webhooks can't be pointed at internal addresses (no metadata service, no LAN) unless a server admin
|
- Webhooks can't be pointed at internal addresses (no metadata service, no LAN) unless a server admin
|
||||||
@@ -212,6 +229,24 @@ headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
|||||||
- **`/metrics`** (Prometheus) when `HAMMERHEAD_METRICS_TOKEN` is set: edge/site/port/target/mail-check
|
- **`/metrics`** (Prometheus) when `HAMMERHEAD_METRICS_TOKEN` is set: edge/site/port/target/mail-check
|
||||||
gauges, traffic counters, alerts firing. Off (404) otherwise.
|
gauges, traffic counters, alerts firing. Off (404) otherwise.
|
||||||
|
|
||||||
|
**Traffic analytics**
|
||||||
|
- Every edge counts connections and bytes in and out per port, plus the ten busiest client addresses
|
||||||
|
per port, and sends them with its regular report (every 20 seconds). Hammerhead adds them up at
|
||||||
|
three resolutions: per minute (kept 48 hours), per hour (31 days) and, for visitor addresses, per
|
||||||
|
hour (7 days). Older rows are deleted by the same hourly clean-up as the logs.
|
||||||
|
- The charts are on the Resources page: **Traffic** on a row, or **Traffic overview** for everything.
|
||||||
|
Pick an hour or 6 hours (per minute), a day, a week or a month (per hour). Visitor addresses cover
|
||||||
|
at most the last 7 days, and very small visitors may be missing because each report names ten.
|
||||||
|
- `GET /api/orgs/{org}/traffic/{resource id or "all"}?range=1h|6h|24h|7d|30d` returns the series,
|
||||||
|
totals and busiest addresses as JSON, for scripts or your own dashboards.
|
||||||
|
- **Traffic alerts** (Alerts → Traffic, per organization, off by default), delivered like every other
|
||||||
|
alert, with the same debounce:
|
||||||
|
- *Spike*: a port carried at least N times its usual traffic in the last 5 minutes, compared with
|
||||||
|
the average over the previous 6 hours. A port needs an hour of history, and the burst must be over
|
||||||
|
10 MB, so quiet ports don't page you.
|
||||||
|
- *Daily amount*: a port carried more than the set number of MB (in plus out) since midnight UTC.
|
||||||
|
Neither blocks traffic; they only tell you.
|
||||||
|
|
||||||
**Mail egress (send your mail server's outgoing mail from the edge's IP)**
|
**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
|
- 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
|
record all have to agree. A mail server at home usually can't satisfy that (residential IP, port 25
|
||||||
@@ -227,6 +262,20 @@ headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
|||||||
record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message
|
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
|
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.
|
unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
|
||||||
|
- **Deliverability dashboard.** The Mail egress page shows a checklist per relay, each line with a fix:
|
||||||
|
reverse DNS, HELO, outbound port 25, every DKIM key, and for each domain you sign mail for, SPF
|
||||||
|
(evaluated against the relay's IP, including `include:`/`redirect=` and the 10-lookup limit), DMARC
|
||||||
|
(missing, monitoring-only, no `rua`, enforcing) and MTA-STS. The edge also looks the IP up on DNS
|
||||||
|
blocklists (Spamhaus ZEN, SpamCop, Barracuda, PSBL) and counts the last 24 hours of delivery outcomes
|
||||||
|
(delivered, deferred, refused, by recipient domain, with the latest refusal messages and what they
|
||||||
|
usually mean). Checks run every 15 minutes; failures raise alerts and clear when fixed.
|
||||||
|
- **DMARC reports.** Put `rua=mailto:...` in a domain's DMARC record, and upload the daily aggregate
|
||||||
|
reports receivers send (the email itself, or its .xml/.gz/.zip attachment, several at once) on the same
|
||||||
|
page. Hammerhead parses them, keeps 90 days, and shows per domain how much mail passed, which sending
|
||||||
|
addresses fail (marking your own relays), and whether the policy can move from `none` to
|
||||||
|
`quarantine`/`reject`. `POST /api/orgs/<org>/dmarc` takes a report as the raw request body, so a mail
|
||||||
|
filter or script can forward reports automatically. Reports are only accepted for domains you have a
|
||||||
|
DKIM key for.
|
||||||
- **MTA-STS enforcement.** When a recipient domain publishes an `enforce` policy, mail only goes to the
|
- **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
|
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
|
server retries. (`testing` mode doesn't block.) Policies are cached for their `max_age`. The policy
|
||||||
@@ -259,6 +308,19 @@ Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammer
|
|||||||
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
|
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
|
||||||
decision function runs in both proxy modes.
|
decision function runs in both proxy modes.
|
||||||
|
|
||||||
|
## Ephemeral exposures (`shark expose`)
|
||||||
|
|
||||||
|
A short-lived port or website for development, created and removed by an API key. [shark-expose](https://git.veillax.com/Veillax/shark-expose) is the command-line client; the API is plain JSON, with an API key (**Access**) as the bearer token. `-` can stand for the key's own organization in the path.
|
||||||
|
|
||||||
|
| Call | Does |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/orgs/-/expose` | `{proto: "http"\|"https"\|"tcp", targetPort, targetHost?, name?, ttlSeconds?, port?, hostname?, edgeId?, auth?: {password?, pin?, sso?}}`. Makes a site and a port on an online edge, and returns the public address plus `siteToken`, the token Bamboo dials in with. |
|
||||||
|
| `POST /api/orgs/-/expose/{id}/renew` | Pushes the deadline out by `ttlSeconds` (default 300) from now. |
|
||||||
|
| `GET /api/orgs/-/expose[/{id}]` | Lists exposures, with whether the edge reports the port listening. |
|
||||||
|
| `DELETE /api/orgs/-/expose/{id}` | Removes it now. Stopping twice is fine. |
|
||||||
|
|
||||||
|
TCP gets a free port from 20000-29999. A website gets `<name>-<random>.<one of your domains>` (so a wildcard DNS record for that domain should point at the edge) on port 80/443, or on `port`. An exposure that isn't renewed within its TTL (30 seconds to 24 hours) is removed, along with its site and any login secrets. The dashboard shows them with the `ephemeral` tag; the deadlines live in memory, so a Hammerhead restart removes any that are still there. An organization can have 20 at a time.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check.
|
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check.
|
||||||
|
|||||||
+20
-2
@@ -54,6 +54,9 @@ func (s *Server) principalFrom(r *http.Request) *principal {
|
|||||||
if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) {
|
if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) {
|
||||||
k.LastUsed = time.Now()
|
k.LastUsed = time.Now()
|
||||||
kc := *k
|
kc := *k
|
||||||
|
if k.OrgID == "" { // a server key acts as a server administrator
|
||||||
|
return &principal{Key: &kc, User: User{Email: "apikey:" + k.Name, ServerAdmin: true}}
|
||||||
|
}
|
||||||
return &principal{Key: &kc}
|
return &principal{Key: &kc}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,14 +76,23 @@ func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serverKey reports whether the caller authenticated with a server-level API key.
|
||||||
|
func (p *principal) serverKey() bool { return p.Key != nil && p.Key.OrgID == "" }
|
||||||
|
|
||||||
|
// withServerAdmin lets signed-in server administrators and server API keys through.
|
||||||
func (s *Server) withServerAdmin(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.principalFrom(r)
|
||||||
|
if p == nil || (p.User.ID == "" && !p.serverKey()) {
|
||||||
|
fail(w, 401, errors.New("Sign in to continue."))
|
||||||
|
return
|
||||||
|
}
|
||||||
if !p.User.ServerAdmin {
|
if !p.User.ServerAdmin {
|
||||||
fail(w, 403, errors.New("server administrators only"))
|
fail(w, 403, errors.New("server administrators only"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fn(w, r, p)
|
fn(w, r, p)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// orgCtx is the caller's standing inside one org.
|
// orgCtx is the caller's standing inside one org.
|
||||||
@@ -100,6 +112,9 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("org")
|
id := r.PathValue("org")
|
||||||
|
if id == "-" && p.Key != nil {
|
||||||
|
id = p.Key.OrgID // an API key belongs to one organization, so "-" can stand for it
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
org := s.findOrg(id)
|
org := s.findOrg(id)
|
||||||
c := &orgCtx{User: p.User, Key: p.Key}
|
c := &orgCtx{User: p.User, Key: p.Key}
|
||||||
@@ -107,6 +122,9 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
|
|||||||
if org != nil {
|
if org != nil {
|
||||||
c.Org = *org
|
c.Org = *org
|
||||||
switch {
|
switch {
|
||||||
|
case p.serverKey():
|
||||||
|
allowed, c.Admin = true, true
|
||||||
|
c.Actor = "apikey:" + p.Key.Name
|
||||||
case p.Key != nil:
|
case p.Key != nil:
|
||||||
allowed = p.Key.OrgID == org.ID
|
allowed = p.Key.OrgID == org.ID
|
||||||
c.Admin = allowed
|
c.Admin = allowed
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
|
||||||
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
||||||
s.containers[st.ID] = in.Containers
|
s.containers[st.ID] = in.Containers
|
||||||
|
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.SiteID != st.ID {
|
if x.SiteID != st.ID {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,19 +24,26 @@ import (
|
|||||||
// message when it starts and one when it clears, and optionally a reminder.
|
// 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.
|
// Active alerts are kept in the database so a restart doesn't repeat them.
|
||||||
|
|
||||||
|
var topicName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDebounce = 2 * time.Minute
|
defaultDebounce = 2 * time.Minute
|
||||||
defaultOffline = 90 * time.Second
|
defaultOffline = 90 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertChannel is somewhere alerts are sent: an email address or a webhook.
|
// AlertChannel is somewhere alerts are sent: an email address, a webhook, a
|
||||||
|
// Sandbar server (which pages whoever is on call and tracks the alert), or a
|
||||||
|
// Sandbar topic (a plain message for everyone subscribed to it).
|
||||||
type AlertChannel struct {
|
type AlertChannel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"` // "email" or "webhook"
|
Kind string `json:"kind"` // "email", "webhook", "sandbar" or "topic"
|
||||||
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens)
|
Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens)
|
||||||
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
||||||
|
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser; optional for a topic)
|
||||||
|
Topic string `json:"topic"` // topic: the topic to publish to
|
||||||
|
Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone
|
||||||
AllowPrivate bool `json:"allowPrivate"`
|
AllowPrivate bool `json:"allowPrivate"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
@@ -50,6 +58,7 @@ type alertState struct {
|
|||||||
type problem struct{ org, kind, name, msg string }
|
type problem struct{ org, kind, name, msg string }
|
||||||
|
|
||||||
type alertNote struct {
|
type alertNote struct {
|
||||||
|
Key string `json:"key"` // stable per problem, so a recovery can find its alert
|
||||||
Status string `json:"status"` // "firing", "resolved" or "test"
|
Status string `json:"status"` // "firing", "resolved" or "test"
|
||||||
Org string `json:"org"`
|
Org string `json:"org"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -123,6 +132,7 @@ func (s *Server) problemsLocked(now time.Time) map[string]problem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.trafficProblemsLocked(now, edgeDown, add)
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
|
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
|
||||||
continue
|
continue
|
||||||
@@ -142,6 +152,27 @@ func (s *Server) problemsLocked(now time.Time) map[string]problem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, x := range s.st.MailEgress {
|
||||||
|
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chk, ok := s.mailChecks[x.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range s.mailEgressFindingsLocked(x, chk) {
|
||||||
|
// ptr, helo, port 25 and dkim have alerts of their own above
|
||||||
|
if !f.alertWorthy() || f.ID == "ptr" || f.ID == "helo" || f.ID == "port25" || strings.HasPrefix(f.ID, "dkim:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("%s: %s: %s", x.Name, f.Label, f.Detail)
|
||||||
|
if f.Hint != "" {
|
||||||
|
msg += ". " + f.Hint
|
||||||
|
}
|
||||||
|
add("mailfind:"+x.ID+":"+f.ID, x.OrgID, "mail", x.Name, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.dmarcProblemsLocked(add)
|
||||||
for _, k := range s.st.DKIM {
|
for _, k := range s.st.DKIM {
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
|
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
|
||||||
@@ -181,6 +212,13 @@ func (s *Server) judgeableLocked(st *alertState) bool {
|
|||||||
}
|
}
|
||||||
_, known := s.mailStatus[rest]
|
_, known := s.mailStatus[rest]
|
||||||
return known
|
return known
|
||||||
|
case "mailfind":
|
||||||
|
id, _, _ := strings.Cut(rest, ":")
|
||||||
|
if s.findMailEgress(id) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, known := s.mailChecks[id]
|
||||||
|
return known
|
||||||
case "mailptr", "mailhelo", "mail25":
|
case "mailptr", "mailhelo", "mail25":
|
||||||
if s.findMailEgress(rest) == nil {
|
if s.findMailEgress(rest) == nil {
|
||||||
return true
|
return true
|
||||||
@@ -251,7 +289,7 @@ func (s *Server) evaluateAlerts(now time.Time) {
|
|||||||
chans = append(chans, *c)
|
chans = append(chans, *c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
out = append(out, delivery{alertNote{Key: st.Key, Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
||||||
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(probs))
|
keys := make([]string, 0, len(probs))
|
||||||
@@ -362,6 +400,10 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
|
case "sandbar":
|
||||||
|
return s.sendSandbar(ch, n)
|
||||||
|
case "topic":
|
||||||
|
return s.sendTopic(ch, n)
|
||||||
case "webhook":
|
case "webhook":
|
||||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||||
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
||||||
@@ -385,6 +427,79 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
|||||||
return errors.New("unknown channel kind")
|
return errors.New("unknown channel kind")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendSandbar raises the alert on a Sandbar server, or resolves it there when the
|
||||||
|
// problem clears. Sandbar de-duplicates by key, so a reminder updates the alert
|
||||||
|
// that is already open instead of opening another. A test raises an info alert
|
||||||
|
// with no policy (nobody is paged) and resolves it straight away.
|
||||||
|
func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error {
|
||||||
|
post := func(body map[string]string) error {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/api/v1/alerts", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ch.Token)
|
||||||
|
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||||
|
return errors.New("Sandbar rejected the API key")
|
||||||
|
case resp.StatusCode >= 300:
|
||||||
|
return fmt.Errorf("Sandbar answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := "hammerhead:" + n.Key
|
||||||
|
switch n.Status {
|
||||||
|
case "resolved":
|
||||||
|
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||||
|
case "test":
|
||||||
|
key = "hammerhead:test:" + ch.ID
|
||||||
|
if err := post(map[string]string{"key": key, "source": "hammerhead", "summary": "Hammerhead test alert", "detail": n.text(), "severity": "info"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||||
|
}
|
||||||
|
return post(map[string]string{"key": key, "source": "hammerhead", "summary": n.Name + ": " + n.Message,
|
||||||
|
"detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTopic publishes the alert as a message on a Sandbar topic (ntfy's request
|
||||||
|
// works too), so everyone subscribed, including the ntfy phone apps, sees it. A
|
||||||
|
// topic has no alert to close, so a recovery is a second, calmer message.
|
||||||
|
func (s *Server) sendTopic(ch AlertChannel, n alertNote) error {
|
||||||
|
prio, tags := "urgent", "rotating_light"
|
||||||
|
if n.Status != "firing" {
|
||||||
|
prio, tags = "default", "white_check_mark"
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/"+ch.Topic, strings.NewReader(n.text()+"\n\nOrganization: "+n.Org))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Title", n.subject())
|
||||||
|
req.Header.Set("Priority", prio)
|
||||||
|
req.Header.Set("Tags", tags)
|
||||||
|
if ch.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ch.Token)
|
||||||
|
}
|
||||||
|
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||||
|
return errors.New("Sandbar rejected the API key")
|
||||||
|
case resp.StatusCode >= 300:
|
||||||
|
return fmt.Errorf("Sandbar answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
||||||
text := n.subject() + "\n" + n.text()
|
text := n.subject() + "\n" + n.text()
|
||||||
switch format {
|
switch format {
|
||||||
@@ -415,12 +530,37 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
|||||||
return errors.New("enter one or more email addresses, separated by commas")
|
return errors.New("enter one or more email addresses, separated by commas")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ch.Format, ch.AllowPrivate = "", false
|
ch.Format, ch.Token, ch.Policy, ch.Topic, ch.AllowPrivate = "", "", "", "", false
|
||||||
|
case "sandbar":
|
||||||
|
u, err := url.Parse(ch.Target)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||||
|
}
|
||||||
|
if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" {
|
||||||
|
return errors.New("enter an API key from Sandbar's Account page")
|
||||||
|
}
|
||||||
|
ch.Policy, ch.Format, ch.Topic = strings.TrimSpace(ch.Policy), "", ""
|
||||||
|
if ch.AllowPrivate && !trusted {
|
||||||
|
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||||
|
}
|
||||||
|
case "topic":
|
||||||
|
u, err := url.Parse(ch.Target)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||||
|
}
|
||||||
|
if ch.Topic = strings.TrimSpace(ch.Topic); !topicName.MatchString(ch.Topic) {
|
||||||
|
return errors.New("a topic is 1 to 64 letters, digits, - or _")
|
||||||
|
}
|
||||||
|
ch.Token, ch.Policy, ch.Format = strings.TrimSpace(ch.Token), "", ""
|
||||||
|
if ch.AllowPrivate && !trusted {
|
||||||
|
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||||
|
}
|
||||||
case "webhook":
|
case "webhook":
|
||||||
u, err := url.Parse(ch.Target)
|
u, err := url.Parse(ch.Target)
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
return errors.New("the webhook must be an http:// or https:// URL")
|
return errors.New("the webhook must be an http:// or https:// URL")
|
||||||
}
|
}
|
||||||
|
ch.Token, ch.Policy, ch.Topic = "", "", ""
|
||||||
switch ch.Format {
|
switch ch.Format {
|
||||||
case "json", "slack", "discord", "ntfy":
|
case "json", "slack", "discord", "ntfy":
|
||||||
case "":
|
case "":
|
||||||
@@ -432,7 +572,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
|||||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return errors.New("a channel is an email or a webhook")
|
return errors.New("a channel is an email, a webhook, a Sandbar server or a Sandbar topic")
|
||||||
}
|
}
|
||||||
if ch.Name == "" {
|
if ch.Name == "" {
|
||||||
ch.Name = ch.Kind
|
ch.Name = ch.Kind
|
||||||
|
|||||||
+12
-4
@@ -23,9 +23,12 @@ type channelView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preview(ch AlertChannel) string {
|
func preview(ch AlertChannel) string {
|
||||||
if ch.Kind != "webhook" {
|
if ch.Kind == "email" {
|
||||||
return ch.Target
|
return ch.Target
|
||||||
}
|
}
|
||||||
|
if u, err := url.Parse(ch.Target); err == nil && ch.Kind == "topic" {
|
||||||
|
return u.Scheme + "://" + u.Host + "/" + ch.Topic
|
||||||
|
}
|
||||||
if u, err := url.Parse(ch.Target); err == nil {
|
if u, err := url.Parse(ch.Target); err == nil {
|
||||||
return u.Scheme + "://" + u.Host + "/…"
|
return u.Scheme + "://" + u.Host + "/…"
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,7 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
|||||||
for _, c := range s.st.Channels {
|
for _, c := range s.st.Channels {
|
||||||
if c.OrgID == sc.org {
|
if c.OrgID == sc.org {
|
||||||
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
||||||
v.Target = "" // webhook URLs often contain tokens
|
v.Target, v.Token = "", "" // webhook URLs often contain tokens
|
||||||
if c.Kind == "email" {
|
if c.Kind == "email" {
|
||||||
v.Target = c.Target
|
v.Target = c.Target
|
||||||
}
|
}
|
||||||
@@ -61,12 +64,13 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
|
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
|
||||||
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
|
settings := map[string]int{"debounceMin": 2, "repeatHours": 0, "spikeFactor": 0, "dailyMb": 0}
|
||||||
if o := s.findOrg(sc.org); o != nil {
|
if o := s.findOrg(sc.org); o != nil {
|
||||||
if o.AlertDebounceMin > 0 {
|
if o.AlertDebounceMin > 0 {
|
||||||
settings["debounceMin"] = o.AlertDebounceMin
|
settings["debounceMin"] = o.AlertDebounceMin
|
||||||
}
|
}
|
||||||
settings["repeatHours"] = o.AlertRepeatHours
|
settings["repeatHours"] = o.AlertRepeatHours
|
||||||
|
settings["spikeFactor"], settings["dailyMb"] = o.TrafficSpikeFactor, o.TrafficDailyMB
|
||||||
}
|
}
|
||||||
mailOK := s.st.Settings.SMTP.Host != ""
|
mailOK := s.st.Settings.SMTP.Host != ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -95,6 +99,9 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco
|
|||||||
if in.Target == "" {
|
if in.Target == "" {
|
||||||
in.Target = existing.Target // blank keeps the saved URL/address
|
in.Target = existing.Target // blank keeps the saved URL/address
|
||||||
}
|
}
|
||||||
|
if id != "" && in.Token == "" && (in.Kind == "sandbar" || in.Kind == "topic") {
|
||||||
|
in.Token = existing.Token // blank keeps the saved API key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trusted := sc.trusted
|
trusted := sc.trusted
|
||||||
if err := s.validateChannel(&in, trusted); err != nil {
|
if err := s.validateChannel(&in, trusted); err != nil {
|
||||||
@@ -111,7 +118,7 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco
|
|||||||
}
|
}
|
||||||
s.save()
|
s.save()
|
||||||
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
|
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
|
||||||
ok(w)
|
writeJSON(w, 200, map[string]any{"ok": true, "id": in.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||||
@@ -195,6 +202,7 @@ func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
|
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("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("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
|
||||||
|
mux.HandleFunc("PUT /api/orgs/{org}/alerts/traffic", s.withOrgAlerts(s.trafficAlertSettings))
|
||||||
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
|
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
|
||||||
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
|
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("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
|
||||||
|
|||||||
+55
-1
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hook struct {
|
type hook struct {
|
||||||
@@ -487,3 +487,57 @@ func TestPromEscape(t *testing.T) {
|
|||||||
t.Errorf("promEscape = %q", got)
|
t.Errorf("promEscape = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSandbarChannelRaisesAndResolves(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
h := newHook(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Channels = []*AlertChannel{{ID: "c2", OrgID: f.org, Name: "pager", Kind: "sandbar", Target: strings.TrimSuffix(h.URL, "/hook/secret-token"),
|
||||||
|
Token: "sb_secret", Policy: "pol1", AllowPrivate: true, Enabled: true}}
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
|
||||||
|
f.eval(f.base)
|
||||||
|
f.setHealthy(false)
|
||||||
|
f.eval(f.base.Add(10 * time.Second))
|
||||||
|
f.eval(f.base.Add(3 * time.Minute))
|
||||||
|
var up map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &up); err != nil {
|
||||||
|
t.Fatal("no Sandbar request:", err)
|
||||||
|
}
|
||||||
|
if up["key"] != "hammerhead:target:m1:0" || up["policy"] != "pol1" || up["severity"] != "critical" || !strings.Contains(up["summary"], "Minecraft") {
|
||||||
|
t.Fatalf("unexpected alert sent to Sandbar: %v", up)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
auth := h.hdrs[0].Get("Authorization")
|
||||||
|
h.mu.Unlock()
|
||||||
|
if auth != "Bearer sb_secret" {
|
||||||
|
t.Fatalf("API key not sent: %q", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.setHealthy(true)
|
||||||
|
f.eval(f.base.Add(4 * time.Minute))
|
||||||
|
var down map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &down); err != nil {
|
||||||
|
t.Fatal("no resolve request:", err)
|
||||||
|
}
|
||||||
|
if down["key"] != up["key"] || down["status"] != "resolved" {
|
||||||
|
t.Fatalf("recovery did not resolve the same alert: %v", down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarChannelValidation(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
for _, ch := range []AlertChannel{
|
||||||
|
{Kind: "sandbar", Target: "not a url", Token: "k"},
|
||||||
|
{Kind: "sandbar", Target: "https://sandbar.example.com"}, // no API key
|
||||||
|
} {
|
||||||
|
ch := ch
|
||||||
|
if err := f.s.validateChannel(&ch, true); err == nil {
|
||||||
|
t.Fatalf("accepted %+v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := AlertChannel{Kind: "sandbar", Target: "https://sandbar.example.com", Token: " k ", Policy: " p "}
|
||||||
|
if err := f.s.validateChannel(&ok, true); err != nil || ok.Token != "k" || ok.Policy != "p" {
|
||||||
|
t.Fatalf("%v %+v", err, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keyCall(t *testing.T, s *Server, key, method, path, body string) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
if key != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
var out map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
return rec.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintKey(t *testing.T, s *Server, org string) string {
|
||||||
|
t.Helper()
|
||||||
|
k := "hhk_" + randHex(4) + "_" + randHex(24)
|
||||||
|
parts := strings.SplitN(k, "_", 3)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: parts[1], OrgID: org, Name: "t", Hash: sha(parts[2])})
|
||||||
|
s.mu.Unlock()
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerKeysManageEdgesAndOrgs(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
server := mintKey(t, f.s, "")
|
||||||
|
orgKey := mintKey(t, f.s, f.org)
|
||||||
|
|
||||||
|
if c, _ := keyCall(t, f.s, server, "POST", "/api/edges", `{"name":"e2","tunnelAddr":"1.2.3.4:51820"}`); c != 200 {
|
||||||
|
t.Fatalf("server key should create an edge, got %d", c)
|
||||||
|
}
|
||||||
|
if c, _ := keyCall(t, f.s, orgKey, "POST", "/api/edges", `{"name":"e3","tunnelAddr":"1.2.3.4:51820"}`); c != 401 {
|
||||||
|
t.Fatalf("org key must not create edges, got %d", c)
|
||||||
|
}
|
||||||
|
if c, _ := keyCall(t, f.s, server, "POST", "/api/orgs", `{"name":"Second"}`); c != 200 {
|
||||||
|
t.Fatalf("server key should create an org, got %d", c)
|
||||||
|
}
|
||||||
|
// server key reaches any org's endpoints; an org key only its own
|
||||||
|
if c, _ := keyCall(t, f.s, server, "POST", "/api/orgs/"+f.org+"/sites", `{"name":"x"}`); c != 200 {
|
||||||
|
t.Fatalf("server key on org: %d", c)
|
||||||
|
}
|
||||||
|
if c, _ := keyCall(t, f.s, orgKey, "POST", "/api/orgs/nope/sites", `{"name":"x"}`); c != 404 {
|
||||||
|
t.Fatalf("org key on other org: %d", c)
|
||||||
|
}
|
||||||
|
// server alert channels
|
||||||
|
if c, _ := keyCall(t, f.s, server, "GET", "/api/admin/alerts", ""); c != 200 {
|
||||||
|
t.Fatalf("server key alerts: %d", c)
|
||||||
|
}
|
||||||
|
// keys can't mint keys at server level
|
||||||
|
if c, _ := keyCall(t, f.s, server, "POST", "/api/admin/apikeys", `{"name":"k"}`); c != 401 {
|
||||||
|
t.Fatalf("server key must not mint server keys, got %d", c)
|
||||||
|
}
|
||||||
|
if c, _ := keyCall(t, f.s, "", "GET", "/api/orgs", ""); c != 401 {
|
||||||
|
t.Fatalf("anonymous list orgs: %d", c)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("GET", "/api/orgs", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+orgKey)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.s.Handler().ServeHTTP(rec, req)
|
||||||
|
var orgs []Org
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &orgs)
|
||||||
|
if rec.Code != http.StatusOK || len(orgs) != 1 || orgs[0].ID != f.org {
|
||||||
|
t.Fatalf("org key should list only its org: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// org key lists edges its org may use
|
||||||
|
req = httptest.NewRequest("GET", "/api/edges", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+orgKey)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
f.s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "edge-1") {
|
||||||
|
t.Fatalf("org key edges: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead"
|
"git.veillax.com/Veillax/hammerhead"
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/dotenv"
|
"git.veillax.com/Veillax/reef/dotenv"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
"git.veillax.com/Veillax/reef/selfupdate"
|
"git.veillax.com/Veillax/reef/selfupdate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
|
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +44,16 @@ CREATE TABLE IF NOT EXISTS traffic_hourly (
|
|||||||
bytes_in INTEGER NOT NULL DEFAULT 0, bytes_out INTEGER NOT NULL DEFAULT 0,
|
bytes_in INTEGER NOT NULL DEFAULT 0, bytes_out INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (mapping, hour)
|
PRIMARY KEY (mapping, hour)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_minutely (
|
||||||
|
mapping TEXT NOT NULL, minute 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, minute)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_sources (
|
||||||
|
mapping TEXT NOT NULL, hour INTEGER NOT NULL, ip TEXT 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, ip)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS alerts (
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
key TEXT PRIMARY KEY, org_id TEXT NOT NULL DEFAULT '', kind TEXT, name TEXT, message TEXT,
|
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
|
since INTEGER NOT NULL, fired INTEGER NOT NULL DEFAULT 0, notified INTEGER NOT NULL DEFAULT 0
|
||||||
@@ -53,7 +63,7 @@ CREATE TABLE IF NOT EXISTS alert_events (
|
|||||||
key TEXT, status TEXT, name TEXT, message TEXT
|
key TEXT, status TEXT, name TEXT, message TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS alert_events_org ON alert_events (org_id, id);
|
CREATE INDEX IF NOT EXISTS alert_events_org ON alert_events (org_id, id);
|
||||||
`
|
` + dmarcSchema
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
sql *sql.DB
|
sql *sql.DB
|
||||||
@@ -113,10 +123,11 @@ func (d *DB) sealed(v any) any {
|
|||||||
return &c
|
return &c
|
||||||
case *AlertChannel:
|
case *AlertChannel:
|
||||||
c := *x
|
c := *x
|
||||||
c.Target = b.Seal(c.Target)
|
c.Target, c.Token = b.Seal(c.Target), b.Seal(c.Token)
|
||||||
return &c
|
return &c
|
||||||
case Settings:
|
case Settings:
|
||||||
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
||||||
|
x.Sandbar.Token = b.Seal(x.Sandbar.Token)
|
||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
@@ -144,9 +155,9 @@ func (d *DB) unseal(v any) error {
|
|||||||
case *model.DKIMKey:
|
case *model.DKIMKey:
|
||||||
return d.open1(&x.PrivateKey)
|
return d.open1(&x.PrivateKey)
|
||||||
case *AlertChannel:
|
case *AlertChannel:
|
||||||
return d.open1(&x.Target)
|
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
|
||||||
case *Settings:
|
case *Settings:
|
||||||
return d.open1(&x.SMTP.Pass)
|
return errors.Join(d.open1(&x.SMTP.Pass), d.open1(&x.Sandbar.Token))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -547,22 +558,167 @@ func (d *DB) queryAudit(org string, limit int, before int64) []AuditEntry {
|
|||||||
|
|
||||||
// purge deletes logs older than the retention periods.
|
// purge deletes logs older than the retention periods.
|
||||||
func (d *DB) purge(accessDays, auditDays int) {
|
func (d *DB) purge(accessDays, auditDays int) {
|
||||||
|
d.purgeDMARC(time.Now().AddDate(0, 0, -dmarcKeepDays))
|
||||||
if accessDays > 0 {
|
if accessDays > 0 {
|
||||||
d.sql.Exec(`DELETE FROM access_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -accessDays)))
|
d.sql.Exec(`DELETE FROM access_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -accessDays)))
|
||||||
}
|
}
|
||||||
if auditDays > 0 {
|
if auditDays > 0 {
|
||||||
d.sql.Exec(`DELETE FROM audit_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -auditDays)))
|
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 traffic_hourly WHERE hour < ?`, time.Now().Add(-trafficHourlyKeep).Unix()/3600)
|
||||||
|
d.sql.Exec(`DELETE FROM traffic_minutely WHERE minute < ?`, time.Now().Add(-trafficMinutelyKeep).Unix()/60)
|
||||||
|
d.sql.Exec(`DELETE FROM traffic_sources WHERE hour < ?`, time.Now().Add(-trafficSourcesKeep).Unix()/3600)
|
||||||
d.sql.Exec(`DELETE FROM alert_events WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -90)))
|
d.sql.Exec(`DELETE FROM alert_events WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -90)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- traffic history ----------
|
// ---------- traffic history ----------
|
||||||
|
|
||||||
func (d *DB) addTraffic(mapping string, st model.Stat) {
|
// How long each resolution of traffic history is kept. Minutes are for the last hours, hours for the
|
||||||
|
// months, and the busiest client addresses sit in between.
|
||||||
|
const (
|
||||||
|
trafficMinutelyKeep = 48 * time.Hour
|
||||||
|
trafficSourcesKeep = 7 * 24 * time.Hour
|
||||||
|
trafficHourlyKeep = 31 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *DB) addTraffic(mapping string, st model.Stat) { d.addTrafficAt(mapping, st, time.Now()) }
|
||||||
|
|
||||||
|
func (d *DB) addTrafficAt(mapping string, st model.Stat, at time.Time) {
|
||||||
d.sql.Exec(`INSERT INTO traffic_hourly (mapping, hour, conns, bytes_in, bytes_out) VALUES (?, ?, ?, ?, ?)
|
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`,
|
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)
|
mapping, at.Unix()/3600, st.Conns, st.BytesIn, st.BytesOut)
|
||||||
|
d.sql.Exec(`INSERT INTO traffic_minutely (mapping, minute, conns, bytes_in, bytes_out) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (mapping, minute) DO UPDATE SET conns = conns + excluded.conns, bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out`,
|
||||||
|
mapping, at.Unix()/60, st.Conns, st.BytesIn, st.BytesOut)
|
||||||
|
for _, src := range st.Sources {
|
||||||
|
d.sql.Exec(`INSERT INTO traffic_sources (mapping, hour, ip, conns, bytes_in, bytes_out) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (mapping, hour, ip) DO UPDATE SET conns = conns + excluded.conns, bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out`,
|
||||||
|
mapping, at.Unix()/3600, src.IP, src.Conns, src.BytesIn, src.BytesOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficPoint is the traffic of one time bucket.
|
||||||
|
type TrafficPoint struct {
|
||||||
|
Time int64 `json:"t"` // unix seconds at the start of the bucket
|
||||||
|
Conns int64 `json:"conns"`
|
||||||
|
BytesIn int64 `json:"bytesIn"`
|
||||||
|
BytesOut int64 `json:"bytesOut"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trafficSeries sums the given ports' traffic into count buckets of the given size ("minute" or
|
||||||
|
// "hour") ending with the current one, oldest first. Empty buckets are included.
|
||||||
|
func (d *DB) trafficSeries(ids []string, unit string, count int, now time.Time) []TrafficPoint {
|
||||||
|
table, col, secs := "traffic_hourly", "hour", int64(3600)
|
||||||
|
if unit == "minute" {
|
||||||
|
table, col, secs = "traffic_minutely", "minute", 60
|
||||||
|
}
|
||||||
|
cur := now.Unix() / secs
|
||||||
|
first := cur - int64(count) + 1
|
||||||
|
out := make([]TrafficPoint, count)
|
||||||
|
for i := range out {
|
||||||
|
out[i].Time = (first + int64(i)) * secs
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
q := `SELECT ` + col + `, SUM(conns), SUM(bytes_in), SUM(bytes_out) FROM ` + table +
|
||||||
|
` WHERE ` + col + ` >= ? AND ` + col + ` <= ? AND mapping IN (` + placeholders(len(ids)) + `) GROUP BY ` + col
|
||||||
|
args := []any{first, cur}
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
rows, err := d.sql.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var at int64
|
||||||
|
var p TrafficPoint
|
||||||
|
if rows.Scan(&at, &p.Conns, &p.BytesIn, &p.BytesOut) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i := int(at - first); i >= 0 && i < count {
|
||||||
|
out[i].Conns, out[i].BytesIn, out[i].BytesOut = p.Conns, p.BytesIn, p.BytesOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficSource is one client address's traffic over a period.
|
||||||
|
type TrafficSource struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Conns int64 `json:"conns"`
|
||||||
|
BytesIn int64 `json:"bytesIn"`
|
||||||
|
BytesOut int64 `json:"bytesOut"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// topSources lists the busiest client addresses of the given ports over the last hours hours.
|
||||||
|
func (d *DB) topSources(ids []string, hours, limit int, now time.Time) []TrafficSource {
|
||||||
|
out := []TrafficSource{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
args := []any{now.Unix()/3600 - int64(hours) + 1}
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
args = append(args, limit)
|
||||||
|
rows, err := d.sql.Query(`SELECT ip, SUM(conns), SUM(bytes_in), SUM(bytes_out) FROM traffic_sources WHERE hour >= ? AND mapping IN (`+placeholders(len(ids))+`)
|
||||||
|
GROUP BY ip ORDER BY SUM(bytes_in) + SUM(bytes_out) DESC, SUM(conns) DESC, ip LIMIT ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var s TrafficSource
|
||||||
|
if rows.Scan(&s.IP, &s.Conns, &s.BytesIn, &s.BytesOut) == nil {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesSince is the total bytes (in + out) a port has carried from the start of the given hour (unix hours) until now.
|
||||||
|
func (d *DB) bytesSince(mapping string, fromHour int64) int64 {
|
||||||
|
var n sql.NullInt64
|
||||||
|
d.sql.QueryRow(`SELECT SUM(bytes_in + bytes_out) FROM traffic_hourly WHERE mapping = ? AND hour >= ?`, mapping, fromHour).Scan(&n)
|
||||||
|
return n.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// spikeWindow and spikeBaseline are the minutes a spike is judged over, and the minutes before it
|
||||||
|
// that say what normal looks like.
|
||||||
|
const (
|
||||||
|
spikeWindow = 5
|
||||||
|
spikeBaseline = 6 * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentTraffic returns the bytes a port carried in the last spikeWindow minutes, the average for
|
||||||
|
// the same span over the baseline before it, and how many minutes that baseline covers (up to spikeBaseline;
|
||||||
|
// fewer for a port with a shorter history, which is averaged over what it has).
|
||||||
|
func (d *DB) recentTraffic(mapping string, now time.Time) (recent int64, baselinePerWindow float64, historyMinutes int64) {
|
||||||
|
cur := now.Unix() / 60
|
||||||
|
var r, b, first sql.NullInt64
|
||||||
|
d.sql.QueryRow(`SELECT SUM(CASE WHEN minute > ? THEN bytes_in + bytes_out END), SUM(CASE WHEN minute <= ? THEN bytes_in + bytes_out END), MIN(minute)
|
||||||
|
FROM traffic_minutely WHERE mapping = ? AND minute > ?`, cur-spikeWindow, cur-spikeWindow, mapping, cur-spikeWindow-spikeBaseline).Scan(&r, &b, &first)
|
||||||
|
if !first.Valid {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
span := cur - spikeWindow - first.Int64 + 1
|
||||||
|
if span > spikeBaseline {
|
||||||
|
span = spikeBaseline
|
||||||
|
}
|
||||||
|
if span <= 0 {
|
||||||
|
return r.Int64, 0, 0
|
||||||
|
}
|
||||||
|
return r.Int64, float64(b.Int64) / float64(span) * spikeWindow, span
|
||||||
}
|
}
|
||||||
|
|
||||||
// trafficHistory returns bytes (in + out) per hour for each mapping, oldest first.
|
// trafficHistory returns bytes (in + out) per hour for each mapping, oldest first.
|
||||||
|
|||||||
+1
-1
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Finding is one line of a mail egress's deliverability checklist: what was
|
||||||
|
// checked, how it came out, and what to do about it. The edge produces the raw
|
||||||
|
// results; the findings put old and new checks in one shape for the dashboard
|
||||||
|
// and the alerts.
|
||||||
|
type Finding struct {
|
||||||
|
ID string `json:"id"` // stable, e.g. "ptr", "spf:example.com"
|
||||||
|
Label string `json:"label"` // "Reverse DNS", "SPF"
|
||||||
|
Domain string `json:"domain,omitempty"` // the sending domain, for per-domain checks
|
||||||
|
Status string `json:"status"` // pass, warn, fail or none (nothing published)
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertWorthy says whether a finding should raise an alert: failures do, and so
|
||||||
|
// does a missing SPF or DMARC record (bulk senders are required to publish both).
|
||||||
|
// Warnings and a missing MTA-STS policy are advice, not problems.
|
||||||
|
func (f Finding) alertWorthy() bool {
|
||||||
|
if f.Status == model.FindingFail {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return f.Status == model.FindingNone && (strings.HasPrefix(f.ID, "spf:") || strings.HasPrefix(f.ID, "dmarc:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// minOutcomeSample is how many messages a window needs before its failure rate says anything.
|
||||||
|
const minOutcomeSample = 10
|
||||||
|
|
||||||
|
func deliverabilityFindings(x *model.MailEgress, chk model.MailCheck, keys []*model.DKIMKey) []Finding {
|
||||||
|
var out []Finding
|
||||||
|
add := func(f Finding) { out = append(out, f) }
|
||||||
|
|
||||||
|
if chk.PTRMatchesHelo {
|
||||||
|
add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingPass, Detail: x.SourceIP + " resolves back to " + x.HeloName})
|
||||||
|
} else {
|
||||||
|
found := "no reverse DNS record"
|
||||||
|
if len(chk.PTR) > 0 {
|
||||||
|
found = "reverse DNS is " + strings.Join(chk.PTR, ", ")
|
||||||
|
}
|
||||||
|
add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingFail, Detail: found + ", not " + x.HeloName,
|
||||||
|
Hint: fmt.Sprintf("Reverse DNS is set at your hosting provider, not in your DNS zone: set the PTR record of %s to %s.", x.SourceIP, x.HeloName)})
|
||||||
|
}
|
||||||
|
if chk.HeloResolves {
|
||||||
|
add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingPass, Detail: x.HeloName + " resolves to " + x.SourceIP})
|
||||||
|
} else {
|
||||||
|
add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingFail, Detail: x.HeloName + " doesn't resolve to " + x.SourceIP,
|
||||||
|
Hint: fmt.Sprintf("Add an A record for %s pointing at %s (an AAAA record for an IPv6 address).", x.HeloName, x.SourceIP)})
|
||||||
|
}
|
||||||
|
if chk.Port25 == "open" {
|
||||||
|
add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingPass, Detail: "connections to other mail servers work"})
|
||||||
|
} else if chk.Port25 != "" {
|
||||||
|
add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingFail, Detail: chk.Port25,
|
||||||
|
Hint: "Ask your hosting provider to unblock outgoing port 25 for this address; many VPS hosts block it until you ask."})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.OrgID != x.OrgID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := k.Selector + "._domainkey." + k.Domain
|
||||||
|
switch chk.DKIM[k.ID] {
|
||||||
|
case "published":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingPass, Detail: name + " is published"})
|
||||||
|
case "missing":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " isn't in DNS",
|
||||||
|
Hint: "Publish the TXT record shown under DKIM signing; signed mail fails verification until it exists."})
|
||||||
|
case "mismatch":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " holds a different key",
|
||||||
|
Hint: "Replace the TXT record's value with the one shown under DKIM signing; it was probably left over from an older key."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doms := make([]string, 0, len(chk.Domains))
|
||||||
|
for d := range chk.Domains {
|
||||||
|
doms = append(doms, d)
|
||||||
|
}
|
||||||
|
sort.Strings(doms)
|
||||||
|
for _, d := range doms {
|
||||||
|
c := chk.Domains[d]
|
||||||
|
add(Finding{ID: "spf:" + d, Label: "SPF", Domain: d, Status: c.SPF.Status, Detail: c.SPF.Detail, Hint: c.SPF.Hint})
|
||||||
|
add(Finding{ID: "dmarc:" + d, Label: "DMARC", Domain: d, Status: c.DMARC.Status, Detail: c.DMARC.Detail, Hint: c.DMARC.Hint})
|
||||||
|
add(Finding{ID: "mtasts:" + d, Label: "MTA-STS", Domain: d, Status: c.MTASTS.Status, Detail: c.MTASTS.Detail, Hint: c.MTASTS.Hint})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(chk.Blocklists) > 0:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingFail, Detail: x.SourceIP + " is listed on " + strings.Join(chk.Blocklists, ", "),
|
||||||
|
Hint: "Look the address up on each list's website to see why it was listed and request removal. Until then expect mail to be rejected or sent to spam; check that no one is relaying spam through this server."})
|
||||||
|
case chk.Listable > 0:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingPass, Detail: fmt.Sprintf("%s isn't on any of the %d blocklists checked", x.SourceIP, chk.Listable)})
|
||||||
|
case chk.Port25 != "" || chk.PTR != nil:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingWarn, Detail: "no blocklist answered, so the address couldn't be checked",
|
||||||
|
Hint: "Blocklists often refuse queries from public DNS resolvers; the edge needs a resolver of its own, or its provider's, to check them."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if o := chk.Outcomes; o != nil {
|
||||||
|
if total := o.Delivered + o.Deferred + o.Failed; total >= minOutcomeSample {
|
||||||
|
bad := float64(o.Deferred+o.Failed) / float64(total)
|
||||||
|
detail := fmt.Sprintf("%d of %d messages in the last day were delivered (%d deferred, %d refused)", o.Delivered, total, o.Deferred, o.Failed)
|
||||||
|
hint := outcomeHint(o)
|
||||||
|
switch {
|
||||||
|
case bad > 0.3:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingFail, Detail: detail, Hint: hint})
|
||||||
|
case bad > 0.1:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingWarn, Detail: detail, Hint: hint})
|
||||||
|
default:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingPass, Detail: detail})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeHint explains the latest refusal in terms of what to fix.
|
||||||
|
func outcomeHint(o *model.MailOutcomes) string {
|
||||||
|
if len(o.Recent) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := o.Recent[0]
|
||||||
|
low := strings.ToLower(r.Text)
|
||||||
|
advice := "Read the message, it usually says what the receiver objected to."
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "spf") || strings.Contains(low, "dmarc") || strings.Contains(low, "dkim") || strings.Contains(low, "authenticat"):
|
||||||
|
advice = "The receiver wants authentication to pass: check the SPF, DKIM and DMARC lines above."
|
||||||
|
case strings.Contains(low, "blocklist") || strings.Contains(low, "blacklist") || strings.Contains(low, "rbl") || strings.Contains(low, "listed") || strings.Contains(low, "spamhaus"):
|
||||||
|
advice = "The receiver rejected the address as listed on a blocklist: see the Blocklists line and request delisting."
|
||||||
|
case strings.Contains(low, "greylist") || strings.Contains(low, "try again") || strings.Contains(low, "temporar"):
|
||||||
|
advice = "That receiver is delaying unfamiliar senders; these retry and normally get through."
|
||||||
|
case strings.Contains(low, "rate") || strings.Contains(low, "too many") || strings.Contains(low, "throttl"):
|
||||||
|
advice = "The receiver is limiting how fast you send to it; slow the sending or lower the relay's per-minute limit."
|
||||||
|
case strings.Contains(low, "ptr") || strings.Contains(low, "reverse"):
|
||||||
|
advice = "The receiver objects to the reverse DNS: see the Reverse DNS line."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Latest from %s: %d %s. %s", r.Domain, r.Code, r.Text, advice)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func find(fs []Finding, id string) *Finding {
|
||||||
|
for i := range fs {
|
||||||
|
if fs[i].ID == id {
|
||||||
|
return &fs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsCarryFixes(t *testing.T) {
|
||||||
|
x := &model.MailEgress{ID: "x1", OrgID: "o", SourceIP: "203.0.113.7", HeloName: "mail.example.com"}
|
||||||
|
keys := []*model.DKIMKey{{ID: "k1", OrgID: "o", Domain: "example.com", Selector: "s1"}, {ID: "k2", OrgID: "other", Domain: "x.test", Selector: "s"}}
|
||||||
|
chk := model.MailCheck{
|
||||||
|
PTR: []string{"host.vps.test"}, Port25: "can't connect",
|
||||||
|
DKIM: map[string]string{"k1": "mismatch", "k2": "missing"},
|
||||||
|
Domains: map[string]model.DomainCheck{"example.com": {
|
||||||
|
SPF: model.MailFinding{Status: model.FindingFail, Detail: "no", Hint: "add ip4"},
|
||||||
|
DMARC: model.MailFinding{Status: model.FindingNone, Detail: "none", Hint: "publish"},
|
||||||
|
MTASTS: model.MailFinding{Status: model.FindingNone, Detail: "none", Hint: "optional"},
|
||||||
|
}},
|
||||||
|
Blocklists: []string{"zen.spamhaus.org"},
|
||||||
|
}
|
||||||
|
fs := deliverabilityFindings(x, chk, keys)
|
||||||
|
for id, want := range map[string]string{"ptr": "fail", "helo": "fail", "port25": "fail", "dkim:k1": "fail", "spf:example.com": "fail", "blocklist": "fail"} {
|
||||||
|
f := find(fs, id)
|
||||||
|
if f == nil || f.Status != want {
|
||||||
|
t.Errorf("%s: %+v", id, f)
|
||||||
|
} else if want == "fail" && f.Hint == "" {
|
||||||
|
t.Errorf("%s fails without saying how to fix it", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if find(fs, "dkim:k2") != nil {
|
||||||
|
t.Error("another org's key leaked into the checklist")
|
||||||
|
}
|
||||||
|
if f := find(fs, "ptr"); !strings.Contains(f.Detail, "host.vps.test") || !strings.Contains(f.Hint, "mail.example.com") {
|
||||||
|
t.Errorf("ptr: %+v", f)
|
||||||
|
}
|
||||||
|
if find(fs, "mtasts:example.com").alertWorthy() {
|
||||||
|
t.Error("a missing MTA-STS policy is advice, not an alert")
|
||||||
|
}
|
||||||
|
if !find(fs, "dmarc:example.com").alertWorthy() {
|
||||||
|
t.Error("a missing DMARC record should alert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsAllGood(t *testing.T) {
|
||||||
|
x := &model.MailEgress{OrgID: "o", SourceIP: "203.0.113.7", HeloName: "mail.example.com"}
|
||||||
|
chk := model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4,
|
||||||
|
Outcomes: &model.MailOutcomes{Delivered: 100}}
|
||||||
|
for _, f := range deliverabilityFindings(x, chk, nil) {
|
||||||
|
if f.Status != model.FindingPass || f.alertWorthy() {
|
||||||
|
t.Errorf("%+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutcomeFindings(t *testing.T) {
|
||||||
|
x := &model.MailEgress{OrgID: "o", SourceIP: "203.0.113.7", HeloName: "h"}
|
||||||
|
status := func(o *model.MailOutcomes) string {
|
||||||
|
f := find(deliverabilityFindings(x, model.MailCheck{Outcomes: o}, nil), "outcomes")
|
||||||
|
if f == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return f.Status
|
||||||
|
}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
o *model.MailOutcomes
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"too few to judge": {&model.MailOutcomes{Delivered: 2, Failed: 5}, ""},
|
||||||
|
"healthy": {&model.MailOutcomes{Delivered: 95, Deferred: 5}, "pass"},
|
||||||
|
"some trouble": {&model.MailOutcomes{Delivered: 80, Failed: 20}, "warn"},
|
||||||
|
"mostly refused": {&model.MailOutcomes{Delivered: 20, Failed: 80}, "fail"},
|
||||||
|
} {
|
||||||
|
if got := status(c.o); got != c.want {
|
||||||
|
t.Errorf("%s: %q, want %q", name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := outcomeHint(&model.MailOutcomes{Recent: []model.OutcomeFailure{{Domain: "gmail.com", Code: 550, Text: "5.7.26 DMARC authentication failed"}}})
|
||||||
|
if !strings.Contains(h, "gmail.com") || !strings.Contains(h, "SPF, DKIM and DMARC") {
|
||||||
|
t.Errorf("hint: %q", h)
|
||||||
|
}
|
||||||
|
if outcomeHint(&model.MailOutcomes{}) != "" {
|
||||||
|
t.Error("no refusals, no hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverabilityRaisesAlertsWithHints(t *testing.T) {
|
||||||
|
f, _ := dmarcFx(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.mailChecks["x1"] = model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4,
|
||||||
|
Blocklists: []string{"bl.spamcop.net"},
|
||||||
|
Domains: map[string]model.DomainCheck{"example.com": {
|
||||||
|
SPF: model.MailFinding{Status: model.FindingFail, Detail: "SPF for example.com does not authorize 203.0.113.7", Hint: "Add ip4:203.0.113.7"},
|
||||||
|
DMARC: model.MailFinding{Status: model.FindingWarn, Detail: "monitoring"},
|
||||||
|
MTASTS: model.MailFinding{Status: model.FindingNone},
|
||||||
|
}}}
|
||||||
|
probs := f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
spf, bl := probs["mailfind:x1:spf:example.com"], probs["mailfind:x1:blocklist"]
|
||||||
|
if !strings.Contains(spf.msg, "Add ip4:203.0.113.7") || !strings.Contains(bl.msg, "bl.spamcop.net") {
|
||||||
|
t.Fatalf("alerts should carry the fix: %+v %+v", spf, bl)
|
||||||
|
}
|
||||||
|
if _, noisy := probs["mailfind:x1:dmarc:example.com"]; noisy {
|
||||||
|
t.Error("a DMARC warning shouldn't alert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed: both clear
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.mailChecks["x1"] = model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4}
|
||||||
|
probs = f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
for k := range probs {
|
||||||
|
if strings.HasPrefix(k, "mailfind:") {
|
||||||
|
t.Errorf("%s should have cleared", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanDiscoveredDropsBadEntriesAndBoundsText(t *testing.T) {
|
||||||
|
in := []model.DiscoveredService{
|
||||||
|
{Host: "192.168.1.5", Port: 22, Proto: "udp", Service: "ssh", Detail: "SSH-2.0", Source: "scan"},
|
||||||
|
{Host: "127.0.0.1", Port: 8080, Service: strings.Repeat("x", 100), Detail: strings.Repeat("d", 1000), Source: "local"},
|
||||||
|
{Host: "<script>", Port: 80},
|
||||||
|
{Host: "10.0.0.1", Port: 0},
|
||||||
|
{Host: "10.0.0.1", Port: 70000},
|
||||||
|
{Host: "10.0.0.2", Port: 80, Source: "whatever"},
|
||||||
|
}
|
||||||
|
got := cleanDiscovered(in)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("want 3 valid entries, got %+v", got)
|
||||||
|
}
|
||||||
|
if got[0].Proto != "tcp" || got[1].Source != "local" || len(got[1].Service) != 20 || len(got[1].Detail) != 160 || got[2].Source != "scan" {
|
||||||
|
t.Errorf("not normalised: %+v", got)
|
||||||
|
}
|
||||||
|
many := make([]model.DiscoveredService, 900)
|
||||||
|
for i := range many {
|
||||||
|
many[i] = model.DiscoveredService{Host: "10.0.0.1", Port: i%60000 + 1}
|
||||||
|
}
|
||||||
|
if n := len(cleanDiscovered(many)); n != 500 {
|
||||||
|
t.Errorf("not capped: %d", n)
|
||||||
|
}
|
||||||
|
if cleanDiscovered(nil) == nil {
|
||||||
|
t.Error("should be an empty list, not nil, so the API returns []")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/dmarc"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMARC aggregate reports. Receivers that handle mail claiming to be from your
|
||||||
|
// domain mail a daily summary to the rua= address in its DMARC record. Those
|
||||||
|
// emails are uploaded here (the raw .xml, .gz, .zip or the whole .eml), parsed,
|
||||||
|
// and kept for 90 days; the dashboard shows how much of the mail sent as your
|
||||||
|
// domain passed, and which senders are failing.
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxDMARCUpload = 8 << 20
|
||||||
|
dmarcKeepDays = 90
|
||||||
|
dmarcWindowDays = 30
|
||||||
|
dmarcAlertMinMsg = 20 // messages from our own relays before their pass rate says anything
|
||||||
|
dmarcAlertBelow = 0.9 // alert when less of our own mail than this passes DMARC
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- storage ----------
|
||||||
|
|
||||||
|
const dmarcSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS dmarc_reports (
|
||||||
|
org_id TEXT NOT NULL, domain TEXT NOT NULL, reporter TEXT NOT NULL, report_id TEXT NOT NULL,
|
||||||
|
begin_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, added INTEGER NOT NULL, data TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (org_id, domain, reporter, report_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS dmarc_end ON dmarc_reports (org_id, end_ts);
|
||||||
|
`
|
||||||
|
|
||||||
|
// addDMARC stores a report; it reports false if this exact report was already there.
|
||||||
|
func (d *DB) addDMARC(org string, r *dmarc.Report) (bool, error) {
|
||||||
|
data, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
res, err := d.sql.Exec(`INSERT OR IGNORE INTO dmarc_reports (org_id, domain, reporter, report_id, begin_ts, end_ts, added, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
org, r.Domain, strings.ToLower(r.Org), r.ReportID, ms(r.Begin), ms(r.End), ms(time.Now()), string(data))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcReports returns an org's reports that ended after since.
|
||||||
|
func (d *DB) dmarcReports(org string, since time.Time) []*dmarc.Report {
|
||||||
|
rows, err := d.sql.Query(`SELECT data FROM dmarc_reports WHERE org_id = ? AND end_ts > ? ORDER BY end_ts`, org, ms(since))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*dmarc.Report
|
||||||
|
for rows.Next() {
|
||||||
|
var data string
|
||||||
|
var r dmarc.Report
|
||||||
|
if rows.Scan(&data) == nil && json.Unmarshal([]byte(data), &r) == nil {
|
||||||
|
out = append(out, &r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) purgeDMARC(before time.Time) {
|
||||||
|
d.sql.Exec(`DELETE FROM dmarc_reports WHERE end_ts < ?`, ms(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) deleteDMARCDomain(org, domain string) (int64, error) {
|
||||||
|
var res sql.Result
|
||||||
|
res, err := d.sql.Exec(`DELETE FROM dmarc_reports WHERE org_id = ? AND domain = ?`, org, domain)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- summary ----------
|
||||||
|
|
||||||
|
type dmarcSource struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Messages int `json:"messages"`
|
||||||
|
Passed int `json:"passed"` // messages that passed DMARC
|
||||||
|
DKIMPass int `json:"dkimPass"`
|
||||||
|
SPFPass int `json:"spfPass"`
|
||||||
|
Ours bool `json:"ours"` // the address is one of this org's mail egress IPs
|
||||||
|
Rejected int `json:"rejected"` // the receiver quarantined or rejected these
|
||||||
|
Reporters int `json:"reporters"` // how many receivers saw it
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dmarcDomain struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Reports int `json:"reports"`
|
||||||
|
Messages int `json:"messages"`
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
OurMsgs int `json:"ourMessages"` // from our own relays
|
||||||
|
OurPassed int `json:"ourPassed"`
|
||||||
|
Policy string `json:"policy"` // the latest published policy a receiver reported
|
||||||
|
Pct int `json:"pct"`
|
||||||
|
Reporters []string `json:"reporters"`
|
||||||
|
First time.Time `json:"first"`
|
||||||
|
Last time.Time `json:"last"`
|
||||||
|
Sources []dmarcSource `json:"sources"`
|
||||||
|
Advice []string `json:"advice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeDMARC totals reports per domain. ours holds the org's egress source IPs.
|
||||||
|
func summarizeDMARC(reports []*dmarc.Report, ours map[string]bool) []dmarcDomain {
|
||||||
|
type acc struct {
|
||||||
|
d *dmarcDomain
|
||||||
|
src map[string]*dmarcSource
|
||||||
|
srcRep map[string]map[string]bool
|
||||||
|
reporters map[string]bool
|
||||||
|
lastEnd time.Time
|
||||||
|
}
|
||||||
|
by := map[string]*acc{}
|
||||||
|
for _, r := range reports {
|
||||||
|
a := by[r.Domain]
|
||||||
|
if a == nil {
|
||||||
|
a = &acc{d: &dmarcDomain{Domain: r.Domain, First: r.Begin}, src: map[string]*dmarcSource{}, srcRep: map[string]map[string]bool{}, reporters: map[string]bool{}}
|
||||||
|
by[r.Domain] = a
|
||||||
|
}
|
||||||
|
d := a.d
|
||||||
|
d.Reports++
|
||||||
|
if r.Begin.Before(d.First) {
|
||||||
|
d.First = r.Begin
|
||||||
|
}
|
||||||
|
if r.End.After(a.lastEnd) { // the newest report's policy is the one in force
|
||||||
|
a.lastEnd, d.Last, d.Policy, d.Pct = r.End, r.End, r.Policy.P, r.Policy.Pct
|
||||||
|
}
|
||||||
|
a.reporters[r.Org] = true
|
||||||
|
for _, rec := range r.Records {
|
||||||
|
s := a.src[rec.SourceIP]
|
||||||
|
if s == nil {
|
||||||
|
s = &dmarcSource{IP: rec.SourceIP, Ours: ours[rec.SourceIP]}
|
||||||
|
a.src[rec.SourceIP] = s
|
||||||
|
a.srcRep[rec.SourceIP] = map[string]bool{}
|
||||||
|
}
|
||||||
|
a.srcRep[rec.SourceIP][r.Org] = true
|
||||||
|
s.Messages += rec.Count
|
||||||
|
d.Messages += rec.Count
|
||||||
|
if rec.Passed() {
|
||||||
|
s.Passed += rec.Count
|
||||||
|
d.Passed += rec.Count
|
||||||
|
}
|
||||||
|
if rec.DKIM == "pass" {
|
||||||
|
s.DKIMPass += rec.Count
|
||||||
|
}
|
||||||
|
if rec.SPF == "pass" {
|
||||||
|
s.SPFPass += rec.Count
|
||||||
|
}
|
||||||
|
if rec.Disposition == "quarantine" || rec.Disposition == "reject" {
|
||||||
|
s.Rejected += rec.Count
|
||||||
|
}
|
||||||
|
if s.Ours {
|
||||||
|
d.OurMsgs += rec.Count
|
||||||
|
if rec.Passed() {
|
||||||
|
d.OurPassed += rec.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []dmarcDomain
|
||||||
|
for _, a := range by {
|
||||||
|
d := a.d
|
||||||
|
for ip, s := range a.src {
|
||||||
|
s.Reporters = len(a.srcRep[ip])
|
||||||
|
s.Hint = sourceHint(s, d.Domain)
|
||||||
|
d.Sources = append(d.Sources, *s)
|
||||||
|
}
|
||||||
|
sort.Slice(d.Sources, func(i, j int) bool {
|
||||||
|
fi, fj := d.Sources[i].Messages-d.Sources[i].Passed, d.Sources[j].Messages-d.Sources[j].Passed
|
||||||
|
if fi != fj {
|
||||||
|
return fi > fj
|
||||||
|
}
|
||||||
|
return d.Sources[i].Messages > d.Sources[j].Messages
|
||||||
|
})
|
||||||
|
for r := range a.reporters {
|
||||||
|
d.Reporters = append(d.Reporters, r)
|
||||||
|
}
|
||||||
|
sort.Strings(d.Reporters)
|
||||||
|
d.Advice = dmarcAdvice(d)
|
||||||
|
out = append(out, *d)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceHint(s *dmarcSource, domain string) string {
|
||||||
|
failed := s.Messages - s.Passed
|
||||||
|
if failed == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case s.Ours && s.DKIMPass == 0 && s.SPFPass == 0:
|
||||||
|
return "Your own relay's mail failed both SPF and DKIM alignment. Check that " + domain + " has a published DKIM key here and an SPF record authorizing " + s.IP + "."
|
||||||
|
case s.Ours && s.DKIMPass == 0:
|
||||||
|
return "Mail from your relay only passes through SPF. Create and publish a DKIM key for " + domain + " so it survives forwarding."
|
||||||
|
case s.Ours:
|
||||||
|
return "Some mail from your relay failed. It is probably mail signed or sent under a different domain than the From address."
|
||||||
|
case s.Rejected >= failed:
|
||||||
|
return "Receivers are already quarantining or rejecting this sender. If it isn't yours, your policy is working; if it is, add it to your SPF record and set up DKIM with it."
|
||||||
|
default:
|
||||||
|
return "A sender outside your relays is failing. If it's a service you use (a newsletter or helpdesk tool), add its SPF include and set up its DKIM; if you don't recognise it, someone may be spoofing your domain and a stricter policy would stop that."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dmarcAdvice(d *dmarcDomain) []string {
|
||||||
|
var out []string
|
||||||
|
failedOther := 0
|
||||||
|
for _, s := range d.Sources {
|
||||||
|
if !s.Ours {
|
||||||
|
failedOther += s.Messages - s.Passed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.OurMsgs >= dmarcAlertMinMsg && float64(d.OurPassed)/float64(d.OurMsgs) < dmarcAlertBelow {
|
||||||
|
out = append(out, fmt.Sprintf("Only %d%% of the mail from your own relays passed DMARC. Fix this before tightening the policy.", 100*d.OurPassed/d.OurMsgs))
|
||||||
|
}
|
||||||
|
if d.Policy == "none" && d.Messages >= dmarcAlertMinMsg && failedOther == 0 && (d.OurMsgs == 0 || d.OurPassed == d.OurMsgs) {
|
||||||
|
out = append(out, "Everything that sent as "+d.Domain+" passed. You can move the policy from p=none to p=quarantine, then p=reject.")
|
||||||
|
}
|
||||||
|
if d.Policy == "none" && failedOther > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("%d messages from other senders failed. Identify them below before you enforce a policy, or legitimate mail will be blocked.", failedOther))
|
||||||
|
}
|
||||||
|
if d.Policy != "none" && d.Pct > 0 && d.Pct < 100 {
|
||||||
|
out = append(out, fmt.Sprintf("The policy applies to only %d%% of mail (pct=%d). Raise it to 100 once reports look clean.", d.Pct, d.Pct))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
func (s *Server) orgEgressIPsLocked(org string) map[string]bool {
|
||||||
|
ips := map[string]bool{}
|
||||||
|
for _, x := range s.st.MailEgress {
|
||||||
|
if x.OrgID == org {
|
||||||
|
ips[x.SourceIP] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dmarcDomainsLocked(org string) map[string]bool {
|
||||||
|
doms := map[string]bool{}
|
||||||
|
for _, k := range s.st.DKIM {
|
||||||
|
if k.OrgID == org {
|
||||||
|
doms[strings.ToLower(k.Domain)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doms
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcForOrg summarizes an org's recent reports. It reads the database without holding s.mu.
|
||||||
|
func (s *Server) dmarcForOrg(org string, ours map[string]bool) []dmarcDomain {
|
||||||
|
return summarizeDMARC(s.db.dmarcReports(org, time.Now().AddDate(0, 0, -dmarcWindowDays)), ours)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
ours := s.orgEgressIPsLocked(c.Org.ID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
out := s.dmarcForOrg(c.Org.ID, ours)
|
||||||
|
if out == nil {
|
||||||
|
out = []dmarcDomain{}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadDMARC accepts one or more reports in the request body (a report, or a whole email with one attached).
|
||||||
|
func (s *Server) uploadDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxDMARCUpload))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 413, errors.New("that file is too large to be a DMARC report"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rep, err := dmarc.Parse(body)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
known := s.dmarcDomainsLocked(c.Org.ID)[rep.Domain]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !known {
|
||||||
|
failf(w, 400, "this report is about %s, which isn't a domain you have a DKIM key for. Create the key first, or check the report is yours.", rep.Domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
added, err := s.db.addDMARC(c.Org.ID, rep)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if added {
|
||||||
|
s.audit(c.Org.ID, c.Actor, "dmarc.upload", rep.Domain+" from "+rep.Org)
|
||||||
|
}
|
||||||
|
msgs := 0
|
||||||
|
for _, rec := range rep.Records {
|
||||||
|
msgs += rec.Count
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"domain": rep.Domain, "reporter": rep.Org, "messages": msgs, "duplicate": !added})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
domain := strings.ToLower(r.PathValue("domain"))
|
||||||
|
if _, err := s.db.deleteDMARCDomain(c.Org.ID, domain); err != nil {
|
||||||
|
fail(w, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(c.Org.ID, c.Actor, "dmarc.delete", domain)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcProblems derives the alerts from reports: the mail our own relays send
|
||||||
|
// as a domain is failing DMARC. Caller holds s.mu; the database is read inside,
|
||||||
|
// which takes no server locks.
|
||||||
|
func (s *Server) dmarcProblemsLocked(add func(key, org, kind, name, msg string)) {
|
||||||
|
orgs := map[string]bool{}
|
||||||
|
for _, k := range s.st.DKIM {
|
||||||
|
orgs[k.OrgID] = true
|
||||||
|
}
|
||||||
|
for org := range orgs {
|
||||||
|
for _, d := range s.dmarcForOrg(org, s.orgEgressIPsLocked(org)) {
|
||||||
|
if d.OurMsgs >= dmarcAlertMinMsg && float64(d.OurPassed)/float64(d.OurMsgs) < dmarcAlertBelow {
|
||||||
|
add("dmarcrate:"+org+":"+d.Domain, org, "mail", d.Domain,
|
||||||
|
fmt.Sprintf("Only %d%% of the mail your relays sent as %s passed DMARC in the last %d days (%d of %d messages). Open Mail → DMARC reports for the failing senders.",
|
||||||
|
100*d.OurPassed/d.OurMsgs, d.Domain, dmarcWindowDays, d.OurPassed, d.OurMsgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- findings for the dashboard ----------
|
||||||
|
|
||||||
|
// mailEgressFindings lists the checklist for one egress. Caller holds s.mu.
|
||||||
|
func (s *Server) mailEgressFindingsLocked(x *model.MailEgress, chk model.MailCheck) []Finding {
|
||||||
|
return deliverabilityFindings(x, chk, s.st.DKIM)
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/dmarc"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func reportXML(domain, id, reporter string, rows ...[4]string) string { // ip, count, dkim, spf
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(`<feedback><report_metadata><org_name>` + reporter + `</org_name><report_id>` + id + `</report_id><date_range><begin>` +
|
||||||
|
strconv.FormatInt(time.Now().Add(-26*time.Hour).Unix(), 10) + `</begin><end>` + strconv.FormatInt(time.Now().Add(-2*time.Hour).Unix(), 10) + `</end></date_range></report_metadata><policy_published><domain>` + domain + `</domain><p>none</p><pct>100</pct></policy_published>`)
|
||||||
|
for _, r := range rows {
|
||||||
|
disp := "none"
|
||||||
|
if r[2] != "pass" && r[3] != "pass" {
|
||||||
|
disp = "quarantine"
|
||||||
|
}
|
||||||
|
sb.WriteString(`<record><row><source_ip>` + r[0] + `</source_ip><count>` + r[1] + `</count><policy_evaluated><disposition>` + disp + `</disposition><dkim>` + r[2] + `</dkim><spf>` + r[3] + `</spf></policy_evaluated></row><identifiers><header_from>` + domain + `</header_from></identifiers></record>`)
|
||||||
|
}
|
||||||
|
sb.WriteString(`</feedback>`)
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipped(s string) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := gzip.NewWriter(&b)
|
||||||
|
w.Write([]byte(s))
|
||||||
|
w.Close()
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcFx is an org that sends as example.com from 203.0.113.7.
|
||||||
|
func dmarcFx(t *testing.T) (*fx, *orgCtx) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.DKIM = append(f.s.st.DKIM, &model.DKIMKey{ID: "k1", OrgID: f.org, Domain: "example.com", Selector: "s1"})
|
||||||
|
f.s.st.MailEgress = append(f.s.st.MailEgress, &model.MailEgress{ID: "x1", OrgID: f.org, Name: "relay", SiteID: "s1", EdgeID: "e1", SourceIP: "203.0.113.7", HeloName: "mail.example.com", Enabled: true})
|
||||||
|
org := f.s.findOrg(f.org)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
return f, &orgCtx{Org: *org, Actor: "test"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(t *testing.T, f *fx, c *orgCtx, body []byte) (int, map[string]any) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.uploadDMARC(w, httptest.NewRequest("POST", "/", bytes.NewReader(body)), c)
|
||||||
|
var out map[string]any
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &out)
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAndSummarize(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
code, out := upload(t, f, c, gzipped(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "30", "pass", "pass"}, [4]string{"198.51.100.9", "5", "fail", "fail"})))
|
||||||
|
if code != 200 || out["messages"] != float64(35) || out["duplicate"] != false {
|
||||||
|
t.Fatalf("%d %v", code, out)
|
||||||
|
}
|
||||||
|
if _, out = upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com"))); out["duplicate"] != true {
|
||||||
|
t.Errorf("the same report twice should be a duplicate: %v", out)
|
||||||
|
}
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r2", "yahoo.com", [4]string{"203.0.113.7", "10", "pass", "fail"})))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.listDMARC(w, httptest.NewRequest("GET", "/", nil), c)
|
||||||
|
var doms []dmarcDomain
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &doms); err != nil || len(doms) != 1 {
|
||||||
|
t.Fatalf("%v %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
d := doms[0]
|
||||||
|
if d.Reports != 2 || d.Messages != 45 || d.Passed != 40 || d.OurMsgs != 40 || d.OurPassed != 40 {
|
||||||
|
t.Errorf("totals: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.Sources) != 2 || d.Sources[0].IP != "198.51.100.9" || d.Sources[0].Ours || d.Sources[0].Hint == "" {
|
||||||
|
t.Errorf("failing sources should come first with a hint: %+v", d.Sources)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(d.Advice, " "), "failed") {
|
||||||
|
t.Errorf("advice should flag the unknown sender: %v", d.Advice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRefusals(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
if code, _ := upload(t, f, c, []byte("garbage")); code != 400 {
|
||||||
|
t.Errorf("garbage: %d", code)
|
||||||
|
}
|
||||||
|
code, out := upload(t, f, c, []byte(reportXML("someone-else.org", "r9", "google.com", [4]string{"1.2.3.4", "1", "pass", "pass"})))
|
||||||
|
if code != 400 || !strings.Contains(out["error"].(string), "someone-else.org") {
|
||||||
|
t.Errorf("a domain the org doesn't send from: %d %v", code, out)
|
||||||
|
}
|
||||||
|
big := make([]byte, maxDMARCUpload+1)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.uploadDMARC(w, httptest.NewRequest("POST", "/", bytes.NewReader(big)), c)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversize: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportsStayInTheirOrg(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "5", "pass", "pass"})))
|
||||||
|
if n := len(f.s.db.dmarcReports("another-org", time.Time{})); n != 0 {
|
||||||
|
t.Errorf("another org sees %d reports", n)
|
||||||
|
}
|
||||||
|
if n, _ := f.s.db.deleteDMARCDomain(f.org, "example.com"); n != 1 {
|
||||||
|
t.Errorf("delete removed %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeAdvice(t *testing.T) {
|
||||||
|
rep := func(p string, pct int, rows ...dmarc.Record) *dmarc.Report {
|
||||||
|
return &dmarc.Report{Org: "g", ReportID: "1", Domain: "example.com", Begin: time.Unix(1, 0), End: time.Unix(2, 0), Policy: dmarc.Policy{P: p, Pct: pct}, Records: rows}
|
||||||
|
}
|
||||||
|
ours := map[string]bool{"203.0.113.7": true}
|
||||||
|
pass := dmarc.Record{SourceIP: "203.0.113.7", Count: 50, DKIM: "pass", SPF: "pass"}
|
||||||
|
|
||||||
|
got := summarizeDMARC([]*dmarc.Report{rep("none", 100, pass)}, ours)[0]
|
||||||
|
if !strings.Contains(strings.Join(got.Advice, " "), "p=quarantine") {
|
||||||
|
t.Errorf("a clean domain at p=none should be told to tighten: %v", got.Advice)
|
||||||
|
}
|
||||||
|
bad := dmarc.Record{SourceIP: "203.0.113.7", Count: 50, DKIM: "fail", SPF: "fail", Disposition: "quarantine"}
|
||||||
|
got = summarizeDMARC([]*dmarc.Report{rep("quarantine", 50, pass, bad)}, ours)[0]
|
||||||
|
joined := strings.Join(got.Advice, " ")
|
||||||
|
if !strings.Contains(joined, "50%") || !strings.Contains(joined, "pct=50") {
|
||||||
|
t.Errorf("advice: %v", got.Advice)
|
||||||
|
}
|
||||||
|
got = summarizeDMARC([]*dmarc.Report{rep("quarantine", 100, bad)}, ours)[0]
|
||||||
|
if got.Sources[0].Hint == "" || !strings.Contains(got.Sources[0].Hint, "SPF and DKIM") {
|
||||||
|
t.Errorf("own relay failing both: %q", got.Sources[0].Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnMailFailingDMARCRaisesAnAlert(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "40", "fail", "fail"}, [4]string{"203.0.113.7", "10", "pass", "pass"})))
|
||||||
|
f.s.mu.Lock()
|
||||||
|
probs := f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
p, ok := probs["dmarcrate:"+f.org+":example.com"]
|
||||||
|
if !ok || !strings.Contains(p.msg, "20%") {
|
||||||
|
t.Fatalf("expected a DMARC pass-rate alert, got %v", probs)
|
||||||
|
}
|
||||||
|
// a healthy domain raises none
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r2", "yahoo.com", [4]string{"203.0.113.7", "500", "pass", "pass"})))
|
||||||
|
f.s.mu.Lock()
|
||||||
|
probs = f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if _, ok := probs["dmarcrate:"+f.org+":example.com"]; ok {
|
||||||
|
t.Errorf("alert should clear once the pass rate recovers: %v", probs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Scripting Hammerhead: API audit
|
||||||
|
|
||||||
|
Everything the dashboard does goes through the JSON API (`server.go` lists the routes). Callers
|
||||||
|
authenticate with the `hh_session` cookie or `Authorization: Bearer hhk_<id>_<secret>`.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
| Credential | Reaches | Made at |
|
||||||
|
|---|---|---|
|
||||||
|
| Organization key | that organization's `/api/orgs/{org}/...` routes (admin rights), `GET /api/orgs`, `GET /api/edges` (edges the org may use) | `POST /api/orgs/{org}/apikeys` |
|
||||||
|
| Server key | every organization, plus the server-admin routes: edges, `/api/admin/alerts`, users, settings, `POST /api/orgs` | `POST /api/admin/apikeys` (signed-in server admin only) |
|
||||||
|
|
||||||
|
The secret is shown once, in the `key` field of the create response. A key cannot mint keys at server
|
||||||
|
level, and a server key has no user, so `/api/me` and other per-user routes refuse it.
|
||||||
|
|
||||||
|
## Gaps found, and what was done
|
||||||
|
|
||||||
|
| Gap | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| Edges, server alerts, `POST /api/orgs` required a browser session (`withServerAdmin` refused keys) | server keys, accepted by `withServerAdmin` |
|
||||||
|
| No way to mint an unattended server credential | `GET/POST/DELETE /api/admin/apikeys` |
|
||||||
|
| `GET /api/edges` was session-only | accepts keys; an org key sees the edges its org may use |
|
||||||
|
| No way to list orgs with a key | `GET /api/orgs` |
|
||||||
|
| Key creation did not return the key's ID | create response now has `id` as well as `key` |
|
||||||
|
| Creating an alert channel returned only `{ok:true}` | response now includes the channel `id` |
|
||||||
|
| `createOrg` added the caller as a member | skipped for server keys (they have no user) |
|
||||||
|
|
||||||
|
## Known remaining gaps (not blocking scripting)
|
||||||
|
|
||||||
|
- No per-resource `GET`: sites, domains, ports and alert channels are read from `GET /api/orgs/{org}/state` and
|
||||||
|
`GET /api/orgs/{org}/alerts`. Clients filter by ID.
|
||||||
|
- Alert channel secrets (webhook URL, Sandbar key) are never returned, so a client can't detect drift in them; a
|
||||||
|
blank value on `PUT` keeps the stored one.
|
||||||
|
- Keys are all-powerful within their scope (no read-only or per-resource scopes), and have no expiry.
|
||||||
|
- `PUT` on ports replaces the whole object; omitted fields reset.
|
||||||
|
- Site tokens are returned on create and, for admins, in `state`.
|
||||||
|
- Deleting an edge or site cascades silently (its ports and domains go with it, as do ports left with no target), so Terraform plans should not rely on dependent resources surviving.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exposed struct {
|
||||||
|
ID, Host, Hostname, URL, SiteToken string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// createExpose retries until an edge is online and the call succeeds.
|
||||||
|
func (w *world) createExpose(c *client, in map[string]any) exposed {
|
||||||
|
w.t.Helper()
|
||||||
|
var out exposed
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
if code, _ := c.do("POST", "/api/orgs/-/expose", in, &out); code == 200 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
c.must("POST", "/api/orgs/-/expose", in, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *world) agentFor(e exposed) context.CancelFunc {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
w.t.Cleanup(cancel)
|
||||||
|
b, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: e.SiteToken, DataDir: w.t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatal(err)
|
||||||
|
}
|
||||||
|
go b.Run(ctx)
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ephemeral exposure carries traffic through the tunnel, and is gone after DELETE.
|
||||||
|
func TestEphemeralExpose(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
var key struct{ Key string }
|
||||||
|
w.admin.must("POST", "/api/orgs/"+w.org+"/apikeys", map[string]string{"name": "shark"}, &key)
|
||||||
|
kc := newClient(t, w.ts.URL)
|
||||||
|
kc.key = key.Key
|
||||||
|
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||||
|
|
||||||
|
// ----- tcp -----
|
||||||
|
svc := tcpBackend(t, "SVC")
|
||||||
|
e := w.createExpose(kc, map[string]any{"proto": "tcp", "targetPort": svc, "ttlSeconds": 60})
|
||||||
|
stop := w.agentFor(e)
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(e.Port))
|
||||||
|
if got := tcpAsk(t, addr, "hi"); got != "SVC:hi" {
|
||||||
|
t.Fatalf("through the tunnel: %q", got)
|
||||||
|
}
|
||||||
|
kc.must("POST", "/api/orgs/-/expose/"+e.ID+"/renew", nil, nil)
|
||||||
|
kc.must("DELETE", "/api/orgs/-/expose/"+e.ID, nil, nil)
|
||||||
|
stop()
|
||||||
|
closed := false
|
||||||
|
for i := 0; i < 40 && !closed; i++ {
|
||||||
|
c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
closed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !closed {
|
||||||
|
t.Error("the port is still open after DELETE")
|
||||||
|
}
|
||||||
|
kc.expect(404, "GET", "/api/orgs/-/expose/"+e.ID, nil)
|
||||||
|
|
||||||
|
// ----- http, with a password -----
|
||||||
|
web := webBackend(t, "app", 200)
|
||||||
|
port := freePort(t)
|
||||||
|
h := w.createExpose(kc, map[string]any{"proto": "http", "name": "demo", "port": port, "targetPort": web,
|
||||||
|
"auth": map[string]any{"password": "hunter22"}})
|
||||||
|
w.agentFor(h)
|
||||||
|
_, get := edgeClient("http", port)
|
||||||
|
var r webResp
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
if r = get("GET", h.Hostname, "/x"); r.status == 302 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if r.status != 302 {
|
||||||
|
t.Fatalf("protected exposure should send visitors to the login, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traffic through an edge shows up in Hammerhead's charts, with the client address named.
|
||||||
|
func TestTrafficAnalyticsFlow(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
target := freeRun(t, 1)
|
||||||
|
pub := freeRun(t, 1)
|
||||||
|
if pub == target {
|
||||||
|
pub = freeRun(t, 2) + 1
|
||||||
|
}
|
||||||
|
tcpOn(t, target)
|
||||||
|
var m model.Mapping
|
||||||
|
if code, raw := w.admin.do("POST", "/api/orgs/"+w.org+"/mappings", map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": pub,
|
||||||
|
"targets": tgt(w.local, target), "enabled": true}, &m); code != 200 {
|
||||||
|
t.Fatalf("open: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if got := tcpAsk(t, "127.0.0.1:"+strconv.Itoa(pub), "hello"); got == "" {
|
||||||
|
t.Fatal("no answer through the edge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type detail struct {
|
||||||
|
Total struct{ Conns, BytesIn, BytesOut int64 } `json:"total"`
|
||||||
|
Points []struct{ Conns int64 } `json:"points"`
|
||||||
|
Sources []struct {
|
||||||
|
IP string
|
||||||
|
Conns int64
|
||||||
|
} `json:"sources"`
|
||||||
|
}
|
||||||
|
var d detail
|
||||||
|
for i := 0; ; i++ { // the edge reports every 20 seconds
|
||||||
|
d = detail{}
|
||||||
|
if code, _ := w.admin.do("GET", "/api/orgs/"+w.org+"/traffic/"+m.ID+"?range=1h", nil, &d); code == 200 && d.Total.Conns >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i >= 450 {
|
||||||
|
t.Fatal("timed out waiting for the edge to report the traffic")
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if d.Total.BytesIn < 15 || d.Total.BytesOut == 0 || len(d.Points) != 60 {
|
||||||
|
t.Errorf("totals: %+v with %d points", d.Total, len(d.Points))
|
||||||
|
}
|
||||||
|
if len(d.Sources) != 1 || d.Sources[0].IP != "127.0.0.1" || d.Sources[0].Conns < 3 {
|
||||||
|
t.Errorf("sources: %+v", d.Sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
var all detail
|
||||||
|
if code, raw := w.admin.do("GET", "/api/orgs/"+w.org+"/traffic/all?range=24h", nil, &all); code != 200 || all.Total.Conns < 3 || len(all.Points) != 24 {
|
||||||
|
t.Errorf("organization total: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
if code, _ := w.admin.do("GET", "/api/orgs/"+w.org+"/traffic/"+m.ID+"?range=5y", nil, nil); code != 400 {
|
||||||
|
t.Errorf("bad range: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := w.admin.do("GET", "/api/orgs/"+w.org+"/traffic/nope", nil, nil); code != 404 {
|
||||||
|
t.Errorf("unknown port: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts := "/api/orgs/" + w.org + "/alerts"
|
||||||
|
if code, _ := w.admin.do("PUT", alerts+"/traffic", map[string]any{"spikeFactor": 1}, nil); code != 400 {
|
||||||
|
t.Errorf("a spike of 1x was accepted: %d", code)
|
||||||
|
}
|
||||||
|
if code, raw := w.admin.do("PUT", alerts+"/traffic", map[string]any{"spikeFactor": 5, "dailyMb": 2048}, nil); code != 200 {
|
||||||
|
t.Fatalf("save traffic alerts: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var ov struct{ Settings map[string]int }
|
||||||
|
if code, _ := w.admin.do("GET", alerts, nil, &ov); code != 200 || ov.Settings["spikeFactor"] != 5 || ov.Settings["dailyMb"] != 2048 || ov.Settings["debounceMin"] != 2 {
|
||||||
|
t.Errorf("settings after save: %d %+v", code, ov.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A website on every address does not take the port from a website that names an address: each
|
||||||
|
// listens where it should, and the named address answers only for its own site.
|
||||||
|
func TestWildcardWebsiteStepsAsideForANamedAddress(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||||
|
wild, named := webBackend(t, "wild", 200), webBackend(t, "named", 200)
|
||||||
|
port := freePort(t)
|
||||||
|
|
||||||
|
w.open(model.Mapping{Proto: "http", BindIP: "0.0.0.0", Port: port, Hostname: "wild.example.test", Targets: tgt(w.site, wild)})
|
||||||
|
w.open(model.Mapping{Proto: "http", BindIP: "127.0.0.2", Port: port, Hostname: "named.example.test", Targets: tgt(w.site, named)})
|
||||||
|
|
||||||
|
ask := func(ip, host string) (int, string) {
|
||||||
|
c := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DisableKeepAlives: true,
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
|
||||||
|
}}}
|
||||||
|
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
var body map[string]string
|
||||||
|
json.Unmarshal(b, &body)
|
||||||
|
return resp.StatusCode, body["backend"]
|
||||||
|
}
|
||||||
|
wait := func(ip, host, wantBackend string) {
|
||||||
|
t.Helper()
|
||||||
|
var code int
|
||||||
|
var got string
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
if code, got = ask(ip, host); code == 200 && got == wantBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("%s via %s: status %d backend %q, want %q", host, ip, code, got, wantBackend)
|
||||||
|
}
|
||||||
|
|
||||||
|
wait("127.0.0.2", "named.example.test", "named") // the named address serves its own site
|
||||||
|
wait("127.0.0.1", "wild.example.test", "wild") // the wildcard still covers the other addresses
|
||||||
|
// and the wildcard gave up the named address: its site is not served there
|
||||||
|
if code, _ := ask("127.0.0.2", "wild.example.test"); code == 200 {
|
||||||
|
t.Errorf("the wildcard website still answers on the address another website names")
|
||||||
|
}
|
||||||
|
if code, _ := ask("127.0.0.1", "named.example.test"); code == 200 {
|
||||||
|
t.Errorf("a website answers on an address it was not given")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ephemeral exposures back `shark expose`: a short-lived site plus one port or hostname that
|
||||||
|
// vanish unless the client keeps renewing them. Both records carry exposeTag so they show up
|
||||||
|
// in the dashboard as temporary, and so a restart (which forgets the deadlines) can sweep them.
|
||||||
|
|
||||||
|
const (
|
||||||
|
exposeTag = "ephemeral"
|
||||||
|
defaultExposeTTL = 5 * time.Minute
|
||||||
|
minExposeTTL = 30 * time.Second
|
||||||
|
maxExposeTTL = 24 * time.Hour
|
||||||
|
exposePortMin = 20000
|
||||||
|
exposePortMax = 29999
|
||||||
|
maxExposePerOrg = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type exposeIn struct {
|
||||||
|
Proto string `json:"proto"` // http, https or tcp
|
||||||
|
Name string `json:"name"` // label for the subdomain and the dashboard
|
||||||
|
EdgeID string `json:"edgeId"`
|
||||||
|
Hostname string `json:"hostname"` // http: full name under one of the org's domains; default <name>-<random>.<domain>
|
||||||
|
Port int `json:"port"` // public port; default 80/443 for web, a free one in the ephemeral range for tcp
|
||||||
|
TargetHost string `json:"targetHost"`
|
||||||
|
TargetPort int `json:"targetPort"`
|
||||||
|
TTL int `json:"ttlSeconds"`
|
||||||
|
Auth *struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
Pin string `json:"pin"`
|
||||||
|
SSO bool `json:"sso"`
|
||||||
|
} `json:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposeOut struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Proto string `json:"proto"`
|
||||||
|
URL string `json:"url,omitempty"` // http: the address to open
|
||||||
|
Host string `json:"host"` // public host (tcp: where to connect)
|
||||||
|
Port int `json:"port"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
SiteToken string `json:"siteToken,omitempty"` // only in the create response: what Bamboo dials in with
|
||||||
|
Hammerhead string `json:"hammerhead,omitempty"` // only in the create response
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
Live bool `json:"live"` // the edge reports the port listening
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var slugBad = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func exposeSlug(n string) string {
|
||||||
|
n = strings.Trim(slugBad.ReplaceAllString(strings.ToLower(n), "-"), "-")
|
||||||
|
if len(n) > 20 {
|
||||||
|
n = strings.Trim(n[:20], "-")
|
||||||
|
}
|
||||||
|
if n == "" {
|
||||||
|
n = "expose"
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func exposeTTL(sec int) (time.Duration, error) {
|
||||||
|
if sec == 0 {
|
||||||
|
return defaultExposeTTL, nil
|
||||||
|
}
|
||||||
|
d := time.Duration(sec) * time.Second
|
||||||
|
if d < minExposeTTL || d > maxExposeTTL {
|
||||||
|
return 0, fmt.Errorf("ttlSeconds must be between %d and %d", int(minExposeTTL.Seconds()), int(maxExposeTTL.Seconds()))
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExposed(tags []string) bool {
|
||||||
|
for _, t := range tags {
|
||||||
|
if t == exposeTag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgeHost is the public host of an edge's WireGuard endpoint, which is where its ports are reachable.
|
||||||
|
func edgeHost(e *model.Edge) string {
|
||||||
|
h, _, err := net.SplitHostPort(e.TunnelAddr)
|
||||||
|
if err != nil {
|
||||||
|
return e.TunnelAddr
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposeEdges lists the edges an exposure may use: the named one, or every online edge the org may use.
|
||||||
|
func (s *Server) exposeEdges(orgID, want string) []*model.Edge {
|
||||||
|
var out []*model.Edge
|
||||||
|
for _, e := range s.st.Edges {
|
||||||
|
if (want != "" && e.ID != want) || !s.edgeAllowedFor(e, orgID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want != "" || s.edgeOnline(e) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) exposeView(m *model.Mapping, e *model.Edge) exposeOut {
|
||||||
|
st, known := s.status[m.ID]
|
||||||
|
o := exposeOut{ID: m.ID, Proto: m.Proto, Hostname: m.Hostname, Port: m.Port, Protected: m.Auth.Protected(),
|
||||||
|
ExpiresAt: s.expose[m.ID], URL: publicURL(m), Live: known && st.Error == "", Error: st.Error}
|
||||||
|
if e != nil {
|
||||||
|
o.Host = edgeHost(e)
|
||||||
|
}
|
||||||
|
if m.Hostname != "" {
|
||||||
|
o.Host = m.Hostname
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
var in exposeIn
|
||||||
|
if !decode(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ttl, err := exposeTTL(in.TTL)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Proto != model.ProtoHTTP && in.Proto != model.ProtoHTTPS && in.Proto != model.ProtoTCP {
|
||||||
|
fail(w, 400, errors.New("proto must be http, https or tcp"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.TargetHost == "" {
|
||||||
|
in.TargetHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if in.Auth != nil && !model.Web(in.Proto) {
|
||||||
|
fail(w, 400, errors.New("login protection works for http and https only"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mi := mappingInput{}
|
||||||
|
if in.Auth != nil {
|
||||||
|
if in.Auth.Password != "" {
|
||||||
|
mi.NewPassword = &in.Auth.Password
|
||||||
|
}
|
||||||
|
if in.Auth.Pin != "" {
|
||||||
|
mi.NewPin = &in.Auth.Pin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw, pin, err := hashSecrets(&mi)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := exposeSlug(in.Name)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
edges := s.exposeEdges(c.Org.ID, in.EdgeID)
|
||||||
|
if len(edges) == 0 {
|
||||||
|
fail(w, 409, errors.New("no edge is online for this organization"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := 0
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if m.OrgID == c.Org.ID && isExposed(m.Tags) {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active >= maxExposePerOrg {
|
||||||
|
fail(w, 429, errors.New("too many active exposures; stop one first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
site := &model.Site{ID: newID(), OrgID: c.Org.ID, Name: "expose " + slug, Token: randHex(24), Connected: []string{},
|
||||||
|
TunnelIP: s.allocIPLocked(), Tags: []string{exposeTag}}
|
||||||
|
if site.TunnelIP == "" {
|
||||||
|
fail(w, 507, errors.New("tunnel address space is full"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// validateMapping looks the site up, so it is added first and taken out again if nothing fits
|
||||||
|
s.st.Sites = append(s.st.Sites, site)
|
||||||
|
lastErr := errors.New("no edge could take it")
|
||||||
|
for _, e := range edges {
|
||||||
|
m := model.Mapping{Name: "expose " + slug, Proto: in.Proto, EdgeID: e.ID, Enabled: true, Tags: []string{exposeTag},
|
||||||
|
Targets: []model.Target{{SiteID: site.ID, Host: in.TargetHost, Port: in.TargetPort}}, HealthSeconds: -1}
|
||||||
|
if model.Web(in.Proto) {
|
||||||
|
m.Port = in.Port
|
||||||
|
if m.Port == 0 {
|
||||||
|
m.Port = map[string]int{model.ProtoHTTP: 80, model.ProtoHTTPS: 443}[in.Proto]
|
||||||
|
}
|
||||||
|
m.Auth = model.Auth{Password: pw != nil, Pin: pin != nil, SSO: in.Auth != nil && in.Auth.SSO}
|
||||||
|
hosts := []string{strings.ToLower(in.Hostname)}
|
||||||
|
if in.Hostname == "" {
|
||||||
|
hosts = nil
|
||||||
|
for _, d := range s.st.Domains {
|
||||||
|
if d.OrgID == c.Org.ID && d.EdgeID == e.ID {
|
||||||
|
hosts = append(hosts, fmt.Sprintf("%s-%s.%s", slug, randHex(2), d.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
lastErr = errors.New("no domain is set up for this edge; add one on the Domains tab, or give a hostname")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range hosts {
|
||||||
|
m.Hostname = h
|
||||||
|
if lastErr = s.validateMapping(c, &m); lastErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tries := 1
|
||||||
|
if in.Port == 0 {
|
||||||
|
tries = 60
|
||||||
|
}
|
||||||
|
for i := 0; i < tries; i++ {
|
||||||
|
m.Port = in.Port
|
||||||
|
if in.Port == 0 {
|
||||||
|
m.Port = exposePortMin + rand.IntN(exposePortMax-exposePortMin+1)
|
||||||
|
}
|
||||||
|
if lastErr = s.validateMapping(c, &m); lastErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.ID = newID()
|
||||||
|
s.applySecrets(&m, pw, pin)
|
||||||
|
s.st.Mappings = append(s.st.Mappings, &m)
|
||||||
|
s.expose[m.ID] = time.Now().Add(ttl)
|
||||||
|
s.bump()
|
||||||
|
s.audit(c.Org.ID, c.Actor, "expose.create", m.Name+" on "+portLabel(m))
|
||||||
|
out := s.exposeView(&m, e)
|
||||||
|
out.SiteToken = site.Token
|
||||||
|
out.Hammerhead = s.dashboardURL()
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return x.ID != site.ID })
|
||||||
|
fail(w, 400, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) findExposeLocked(c *orgCtx, id string) *model.Mapping {
|
||||||
|
m := s.findMapping(id)
|
||||||
|
if m == nil || m.OrgID != c.Org.ID || !isExposed(m.Tags) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renewExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
var in struct {
|
||||||
|
TTL int `json:"ttlSeconds"`
|
||||||
|
}
|
||||||
|
if r.ContentLength != 0 && !decode(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ttl, err := exposeTTL(in.TTL)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
m := s.findExposeLocked(c, r.PathValue("id"))
|
||||||
|
if m == nil {
|
||||||
|
fail(w, 404, errors.New("no such exposure (it may have expired)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.expose[m.ID] = time.Now().Add(ttl)
|
||||||
|
writeJSON(w, 200, s.exposeView(m, s.findEdge(m.EdgeID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
m := s.findExposeLocked(c, r.PathValue("id"))
|
||||||
|
if m == nil {
|
||||||
|
fail(w, 404, errors.New("no such exposure (it may have expired)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, s.exposeView(m, s.findEdge(m.EdgeID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
out := []exposeOut{}
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if m.OrgID == c.Org.ID && isExposed(m.Tags) {
|
||||||
|
out = append(out, s.exposeView(m, s.findEdge(m.EdgeID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
m := s.findExposeLocked(c, r.PathValue("id"))
|
||||||
|
if m == nil {
|
||||||
|
ok(w) // already gone: stopping twice is fine
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := m.Name
|
||||||
|
s.dropExposeLocked(m.ID)
|
||||||
|
s.audit(c.Org.ID, c.Actor, "expose.delete", name)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropExposeLocked removes an ephemeral mapping and the site that served it.
|
||||||
|
func (s *Server) dropExposeLocked(mappingID string) {
|
||||||
|
m := s.findMapping(mappingID)
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gone := map[string]bool{}
|
||||||
|
for _, t := range m.Targets {
|
||||||
|
if st := s.findSite(t.SiteID); st != nil && !st.Local && isExposed(st.Tags) {
|
||||||
|
gone[st.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.dropMappingLocked(mappingID)
|
||||||
|
delete(s.expose, mappingID)
|
||||||
|
s.st.Mappings = filter(s.st.Mappings, func(x *model.Mapping) bool { return x.ID != mappingID })
|
||||||
|
s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !gone[x.ID] })
|
||||||
|
s.bump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepExposedLocked removes every ephemeral mapping whose deadline has passed, or that has no
|
||||||
|
// deadline at all (left over from before a restart), and any ephemeral site nothing uses.
|
||||||
|
func (s *Server) sweepExposedLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
var dead []string
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if !isExposed(m.Tags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at, known := s.expose[m.ID]; !known || now.After(at) {
|
||||||
|
dead = append(dead, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range dead {
|
||||||
|
s.dropExposeLocked(id)
|
||||||
|
}
|
||||||
|
used := map[string]bool{}
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
for _, t := range m.Targets {
|
||||||
|
used[t.SiteID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := len(s.st.Sites)
|
||||||
|
s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !(isExposed(x.Tags) && !x.Local && !used[x.ID]) })
|
||||||
|
if n != len(s.st.Sites) {
|
||||||
|
s.bump()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposeLoop expires ephemeral exposures. Its first pass clears everything left from the last run,
|
||||||
|
// since deadlines are kept in memory only.
|
||||||
|
func (s *Server) exposeLoop() {
|
||||||
|
t := time.NewTicker(10 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exposeServer(t *testing.T) (*Server, func(method, path string, in any) (int, []byte), string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
do := apiClient(t, s)
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
code, raw := do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("edge: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var e model.Edge
|
||||||
|
json.Unmarshal(raw, &e)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.findEdge(e.ID).LastSeen = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if code, raw := do("POST", "/api/orgs/"+me.Orgs[0].ID+"/domains", map[string]any{"name": "t.example", "edgeId": e.ID}); code != 200 {
|
||||||
|
t.Fatalf("domain: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
return s, do, me.Orgs[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExposeLifecycle(t *testing.T) {
|
||||||
|
s, do, org := exposeServer(t)
|
||||||
|
base := "/api/orgs/" + org + "/expose"
|
||||||
|
|
||||||
|
code, raw := do("POST", base, map[string]any{"proto": "tcp", "targetPort": 3000})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create tcp: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var tcp exposeOut
|
||||||
|
json.Unmarshal(raw, &tcp)
|
||||||
|
if tcp.Host != "203.0.113.5" || tcp.Port < exposePortMin || tcp.Port > exposePortMax || tcp.SiteToken == "" || tcp.ID == "" {
|
||||||
|
t.Fatalf("tcp exposure: %+v", tcp)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, raw = do("POST", base, map[string]any{"proto": "http", "name": "My App!", "targetPort": 8080, "auth": map[string]any{"password": "hunter22"}})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create http: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var web exposeOut
|
||||||
|
json.Unmarshal(raw, &web)
|
||||||
|
if !strings.HasPrefix(web.Hostname, "my-app-") || !strings.HasSuffix(web.Hostname, ".t.example") || web.URL != "http://"+web.Hostname || !web.Protected {
|
||||||
|
t.Fatalf("http exposure: %+v", web)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
for name, in := range map[string]map[string]any{
|
||||||
|
"bad proto": {"proto": "udp", "targetPort": 1},
|
||||||
|
"bad ttl": {"proto": "tcp", "targetPort": 1, "ttlSeconds": 5},
|
||||||
|
"bad target": {"proto": "tcp", "targetPort": 0},
|
||||||
|
"tcp login": {"proto": "tcp", "targetPort": 1, "auth": map[string]any{"password": "hunter22"}},
|
||||||
|
"short pass": {"proto": "http", "targetPort": 1, "auth": map[string]any{"password": "x"}},
|
||||||
|
"foreign host": {"proto": "http", "targetPort": 1, "hostname": "evil.example.com"},
|
||||||
|
"unknown edge": {"proto": "tcp", "targetPort": 1, "edgeId": "nope"},
|
||||||
|
"bad pin": {"proto": "http", "targetPort": 1, "auth": map[string]any{"pin": "12"}},
|
||||||
|
} {
|
||||||
|
if code, raw := do("POST", base, in); code < 400 {
|
||||||
|
t.Errorf("%s accepted: %d %s", name, code, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, raw = do("GET", base, nil)
|
||||||
|
var list []exposeOut
|
||||||
|
json.Unmarshal(raw, &list)
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("list: %s", raw)
|
||||||
|
}
|
||||||
|
// failed creates must not leave sites behind
|
||||||
|
if sites := s.ephemeralSites(); sites != 2 {
|
||||||
|
t.Errorf("want 2 ephemeral sites, have %d", sites)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renew pushes the deadline out; a deadline in the past expires it
|
||||||
|
s.mu.Lock()
|
||||||
|
s.expose[tcp.ID] = time.Now().Add(time.Second)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if code, _ := do("POST", base+"/"+tcp.ID+"/renew", map[string]any{"ttlSeconds": 600}); code != 200 {
|
||||||
|
t.Fatalf("renew: %d", code)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
left := time.Until(s.expose[tcp.ID])
|
||||||
|
s.expose[web.ID] = time.Now().Add(-time.Second)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if left < 9*time.Minute {
|
||||||
|
t.Errorf("renew left %v", left)
|
||||||
|
}
|
||||||
|
if code, _ := do("GET", base+"/"+web.ID, nil); code != 404 {
|
||||||
|
t.Errorf("expired exposure still there: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := do("POST", base+"/"+web.ID+"/renew", nil); code != 404 {
|
||||||
|
t.Errorf("renewed an expired exposure: %d", code)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.ephemeralSitesLocked() != 1 || len(s.st.Mappings) != 1 || len(s.st.Secrets) != 0 {
|
||||||
|
t.Errorf("expiry left debris: %d sites, %d mappings, %d secrets", s.ephemeralSitesLocked(), len(s.st.Mappings), len(s.st.Secrets))
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// stopping is idempotent
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if code, _ := do("DELETE", base+"/"+tcp.ID, nil); code != 200 {
|
||||||
|
t.Errorf("delete %d: %d", i, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.ephemeralSitesLocked() != 0 || len(s.st.Mappings) != 0 {
|
||||||
|
t.Errorf("delete left debris")
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExposeSweptAfterRestart(t *testing.T) {
|
||||||
|
s, do, org := exposeServer(t)
|
||||||
|
if code, raw := do("POST", "/api/orgs/"+org+"/expose", map[string]any{"proto": "tcp", "targetPort": 3000}); code != 200 {
|
||||||
|
t.Fatalf("%d %s", code, raw)
|
||||||
|
}
|
||||||
|
// a regular mapping on a regular site must survive the sweep
|
||||||
|
_, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||||
|
var site model.Site
|
||||||
|
json.Unmarshal(raw, &site)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.expose = map[string]time.Time{} // what a restart does
|
||||||
|
s.sweepExposedLocked()
|
||||||
|
n, m := len(s.st.Mappings), 0
|
||||||
|
for _, x := range s.st.Sites {
|
||||||
|
if x.ID == site.ID {
|
||||||
|
m++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if n != 0 || m != 1 {
|
||||||
|
t.Fatalf("mappings left %d, regular site kept %d", n, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ephemeralSitesLocked() int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range s.st.Sites {
|
||||||
|
if isExposed(x.Tags) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ephemeralSites() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.ephemeralSitesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API key belongs to one organization, so "-" in the path stands for it.
|
||||||
|
func TestExposeKeyOrgShorthand(t *testing.T) {
|
||||||
|
s, do, org := exposeServer(t)
|
||||||
|
_, raw := do("POST", "/api/orgs/"+org+"/apikeys", map[string]any{"name": "cli"})
|
||||||
|
var k struct{ Key string }
|
||||||
|
json.Unmarshal(raw, &k)
|
||||||
|
if !strings.HasPrefix(k.Key, "hhk_") {
|
||||||
|
t.Fatal(string(raw))
|
||||||
|
}
|
||||||
|
call := func(key, body string) int {
|
||||||
|
req := httptest.NewRequest("POST", "/api/orgs/-/expose", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
if got := call(k.Key, `{"proto":"tcp","targetPort":3000}`); got != 200 {
|
||||||
|
t.Errorf("key: %d", got)
|
||||||
|
}
|
||||||
|
if got := call("hhk_dead_beef", `{"proto":"tcp","targetPort":3000}`); got != 401 {
|
||||||
|
t.Errorf("bad key: %d", got)
|
||||||
|
}
|
||||||
|
if got := call("", `{"proto":"tcp","targetPort":3000}`); got != 401 {
|
||||||
|
t.Errorf("no key: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ go 1.27.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.veillax.com/Veillax/bamboo v0.1.5
|
git.veillax.com/Veillax/bamboo v0.1.5
|
||||||
git.veillax.com/Veillax/blacktip v0.1.7
|
git.veillax.com/Veillax/blacktip v0.1.9
|
||||||
git.veillax.com/Veillax/reef v0.1.7
|
git.veillax.com/Veillax/reef v0.1.11
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
modernc.org/sqlite v1.61.0
|
modernc.org/sqlite v1.61.0
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.3/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.4 h1:pgiBJA46CBJx7T3bloCjPekb6wl6W5H8teokFAH5gj0=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.4/go.mod h1:wzSpfql7U4a3b9mCVH01OPG9H9szrzwSFDufWw02e0g=
|
|
||||||
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
|
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
|
||||||
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
|
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
|
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
|
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0=
|
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
|
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.6 h1:dUJzNGXgieWX05ftWjnMfh6norhaeKTqo1P13lJvjtk=
|
|
||||||
git.veillax.com/Veillax/blacktip v0.1.6/go.mod h1:ZnPRc2gPh9ElILfPH39G/x3B6KAiHQyeoDLycSDgVPM=
|
|
||||||
git.veillax.com/Veillax/blacktip v0.1.7 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ=
|
|
||||||
git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.4 h1:T2+B8P1GAitEyTYRkwMT3UHbG9mN5zXb/inE2muXdQ4=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.4/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -64,11 +65,22 @@ func validAddr(a string) bool {
|
|||||||
return n > 0
|
return n > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) listEdges(w http.ResponseWriter, r *http.Request, p *principal) {
|
func (s *Server) listEdges(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.principalFrom(r)
|
||||||
|
if p == nil {
|
||||||
|
fail(w, 401, errors.New("Sign in to continue."))
|
||||||
|
return
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
out := []edgeView{}
|
out := []edgeView{}
|
||||||
for _, e := range s.st.Edges {
|
for _, e := range s.st.Edges {
|
||||||
|
if p.Key != nil && !p.serverKey() { // an org key sees the edges its organization may use
|
||||||
|
if s.edgeAllowedFor(e, p.Key.OrgID) {
|
||||||
|
out = append(out, s.viewEdge(e, false))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if p.User.ServerAdmin {
|
if p.User.ServerAdmin {
|
||||||
out = append(out, s.viewEdge(e, true))
|
out = append(out, s.viewEdge(e, true))
|
||||||
continue
|
continue
|
||||||
@@ -204,6 +216,43 @@ func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
writeJSON(w, 200, st)
|
writeJSON(w, 200, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// putSite renames a site and sets its tags. The built-in local site is shared between
|
||||||
|
// organizations, so it can't be edited here.
|
||||||
|
func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Name == "" {
|
||||||
|
fail(w, 400, errors.New("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := normalizeTags(in.Tags)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.findSite(r.PathValue("id"))
|
||||||
|
if st == nil || st.OrgID != c.Org.ID {
|
||||||
|
fail(w, 404, errors.New("no such site"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Local {
|
||||||
|
fail(w, 400, errors.New("the built-in local site can't be edited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.Name, st.Tags = in.Name, tags
|
||||||
|
s.bump()
|
||||||
|
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -233,7 +282,7 @@ func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 })
|
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 || m.IsRedirect() })
|
||||||
s.bump()
|
s.bump()
|
||||||
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
|
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
|
||||||
ok(w)
|
ok(w)
|
||||||
@@ -254,6 +303,47 @@ func (s *Server) siteContainers(w http.ResponseWriter, r *http.Request, c *orgCt
|
|||||||
writeJSON(w, 200, out)
|
writeJSON(w, 200, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanDiscovered keeps what an agent reports as suggestions only if it is well formed: it is shown in
|
||||||
|
// the dashboard, so lengths are bounded and anything that is not a plain address is dropped.
|
||||||
|
func cleanDiscovered(in []model.DiscoveredService) []model.DiscoveredService {
|
||||||
|
out := []model.DiscoveredService{}
|
||||||
|
clip := func(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
s = s[:n]
|
||||||
|
}
|
||||||
|
return strings.ToValidUTF8(s, "")
|
||||||
|
}
|
||||||
|
for _, d := range in {
|
||||||
|
if len(out) >= 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(d.Host); err != nil || d.Port < 1 || d.Port > 65535 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := "scan"
|
||||||
|
if d.Source == "local" {
|
||||||
|
src = "local"
|
||||||
|
}
|
||||||
|
out = append(out, model.DiscoveredService{Host: d.Host, Port: d.Port, Proto: "tcp", Service: clip(d.Service, 20), Detail: clip(d.Detail, 160), Source: src})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) siteDiscovered(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.discovered[st.ID]
|
||||||
|
if out == nil {
|
||||||
|
out = []model.DiscoveredService{}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- domains ----------
|
// ---------- domains ----------
|
||||||
|
|
||||||
func domainsOverlap(a, b string) bool {
|
func domainsOverlap(a, b string) bool {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// Package secretbox encrypts small secrets (tokens, passwords, private keys)
|
|
||||||
// before they are written to the database, using AES-256-GCM.
|
|
||||||
//
|
|
||||||
// Sealed values look like "enc1:<base64>". Opening a value that isn't sealed
|
|
||||||
// returns it unchanged, so data written before encryption was turned on is
|
|
||||||
// still readable and gets sealed the next time it is saved.
|
|
||||||
package secretbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const prefix = "enc1:"
|
|
||||||
|
|
||||||
type Box struct{ aead cipher.AEAD }
|
|
||||||
|
|
||||||
// GenerateKey returns a new random key in the form New accepts.
|
|
||||||
func GenerateKey() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a Box from a key given as base64 or hex of 32 bytes. Any other
|
|
||||||
// text is treated as a passphrase and stretched with SHA-256.
|
|
||||||
func New(key string) (*Box, error) {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return nil, errors.New("empty key")
|
|
||||||
}
|
|
||||||
raw := decodeKey(key)
|
|
||||||
block, err := aes.NewCipher(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
aead, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Box{aead: aead}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeKey(key string) []byte {
|
|
||||||
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
if b, err := hex.DecodeString(key); err == nil && len(b) == 32 {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(key))
|
|
||||||
return sum[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset.
|
|
||||||
func (b *Box) Seal(s string) string {
|
|
||||||
if s == "" || strings.HasPrefix(s, prefix) {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
nonce := make([]byte, b.aead.NonceSize())
|
|
||||||
rand.Read(nonce)
|
|
||||||
return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open decrypts a sealed value. A wrong key or damaged data is an error, so a
|
|
||||||
// bad key can never silently turn secrets into garbage.
|
|
||||||
func (b *Box) Open(s string) (string, error) {
|
|
||||||
if !strings.HasPrefix(s, prefix) {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(s[len(prefix):])
|
|
||||||
if err != nil || len(raw) < b.aead.NonceSize() {
|
|
||||||
return "", errors.New("damaged secret")
|
|
||||||
}
|
|
||||||
n := b.aead.NonceSize()
|
|
||||||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?")
|
|
||||||
}
|
|
||||||
return string(plain), nil
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package secretbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSealOpenRoundTripAndKeys(t *testing.T) {
|
|
||||||
key := GenerateKey()
|
|
||||||
b, err := New(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sealed := b.Seal("hunter2")
|
|
||||||
if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") {
|
|
||||||
t.Fatalf("not sealed: %q", sealed)
|
|
||||||
}
|
|
||||||
if again := b.Seal("hunter2"); again == sealed {
|
|
||||||
t.Error("two seals of the same value must differ (random nonce)")
|
|
||||||
}
|
|
||||||
if b.Seal(sealed) != sealed {
|
|
||||||
t.Error("sealing an already sealed value must not double-encrypt")
|
|
||||||
}
|
|
||||||
if got, err := b.Open(sealed); err != nil || got != "hunter2" {
|
|
||||||
t.Errorf("Open = %q, %v", got, err)
|
|
||||||
}
|
|
||||||
if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" {
|
|
||||||
t.Errorf("unsealed legacy data should pass through, got %q", got)
|
|
||||||
}
|
|
||||||
if b.Seal("") != "" {
|
|
||||||
t.Error("empty stays empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
other, _ := New(GenerateKey())
|
|
||||||
if _, err := other.Open(sealed); err == nil {
|
|
||||||
t.Error("the wrong key must fail loudly, not return garbage")
|
|
||||||
}
|
|
||||||
if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil {
|
|
||||||
t.Error("tampered data must fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
// a passphrase works as a key, and is stable
|
|
||||||
p1, _ := New("correct horse battery staple")
|
|
||||||
p2, _ := New("correct horse battery staple")
|
|
||||||
if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" {
|
|
||||||
t.Errorf("passphrase keys must be reproducible: %q %v", got, err)
|
|
||||||
}
|
|
||||||
if _, err := New(" "); err == nil {
|
|
||||||
t.Error("an empty key must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -149,6 +149,7 @@ type mailEgressView struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusKnown bool `json:"statusKnown"`
|
StatusKnown bool `json:"statusKnown"`
|
||||||
Check *model.MailCheck `json:"check,omitempty"`
|
Check *model.MailCheck `json:"check,omitempty"`
|
||||||
|
Findings []Finding `json:"findings"` // the deliverability checklist, with fixes
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
||||||
@@ -164,6 +165,10 @@ func (s *Server) mailEgressViewsLocked(orgID string) []mailEgressView {
|
|||||||
if chk, ok := s.mailChecks[e.ID]; ok {
|
if chk, ok := s.mailChecks[e.ID]; ok {
|
||||||
c := chk
|
c := chk
|
||||||
v.Check = &c
|
v.Check = &c
|
||||||
|
v.Findings = s.mailEgressFindingsLocked(e, chk)
|
||||||
|
}
|
||||||
|
if v.Findings == nil {
|
||||||
|
v.Findings = []Finding{}
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-2
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,9 +55,39 @@ func listFromCSV(in []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins)
|
||||||
|
// and keeps the list short enough to stay readable in the dashboard.
|
||||||
|
func normalizeTags(in []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range in {
|
||||||
|
t = strings.Join(strings.Fields(t), " ")
|
||||||
|
if t == "" || seen[strings.ToLower(t)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len([]rune(t)) > 32 {
|
||||||
|
return nil, errors.New("A tag can be at most 32 characters.")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(t, ",<>\"") {
|
||||||
|
return nil, errors.New("Tags can't contain commas, quotes or angle brackets.")
|
||||||
|
}
|
||||||
|
seen[strings.ToLower(t)] = true
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if len(out) > 10 {
|
||||||
|
return nil, errors.New("Use at most 10 tags.")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
||||||
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||||
m.Name = strings.TrimSpace(m.Name)
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
tags, err := normalizeTags(m.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Tags = tags
|
||||||
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
||||||
m.OrgID = c.Org.ID
|
m.OrgID = c.Org.ID
|
||||||
switch m.Proto {
|
switch m.Proto {
|
||||||
@@ -67,7 +99,16 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||||
return errors.New("choose an edge")
|
return errors.New("choose an edge")
|
||||||
}
|
}
|
||||||
if len(m.Targets) == 0 {
|
redirect := strings.TrimSpace(m.RedirectTo) != ""
|
||||||
|
if redirect {
|
||||||
|
if !model.Web(m.Proto) {
|
||||||
|
return errors.New("only websites (http or https) can redirect")
|
||||||
|
}
|
||||||
|
m.Targets = []model.Target{} // a redirect has nothing behind it
|
||||||
|
} else {
|
||||||
|
m.RedirectTo, m.RedirectPermanent, m.RedirectKeepPath = "", false, false
|
||||||
|
}
|
||||||
|
if len(m.Targets) == 0 && !redirect {
|
||||||
return errors.New("add at least one target")
|
return errors.New("add at least one target")
|
||||||
}
|
}
|
||||||
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
||||||
@@ -143,9 +184,19 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
return errors.New("PROXY protocol must be v1, v2 or off")
|
return errors.New("PROXY protocol must be v1, v2 or off")
|
||||||
}
|
}
|
||||||
// web-only options
|
// web-only options
|
||||||
if !model.Web(m.Proto) {
|
if redirect {
|
||||||
|
to, err := cleanRedirect(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.RedirectTo = to
|
||||||
|
}
|
||||||
|
if !model.Web(m.Proto) || redirect {
|
||||||
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
||||||
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
||||||
|
if redirect {
|
||||||
|
m.HealthSeconds = -1 // nothing to check
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
a := &m.Auth
|
a := &m.Auth
|
||||||
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
||||||
@@ -204,8 +255,24 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
||||||
continue // same listener, different hostname
|
continue // same listener, different hostname
|
||||||
}
|
}
|
||||||
|
if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) {
|
||||||
|
continue // a website on every address steps aside for one that names an address
|
||||||
|
}
|
||||||
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
|
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
|
||||||
}
|
}
|
||||||
|
// an https resource with the redirect on also answers on port 80 at its address, so a raw port
|
||||||
|
// there would clash with it (and the other way round)
|
||||||
|
for _, o := range s.st.Mappings {
|
||||||
|
if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort():
|
||||||
|
return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name)
|
||||||
|
case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort():
|
||||||
|
return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
if m.Name == "" {
|
if m.Name == "" {
|
||||||
if m.Hostname != "" {
|
if m.Hostname != "" {
|
||||||
m.Name = m.Hostname
|
m.Name = m.Hostname
|
||||||
@@ -216,6 +283,28 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanRedirect checks where a redirect sends visitors and refuses a destination that would
|
||||||
|
// send them straight back to the same address.
|
||||||
|
func cleanRedirect(m *model.Mapping) (string, error) {
|
||||||
|
to := strings.TrimSpace(m.RedirectTo)
|
||||||
|
u, err := url.Parse(to)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
|
||||||
|
return "", errors.New("Redirect to a full address such as https://www.example.com.")
|
||||||
|
}
|
||||||
|
if len(to) > 2048 || strings.ContainsAny(to, " \t\r\n$") {
|
||||||
|
return "", errors.New("The redirect address is too long or contains spaces or a dollar sign.")
|
||||||
|
}
|
||||||
|
port := u.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
|
||||||
|
}
|
||||||
|
same := u.Scheme == m.Proto && strings.EqualFold(u.Hostname(), m.Hostname) && port == strconv.Itoa(m.Port)
|
||||||
|
if same && (m.RedirectKeepPath || u.Path == "" || u.Path == "/") {
|
||||||
|
return "", errors.New("That redirect would send visitors back to the same address.")
|
||||||
|
}
|
||||||
|
return to, nil
|
||||||
|
}
|
||||||
|
|
||||||
// portLabel is "25565" or, for a range, "2456-2458".
|
// portLabel is "25565" or, for a range, "2456-2458".
|
||||||
func portLabel(m model.Mapping) string {
|
func portLabel(m model.Mapping) string {
|
||||||
if m.LastPort() > m.Port {
|
if m.LastPort() > m.Port {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// websites share a port by hostname, and a website on every address steps aside for one that names an address
|
||||||
|
func TestWebsitesShareAPortAndWildcardsStepAside(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
var edge, site struct{ ID string }
|
||||||
|
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||||
|
json.Unmarshal(b, &edge)
|
||||||
|
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||||
|
json.Unmarshal(b, &site)
|
||||||
|
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||||
|
t.Fatalf("domain: %d %s", code, b)
|
||||||
|
}
|
||||||
|
base := "/api/orgs/" + org + "/mappings"
|
||||||
|
open := func(m map[string]any) (int, string) {
|
||||||
|
m["edgeId"] = edge.ID
|
||||||
|
m["enabled"] = true
|
||||||
|
m["targets"] = []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}
|
||||||
|
code, b := do("POST", base, m)
|
||||||
|
return code, string(b)
|
||||||
|
}
|
||||||
|
want := func(code int, got int, body, what string) {
|
||||||
|
t.Helper()
|
||||||
|
if code != got {
|
||||||
|
t.Errorf("%s: got %d, want %d (%s)", what, got, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c, body := open(map[string]any{"proto": "https", "bindIp": "0.0.0.0", "port": 443, "hostname": "a.example.test"})
|
||||||
|
want(200, c, body, "first website on every address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||||
|
want(200, c, body, "a website naming an address shares the port with the wildcard one")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.11", "port": 443, "hostname": "c.example.test"})
|
||||||
|
want(200, c, body, "a second named address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "d.example.test"})
|
||||||
|
want(200, c, body, "another hostname on a named address")
|
||||||
|
// still refused: the same hostname twice on one listener, a raw port on a website's port, and another protocol
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||||
|
want(400, c, body, "the same hostname twice on one address")
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "0.0.0.0", "port": 443})
|
||||||
|
want(400, c, body, "a raw port on top of websites")
|
||||||
|
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.10", "port": 443, "hostname": "e.example.test"})
|
||||||
|
want(400, c, body, "http on an https port")
|
||||||
|
|
||||||
|
// the HTTP to HTTPS redirect also uses port 80 at its address
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.20", "port": 80})
|
||||||
|
want(200, c, body, "a raw port 80 on its own address")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test", "redirectHttp": true})
|
||||||
|
want(400, c, body, "a redirect onto a raw port 80")
|
||||||
|
if !strings.Contains(body, "port 80") {
|
||||||
|
t.Errorf("the error should name port 80: %s", body)
|
||||||
|
}
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test"})
|
||||||
|
want(200, c, body, "the same website without the redirect")
|
||||||
|
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.21", "port": 443, "hostname": "g.example.test", "redirectHttp": true})
|
||||||
|
want(200, c, body, "a redirect where port 80 is free")
|
||||||
|
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.21", "port": 80})
|
||||||
|
want(400, c, body, "a raw port 80 where a redirect lives")
|
||||||
|
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.21", "port": 80, "hostname": "h.example.test"})
|
||||||
|
want(200, c, body, "an http website beside a redirect on port 80")
|
||||||
|
}
|
||||||
@@ -84,6 +84,11 @@ func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
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.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.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")
|
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.Port25 == "open"), "relay", x.Name, "org", org, "check", "port25")
|
||||||
|
for _, f := range s.mailEgressFindingsLocked(x, c) {
|
||||||
|
if f.Domain != "" || f.ID == "blocklist" || f.ID == "outcomes" {
|
||||||
|
line("hammerhead_mail_finding_ok", "gauge", "1 if a per-domain, blocklist or delivery check passes", b2f(f.Status == "pass"), "relay", x.Name, "org", org, "check", f.Label, "domain", f.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firing := map[string]int{}
|
firing := map[string]int{}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal)
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
|
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
|
||||||
|
if p.User.ID != "" { // a server key belongs to no user
|
||||||
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
||||||
|
}
|
||||||
s.save()
|
s.save()
|
||||||
o := *org
|
o := *org
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -423,7 +425,8 @@ func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
writeJSON(w, 200, out)
|
writeJSON(w, 200, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
// issueKey stores a new key for the org ("" = a server key) and returns its ID and full token.
|
||||||
|
func (s *Server) issueKey(w http.ResponseWriter, r *http.Request, org, actor string) {
|
||||||
var in struct{ Name string }
|
var in struct{ Name string }
|
||||||
if !decode(w, r, &in) {
|
if !decode(w, r, &in) {
|
||||||
return
|
return
|
||||||
@@ -435,11 +438,82 @@ func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|||||||
}
|
}
|
||||||
id, secret := randHex(4), randHex(24)
|
id, secret := randHex(4), randHex(24)
|
||||||
s.mu.Lock()
|
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.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: org, Name: in.Name, Hash: sha(secret), Created: time.Now()})
|
||||||
s.save()
|
s.save()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name)
|
s.audit(org, actor, "apikey.create", in.Name)
|
||||||
writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret})
|
writeJSON(w, 200, map[string]string{"id": id, "key": "hhk_" + id + "_" + secret})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.issueKey(w, r, c.Org.ID, c.Actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server keys manage edges, server-level alerts and organizations, so only a signed-in
|
||||||
|
// server administrator may mint, list or revoke them (a key can't make more keys).
|
||||||
|
|
||||||
|
func (s *Server) withServerAdminUser(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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listServerKeys(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := []APIKey{}
|
||||||
|
for _, k := range s.st.APIKeys {
|
||||||
|
if k.OrgID == "" {
|
||||||
|
cp := *k
|
||||||
|
cp.Hash = ""
|
||||||
|
out = append(out, cp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createServerKey(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.issueKey(w, r, "", p.User.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteServerKey(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
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 == "") })
|
||||||
|
s.save()
|
||||||
|
s.audit("", p.User.Email, "apikey.delete", id)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOrgs shows the organizations the caller can reach: a server key sees all of them,
|
||||||
|
// an org key its own.
|
||||||
|
func (s *Server) listOrgs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.principalFrom(r)
|
||||||
|
if p == nil {
|
||||||
|
fail(w, 401, errors.New("Sign in to continue."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := []Org{}
|
||||||
|
for _, o := range s.st.Orgs {
|
||||||
|
switch {
|
||||||
|
case p.serverKey() || p.User.ServerAdmin:
|
||||||
|
out = append(out, *o)
|
||||||
|
case p.Key != nil:
|
||||||
|
if p.Key.OrgID == o.ID {
|
||||||
|
out = append(out, *o)
|
||||||
|
}
|
||||||
|
case s.member(p.User.ID, o.ID) != nil:
|
||||||
|
out = append(out, *o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// a redirect is a website with a destination instead of a target
|
||||||
|
func TestDomainRedirects(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
var edge, site struct{ ID string }
|
||||||
|
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||||
|
json.Unmarshal(b, &edge)
|
||||||
|
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||||
|
json.Unmarshal(b, &site)
|
||||||
|
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||||
|
t.Fatalf("domain: %d %s", code, b)
|
||||||
|
}
|
||||||
|
base := "/api/orgs/" + org + "/mappings"
|
||||||
|
open := func(m map[string]any) (int, []byte) {
|
||||||
|
m["edgeId"], m["enabled"], m["bindIp"] = edge.ID, true, "0.0.0.0"
|
||||||
|
return do("POST", base, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the redirect needs no target, and anything that only makes sense with one is dropped
|
||||||
|
code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "old.example.test", "redirectTo": " https://new.example.test/home ", "redirectPermanent": true, "redirectKeepPath": true,
|
||||||
|
"auth": map[string]any{"password": true}, "rewriteHost": "x", "healthPath": "/h"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
ID, RedirectTo, RewriteHost, HealthPath string
|
||||||
|
RedirectPermanent, RedirectKeepPath bool
|
||||||
|
Targets []any
|
||||||
|
HealthSeconds int
|
||||||
|
Auth struct{ Password bool }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &m)
|
||||||
|
if m.RedirectTo != "https://new.example.test/home" || !m.RedirectPermanent || !m.RedirectKeepPath || len(m.Targets) != 0 ||
|
||||||
|
m.Auth.Password || m.RewriteHost != "" || m.HealthPath != "" || m.HealthSeconds != -1 {
|
||||||
|
t.Fatalf("redirect was not normalised: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// it shares the port with other websites by hostname, like any website
|
||||||
|
if code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "app.example.test", "targets": []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}}); code != 200 {
|
||||||
|
t.Fatalf("a website beside the redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
// and its plain-HTTP twin on port 80 is its own resource
|
||||||
|
if code, raw := open(map[string]any{"proto": "http", "port": 80, "hostname": "old.example.test", "redirectTo": "https://new.example.test/home"}); code != 200 {
|
||||||
|
t.Fatalf("http side of the redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, in := range map[string]map[string]any{
|
||||||
|
"not a web port": {"proto": "tcp", "port": 9000, "redirectTo": "https://new.example.test"},
|
||||||
|
"no scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "new.example.test"},
|
||||||
|
"other scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "ftp://new.example.test"},
|
||||||
|
"credentials": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://u:p@new.example.test"},
|
||||||
|
"a dollar sign": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://new.example.test/$1"},
|
||||||
|
"back to itself": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"},
|
||||||
|
"itself with a path": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/x", "redirectKeepPath": true},
|
||||||
|
"host not in domains": {"proto": "https", "port": 443, "hostname": "a.elsewhere.test", "redirectTo": "https://new.example.test"},
|
||||||
|
} {
|
||||||
|
if code, raw := open(in); code != 400 {
|
||||||
|
t.Errorf("%s: expected 400, got %d %s", name, code, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// http to https on the same name is the normal use, and is allowed
|
||||||
|
if code, raw := open(map[string]any{"proto": "http", "port": 8080, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}); code != 200 {
|
||||||
|
t.Errorf("an http to https redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// turning a redirect into a normal website needs a target again
|
||||||
|
code, raw = do("PUT", base+"/"+m.ID, map[string]any{"proto": "https", "edgeId": edge.ID, "bindIp": "0.0.0.0", "port": 443, "hostname": "old.example.test", "enabled": true})
|
||||||
|
if code != 400 || !strings.Contains(string(raw), "target") {
|
||||||
|
t.Errorf("a website with no target and no redirect: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleting a site must not sweep away redirects, which have no targets
|
||||||
|
if code, raw := do("DELETE", "/api/orgs/"+org+"/sites/"+site.ID, nil); code != 200 {
|
||||||
|
t.Fatalf("delete site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||||
|
var st struct {
|
||||||
|
Mappings []struct{ Hostname, RedirectTo string }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &st)
|
||||||
|
var redirects, apps int
|
||||||
|
for _, x := range st.Mappings {
|
||||||
|
if x.RedirectTo != "" {
|
||||||
|
redirects++
|
||||||
|
}
|
||||||
|
if x.Hostname == "app.example.test" {
|
||||||
|
apps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if redirects != 3 || apps != 0 {
|
||||||
|
t.Errorf("after deleting the site: %d redirects (want 3), %d apps (want 0): %s", redirects, apps, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/netguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sandbar monitors: with this switched on under Server, Settings, every enabled
|
||||||
|
// resource that has health checks gets a monitor on a Sandbar server, which then
|
||||||
|
// tracks its uptime, shows it on the status page and pages whoever is on call.
|
||||||
|
// Sandbar probes the public address (so it sees what visitors see), where
|
||||||
|
// Hammerhead's own health checks look at the targets behind it. Hammerhead only
|
||||||
|
// ever changes the monitors it created: the link from resource to monitor is
|
||||||
|
// kept in the settings, so a monitor you add in Sandbar by hand is never touched.
|
||||||
|
|
||||||
|
// SandbarSync is the connection to Sandbar for creating monitors.
|
||||||
|
type SandbarSync struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Token string `json:"token"` // an administrator's API key (secret, never sent back to the browser)
|
||||||
|
PolicyID string `json:"policyId"` // escalation policy for the alerts the monitors raise; blank pages nobody
|
||||||
|
Public bool `json:"public"` // show the monitors on Sandbar's status page
|
||||||
|
AllowPrivate bool `json:"allowPrivate"` // Sandbar is on a private network, and so are the edge addresses it will probe
|
||||||
|
Monitors map[string]string `json:"monitors,omitempty"` // resource id -> Sandbar monitor id
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandbarSyncStatus is how the last sync went, for the settings page.
|
||||||
|
type sandbarSyncStatus struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Monitors int `json:"monitors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbarSyncEvery = time.Minute
|
||||||
|
|
||||||
|
type monitorSpec struct {
|
||||||
|
Name string
|
||||||
|
Kind string
|
||||||
|
Target string
|
||||||
|
IntervalS int
|
||||||
|
Expect string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandbarEdgeHost is the address visitors reach a resource's edge on: the address it is
|
||||||
|
// bound to, else the first public address the edge reported, else the host of its
|
||||||
|
// tunnel endpoint.
|
||||||
|
func sandbarEdgeHost(e *model.Edge, bind string) string {
|
||||||
|
if ip := net.ParseIP(bind); ip != nil && !ip.IsUnspecified() {
|
||||||
|
return bind
|
||||||
|
}
|
||||||
|
var first string
|
||||||
|
for _, a := range e.IPs {
|
||||||
|
ip := net.ParseIP(a)
|
||||||
|
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ip.IsPrivate() {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
first = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first != "" {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
host, _, _ := net.SplitHostPort(e.TunnelAddr)
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorFor describes the Sandbar monitor a resource should have, or false when
|
||||||
|
// it has nothing worth probing (a redirect, UDP, health checks off, a wildcard).
|
||||||
|
func (s *Server) monitorFor(m *model.Mapping) (monitorSpec, bool) {
|
||||||
|
if !m.Enabled || m.HealthSeconds == -1 || m.RedirectTo != "" || m.Proto == model.ProtoUDP {
|
||||||
|
return monitorSpec{}, false
|
||||||
|
}
|
||||||
|
e := s.findEdge(m.EdgeID)
|
||||||
|
if e == nil {
|
||||||
|
return monitorSpec{}, false
|
||||||
|
}
|
||||||
|
spec := monitorSpec{Name: m.Name, IntervalS: 30}
|
||||||
|
if m.HealthSeconds > 0 {
|
||||||
|
spec.IntervalS = max(m.HealthSeconds, 10)
|
||||||
|
}
|
||||||
|
host := m.Hostname
|
||||||
|
if strings.HasPrefix(host, "*") {
|
||||||
|
return monitorSpec{}, false
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
if host = sandbarEdgeHost(e, m.BindIP); host == "" {
|
||||||
|
return monitorSpec{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(m.Port))
|
||||||
|
switch m.Proto {
|
||||||
|
case model.ProtoHTTP, model.ProtoHTTPS:
|
||||||
|
u := url.URL{Scheme: m.Proto, Host: addr, Path: m.HealthPath}
|
||||||
|
if (m.Proto == model.ProtoHTTP && m.Port == 80) || (m.Proto == model.ProtoHTTPS && m.Port == 443) {
|
||||||
|
u.Host = strings.Trim(host, "[]") // a bare IPv6 address needs its brackets back
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
u.Host = "[" + u.Host + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec.Kind, spec.Target = "http", u.String()
|
||||||
|
if m.Auth.Protected() {
|
||||||
|
spec.Expect = "200-399" // visitors are sent to a sign-in page, which is the site working
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
spec.Kind, spec.Target = "tcp", addr
|
||||||
|
}
|
||||||
|
return spec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// desiredMonitorsLocked is the monitor every qualifying resource should have, by resource id.
|
||||||
|
func (s *Server) desiredMonitorsLocked() map[string]monitorSpec {
|
||||||
|
out := map[string]monitorSpec{}
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if spec, ok := s.monitorFor(m); ok {
|
||||||
|
out[m.ID] = spec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sandbarCall(cfg SandbarSync, method, path string, body any, out any) error {
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, strings.TrimRight(cfg.URL, "/")+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||||
|
resp, err := netguard.Client(cfg.AllowPrivate).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == 401:
|
||||||
|
return errors.New("Sandbar rejected the API key")
|
||||||
|
case resp.StatusCode == 403:
|
||||||
|
return errors.New("Sandbar needs an administrator's API key to manage monitors")
|
||||||
|
case resp.StatusCode == 404 && method == "DELETE":
|
||||||
|
return nil // already gone
|
||||||
|
case resp.StatusCode >= 300:
|
||||||
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||||
|
return fmt.Errorf("Sandbar answered %s %s", resp.Status, strings.TrimSpace(string(msg)))
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncSandbar makes Sandbar's monitors match the resources: it creates the ones
|
||||||
|
// that are missing, corrects the ones that drifted, and removes those whose
|
||||||
|
// resource is gone or no longer has health checks.
|
||||||
|
func (s *Server) syncSandbar() (err error) {
|
||||||
|
s.sandbarMu.Lock()
|
||||||
|
defer s.sandbarMu.Unlock()
|
||||||
|
s.mu.Lock()
|
||||||
|
cfg := s.st.Settings.Sandbar
|
||||||
|
if !cfg.Enabled {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
want := s.desiredMonitorsLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
links := map[string]string{}
|
||||||
|
for k, v := range cfg.Monitors {
|
||||||
|
links[k] = v
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
st := sandbarSyncStatus{At: time.Now(), Monitors: len(links)}
|
||||||
|
if err != nil {
|
||||||
|
st.Error = err.Error()
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sandbarStatus = st
|
||||||
|
if now := &s.st.Settings.Sandbar; now.URL == cfg.URL && !sameLinks(now.Monitors, links) {
|
||||||
|
now.Monitors = links // kept even after a failure: a monitor already created must not be created twice
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var remote []map[string]any
|
||||||
|
if err := sandbarCall(cfg, "GET", "/api/v1/monitors", nil, &remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byID := map[string]map[string]any{}
|
||||||
|
for _, m := range remote {
|
||||||
|
if id, _ := m["id"].(string); id != "" {
|
||||||
|
byID[id] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0, len(want))
|
||||||
|
for id := range want {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
var errs []error
|
||||||
|
for _, id := range ids {
|
||||||
|
spec := want[id]
|
||||||
|
fields := map[string]any{"name": spec.Name, "kind": spec.Kind, "target": spec.Target, "intervalS": spec.IntervalS, "expect": spec.Expect,
|
||||||
|
"policyId": cfg.PolicyID, "public": cfg.Public, "allowPrivate": cfg.AllowPrivate, "enabled": true}
|
||||||
|
cur, exists := byID[links[id]]
|
||||||
|
if !exists {
|
||||||
|
var made struct{ ID string }
|
||||||
|
if err := sandbarCall(cfg, "POST", "/api/v1/monitors", fields, &made); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", spec.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links[id] = made.ID
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drift := false
|
||||||
|
for k, v := range fields {
|
||||||
|
if !sameJSON(cur[k], v) {
|
||||||
|
cur[k], drift = v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drift { // the whole document is replaced, so what Sandbar added (token, retries) rides along
|
||||||
|
if err := sandbarCall(cfg, "PUT", "/api/v1/monitors/"+links[id], cur, nil); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", spec.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, rid := range links {
|
||||||
|
if _, still := want[id]; still {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sandbarCall(cfg, "DELETE", "/api/v1/monitors/"+rid, nil, nil); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(links, id)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameJSON(a, b any) bool {
|
||||||
|
x, _ := json.Marshal(a)
|
||||||
|
y, _ := json.Marshal(b)
|
||||||
|
if string(x) == "null" { // a field Sandbar left out is its zero value
|
||||||
|
x = []byte(`""`)
|
||||||
|
if _, isNum := b.(int); isNum {
|
||||||
|
x = []byte("0")
|
||||||
|
} else if _, isBool := b.(bool); isBool {
|
||||||
|
x = []byte("false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.Equal(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLinks(a, b map[string]string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range a {
|
||||||
|
if b[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sandbarLoop() {
|
||||||
|
t := time.NewTicker(sandbarSyncEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := s.syncSandbar(); err != nil {
|
||||||
|
log.Printf("sandbar monitors: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncSandbarNow is the "Sync now" button: the same sync, with its outcome shown.
|
||||||
|
func (s *Server) syncSandbarNow(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
|
s.mu.Lock()
|
||||||
|
on := s.st.Settings.Sandbar.Enabled
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !on {
|
||||||
|
fail(w, 400, errors.New("Turn on Sandbar monitors and save the settings first."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.syncSandbar(); err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
st := s.sandbarStatus
|
||||||
|
s.mu.Unlock()
|
||||||
|
writeJSON(w, 200, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSandbar validates the settings in `in`, filling in what a blank field means.
|
||||||
|
// Caller holds s.mu.
|
||||||
|
func (s *Server) cleanSandbar(in *SandbarSync) error {
|
||||||
|
old := s.st.Settings.Sandbar
|
||||||
|
in.URL, in.PolicyID = strings.TrimRight(strings.TrimSpace(in.URL), "/"), strings.TrimSpace(in.PolicyID)
|
||||||
|
if in.Token = strings.TrimSpace(in.Token); in.Token == "" {
|
||||||
|
in.Token = old.Token // blank keeps the saved API key
|
||||||
|
}
|
||||||
|
in.Monitors = old.Monitors // not editable here
|
||||||
|
if in.URL != old.URL {
|
||||||
|
in.Monitors = nil // those belong to the previous server
|
||||||
|
}
|
||||||
|
if !in.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(in.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||||
|
}
|
||||||
|
if in.Token == "" {
|
||||||
|
return errors.New("enter an administrator's API key from Sandbar's Account page")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSandbar keeps monitors in memory the way Sandbar's /api/v1/monitors does.
|
||||||
|
type fakeSandbar struct {
|
||||||
|
URL string
|
||||||
|
mu sync.Mutex
|
||||||
|
mon map[string]map[string]any
|
||||||
|
n int
|
||||||
|
// requests that carried the wrong key are counted, not served
|
||||||
|
badAuth int
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSandbar(t *testing.T) *fakeSandbar {
|
||||||
|
f := &fakeSandbar{mon: map[string]map[string]any{}}
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if r.Header.Get("Authorization") != "Bearer sb_admin" {
|
||||||
|
f.badAuth++
|
||||||
|
http.Error(w, "no", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, r.Method)
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/v1/monitors/")
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/api/v1/monitors":
|
||||||
|
out := []any{}
|
||||||
|
for _, m := range f.mon {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(out)
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/api/v1/monitors":
|
||||||
|
var m map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&m)
|
||||||
|
f.n++
|
||||||
|
m["id"], m["retries"] = "mon"+strconv.Itoa(f.n), 2 // Sandbar adds its own fields
|
||||||
|
f.mon[m["id"].(string)] = m
|
||||||
|
json.NewEncoder(w).Encode(m)
|
||||||
|
case r.Method == "PUT" && f.mon[id] != nil:
|
||||||
|
var m map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&m)
|
||||||
|
f.mon[id] = m
|
||||||
|
json.NewEncoder(w).Encode(m)
|
||||||
|
case r.Method == "DELETE":
|
||||||
|
delete(f.mon, id)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
f.URL = ts.URL
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSandbar) names() map[string]string { // monitor id -> "kind target"
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := map[string]string{}
|
||||||
|
for id, m := range f.mon {
|
||||||
|
out[id] = m["kind"].(string) + " " + m["target"].(string)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSandbar) count(method string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
n := 0
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if c == method {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableSandbarSync(f *fx, sb *fakeSandbar) {
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Edges[0].IPs = []string{"10.0.0.5", "203.0.113.7"}
|
||||||
|
f.s.st.Settings.Sandbar = SandbarSync{Enabled: true, URL: sb.URL, Token: "sb_admin", PolicyID: "pol1", Public: true, AllowPrivate: true}
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarMonitorsFollowTheResources(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
sb := newFakeSandbar(t)
|
||||||
|
enableSandbarSync(f, sb)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Mappings = append(f.s.st.Mappings,
|
||||||
|
&model.Mapping{ID: "m2", OrgID: f.org, Name: "Blog", EdgeID: "e1", Proto: "https", Port: 443, Hostname: "blog.example.com", HealthPath: "/ping", Enabled: true,
|
||||||
|
Targets: []model.Target{{SiteID: "s1", Host: "192.168.1.31", Port: 80}}},
|
||||||
|
&model.Mapping{ID: "m3", OrgID: f.org, Name: "Docs", EdgeID: "e1", Proto: "https", Port: 8443, Hostname: "docs.example.com", Enabled: true, Auth: model.Auth{Password: true},
|
||||||
|
Targets: []model.Target{{SiteID: "s1", Host: "192.168.1.32", Port: 80}}},
|
||||||
|
&model.Mapping{ID: "m4", OrgID: f.org, Name: "Unchecked", EdgeID: "e1", Proto: "tcp", Port: 2222, HealthSeconds: -1, Enabled: true},
|
||||||
|
&model.Mapping{ID: "m5", OrgID: f.org, Name: "Voice", EdgeID: "e1", Proto: "udp", Port: 9987, Enabled: true},
|
||||||
|
&model.Mapping{ID: "m6", OrgID: f.org, Name: "Wildcard", EdgeID: "e1", Proto: "https", Port: 443, Hostname: "*.example.com", Enabled: true},
|
||||||
|
&model.Mapping{ID: "m7", OrgID: f.org, Name: "Off", EdgeID: "e1", Proto: "tcp", Port: 3333},
|
||||||
|
)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := sb.names()
|
||||||
|
want := map[string]bool{"tcp 203.0.113.7:25565": true, "http https://blog.example.com/ping": true, "http https://docs.example.com:8443": true}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("monitors created: %v", got)
|
||||||
|
}
|
||||||
|
for _, v := range got {
|
||||||
|
if !want[v] {
|
||||||
|
t.Errorf("unexpected monitor %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range sb.mon {
|
||||||
|
if m["policyId"] != "pol1" || m["public"] != true || m["enabled"] != true {
|
||||||
|
t.Errorf("monitor settings not applied: %v", m)
|
||||||
|
}
|
||||||
|
if m["name"] == "Docs" && m["expect"] != "200-399" {
|
||||||
|
t.Errorf("a protected site should accept its sign-in redirect: %v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second pass changes nothing.
|
||||||
|
posts := sb.count("POST")
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sb.count("POST") != posts || sb.count("PUT") != 0 || sb.count("DELETE") != 0 {
|
||||||
|
t.Fatalf("a settled sync still wrote: %v", sb.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rename and a changed policy are pushed, and Sandbar's own fields survive.
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.findMapping("m1").Name = "Minecraft Java"
|
||||||
|
f.s.st.Settings.Sandbar.PolicyID = "pol2"
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var renamed bool
|
||||||
|
for _, m := range sb.mon {
|
||||||
|
if m["policyId"] != "pol2" || m["retries"] != float64(2) {
|
||||||
|
t.Errorf("update lost data or missed the policy: %v", m)
|
||||||
|
}
|
||||||
|
renamed = renamed || m["name"] == "Minecraft Java"
|
||||||
|
}
|
||||||
|
if !renamed {
|
||||||
|
t.Error("the rename was not pushed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a resource, or turning its checks off, removes its monitor; one added by hand stays.
|
||||||
|
sb.mu.Lock()
|
||||||
|
sb.mon["hand"] = map[string]any{"id": "hand", "name": "mine", "kind": "tcp", "target": "x:1"}
|
||||||
|
sb.mu.Unlock()
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.findMapping("m2").HealthSeconds = -1
|
||||||
|
f.s.st.Mappings = filter(f.s.st.Mappings, func(m *model.Mapping) bool { return m.ID != "m3" })
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = sb.names()
|
||||||
|
if len(got) != 2 || got["hand"] == "" {
|
||||||
|
t.Fatalf("after removing resources: %v", got)
|
||||||
|
}
|
||||||
|
f.s.mu.Lock()
|
||||||
|
links := len(f.s.st.Settings.Sandbar.Monitors)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if links != 1 {
|
||||||
|
t.Errorf("links kept for %d resources, want 1", links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarSyncRecreatesAMonitorDeletedInSandbarAndSurvivesARestart(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
sb := newFakeSandbar(t)
|
||||||
|
enableSandbarSync(f, sb)
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sb.mu.Lock()
|
||||||
|
sb.mon = map[string]map[string]any{}
|
||||||
|
sb.mu.Unlock()
|
||||||
|
if err := f.s.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sb.names()) != 1 {
|
||||||
|
t.Fatalf("not recreated: %v", sb.names())
|
||||||
|
}
|
||||||
|
|
||||||
|
// the links are stored, so a restart does not create a second monitor
|
||||||
|
f.s.mu.Lock()
|
||||||
|
err := f.s.save()
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir, key := f.s.dir, f.s.opts.MasterKey
|
||||||
|
f.s.Close()
|
||||||
|
s2, err := New(dir, Options{MasterKey: key, AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
if err := s2.syncSandbar(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sb.names()) != 1 {
|
||||||
|
t.Fatalf("a restart duplicated the monitor: %v", sb.names())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarSyncReportsABadKeyAndIsOffByDefault(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
sb := newFakeSandbar(t)
|
||||||
|
if err := f.s.syncSandbar(); err != nil || sb.badAuth != 0 || len(sb.calls) != 0 {
|
||||||
|
t.Fatal("sync ran while switched off")
|
||||||
|
}
|
||||||
|
enableSandbarSync(f, sb)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Settings.Sandbar.Token = "wrong"
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err := f.s.syncSandbar(); err == nil || !strings.Contains(err.Error(), "API key") {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
f.s.mu.Lock()
|
||||||
|
msg := f.s.sandbarStatus.Error
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if msg == "" {
|
||||||
|
t.Error("the failure was not recorded for the settings page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarSettingsKeepTheKeySealedAndHidden(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
in := SandbarSync{Enabled: true, URL: " https://sandbar.example.com/ ", Token: " sb_admin "}
|
||||||
|
err := f.s.cleanSandbar(&in)
|
||||||
|
f.s.st.Settings.Sandbar = in
|
||||||
|
f.s.save()
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err != nil || in.URL != "https://sandbar.example.com" || in.Token != "sb_admin" {
|
||||||
|
t.Fatalf("%v %+v", err, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a blank key in a later save keeps the saved one; a new address forgets the old server's links
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Settings.Sandbar.Monitors = map[string]string{"m1": "mon1"}
|
||||||
|
next := SandbarSync{Enabled: true, URL: "https://other.example.com"}
|
||||||
|
err = f.s.cleanSandbar(&next)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err != nil || next.Token != "sb_admin" || next.Monitors != nil {
|
||||||
|
t.Fatalf("%v %+v", err, next)
|
||||||
|
}
|
||||||
|
for _, bad := range []SandbarSync{{Enabled: true, URL: "not a url", Token: "k"}, {Enabled: true, URL: "https://x.example.com", Token: ""}} {
|
||||||
|
bad := bad
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Settings.Sandbar.Token = ""
|
||||||
|
err := f.s.cleanSandbar(&bad)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("accepted %+v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// at rest the key is encrypted, and the settings API never returns it
|
||||||
|
raw, _ := json.Marshal(f.s.db.sealed(f.s.st.Settings))
|
||||||
|
if strings.Contains(string(raw), "sb_admin") {
|
||||||
|
t.Error("the API key is stored in the clear")
|
||||||
|
}
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.Settings.Sandbar.Token = "sb_admin"
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.s.getSettings(rec, httptest.NewRequest("GET", "/api/admin/settings", nil), nil)
|
||||||
|
if strings.Contains(rec.Body.String(), "sb_admin") || !strings.Contains(rec.Body.String(), `"sandbarTokenSet":true`) {
|
||||||
|
t.Errorf("settings response: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicChannelPublishes(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
var gotPath, gotTitle, gotPrio, gotAuth, gotBody string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b := make([]byte, 512)
|
||||||
|
n, _ := r.Body.Read(b)
|
||||||
|
gotPath, gotTitle, gotPrio, gotAuth, gotBody = r.URL.Path, r.Header.Get("Title"), r.Header.Get("Priority"), r.Header.Get("Authorization"), string(b[:n])
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
ch := AlertChannel{ID: "t1", Kind: "topic", Target: ts.URL + "/", Topic: "ops", Token: "sb_k", AllowPrivate: true, Enabled: true}
|
||||||
|
n := alertNote{Status: "firing", Org: "Acme", Name: "Minecraft", Message: "Minecraft can't listen", Since: time.Now()}
|
||||||
|
if err := f.s.send(ch, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPath != "/ops" || gotTitle != "[Hammerhead] Problem: Minecraft" || gotPrio != "urgent" || gotAuth != "Bearer sb_k" || !strings.Contains(gotBody, "can't listen") {
|
||||||
|
t.Errorf("published %q %q %q %q %q", gotPath, gotTitle, gotPrio, gotAuth, gotBody)
|
||||||
|
}
|
||||||
|
n.Status, ch.Token = "resolved", ""
|
||||||
|
if err := f.s.send(ch, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPrio != "default" || gotAuth != "" || !strings.HasPrefix(gotBody, "Resolved") {
|
||||||
|
t.Errorf("recovery %q %q %q", gotPrio, gotAuth, gotBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a refusal surfaces as an error
|
||||||
|
deny := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "no", 401) }))
|
||||||
|
defer deny.Close()
|
||||||
|
ch.Target = deny.URL
|
||||||
|
if err := f.s.send(ch, n); err == nil || !strings.Contains(err.Error(), "API key") {
|
||||||
|
t.Errorf("error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicChannelValidation(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
for _, ch := range []AlertChannel{
|
||||||
|
{Kind: "topic", Target: "not a url", Topic: "ops"},
|
||||||
|
{Kind: "topic", Target: "https://sandbar.example.com", Topic: ""},
|
||||||
|
{Kind: "topic", Target: "https://sandbar.example.com", Topic: "has space"},
|
||||||
|
{Kind: "topic", Target: "https://sandbar.example.com", Topic: "ops", AllowPrivate: true}, // needs a trusted caller
|
||||||
|
} {
|
||||||
|
ch := ch
|
||||||
|
if err := f.s.validateChannel(&ch, false); err == nil {
|
||||||
|
t.Fatalf("accepted %+v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := AlertChannel{Kind: "topic", Target: "https://sandbar.example.com", Topic: " ops ", Token: " ", Policy: "x", Format: "slack"}
|
||||||
|
if err := f.s.validateChannel(&ok, false); err != nil || ok.Topic != "ops" || ok.Token != "" || ok.Policy != "" || ok.Format != "" {
|
||||||
|
t.Fatalf("%v %+v", err, ok)
|
||||||
|
}
|
||||||
|
if p := preview(ok); p != "https://sandbar.example.com/ops" {
|
||||||
|
t.Errorf("preview %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
|
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
|
||||||
|
|||||||
@@ -79,13 +79,17 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/me/sessions", s.withUser(s.mySessions))
|
mux.HandleFunc("GET /api/me/sessions", s.withUser(s.mySessions))
|
||||||
mux.HandleFunc("DELETE /api/me/sessions/{id}", s.withUser(s.endSession))
|
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("POST /api/me/sessions/revoke-others", s.withUser(s.endOtherSessions))
|
||||||
mux.HandleFunc("GET /api/edges", s.withUser(s.listEdges))
|
mux.HandleFunc("GET /api/edges", s.listEdges)
|
||||||
|
|
||||||
// server administrators
|
// server administrators
|
||||||
mux.HandleFunc("POST /api/edges", s.withServerAdmin(s.saveEdge))
|
mux.HandleFunc("POST /api/edges", s.withServerAdmin(s.saveEdge))
|
||||||
mux.HandleFunc("PUT /api/edges/{id}", 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("DELETE /api/edges/{id}", s.withServerAdmin(s.deleteEdge))
|
||||||
|
mux.HandleFunc("GET /api/orgs", s.listOrgs)
|
||||||
mux.HandleFunc("POST /api/orgs", s.withServerAdmin(s.createOrg))
|
mux.HandleFunc("POST /api/orgs", s.withServerAdmin(s.createOrg))
|
||||||
|
mux.HandleFunc("GET /api/admin/apikeys", s.withServerAdminUser(s.listServerKeys))
|
||||||
|
mux.HandleFunc("POST /api/admin/apikeys", s.withServerAdminUser(s.createServerKey))
|
||||||
|
mux.HandleFunc("DELETE /api/admin/apikeys/{id}", s.withServerAdminUser(s.deleteServerKey))
|
||||||
mux.HandleFunc("GET /api/admin/users", s.withServerAdmin(s.adminUsers))
|
mux.HandleFunc("GET /api/admin/users", s.withServerAdmin(s.adminUsers))
|
||||||
mux.HandleFunc("POST /api/admin/users", s.withServerAdmin(s.adminCreateUser))
|
mux.HandleFunc("POST /api/admin/users", s.withServerAdmin(s.adminCreateUser))
|
||||||
mux.HandleFunc("PUT /api/admin/users/{id}", s.withServerAdmin(s.adminUpdateUser))
|
mux.HandleFunc("PUT /api/admin/users/{id}", s.withServerAdmin(s.adminUpdateUser))
|
||||||
@@ -97,6 +101,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
|
||||||
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
|
||||||
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
|
||||||
|
mux.HandleFunc("POST /api/admin/settings/sandbar-sync", s.withServerAdmin(s.syncSandbarNow))
|
||||||
mux.HandleFunc("GET /api/admin/update", s.withServerAdmin(s.checkUpdate))
|
mux.HandleFunc("GET /api/admin/update", s.withServerAdmin(s.checkUpdate))
|
||||||
mux.HandleFunc("POST /api/admin/update/apply", s.withServerAdmin(s.applyUpdate))
|
mux.HandleFunc("POST /api/admin/update/apply", s.withServerAdmin(s.applyUpdate))
|
||||||
mux.HandleFunc("GET /api/admin/update/status", s.withServerAdmin(s.updateStatus))
|
mux.HandleFunc("GET /api/admin/update/status", s.withServerAdmin(s.updateStatus))
|
||||||
@@ -127,8 +132,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
||||||
|
mux.HandleFunc("PUT /api/orgs/{org}/sites/{id}", s.withOrg(true, s.putSite))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/discovered", s.withOrg(true, s.siteDiscovered))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
|
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("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}/domains/{id}/check", s.withOrg(true, s.checkDomain))
|
||||||
@@ -139,15 +146,24 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.createShare))
|
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("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("DELETE /api/orgs/{org}/mappings/{id}/shares/{share}", s.withOrg(true, s.deleteShare))
|
||||||
|
mux.HandleFunc("POST /api/orgs/{org}/expose", s.withOrg(true, s.createExpose))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/expose", s.withOrg(true, s.listExpose))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/expose/{id}", s.withOrg(true, s.getExpose))
|
||||||
|
mux.HandleFunc("POST /api/orgs/{org}/expose/{id}/renew", s.withOrg(true, s.renewExpose))
|
||||||
|
mux.HandleFunc("DELETE /api/orgs/{org}/expose/{id}", s.withOrg(true, s.deleteExpose))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/mail-egress", s.withOrg(true, s.saveMailEgress))
|
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("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("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("GET /api/orgs/{org}/dkim", s.withOrg(true, s.listDKIM))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/dkim", s.withOrg(true, s.createDKIM))
|
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("DELETE /api/orgs/{org}/dkim/{id}", s.withOrg(true, s.deleteDKIM))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/dmarc", s.withOrg(true, s.listDMARC))
|
||||||
|
mux.HandleFunc("POST /api/orgs/{org}/dmarc", s.withOrg(true, s.uploadDMARC))
|
||||||
|
mux.HandleFunc("DELETE /api/orgs/{org}/dmarc/{domain}", s.withOrg(true, s.deleteDMARC))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/logs", s.withOrg(true, s.orgLogs))
|
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}/stats", s.withOrg(false, s.orgStats))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
|
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/traffic/{id}", s.withOrg(false, s.orgTrafficDetail))
|
||||||
|
|
||||||
// agents
|
// agents
|
||||||
mux.HandleFunc("GET /edge/config", s.edgeConfig)
|
mux.HandleFunc("GET /edge/config", s.edgeConfig)
|
||||||
|
|||||||
+7
-1
@@ -17,7 +17,8 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *principa
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.st.Settings
|
st := s.st.Settings
|
||||||
st.SMTP.Pass = "" // write-only
|
st.SMTP.Pass = "" // write-only
|
||||||
writeJSON(w, 200, map[string]any{"settings": st, "smtpPassSet": s.st.Settings.SMTP.Pass != ""})
|
st.Sandbar.Token, st.Sandbar.Monitors = "", nil // write-only
|
||||||
|
writeJSON(w, 200, map[string]any{"settings": st, "smtpPassSet": s.st.Settings.SMTP.Pass != "", "sandbarTokenSet": s.st.Settings.Sandbar.Token != "", "sandbarSync": s.sandbarStatus})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principal) {
|
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||||
@@ -38,6 +39,11 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
|
|||||||
if in.SMTP.Pass == "" {
|
if in.SMTP.Pass == "" {
|
||||||
in.SMTP.Pass = s.st.Settings.SMTP.Pass // blank keeps the saved password
|
in.SMTP.Pass = s.st.Settings.SMTP.Pass // blank keeps the saved password
|
||||||
}
|
}
|
||||||
|
if err := s.cleanSandbar(&in.Sandbar); err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.opts.DashboardURL != "" {
|
if s.opts.DashboardURL != "" {
|
||||||
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
|
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"git.veillax.com/Veillax/reef/secretbox"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -29,6 +29,10 @@ type Org struct {
|
|||||||
|
|
||||||
AlertDebounceMin int `json:"alertDebounceMin,omitempty"` // minutes a problem must last before alerting; 0 = 2
|
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
|
AlertRepeatHours int `json:"alertRepeatHours,omitempty"` // remind about an unresolved problem this often; 0 = never
|
||||||
|
|
||||||
|
// Traffic alerts, applied to every port of the organization. 0 = off.
|
||||||
|
TrafficSpikeFactor int `json:"trafficSpikeFactor,omitempty"` // alert when a port carries this many times its usual traffic
|
||||||
|
TrafficDailyMB int `json:"trafficDailyMb,omitempty"` // alert when a port carries more than this many MB in one UTC day
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
@@ -113,6 +117,7 @@ type Settings struct {
|
|||||||
AcmeEmail string `json:"acmeEmail"`
|
AcmeEmail string `json:"acmeEmail"`
|
||||||
AcmeStaging bool `json:"acmeStaging"`
|
AcmeStaging bool `json:"acmeStaging"`
|
||||||
SMTP SMTP `json:"smtp"`
|
SMTP SMTP `json:"smtp"`
|
||||||
|
Sandbar SandbarSync `json:"sandbar"`
|
||||||
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
|
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
|
||||||
SelfEdgeID string `json:"selfEdgeId,omitempty"`
|
SelfEdgeID string `json:"selfEdgeId,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -202,6 +207,7 @@ type Server struct {
|
|||||||
mailStatus map[string]string // mail egress ID -> "" (listening) or an error
|
mailStatus map[string]string // mail egress ID -> "" (listening) or an error
|
||||||
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
||||||
containers map[string][]model.Container // by site ID
|
containers map[string][]model.Container // by site ID
|
||||||
|
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
|
||||||
changed chan struct{}
|
changed chan struct{}
|
||||||
|
|
||||||
tickets map[string]ticket
|
tickets map[string]ticket
|
||||||
@@ -218,6 +224,11 @@ type Server struct {
|
|||||||
verAt time.Time
|
verAt time.Time
|
||||||
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
||||||
|
|
||||||
|
expose map[string]time.Time // deadline of each ephemeral mapping, by ID (see expose.go)
|
||||||
|
|
||||||
|
sandbarMu sync.Mutex // one monitor sync at a time (see sandbar.go)
|
||||||
|
sandbarStatus sandbarSyncStatus // how the last one went
|
||||||
|
|
||||||
logs *logStore
|
logs *logStore
|
||||||
db *DB
|
db *DB
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
@@ -249,6 +260,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
mailStatus: map[string]string{},
|
mailStatus: map[string]string{},
|
||||||
mailChecks: map[string]model.MailCheck{},
|
mailChecks: map[string]model.MailCheck{},
|
||||||
containers: map[string][]model.Container{},
|
containers: map[string][]model.Container{},
|
||||||
|
discovered: map[string][]model.DiscoveredService{},
|
||||||
changed: make(chan struct{}),
|
changed: make(chan struct{}),
|
||||||
tickets: map[string]ticket{},
|
tickets: map[string]ticket{},
|
||||||
oauth: map[string]oauthState{},
|
oauth: map[string]oauthState{},
|
||||||
@@ -258,6 +270,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
alerts: map[string]*alertState{},
|
alerts: map[string]*alertState{},
|
||||||
mtaSent: map[string]time.Time{},
|
mtaSent: map[string]time.Time{},
|
||||||
updRuns: map[string]*updRun{},
|
updRuns: map[string]*updRun{},
|
||||||
|
expose: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
s.st.Sessions = map[string]*Session{}
|
s.st.Sessions = map[string]*Session{}
|
||||||
s.st.Secrets = map[string]*Secret{}
|
s.st.Secrets = map[string]*Secret{}
|
||||||
@@ -298,7 +311,9 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
}
|
}
|
||||||
s.loadAlerts()
|
s.loadAlerts()
|
||||||
go s.housekeeping()
|
go s.housekeeping()
|
||||||
|
go s.exposeLoop()
|
||||||
go s.alertLoop()
|
go s.alertLoop()
|
||||||
|
go s.sandbarLoop()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeTags(t *testing.T) {
|
||||||
|
got, err := normalizeTags([]string{" Minecraft ", "minecraft", "", "prod east", "PROD east"})
|
||||||
|
if err != nil || !reflect.DeepEqual(got, []string{"Minecraft", "prod east"}) {
|
||||||
|
t.Fatalf("got %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := normalizeTags(nil); err != nil || got != nil {
|
||||||
|
t.Fatalf("nil in should give nil out: %v, %v", got, err)
|
||||||
|
}
|
||||||
|
tooMany := make([]string, 11)
|
||||||
|
for i := range tooMany {
|
||||||
|
tooMany[i] = "t" + strings.Repeat("x", i)
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{{strings.Repeat("a", 33)}, {"a,b"}, {"<b>"}, tooMany} {
|
||||||
|
if _, err := normalizeTags(bad); err == nil {
|
||||||
|
t.Errorf("accepted %v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteTagsAndRenameThroughTheAPI(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
|
||||||
|
code, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "Home"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("add site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
var site struct{ ID string }
|
||||||
|
json.Unmarshal(raw, &site)
|
||||||
|
|
||||||
|
if code, raw := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, map[string]any{"name": " Home lab ", "tags": []string{"Lab", "lab", "nas"}}); code != 200 {
|
||||||
|
t.Fatalf("edit site: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||||
|
var st struct {
|
||||||
|
Sites []struct {
|
||||||
|
ID, Name string
|
||||||
|
Tags []string
|
||||||
|
Local bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &st)
|
||||||
|
found := false
|
||||||
|
for _, x := range st.Sites {
|
||||||
|
if x.ID == site.ID {
|
||||||
|
found = true
|
||||||
|
if x.Name != "Home lab" || !reflect.DeepEqual(x.Tags, []string{"Lab", "nas"}) {
|
||||||
|
t.Fatalf("site after edit: %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("site missing from state: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, body := range map[string]map[string]any{"blank name": {"name": " "}, "bad tag": {"name": "x", "tags": []string{"a,b"}}} {
|
||||||
|
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, body); code != 400 {
|
||||||
|
t.Errorf("%s: expected 400, got %d", name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/nope", map[string]any{"name": "x"}); code != 404 {
|
||||||
|
t.Errorf("unknown site: expected 404, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandbarKeyIsNeverSentBackAndBlankKeepsIt(t *testing.T) {
|
||||||
|
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
do := apiClient(t, s)
|
||||||
|
_, b := do("GET", "/api/me", nil)
|
||||||
|
var me struct{ Orgs []struct{ ID string } }
|
||||||
|
json.Unmarshal(b, &me)
|
||||||
|
org := me.Orgs[0].ID
|
||||||
|
|
||||||
|
code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels", map[string]any{"name": "pager", "kind": "sandbar", "target": "https://sandbar.example.com", "token": "sb_topsecret", "policy": "p1", "enabled": true})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create channel: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
_, raw = do("GET", "/api/orgs/"+org+"/alerts", nil)
|
||||||
|
if strings.Contains(string(raw), "sb_topsecret") || strings.Contains(string(raw), "sandbar.example.com/api") {
|
||||||
|
t.Fatalf("the overview leaks Sandbar details: %s", raw)
|
||||||
|
}
|
||||||
|
var ov struct {
|
||||||
|
Channels []struct{ ID, Kind, Policy string }
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &ov)
|
||||||
|
if len(ov.Channels) != 1 || ov.Channels[0].Kind != "sandbar" || ov.Channels[0].Policy != "p1" {
|
||||||
|
t.Fatalf("overview: %s", raw)
|
||||||
|
}
|
||||||
|
// editing without re-entering the address or key keeps both
|
||||||
|
if code, raw := do("PUT", "/api/orgs/"+org+"/alerts/channels/"+ov.Channels[0].ID, map[string]any{"name": "pager 2", "kind": "sandbar", "policy": "p2", "enabled": true}); code != 200 {
|
||||||
|
t.Fatalf("edit channel: %d %s", code, raw)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
ch := s.st.Channels[0]
|
||||||
|
kept := ch.Token == "sb_topsecret" && ch.Target == "https://sandbar.example.com" && ch.Policy == "p2"
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !kept {
|
||||||
|
t.Fatalf("an edit lost the saved key or address: %+v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traffic analytics: edges report per-port bytes, connections and their busiest client addresses with
|
||||||
|
// every report. Hammerhead keeps them at three resolutions (see the *Keep constants in db.go) and draws
|
||||||
|
// charts from them. Organizations can also be alerted when a port suddenly carries far more traffic
|
||||||
|
// than usual, or more than a daily amount.
|
||||||
|
|
||||||
|
// Spikes are judged on the last few minutes against the hours before them. A port has to have an
|
||||||
|
// hour of history before it can spike, and the burst has to be worth noticing in absolute terms, so
|
||||||
|
// a quiet port going from 1 KB to 50 KB doesn't page anyone.
|
||||||
|
const (
|
||||||
|
spikeMinHistory = 60 // minutes of baseline needed before a spike can be called
|
||||||
|
spikeMinBytes = 10 * 1024 * 1024 // bytes in the window below which nothing counts as a spike
|
||||||
|
)
|
||||||
|
|
||||||
|
// spiking says whether recent traffic is a spike over the baseline for a port.
|
||||||
|
func spiking(recent int64, baseline float64, history int64, factor int) bool {
|
||||||
|
if factor <= 0 || history < spikeMinHistory || recent < spikeMinBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return float64(recent) > baseline*float64(factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trafficProblemsLocked adds an alert problem for each port of an organization that is spiking or has
|
||||||
|
// gone over its daily amount. Caller holds s.mu.
|
||||||
|
func (s *Server) trafficProblemsLocked(now time.Time, edgeDown map[string]bool, add func(key, org, kind, name, msg string)) {
|
||||||
|
dayStart := now.UTC().Truncate(24*time.Hour).Unix() / 3600
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if !m.Enabled || edgeDown[m.EdgeID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
o := s.findOrg(m.OrgID)
|
||||||
|
if o == nil || (o.TrafficSpikeFactor <= 0 && o.TrafficDailyMB <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if o.TrafficSpikeFactor > 0 {
|
||||||
|
recent, base, history := s.db.recentTraffic(m.ID, now)
|
||||||
|
if spiking(recent, base, history, o.TrafficSpikeFactor) {
|
||||||
|
add("spike:"+m.ID, m.OrgID, "traffic", m.Name, fmt.Sprintf("%s: %s in the last %d minutes, about %.0f times its usual %s",
|
||||||
|
m.Name, byteText(recent), spikeWindow, float64(recent)/maxFloat(base, 1), byteText(int64(base))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit := int64(o.TrafficDailyMB) << 20; limit > 0 {
|
||||||
|
if used := s.db.bytesSince(m.ID, dayStart); used > limit {
|
||||||
|
add("quota:"+m.ID, m.OrgID, "traffic", m.Name, fmt.Sprintf("%s has carried %s today, over the %s a day you set", m.Name, byteText(used), byteText(limit)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxFloat(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func byteText(n int64) string {
|
||||||
|
f, units := float64(n), []string{"B", "KB", "MB", "GB", "TB"}
|
||||||
|
i := 0
|
||||||
|
for f >= 1024 && i < len(units)-1 {
|
||||||
|
f /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %s", f, units[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// trafficRanges maps the ranges the dashboard offers to a bucket size, a bucket count and how many hours of top sources to include.
|
||||||
|
var trafficRanges = map[string]struct {
|
||||||
|
unit string
|
||||||
|
count int
|
||||||
|
hours int
|
||||||
|
bucket time.Duration
|
||||||
|
}{
|
||||||
|
"1h": {"minute", 60, 1, time.Minute},
|
||||||
|
"6h": {"minute", 360, 6, time.Minute},
|
||||||
|
"24h": {"hour", 24, 24, time.Hour},
|
||||||
|
"7d": {"hour", 7 * 24, 7 * 24, time.Hour},
|
||||||
|
"30d": {"hour", 30 * 24, 7 * 24, time.Hour}, // client addresses are only kept for a week
|
||||||
|
}
|
||||||
|
|
||||||
|
// orgTrafficDetail serves one port's traffic chart and busiest clients, or the organization's total
|
||||||
|
// for id "all". ?range= is one of 1h, 6h, 24h (default), 7d or 30d.
|
||||||
|
func (s *Server) orgTrafficDetail(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
rng := r.URL.Query().Get("range")
|
||||||
|
if rng == "" {
|
||||||
|
rng = "24h"
|
||||||
|
}
|
||||||
|
spec, found := trafficRanges[rng]
|
||||||
|
if !found {
|
||||||
|
fail(w, 400, errors.New("range must be 1h, 6h, 24h, 7d or 30d"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
s.mu.Lock()
|
||||||
|
var ids []string
|
||||||
|
name := "All resources"
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if m.OrgID != c.Org.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id == "all" {
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
} else if m.ID == id {
|
||||||
|
ids, name = []string{m.ID}, m.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if id != "all" && len(ids) == 0 {
|
||||||
|
fail(w, 404, errors.New("no such resource"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
points := s.db.trafficSeries(ids, spec.unit, spec.count, now)
|
||||||
|
var tot TrafficPoint
|
||||||
|
var peak int64
|
||||||
|
for _, p := range points {
|
||||||
|
tot.Conns += p.Conns
|
||||||
|
tot.BytesIn += p.BytesIn
|
||||||
|
tot.BytesOut += p.BytesOut
|
||||||
|
if b := p.BytesIn + p.BytesOut; b > peak {
|
||||||
|
peak = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{
|
||||||
|
"name": name, "range": rng, "bucketSeconds": int(spec.bucket / time.Second), "points": points,
|
||||||
|
"total": tot, "peakBucket": peak, "sources": s.db.topSources(ids, spec.hours, 10, now), "sourcesHours": spec.hours,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// trafficAlertSettings sets when an organization is told about unusual traffic.
|
||||||
|
func (s *Server) trafficAlertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||||
|
var in struct{ SpikeFactor, DailyMB int }
|
||||||
|
if !decode(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (in.SpikeFactor != 0 && (in.SpikeFactor < 2 || in.SpikeFactor > 1000)) || in.DailyMB < 0 || in.DailyMB > 100_000_000 {
|
||||||
|
fail(w, 400, errors.New("A spike is 2 to 1000 times the usual traffic (0 turns it off), and the daily amount can't be negative (0 turns it off)."))
|
||||||
|
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.TrafficSpikeFactor, o.TrafficDailyMB = in.SpikeFactor, in.DailyMB
|
||||||
|
s.save()
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpiking(t *testing.T) {
|
||||||
|
const mb = 1 << 20
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
recent int64
|
||||||
|
base float64
|
||||||
|
history int64
|
||||||
|
factor int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ten times usual", 200 * mb, 10 * mb, 180, 5, true},
|
||||||
|
{"just under the factor", 49 * mb, 10 * mb, 180, 5, false},
|
||||||
|
{"off", 500 * mb, 10 * mb, 180, 0, false},
|
||||||
|
{"too little history", 500 * mb, 10 * mb, 30, 5, false},
|
||||||
|
{"tiny in absolute terms", 5 * mb, 0.01 * mb, 180, 5, false},
|
||||||
|
{"from nothing to a lot", 50 * mb, 0, 180, 5, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := spiking(c.recent, c.base, c.history, c.factor); got != c.want {
|
||||||
|
t.Errorf("%s: spiking = %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficStorage(t *testing.T) {
|
||||||
|
s := newFx(t).s
|
||||||
|
now := time.Now()
|
||||||
|
st := func(in, out int64, src ...model.SourceStat) model.Stat {
|
||||||
|
return model.Stat{Conns: 1, BytesIn: in, BytesOut: out, Sources: src}
|
||||||
|
}
|
||||||
|
s.db.addTrafficAt("m0", st(100, 900, model.SourceStat{IP: "203.0.113.1", Conns: 1, BytesIn: 100, BytesOut: 900}), now)
|
||||||
|
s.db.addTrafficAt("m0", st(10, 10, model.SourceStat{IP: "203.0.113.1", Conns: 1, BytesIn: 10, BytesOut: 10}, model.SourceStat{IP: "203.0.113.2", Conns: 1, BytesIn: 1}), now)
|
||||||
|
s.db.addTrafficAt("m0", st(1, 1), now.Add(-3*time.Minute))
|
||||||
|
s.db.addTrafficAt("m1", st(7, 7), now)
|
||||||
|
|
||||||
|
pts := s.db.trafficSeries([]string{"m0"}, "minute", 60, now)
|
||||||
|
if len(pts) != 60 || pts[59].BytesIn != 110 || pts[59].BytesOut != 910 || pts[59].Conns != 2 || pts[56].BytesIn != 1 {
|
||||||
|
t.Fatalf("minute series: last=%+v 3 back=%+v", pts[59], pts[56])
|
||||||
|
}
|
||||||
|
if pts[59].Time != now.Unix()/60*60 || pts[58].Time != pts[59].Time-60 {
|
||||||
|
t.Errorf("bucket times: %d %d", pts[58].Time, pts[59].Time)
|
||||||
|
}
|
||||||
|
all := s.db.trafficSeries([]string{"m0", "m1"}, "hour", 24, now)
|
||||||
|
if all[23].BytesIn+all[22].BytesIn < 118 || len(all) != 24 {
|
||||||
|
t.Errorf("hour series over two ports: %+v", all[22:])
|
||||||
|
}
|
||||||
|
if got := s.db.trafficSeries(nil, "hour", 3, now); len(got) != 3 || got[2].BytesIn != 0 {
|
||||||
|
t.Errorf("no ports: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
top := s.db.topSources([]string{"m0"}, 24, 10, now)
|
||||||
|
if len(top) != 2 || top[0].IP != "203.0.113.1" || top[0].BytesOut != 910 || top[0].Conns != 2 {
|
||||||
|
t.Fatalf("top sources: %+v", top)
|
||||||
|
}
|
||||||
|
if got := s.db.topSources([]string{"m0"}, 24, 1, now); len(got) != 1 {
|
||||||
|
t.Errorf("limit: %+v", got)
|
||||||
|
}
|
||||||
|
if got := s.db.topSources([]string{"m1"}, 24, 10, now); len(got) != 0 {
|
||||||
|
t.Errorf("another port's sources leaked: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficRetention(t *testing.T) {
|
||||||
|
s := newFx(t).s
|
||||||
|
now := time.Now()
|
||||||
|
old := model.Stat{Conns: 1, BytesIn: 5, Sources: []model.SourceStat{{IP: "203.0.113.9", Conns: 1, BytesIn: 5}}}
|
||||||
|
s.db.addTrafficAt("m0", old, now.Add(-trafficMinutelyKeep-time.Hour)) // too old for minutes, fine for hours and sources
|
||||||
|
s.db.addTrafficAt("m0", old, now.Add(-trafficSourcesKeep-time.Hour)) // too old for sources too
|
||||||
|
s.db.addTrafficAt("m0", old, now.Add(-trafficHourlyKeep-time.Hour)) // gone everywhere
|
||||||
|
s.db.addTrafficAt("m0", old, now)
|
||||||
|
s.db.purge(30, 365)
|
||||||
|
count := func(table string) (n int) {
|
||||||
|
s.db.sql.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := count("traffic_minutely"); n != 1 {
|
||||||
|
t.Errorf("minutely rows after purge: %d, want 1", n)
|
||||||
|
}
|
||||||
|
if n := count("traffic_sources"); n != 2 {
|
||||||
|
t.Errorf("source rows after purge: %d, want 2", n)
|
||||||
|
}
|
||||||
|
if n := count("traffic_hourly"); n != 3 {
|
||||||
|
t.Errorf("hourly rows after purge: %d, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentTraffic(t *testing.T) {
|
||||||
|
s := newFx(t).s
|
||||||
|
now := time.Now()
|
||||||
|
const mb = 1 << 20
|
||||||
|
for h := 3 * 60; h > spikeWindow; h-- { // three hours of 1 MB a minute
|
||||||
|
s.db.addTrafficAt("m0", model.Stat{BytesIn: mb}, now.Add(-time.Duration(h)*time.Minute))
|
||||||
|
}
|
||||||
|
s.db.addTrafficAt("m0", model.Stat{BytesIn: 100 * mb}, now)
|
||||||
|
recent, base, hist := s.db.recentTraffic("m0", now)
|
||||||
|
if recent != 100*mb || hist < 170 || base < 4.9*mb || base > 5.1*mb { // 1 MB a minute is 5 MB per window
|
||||||
|
t.Fatalf("recent=%d base=%.0f history=%d", recent, base, hist)
|
||||||
|
}
|
||||||
|
if !spiking(recent, base, hist, 5) {
|
||||||
|
t.Error("100 MB over a 5 MB norm should spike")
|
||||||
|
}
|
||||||
|
if r, b, h := s.db.recentTraffic("nothing", now); r != 0 || b != 0 || h != 0 {
|
||||||
|
t.Errorf("unknown port: %d %f %d", r, b, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficAlertsFireAndClear(t *testing.T) {
|
||||||
|
f := newFx(t)
|
||||||
|
s := f.s
|
||||||
|
const mb = 1 << 20
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
m := s.st.Mappings[0]
|
||||||
|
o := s.findOrg(m.OrgID)
|
||||||
|
o.TrafficSpikeFactor, o.TrafficDailyMB = 4, 500
|
||||||
|
id := m.ID
|
||||||
|
s.mu.Unlock()
|
||||||
|
for min := 3 * 60; min > spikeWindow; min-- {
|
||||||
|
s.db.addTrafficAt(id, model.Stat{BytesIn: mb}, now.Add(-time.Duration(min)*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
problems := func() map[string]problem {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.problemsLocked(now)
|
||||||
|
}
|
||||||
|
if p := problems(); p["spike:"+id].msg != "" || p["quota:"+id].msg != "" {
|
||||||
|
t.Fatalf("steady traffic raised a problem: %+v", p)
|
||||||
|
}
|
||||||
|
s.db.addTrafficAt(id, model.Stat{BytesOut: 300 * mb}, now)
|
||||||
|
p := problems()
|
||||||
|
if !strings.Contains(p["spike:"+id].msg, "usual") || p["spike:"+id].kind != "traffic" {
|
||||||
|
t.Fatalf("no spike problem: %+v", p)
|
||||||
|
}
|
||||||
|
if p["quota:"+id].msg != "" {
|
||||||
|
t.Errorf("under the daily amount, yet: %+v", p["quota:"+id])
|
||||||
|
}
|
||||||
|
s.db.addTrafficAt(id, model.Stat{BytesOut: 300 * mb}, now)
|
||||||
|
if q := problems()["quota:"+id]; !strings.Contains(q.msg, "over the") {
|
||||||
|
t.Fatalf("no quota problem: %+v", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
o.TrafficSpikeFactor, o.TrafficDailyMB = 0, 0
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p := problems(); p["spike:"+id].msg != "" || p["quota:"+id].msg != "" {
|
||||||
|
t.Errorf("alerts off, yet: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
+278
-46
@@ -159,6 +159,10 @@
|
|||||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||||
|
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
|
||||||
|
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
|
||||||
|
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
|
||||||
|
.chip.on { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
|
||||||
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
|
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
|
||||||
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
||||||
|
|
||||||
@@ -271,6 +275,25 @@ const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'
|
|||||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
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 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 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}); };
|
||||||
|
// ----- tags -----
|
||||||
|
// Tags are free-form labels on resources and sites. The filter choice is remembered per list until the page reloads.
|
||||||
|
const TAGSEL = {ports:'', sites:''};
|
||||||
|
const allTags = (...lists) => [...new Set(lists.flat().flatMap(x => x.tags || []))].sort((a, b) => a.localeCompare(b));
|
||||||
|
const hasTag = (x, t) => !t || (x.tags || []).some(v => v.toLowerCase() === t.toLowerCase());
|
||||||
|
const tagList = x => (x.tags || []).length ? h('div', {class:'tags'}, x.tags.map(t => h('span', {class:'tag'}, t))) : null;
|
||||||
|
function tagInput(tags, known) {
|
||||||
|
const id = 'tags-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
const i = h('input', {value:(tags || []).join(', '), placeholder:'e.g. minecraft, production', list:id});
|
||||||
|
const el = field('Tags', h('div', {}, i, h('datalist', {id}, known.map(t => h('option', {value:t})))), 'Optional. Separate with commas. Use them to filter this list.');
|
||||||
|
return {el, get:() => i.value.split(',').map(t => t.trim()).filter(Boolean)};
|
||||||
|
}
|
||||||
|
// chips that narrow a list to one tag; pressing the active chip, or "All", clears it
|
||||||
|
function tagBar(key, tags) {
|
||||||
|
if (!tags.length) return null;
|
||||||
|
const chip = (label, val) => h('button', {class:'chip' + (TAGSEL[key] === val ? ' on' : ''), type:'button', 'aria-pressed':String(TAGSEL[key] === val),
|
||||||
|
onclick:() => { TAGSEL[key] = TAGSEL[key] === val ? '' : val; refresh(); }}, label);
|
||||||
|
return h('div', {class:'tagbar', role:'group', 'aria-label':'Filter by tag'}, chip('All', ''), tags.map(t => chip(t, t)));
|
||||||
|
}
|
||||||
// ----- versions and updates -----
|
// ----- versions and updates -----
|
||||||
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
|
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
|
||||||
// until the update is over, and keeps going through the gap while a program restarts into its new version.
|
// until the update is over, and keeps going through the gap while a program restarts into its new version.
|
||||||
@@ -600,7 +623,9 @@ views.ports = async () => {
|
|||||||
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
|
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
|
||||||
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
|
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 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 tags = allTags(st.mappings);
|
||||||
|
if (TAGSEL.ports && !tags.some(t => t.toLowerCase() === TAGSEL.ports.toLowerCase())) TAGSEL.ports = ''; // the last resource with that tag is gone
|
||||||
|
const rows = st.mappings.filter(m => hasTag(m, TAGSEL.ports)).sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||||
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
|
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
|
||||||
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
|
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
|
||||||
h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
|
h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
|
||||||
@@ -609,7 +634,7 @@ views.ports = async () => {
|
|||||||
S.headerAction = ['Add resource', () => portForm()];
|
S.headerAction = ['Add resource', () => portForm()];
|
||||||
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
|
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
|
||||||
return h('div', {}, unsetDash ? h('div', {class:'notice'}, '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,
|
return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
||||||
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
tagBar('ports', tags), rows.length ? h('div', {class:'toolbar'}, h('button', {class:'ghost', onclick:() => trafficDialog('all', 'All resources')}, 'Traffic overview')) : null, rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||||
};
|
};
|
||||||
|
|
||||||
function spark(series) {
|
function spark(series) {
|
||||||
@@ -623,6 +648,57 @@ function spark(series) {
|
|||||||
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
|
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Traffic chart for one resource (or "all"): stacked bars per time bucket.
|
||||||
|
function trafficChart(d) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg', W = 640, H = 170, L = 52, B = 22, T = 8, n = d.points.length;
|
||||||
|
const mk = (tag, attrs, text) => { const e = document.createElementNS(ns, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; };
|
||||||
|
const per = d.bucketSeconds >= 3600 ? 'hour' : 'minute';
|
||||||
|
const svg = mk('svg', {viewBox:`0 0 ${W} ${H}`, width:'100%', role:'img', 'aria-label':`Traffic per ${per}: ${bytes(d.total.bytesOut)} out and ${bytes(d.total.bytesIn)} in`});
|
||||||
|
const max = Math.max(d.peakBucket, 1), plotH = H - B - T, bw = (W - L) / n;
|
||||||
|
for (const f of [0, .5, 1]) {
|
||||||
|
const y = T + plotH * (1 - f);
|
||||||
|
svg.append(mk('line', {x1:L, x2:W, y1:y, y2:y, stroke:'var(--border)', 'stroke-width':1}), mk('text', {x:L - 6, y:y + 4, 'text-anchor':'end', fill:'var(--text-faint)', 'font-size':11}, bytes(max * f)));
|
||||||
|
}
|
||||||
|
d.points.forEach((p, i) => {
|
||||||
|
const hOut = p.bytesOut / max * plotH, hIn = p.bytesIn / max * plotH, x = L + i * bw, g = mk('g', {});
|
||||||
|
g.append(mk('title', {}, `${new Date(p.t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', hour:'numeric', minute:per === 'minute' ? '2-digit' : undefined})}: ${bytes(p.bytesOut)} out, ${bytes(p.bytesIn)} in, ${p.conns} conn`),
|
||||||
|
mk('rect', {x:x + .5, y:T + plotH - hOut, width:Math.max(bw - 1, .5), height:hOut, fill:'var(--accent)'}),
|
||||||
|
mk('rect', {x:x + .5, y:T + plotH - hOut - hIn, width:Math.max(bw - 1, .5), height:hIn, fill:'var(--text-faint)'}));
|
||||||
|
svg.append(g);
|
||||||
|
});
|
||||||
|
for (const i of [0, Math.floor(n / 2), n - 1]) {
|
||||||
|
const t = new Date(d.points[i].t * 1000);
|
||||||
|
svg.append(mk('text', {x:i === n - 1 ? W : L + i * bw, y:H - 6, 'text-anchor':i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle', fill:'var(--text-faint)', 'font-size':11},
|
||||||
|
per === 'minute' || n <= 24 ? t.toLocaleTimeString(undefined, {hour:'numeric', minute:'2-digit'}) : t.toLocaleDateString(undefined, {month:'short', day:'numeric'})));
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dialog with the traffic chart, totals and busiest visitors of one resource ("all" = every resource).
|
||||||
|
function trafficDialog(id, name) {
|
||||||
|
const body = h('div', {class:'stack'}, h('p', {class:'dim'}, 'Loading…'));
|
||||||
|
const close = modal('Traffic: ' + name, body, {wide:true});
|
||||||
|
const load = async r => {
|
||||||
|
const d = await api('GET', O(`/traffic/${id}?range=${r}`));
|
||||||
|
const per = d.bucketSeconds >= 3600 ? 'hour' : 'minute';
|
||||||
|
const ranges = [['1h', 'Hour'], ['6h', '6 hours'], ['24h', 'Day'], ['7d', 'Week'], ['30d', 'Month']];
|
||||||
|
const covered = d.sourcesHours >= 48 ? Math.round(d.sourcesHours / 24) + ' days' : d.sourcesHours === 1 ? 'hour' : d.sourcesHours + ' hours';
|
||||||
|
body.replaceChildren(
|
||||||
|
h('div', {class:'tabs2'}, ranges.map(([k, l]) => h('button', {class:k === r ? 'on' : '', onclick:() => load(k).catch(e => toast(e.message))}, l))),
|
||||||
|
d.total.bytesIn + d.total.bytesOut ? h('div', {}, trafficChart(d),
|
||||||
|
h('div', {class:'small dim'}, h('span', {style:'color:var(--accent)'}, '■ '), 'To visitors (out) ', h('span', {style:'margin-left:12px;color:var(--text-faint)'}, '■ '), 'From visitors (in) · per ' + per)) :
|
||||||
|
emptyState('ports', 'No traffic in this period', 'Traffic appears here a few seconds after it happens.'),
|
||||||
|
h('div', {class:'small'}, `${d.total.conns} connections or requests, ${bytes(d.total.bytesOut)} out, ${bytes(d.total.bytesIn)} in.`),
|
||||||
|
h('h3', {}, 'Busiest visitors'),
|
||||||
|
d.sources.length ? h('div', {class:'scroll'}, h('table', {},
|
||||||
|
h('thead', {}, h('tr', {}, ['Address', 'Connections', 'Out', 'In'].map(x => h('th', {}, x)))),
|
||||||
|
h('tbody', {}, d.sources.map(s => h('tr', {}, h('td', {class:'mono'}, s.ip), h('td', {}, s.conns), h('td', {}, bytes(s.bytesOut)), h('td', {}, bytes(s.bytesIn))))))) : h('p', {class:'dim small'}, 'None named yet.'),
|
||||||
|
h('p', {class:'dim small'}, `Counted over the last ${covered} (visitor addresses are kept for 7 days). Each report names the busiest ten per resource, so very small visitors may be missing.`),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close')));
|
||||||
|
};
|
||||||
|
load('24h').catch(e => { close(); toast(e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
|
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
|
||||||
|
|
||||||
function mappingRow(m, st, series) {
|
function mappingRow(m, st, series) {
|
||||||
@@ -641,29 +717,78 @@ function mappingRow(m, st, series) {
|
|||||||
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
|
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
|
||||||
return h('tr', {},
|
return h('tr', {},
|
||||||
h('td', {title:why}, badge(label, tone)),
|
h('td', {title:why}, badge(label, tone)),
|
||||||
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', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
|
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.redirectTo ? h('span', {class:'tag', style:'margin-left:4px'}, 'redirect') : null,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', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
|
||||||
|
tagList(m),
|
||||||
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
|
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
|
||||||
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
|
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
|
||||||
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
|
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
|
||||||
h('td', {class:'small'}, m.targets.map((t, i) => {
|
h('td', {class:'small'}, m.redirectTo ? h('div', {}, h('span', {class:'mono clip', title:m.redirectTo}, '→ ' + m.redirectTo),
|
||||||
|
h('div', {class:'dim'}, (m.redirectPermanent ? 'Permanent (301)' : 'Temporary (302)') + (m.redirectKeepPath ? ', keeps the path' : ''))) : m.targets.map((t, i) => {
|
||||||
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
||||||
return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port));
|
return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port));
|
||||||
})),
|
})),
|
||||||
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
|
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
|
||||||
h('td', {}, h('div', {class:'actions'},
|
h('td', {}, h('div', {class:'actions'},
|
||||||
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
|
m.redirectTo ? null : h('button', {class:'ghost', onclick:() => trafficDialog(m.id, m.name)}, 'Traffic'),
|
||||||
|
h('button', {class:'ghost', onclick:() => m.redirectTo ? redirectForm(m) : 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'),
|
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', 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: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null,
|
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null,
|
||||||
h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close'))));
|
h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A redirect sends visitors from one hostname to another address. New ones open the HTTPS side on port 443 and,
|
||||||
|
// if asked, the plain HTTP side on port 80, so both ways of typing the old name land on the new one.
|
||||||
|
function redirectForm(existing) {
|
||||||
|
const st = S.st, m = existing || {proto:'https', enabled:true, bindIp:'0.0.0.0', redirectPermanent:true, redirectKeepPath:true};
|
||||||
|
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('select', {});
|
||||||
|
const refreshIPs = () => {
|
||||||
|
const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0';
|
||||||
|
const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : [];
|
||||||
|
const ips = [...new Set([...((e && e.ips) || []), ...saved])];
|
||||||
|
const label = ip => { const d = st.domains.filter(x => x.ip === ip).map(x => x.name); return d.length ? `${ip} (${d.join(', ')})` : ip; };
|
||||||
|
ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, label(ip))));
|
||||||
|
ipIn.value = ips.includes(keep) ? keep : '0.0.0.0';
|
||||||
|
};
|
||||||
|
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||||
|
const from = h('input', {value:m.hostname || '', list:'rdoms', placeholder:'old.example.com or *.example.com'});
|
||||||
|
const doms = h('datalist', {id:'rdoms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||||
|
const to = h('input', {value:m.redirectTo || '', placeholder:'https://new.example.com'});
|
||||||
|
const kind = h('select', {}, [[true, 'Permanent (301): browsers and search engines remember it'], [false, 'Temporary (302): checked every time']].map(([v, t]) => h('option', {value:String(v), selected:!!m.redirectPermanent === v}, t)));
|
||||||
|
const keep = chk('Keep the path and query, so old.example.com/a?b=1 goes to the same place on the new address', m.redirectKeepPath);
|
||||||
|
const both = chk('Also redirect plain HTTP (port 80)', true);
|
||||||
|
const name = h('input', {value:m.name || '', placeholder:'optional'}), tg = tagInput(m.tags, allTags(st.mappings));
|
||||||
|
const save = act(async () => {
|
||||||
|
const base = {name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', hostname:from.value, redirectTo:to.value, redirectPermanent:kind.value === 'true',
|
||||||
|
redirectKeepPath:keep.box.checked, tags:tg.get(), enabled:m.enabled !== false, targets:[]};
|
||||||
|
if (existing) { await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, port:m.port}); }
|
||||||
|
else {
|
||||||
|
await api('POST', O('/mappings'), {...base, proto:'https', port:443});
|
||||||
|
if (both.box.checked) {
|
||||||
|
try { await api('POST', O('/mappings'), {...base, proto:'http', port:80, name:name.value ? name.value + ' (HTTP)' : ''}); }
|
||||||
|
catch (e) { throw new Error('The HTTPS redirect was added, but the HTTP one could not be: ' + e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(); toast(existing ? 'Redirect saved' : 'Redirect added'); refresh();
|
||||||
|
});
|
||||||
|
const close = modal(existing ? 'Edit redirect' : 'Add a domain redirect', h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim'}, existing ? `This redirect answers ${m.proto === 'https' ? 'HTTPS on port ' : 'HTTP on port '}${m.port}.` : 'Visitors who open the old address are sent to the new one. HTTPS gets its certificate automatically.'),
|
||||||
|
h('div', {class:'grid'}, field('Redirect this address', from, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first'), field('To', to, 'A full address, starting with http:// or https://')),
|
||||||
|
doms, field('Type', kind), keep, existing ? null : both,
|
||||||
|
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it')),
|
||||||
|
h('div', {class:'grid'}, field('Name', name), tg.el),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add redirect'))), {sticky:true});
|
||||||
|
}
|
||||||
|
|
||||||
function portForm(existing) {
|
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 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 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 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'});
|
// the public address is picked from the edge's own addresses, each shown with the domains pointed at it
|
||||||
|
const ipIn = h('select', {}), ipList = document.createTextNode('');
|
||||||
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 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 tagsIn = tagInput(m.tags, allTags(st.mappings));
|
||||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port =h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
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 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 domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||||
@@ -687,7 +812,25 @@ function portForm(existing) {
|
|||||||
const webBox = h('div', {class:'stack'});
|
const webBox = h('div', {class:'stack'});
|
||||||
const hostLbl = hostBox;
|
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 ipLabel = (e, ip) => {
|
||||||
|
const doms = st.domains.filter(d => d.ip === ip && (!d.edgeId || !e || d.edgeId === e.id)).map(d => d.name);
|
||||||
|
return doms.length ? `${ip} (${doms.join(', ')})` : ip;
|
||||||
|
};
|
||||||
|
const refreshIPs = () => {
|
||||||
|
const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0';
|
||||||
|
// an address saved earlier stays selectable even if the edge has not reported it yet
|
||||||
|
const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : [];
|
||||||
|
const ips = [...new Set([...((e && e.ips) || []), ...saved])];
|
||||||
|
ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, ipLabel(e, ip))));
|
||||||
|
ipIn.value = ips.includes(keep) ? keep : '0.0.0.0';
|
||||||
|
};
|
||||||
|
// choosing a hostname under a domain that points at one of the edge's addresses selects that address
|
||||||
|
const pickIPForHost = () => {
|
||||||
|
const name = host.value.trim().toLowerCase().replace(/^\*\./, ''), e = byId(st.edges, edgeSel.value);
|
||||||
|
const d = st.domains.find(d => d.ip && (name === d.name || name.endsWith('.' + d.name)) && (!d.edgeId || d.edgeId === edgeSel.value));
|
||||||
|
if (d && ipIn.value === '0.0.0.0' && e && (e.ips || []).includes(d.ip)) ipIn.value = d.ip;
|
||||||
|
};
|
||||||
|
host.addEventListener('change', pickIPForHost);
|
||||||
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
|
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
|
||||||
const drawTargets = () => {
|
const drawTargets = () => {
|
||||||
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
|
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
|
||||||
@@ -697,21 +840,22 @@ function portForm(existing) {
|
|||||||
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 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});
|
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),
|
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)}, 'Containers'),
|
h('button', {class:'ghost', type:'button', title:'Pick a Docker container or discovered service on that site', onclick:() => discover(t, th, tp)}, 'Containers'),
|
||||||
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove target', onclick:() => { targets.splice(i, 1); drawTargets(); }}, icon('close', 16)) : h('span'));
|
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove target', onclick:() => { targets.splice(i, 1); drawTargets(); }}, icon('close', 16)) : h('span'));
|
||||||
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', title:'Spread visitors across targets, or fail over when one is down', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, icon('add', 16), 'Add another target') : null);
|
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', title:'Spread visitors across targets, or fail over when one is down', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, icon('add', 16), 'Add another target') : null);
|
||||||
};
|
};
|
||||||
const discover = act(async (t, th, tp) => {
|
const discover = act(async (t, th, tp) => {
|
||||||
if (!t.siteId) throw new Error('Choose a site first');
|
if (!t.siteId) throw new Error('Choose a site first');
|
||||||
const list = await api('GET', O(`/sites/${t.siteId}/containers`));
|
const [list, found] = await Promise.all([api('GET', O(`/sites/${t.siteId}/containers`)), api('GET', O(`/sites/${t.siteId}/discovered`))]);
|
||||||
if (!list.length) throw new Error('No containers reported. Run the agent with --docker (and make sure Docker is running).');
|
if (!list.length && !found.length) throw new Error('Nothing reported. Run the agent with --docker for containers, or --discover (optionally --discover-subnets) for services on the network.');
|
||||||
const opts = [];
|
const opts = [];
|
||||||
|
for (const d of found) opts.push({label:`${d.service || 'tcp'} ${d.host}:${d.port}${d.detail ? ' ' + d.detail : ''} (${d.source === 'local' ? 'listening here' : 'found by scan'})`, host:d.host, port:d.port});
|
||||||
for (const c of list) for (const p of c.ports) {
|
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});
|
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});
|
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 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 close = modal('Pick a container or discovered service', 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'},
|
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.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
|
||||||
@@ -743,7 +887,7 @@ function portForm(existing) {
|
|||||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
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 roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||||
const base = {
|
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,
|
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets, tags:tagsIn.get(),
|
||||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
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 || [],
|
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},
|
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||||
@@ -758,7 +902,7 @@ function portForm(existing) {
|
|||||||
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
|
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
|
||||||
});
|
});
|
||||||
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
|
const close = existing ? modal('Edit ' + (m.name || 'resource'), 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 for TCP and UDP')),
|
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP'), tagsIn.el),
|
||||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
|
||||||
|
|
||||||
@@ -771,6 +915,7 @@ function portForm(existing) {
|
|||||||
['both', 'TCP + UDP', 'The same port for both, as many games want.'],
|
['both', 'TCP + UDP', 'The same port for both, as many games want.'],
|
||||||
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
|
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
|
||||||
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
|
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
|
||||||
|
['redirect', 'Domain redirect', 'Send visitors from one address to another, such as old.example.com to new.example.com.'],
|
||||||
];
|
];
|
||||||
const [accessFs, rulesFs, advFs] = [...webBox.children];
|
const [accessFs, rulesFs, advFs] = [...webBox.children];
|
||||||
let step = 0, chosen = false;
|
let step = 0, chosen = false;
|
||||||
@@ -786,7 +931,7 @@ function portForm(existing) {
|
|||||||
const t = targets.map(x => ((byId(st.sites, x.siteId) || {name:'?'}).name) + ' → ' + x.host + ':' + x.port).join(', '), pr = (port.value || '').trim();
|
const t = targets.map(x => ((byId(st.sites, x.siteId) || {name:'?'}).name) + ' → ' + x.host + ':' + x.port).join(', '), pr = (port.value || '').trim();
|
||||||
const protoName = {both:'TCP + UDP', tcp:'TCP', udp:'UDP', tls:'TLS'}[protoSel.value];
|
const protoName = {both:'TCP + UDP', tcp:'TCP', udp:'UDP', tls:'TLS'}[protoSel.value];
|
||||||
const where = isWeb() ? protoSel.value + '://' + (host.value.trim() || '*') + (pr && pr !== '443' && pr !== '80' ? ':' + pr : '')
|
const where = isWeb() ? protoSel.value + '://' + (host.value.trim() || '*') + (pr && pr !== '443' && pr !== '80' ? ':' + pr : '')
|
||||||
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value.trim() || 'every address');
|
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value === '0.0.0.0' ? 'every address' : ipIn.selectedOptions[0].textContent);
|
||||||
const login = ssoC.box.checked || pwC.box.checked || pinC.box.checked || mailC.box.checked;
|
const login = ssoC.box.checked || pwC.box.checked || pinC.box.checked || mailC.box.checked;
|
||||||
return h('div', {class:'stack'}, h('div', {}, h('b', {class:'mono'}, where)), h('div', {class:'small'}, '→ ' + t),
|
return h('div', {class:'stack'}, h('div', {}, h('b', {class:'mono'}, where)), h('div', {class:'small'}, '→ ' + t),
|
||||||
isWeb() ? h('div', {class:'small dim'}, login ? 'Visitors have to sign in.' : 'Anyone can open it.') : null);
|
isWeb() ? h('div', {class:'small dim'}, login ? 'Visitors have to sign in.' : 'Anyone can open it.') : null);
|
||||||
@@ -810,6 +955,7 @@ function portForm(existing) {
|
|||||||
port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
|
port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
|
||||||
};
|
};
|
||||||
const pick = v => {
|
const pick = v => {
|
||||||
|
if (v === 'redirect') { close(); redirectForm(); return; }
|
||||||
protoSel.value = v; chosen = true; protoSel.onchange();
|
protoSel.value = v; chosen = true; protoSel.onchange();
|
||||||
if (v === 'https') redir.box.checked = true;
|
if (v === 'https') redir.box.checked = true;
|
||||||
port.value = ''; step = 1; draw();
|
port.value = ''; step = 1; draw();
|
||||||
@@ -846,11 +992,11 @@ function portForm(existing) {
|
|||||||
const web = isWeb();
|
const web = isWeb();
|
||||||
kids.push(h('div', {class:'grid'}, web || protoSel.value === 'tls' ? hostBox : null,
|
kids.push(h('div', {class:'grid'}, web || protoSel.value === 'tls' ? hostBox : null,
|
||||||
field('Public port', port, web ? 'Websites normally use ' + (protoSel.value === 'https' ? '443' : '80') + '.' : 'One port, or a range like 2456-2458: each port goes to the target port plus the same offset.'),
|
field('Public port', port, web ? 'Websites normally use ' + (protoSel.value === 'https' ? '443' : '80') + '.' : 'One port, or a range like 2456-2458: each port goes to the target port plus the same offset.'),
|
||||||
field('Public IP', ipIn, '0.0.0.0 means every address on the edge.')), ipList, domList);
|
field('Public IP', ipIn, (byId(st.edges, edgeSel.value) || {}).ips && byId(st.edges, edgeSel.value).ips.length ? 'Domains pointed at an address are shown next to it.' : 'The edge has not reported its addresses yet. Start it to pick a specific one.')), ipList, domList);
|
||||||
} else if (cur === 'access') {
|
} else if (cur === 'access') {
|
||||||
kids.push(accessFs);
|
kids.push(accessFs);
|
||||||
} else {
|
} else {
|
||||||
kids.push(field('Name', name, 'Optional. Shown in the list.'), h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
|
kids.push(field('Name', name, 'Optional. Shown in the list.'), tagsIn.el, h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
|
||||||
h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'),
|
h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'),
|
||||||
h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : []))));
|
h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : []))));
|
||||||
}
|
}
|
||||||
@@ -891,10 +1037,19 @@ views.sites = async () => {
|
|||||||
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
|
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
|
||||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
|
||||||
const latest = await latestVersions();
|
const latest = await latestVersions();
|
||||||
|
const tags = allTags(st.sites);
|
||||||
|
if (TAGSEL.sites && !tags.some(t => t.toLowerCase() === TAGSEL.sites.toLowerCase())) TAGSEL.sites = '';
|
||||||
|
const editSite = s => { // rename a site and set its tags
|
||||||
|
const nm = h('input', {value:s.name}), tg = tagInput(s.tags, tags);
|
||||||
|
const close = modal('Edit ' + s.name, h('div', {class:'stack'}, field('Name', nm), tg.el,
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'),
|
||||||
|
h('button', {class:'btn', onclick: act(async () => { await api('PUT', O('/sites/' + s.id), {name:nm.value, tags:tg.get()}); close(); toast('Saved ' + nm.value.trim()); refresh(); })}, 'Save changes'))));
|
||||||
|
};
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
|
tagBar('sites', tags),
|
||||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
||||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
|
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
||||||
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
||||||
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||||
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
||||||
@@ -903,6 +1058,7 @@ views.sites = async () => {
|
|||||||
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(', ')))))) :
|
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('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('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||||
|
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
|
||||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
||||||
};
|
};
|
||||||
@@ -926,23 +1082,58 @@ views.domains = async () => {
|
|||||||
// ----- mail egress -----
|
// ----- mail egress -----
|
||||||
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
||||||
|
|
||||||
|
const findingBadge = f => f.status === 'pass' ? badge('Pass', 'success') : f.status === 'fail' ? badge('Fix', 'danger')
|
||||||
|
: f.status === 'none' ? (f.id.startsWith('mtasts:') ? badge('Optional') : badge('Set up', 'danger')) : badge('Check', 'warning');
|
||||||
|
const findingsOk = e => !!e.check && (e.findings || []).every(f => f.status === 'pass' || f.status === 'warn' || (f.status === 'none' && f.id.startsWith('mtasts:')));
|
||||||
|
|
||||||
function checklist(e) {
|
function checklist(e) {
|
||||||
const c = e.check, ip = e.sourceIp, helo = e.heloName;
|
const c = e.check, ip = e.sourceIp;
|
||||||
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
const row = f => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||||
h('span', {style:'margin-top:1px;flex:none'}, ok === true ? badge('Pass', 'success') : ok === false ? badge('Fix', 'danger') : badge('Check', 'warning')),
|
h('span', {style:'margin-top:1px;flex:none'}, findingBadge(f)),
|
||||||
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
|
h('div', {}, h('div', {}, h('strong', {}, f.label + (f.domain ? ' · ' + f.domain : '')), ' ', f.detail), f.hint ? h('div', {class:'dim small'}, f.hint) : null));
|
||||||
const body = h('div', {},
|
const o = c && c.outcomes;
|
||||||
|
return 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 ? 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'),
|
...(e.findings || []).map(row),
|
||||||
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
|
!(e.findings || []).some(f => f.id.startsWith('spf:')) && c ? row({id:'spf', label:'SPF', status:'warn', detail:'Create a DKIM key for each domain you send from and the edge will check its SPF, DMARC and MTA-STS records too.', hint:'Meanwhile, publish this on each sending domain: ' + spfFor(ip)}) : null,
|
||||||
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
|
o ? h('div', {style:'margin-top:16px'}, h('h3', {}, 'Delivery, last 24 hours'),
|
||||||
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
|
h('p', {class:'small'}, `${o.delivered} delivered, ${o.deferred} deferred, ${o.failed} refused.`),
|
||||||
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
|
Object.keys(o.byDomain || {}).length ? h('table', {}, h('thead', {}, h('tr', {}, ['Recipient domain', 'Delivered', 'Deferred', 'Refused'].map(t => h('th', {}, t)))),
|
||||||
c.port25 === 'open' ? null : c.port25) : null,
|
h('tbody', {}, Object.entries(o.byDomain).sort((a, b) => (b[1].failed + b[1].deferred) - (a[1].failed + a[1].deferred)).slice(0, 15).map(([d, n]) =>
|
||||||
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
|
h('tr', {}, h('td', {}, d), h('td', {}, n.delivered), h('td', {}, n.deferred), h('td', {}, n.failed))))) : null,
|
||||||
cmdBox(spfFor(ip)),
|
(o.recent || []).length ? h('div', {style:'margin-top:8px'}, h('div', {class:'dim small'}, 'Latest problems'),
|
||||||
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.'));
|
...o.recent.slice(0, 5).map(r => h('div', {class:'mono small'}, `${ago(r.time)} · ${r.domain} · ${r.code} ${r.text}`))) : null) : null);
|
||||||
return body;
|
}
|
||||||
|
|
||||||
|
async function dmarcCard() {
|
||||||
|
const doms = await api('GET', O('/dmarc'));
|
||||||
|
const upload = h('input', {type:'file', multiple:true, accept:'.xml,.gz,.zip,.eml,.txt', style:'display:none'});
|
||||||
|
upload.onchange = act(async () => {
|
||||||
|
let added = 0, dup = 0, msgs = 0;
|
||||||
|
for (const f of upload.files) {
|
||||||
|
const r = await fetch(O('/dmarc'), {method:'POST', body: await f.arrayBuffer()});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(f.name + ': ' + (j.error || r.statusText));
|
||||||
|
j.duplicate ? dup++ : (added++, msgs += j.messages);
|
||||||
|
}
|
||||||
|
upload.value = '';
|
||||||
|
toast(`${added} report${added === 1 ? '' : 's'} added (${msgs} messages)` + (dup ? `, ${dup} already uploaded` : ''));
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
const pct = (a, b) => b ? Math.round(100 * a / b) + '%' : '-';
|
||||||
|
return card('DMARC reports',
|
||||||
|
h('p', {class:'dim'}, 'Add rua=mailto:you@yourdomain to the DMARC record of a domain and receivers send a daily summary of every message that claimed to be from it. Upload those emails or their attachments (.xml, .gz, .zip or .eml) to see what passed and which senders are failing. Reports for the last 30 days are shown.'),
|
||||||
|
doms.length ? h('div', {}, ...doms.map(d => h('div', {style:'margin-bottom:18px'},
|
||||||
|
h('div', {class:'row-h'}, h('strong', {}, d.domain), ' ', badge('Policy: ' + (d.policy || '?')), ' ',
|
||||||
|
badge(pct(d.passed, d.messages) + ' passed', d.messages && d.passed / d.messages >= 0.9 ? 'success' : 'danger'),
|
||||||
|
h('span', {class:'dim small', style:'margin-left:8px'}, `${d.messages} messages, ${d.reports} reports from ${d.reporters.join(', ')}`),
|
||||||
|
h('button', {class:'ghost danger', style:'margin-left:auto', onclick: ask({title:`Delete the DMARC reports for ${d.domain}?`, body:'The uploaded reports are removed. You can upload them again.', confirm:'Delete reports'}, async () => { await api('DELETE', O('/dmarc/' + d.domain)); refresh(); })}, 'Delete')),
|
||||||
|
...(d.advice || []).map(a => h('p', {class:'small'}, a)),
|
||||||
|
h('table', {}, h('thead', {}, h('tr', {}, ['Sending address', 'Messages', 'Passed', 'DKIM', 'SPF', 'Fix'].map(t => h('th', {}, t)))),
|
||||||
|
h('tbody', {}, d.sources.slice(0, 12).map(s => h('tr', {}, h('td', {class:'mono'}, s.ip, s.ours ? h('span', {style:'margin-left:6px'}, badge('Your relay', 'accent')) : null),
|
||||||
|
h('td', {}, s.messages), h('td', {}, pct(s.passed, s.messages)), h('td', {}, pct(s.dkimPass, s.messages)), h('td', {}, pct(s.spfPass, s.messages)),
|
||||||
|
h('td', {class:'dim small'}, s.hint || '')))))))) : h('p', {class:'dim'}, 'No reports yet.'),
|
||||||
|
h('div', {class:'row-h'}, h('button', {class:'ghost', onclick:() => upload.click()}, icon('add', 16), 'Upload reports'), upload));
|
||||||
}
|
}
|
||||||
|
|
||||||
views.egress = async () => {
|
views.egress = async () => {
|
||||||
@@ -959,7 +1150,7 @@ views.egress = async () => {
|
|||||||
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
|
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
|
||||||
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
|
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
|
||||||
}
|
}
|
||||||
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
|
const good = findingsOk(e);
|
||||||
return h('tr', {},
|
return h('tr', {},
|
||||||
h('td', {title:why}, badge(label, tone)),
|
h('td', {title:why}, badge(label, tone)),
|
||||||
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
||||||
@@ -978,6 +1169,7 @@ views.egress = async () => {
|
|||||||
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
!remote.length ? h('div', {class:'notice'}, '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),
|
h('div', {class:'card flush'}, table),
|
||||||
await dkimCard(),
|
await dkimCard(),
|
||||||
|
await dmarcCard(),
|
||||||
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.'),
|
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')));
|
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')));
|
||||||
};
|
};
|
||||||
@@ -1037,34 +1229,52 @@ const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], [
|
|||||||
function channelForm(base, existing, trusted) {
|
function channelForm(base, existing, trusted) {
|
||||||
const c = existing || {kind:'email', format:'json', enabled:true};
|
const c = existing || {kind:'email', format:'json', enabled:true};
|
||||||
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
|
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 kind = h('select', {}, [['email', 'Email'], ['sandbar', 'Sandbar (pages on-call)'], ['topic', 'Sandbar topic'], ['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 target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
|
||||||
|
const token = h('input', {type:'password', autocomplete:'off', placeholder:existing && c.kind === 'sandbar' ? 'unchanged' : 'sb_…'});
|
||||||
|
const policy = h('input', {value:c.policy || '', placeholder:'Optional'});
|
||||||
|
const topic = h('input', {value:c.topic || '', placeholder:'e.g. alerts'});
|
||||||
|
const topicToken = h('input', {type:'password', autocomplete:'off', placeholder:existing && c.kind === 'topic' ? 'unchanged' : 'Optional'});
|
||||||
|
const sandbarBox = h('div', {class:'grid'}, field('API key', token, 'Create one on Sandbar\'s Account page.'), field('Escalation policy ID', policy, 'Who gets paged. Leave blank to send alerts without paging anyone.'));
|
||||||
|
const topicBox = h('div', {class:'grid'}, field('Topic', topic, 'Messages are published here, so anyone subscribed (including the ntfy app) sees them.'), field('API key (optional)', topicToken, 'Leave blank for a server that lets anyone publish. Sandbar itself asks for a key.'));
|
||||||
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
|
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 priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
|
||||||
const en = chk('Enabled', c.enabled !== false);
|
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 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 targetBox = field('Where', target);
|
||||||
const redraw = () => {
|
const redraw = () => {
|
||||||
const w = kind.value === 'webhook';
|
const w = kind.value === 'webhook', sb = kind.value === 'sandbar' || kind.value === 'topic';
|
||||||
fmtBox.style.display = w ? '' : 'none';
|
fmtBox.style.display = w ? '' : 'none';
|
||||||
priv.style.display = w && trusted ? '' : 'none';
|
sandbarBox.style.display = kind.value === 'sandbar' ? '' : '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';
|
topicBox.style.display = kind.value === 'topic' ? '' : 'none';
|
||||||
|
priv.style.display = (w || sb) && trusted ? '' : 'none';
|
||||||
|
targetBox.firstChild.textContent =sb ? 'Sandbar address' : 'Where';
|
||||||
|
target.placeholder = sb ? (existing && c.kind === kind.value ? 'unchanged' : 'https://sandbar.example.com') :
|
||||||
|
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();
|
kind.onchange = redraw; redraw();
|
||||||
const save = act(async () => {
|
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});
|
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 : '', token:kind.value === 'topic' ? topicToken.value : token.value, policy:policy.value, topic:topic.value, allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||||
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
|
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
|
||||||
});
|
});
|
||||||
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
|
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:'grid'}, field('Name', name), field('Type', kind)), targetBox, sandbarBox, topicBox, fmtBox, priv, en,
|
||||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trafficAlertCard(base, settings) {
|
||||||
|
const spike = h('input', {type:'number', min:0, max:1000, value:settings.spikeFactor || 0}), daily = h('input', {type:'number', min:0, value:settings.dailyMb || 0});
|
||||||
|
return card('Traffic', h('p', {class:'dim'}, 'Be told when a port suddenly carries far more than it usually does, or more than you expect in a day. These apply to every port in this organization. Leave them at 0 to switch them off.'),
|
||||||
|
h('div', {class:'grid'}, field('Spike: times the usual traffic', spike, 'Judged on the last 5 minutes against the previous hours, once a port has an hour of history and carried over 10 MB in those minutes. Try 5.'),
|
||||||
|
field('Most traffic a port may carry in a day (MB)', daily, 'Counted per port from midnight UTC, in and out together.'),
|
||||||
|
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/traffic', {spikeFactor:+spike.value, dailyMb:+daily.value}); toast('Traffic alerts saved'); })}, 'Save traffic alerts')));
|
||||||
|
}
|
||||||
|
|
||||||
async function alertsView(base, scoped, trusted) {
|
async function alertsView(base, scoped, trusted) {
|
||||||
const d = await api('GET', base);
|
const d = await api('GET', base);
|
||||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
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('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', {}, c.kind === 'email' ? 'Email' : c.kind === 'sandbar' ? 'Sandbar' : c.kind === 'topic' ? 'Sandbar topic' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||||
h('td', {}, h('div', {class:'actions'},
|
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: 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', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
||||||
@@ -1072,14 +1282,15 @@ async function alertsView(base, scoped, trusted) {
|
|||||||
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});
|
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
||||||
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
|
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use Sandbar or a webhook.') : null,
|
||||||
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
||||||
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
|
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
|
||||||
card('Where alerts go',
|
card('Where alerts go',
|
||||||
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
|
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address, a Sandbar server or a webhook to be told when something stops working.')),
|
||||||
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.'),
|
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)', repeat, 'Use 0 to send no reminders'),
|
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
|
||||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
|
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
|
||||||
|
scoped ? trafficAlertCard(base, d.settings) : null,
|
||||||
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, fmtTime(e.time)),
|
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, fmtTime(e.time)),
|
||||||
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
|
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
|
||||||
}
|
}
|
||||||
@@ -1168,12 +1379,24 @@ serverViews.edges = async () => {
|
|||||||
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
|
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
|
||||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => {
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => {
|
||||||
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge')))); }];
|
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge')))); }];
|
||||||
|
// rename an edge, move its endpoint, or change which organizations may use it
|
||||||
|
const editEdge = e => {
|
||||||
|
const n = h('input', {value:e.name}), a = h('input', {value:e.tunnelAddr});
|
||||||
|
const checks = orgs.map(o => ({o, c:chk(o.name, (e.orgIds || []).includes(o.id))}));
|
||||||
|
dialogForm('Edit ' + e.name, 'Sites use the WireGuard endpoint to reach this edge, so change it only when the address or port has changed.',
|
||||||
|
[field('Name', n), field('WireGuard endpoint (public host:UDP port)', a, 'host:port'),
|
||||||
|
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, checks.map(x => x.c))) : null],
|
||||||
|
'Save edge', async () => {
|
||||||
|
await api('PUT', '/api/edges/' + e.id, {name:n.value, tunnelAddr:a.value, orgIds:orgs.length > 1 ? checks.filter(x => x.c.box.checked).map(x => x.o.id) : (e.orgIds || [])});
|
||||||
|
toast('Saved ' + n.value.trim()); refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
const latest = await latestVersions();
|
const latest = await latestVersions();
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
card('', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
|
card('', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
|
||||||
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
||||||
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
|
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), 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('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => editEdge(e)}, 'Edit'), h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
|
||||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${e.name}?`, body:'Every port on this edge is deleted and its tunnel closes. This cannot be undone.', confirm:'Delete edge'}, async () => { await api('DELETE', '/api/edges/' + e.id); refresh(); })}, 'Delete'))))))) : emptyState('edges', 'No edges yet', 'An edge is a public server running Blacktip. Add one to start opening ports.')));
|
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${e.name}?`, body:'Every port on this edge is deleted and its tunnel closes. This cannot be undone.', confirm:'Delete edge'}, async () => { await api('DELETE', '/api/edges/' + e.id); refresh(); })}, 'Delete'))))))) : emptyState('edges', 'No edges yet', 'An edge is a public server running Blacktip. Add one to start opening ports.')));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1233,13 +1456,19 @@ serverViews.idps = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
serverViews.settings = async () => {
|
serverViews.settings = async () => {
|
||||||
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
|
const { settings: s, smtpPassSet, sandbarTokenSet, sandbarSync } = 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'}),
|
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>'}),
|
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', 'STARTTLS'], ['tls', 'TLS'], ['none', 'None']].map(([m, l]) => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, l)))};
|
mode:h('select', {}, [['starttls', 'STARTTLS'], ['tls', 'TLS'], ['none', 'None']].map(([m, l]) => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, l)))};
|
||||||
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
|
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
|
||||||
|
const sb = s.sandbar || {};
|
||||||
|
const sbOn = chk('Create a Sandbar monitor for every resource that has health checks', sb.enabled);
|
||||||
|
const sbf = {url:h('input', {value:sb.url || '', placeholder:'https://sandbar.example.com'}), key:h('input', {type:'password', autocomplete:'off', placeholder:sandbarTokenSet ? 'unchanged' : 'sb_…'}), policy:h('input', {value:sb.policyId || '', placeholder:'Optional'})};
|
||||||
|
const sbPublic = chk('Show the monitors on the status page', sb.public), sbPriv = chk('Sandbar and the edge addresses are on a private network', sb.allowPrivate);
|
||||||
|
const sbOut = h('div', {class:'dim'}, sandbarSync && sandbarSync.at && sandbarSync.at.slice(0, 4) !== '0001' ? (sandbarSync.error ? 'The last sync failed: ' + sandbarSync.error : 'Last synced ' + ago(sandbarSync.at) + ', ' + sandbarSync.monitors + ' monitors.') : 'Not synced yet.');
|
||||||
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
|
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('Settings saved'); });
|
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},
|
||||||
|
sandbar:{enabled:sbOn.box.checked, url:sbf.url.value, token:sbf.key.value, policyId:sbf.policy.value, public:sbPublic.box.checked, allowPrivate:sbPriv.box.checked}}); S.me = await api('GET', '/api/me'); toast('Settings saved'); });
|
||||||
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
|
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
|
||||||
const updNow = h('button', {class:'ghost', style:'display:none', onclick: act(async () => {
|
const updNow = h('button', {class:'ghost', style:'display:none', onclick: act(async () => {
|
||||||
await api('POST', '/api/admin/update/apply');
|
await api('POST', '/api/admin/update/apply');
|
||||||
@@ -1267,6 +1496,9 @@ serverViews.settings = async () => {
|
|||||||
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
|
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:'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('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'))),
|
||||||
|
card('Sandbar monitors', h('p', {class:'dim'}, 'Hammerhead adds a monitor to Sandbar for each enabled resource with health checks (a website is fetched at its address, a port is connected to), so it gets uptime history, a status page and on-call paging. Monitors you add in Sandbar yourself are never touched. Hammerhead keeps them up to date every minute and removes the ones it created when a resource is deleted or its checks are turned off.'),
|
||||||
|
sbOn, h('div', {class:'grid'}, field('Sandbar address', sbf.url), field('Administrator API key', sbf.key, 'Create one on the Account page in Sandbar. Managing monitors needs an administrator.'), field('Escalation policy ID', sbf.policy, 'Who gets paged when a monitor goes down. Leave blank to page nobody.')), sbPublic, sbPriv,
|
||||||
|
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); const r = await api('POST', '/api/admin/settings/sandbar-sync'); sbOut.textContent = 'Synced, ' + r.monitors + ' monitors.'; toast('Sandbar monitors synced'); })}, 'Save and sync now')), sbOut),
|
||||||
h('div', {class:'actions form-actions', style:'margin-bottom:24px'}, h('button', {class:'btn', onclick:save}, 'Save settings')),
|
h('div', {class:'actions form-actions', style:'margin-bottom:24px'}, h('button', {class:'btn', onclick:save}, 'Save settings')),
|
||||||
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
|
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
|
||||||
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox));
|
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox));
|
||||||
|
|||||||
Reference in new issue
Block a user