7 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 227c890409 Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
CI / test (push) Successful in 4m52s
Release / release (push) Successful in 5m24s
Sandbar channels raise and resolve alerts through its API, with the API key
sealed at rest and never returned to the browser. Resources and sites can be
tagged and filtered by tag; sites can be renamed. The secretbox package now
comes from Reef v0.1.9 instead of a private copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:32:43 -06:00
VeillaxandClaude Sonnet 5.5 38ab4f7db2 Use Blacktip v0.1.8
CI / test (push) Successful in 4m54s
Release / release (push) Successful in 5m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 23:10:47 -06:00
VeillaxandClaude Sonnet 5.5 f93698f63f Let websites share a port across addresses, and refuse an HTTPS redirect that would take port 80 from a raw port
CI / test (push) Failing after 2m18s
A website on 0.0.0.0 and websites on named addresses can use the same port. An https resource with
the redirect on also uses port 80 at its address, so a raw port there is refused, in either order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:48:17 -06:00
VeillaxandClaude Opus 5.5 c2845d8630 Pick a resource's public IP from the edge's addresses, labeled with the domains pointed at each
CI / test (push) Successful in 2m22s
Release / release (push) Successful in 3m45s
A hostname under a domain that points at one of the edge's addresses selects that address. An address
saved earlier stays selectable even before the edge reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:56:20 -06:00
VeillaxandClaude Opus 5.5 42eb6410b4 Edit edges from the dashboard: rename, change the endpoint or the allowed organizations
CI / test (push) Successful in 2m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:52:28 -06:00
VeillaxandClaude Opus 5.5 c585ddbf63 Review pass on the Lightwell redesign: layout, copy and behavior fixes
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:38:56 -06:00
VeillaxandClaude Sonnet 5.5 d74e6c1e72 Redesign the dashboard and sign-in page with the Lightwell design language
CI / test (push) Successful in 2m27s
Warm neutrals, teal accent, 15px type, and Lightwell's component shapes. Status is a badge with text
instead of a colored dot. Every destructive action asks first, naming the object and what is lost.
Dialogs trap focus, close on Escape and return focus. The sidebar is a drawer on phones, there is a
theme switch, loading shows skeleton rows, and empty states explain what belongs there. Emoji and
dashes are gone, and server errors are capitalized and tell visitors what to do next.
Adds scripts/lint-design.mjs and docs/design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:18:55 -06:00
27 changed files with 1233 additions and 547 deletions

No files matched your search

+4
View File
@@ -259,6 +259,10 @@ Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammer
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
decision function runs in both proxy modes. decision function runs in both proxy modes.
## Design
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check.
## Limits and notes ## Limits and notes
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import. - No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
+11 -11
View File
@@ -66,7 +66,7 @@ func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r) p := s.principalFrom(r)
if p == nil || p.User.ID == "" { if p == nil || p.User.ID == "" {
fail(w, 401, errors.New("not logged in")) fail(w, 401, errors.New("Sign in to continue."))
return return
} }
fn(w, r, p) fn(w, r, p)
@@ -96,7 +96,7 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r) p := s.principalFrom(r)
if p == nil { if p == nil {
fail(w, 401, errors.New("not logged in")) fail(w, 401, errors.New("Sign in to continue."))
return return
} }
id := r.PathValue("org") id := r.PathValue("org")
@@ -169,7 +169,7 @@ func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if len(s.st.Users) != 0 { if len(s.st.Users) != 0 {
return errors.New("setup already done") return errors.New("Hammerhead is already set up. Sign in instead.")
} }
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()} u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
s.st.Users = append(s.st.Users, u) s.st.Users = append(s.st.Users, u)
@@ -240,7 +240,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
} }
email := strings.ToLower(strings.TrimSpace(in.Email)) email := strings.ToLower(strings.TrimSpace(in.Email))
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) { if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes")) fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -254,7 +254,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if u == nil || !good || u.PassHash == "" { if u == nil || !good || u.PassHash == "" {
s.audit("", email, "login.failed", clientIP(r)) s.audit("", email, "login.failed", clientIP(r))
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong email or password")) fail(w, 401, errors.New("That email and password do not match. Check them and try again."))
return return
} }
if u.TOTPEnabled { if u.TOTPEnabled {
@@ -276,7 +276,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) { if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes")) fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -307,7 +307,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
} }
s.mu.Unlock() s.mu.Unlock()
if !good { if !good {
fail(w, 401, errors.New("wrong code")) fail(w, 401, errors.New("That code is not right. Check your authenticator and try again."))
return return
} }
s.startSession(w, r, u.ID) s.startSession(w, r, u.ID)
@@ -413,7 +413,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *princ
return return
} }
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil { if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
fail(w, 400, errors.New("current password is wrong")) fail(w, 400, errors.New("Your current password is not right. Check it and try again."))
return return
} }
hash, err := hashPassword(in.New) hash, err := hashPassword(in.New)
@@ -464,7 +464,7 @@ func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal
defer s.mu.Unlock() defer s.mu.Unlock()
u := s.findUser(p.User.ID) u := s.findUser(p.User.ID)
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) { if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("that code doesn't match; check your authenticator's clock")) fail(w, 400, errors.New("That code does not match. Check that your authenticator's clock is right and try again."))
return return
} }
codes, hashes := make([]string, 8), make([]string, 8) codes, hashes := make([]string, 8), make([]string, 8)
@@ -483,14 +483,14 @@ func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principa
return return
} }
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil { if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
fail(w, 400, errors.New("password is wrong")) fail(w, 400, errors.New("That password is not right. Check it and try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u := s.findUser(p.User.ID) u := s.findUser(p.User.ID)
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) { if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("code is wrong")) fail(w, 400, errors.New("That code is not right. Check your authenticator and try again."))
return return
} }
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
+66 -6
View File
@@ -28,14 +28,17 @@ const (
defaultOffline = 90 * time.Second defaultOffline = 90 * time.Second
) )
// AlertChannel is somewhere alerts are sent: an email address or a webhook. // AlertChannel is somewhere alerts are sent: an email address, a webhook, or a
// Sandbar server (which pages whoever is on call and tracks the alert).
type AlertChannel struct { type AlertChannel struct {
ID string `json:"id"` ID string `json:"id"`
OrgID string `json:"orgId"` // "" = a server-level channel (for edges) OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` // "email" or "webhook" Kind string `json:"kind"` // "email", "webhook" or "sandbar"
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens) Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens)
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy" Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser)
Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone
AllowPrivate bool `json:"allowPrivate"` AllowPrivate bool `json:"allowPrivate"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
@@ -50,6 +53,7 @@ type alertState struct {
type problem struct{ org, kind, name, msg string } type problem struct{ org, kind, name, msg string }
type alertNote struct { type alertNote struct {
Key string `json:"key"` // stable per problem, so a recovery can find its alert
Status string `json:"status"` // "firing", "resolved" or "test" Status string `json:"status"` // "firing", "resolved" or "test"
Org string `json:"org"` Org string `json:"org"`
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -251,7 +255,7 @@ func (s *Server) evaluateAlerts(now time.Time) {
chans = append(chans, *c) chans = append(chans, *c)
} }
} }
out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans}) out = append(out, delivery{alertNote{Key: st.Key, Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message) s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
} }
keys := make([]string, 0, len(probs)) keys := make([]string, 0, len(probs))
@@ -362,6 +366,8 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
} }
} }
return errors.Join(errs...) return errors.Join(errs...)
case "sandbar":
return s.sendSandbar(ch, n)
case "webhook": case "webhook":
body, ctype, hdr := webhookBody(ch.Format, n) body, ctype, hdr := webhookBody(ch.Format, n)
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body)) req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
@@ -385,6 +391,47 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
return errors.New("unknown channel kind") return errors.New("unknown channel kind")
} }
// sendSandbar raises the alert on a Sandbar server, or resolves it there when the
// problem clears. Sandbar de-duplicates by key, so a reminder updates the alert
// that is already open instead of opening another. A test raises an info alert
// with no policy (nobody is paged) and resolves it straight away.
func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error {
post := func(body map[string]string) error {
raw, _ := json.Marshal(body)
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/api/v1/alerts", bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+ch.Token)
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == 401 || resp.StatusCode == 403:
return errors.New("Sandbar rejected the API key")
case resp.StatusCode >= 300:
return fmt.Errorf("Sandbar answered %s", resp.Status)
}
return nil
}
key := "hammerhead:" + n.Key
switch n.Status {
case "resolved":
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
case "test":
key = "hammerhead:test:" + ch.ID
if err := post(map[string]string{"key": key, "source": "hammerhead", "summary": "Hammerhead test alert", "detail": n.text(), "severity": "info"}); err != nil {
return err
}
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
}
return post(map[string]string{"key": key, "source": "hammerhead", "summary": n.Name + ": " + n.Message,
"detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy})
}
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) { func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
text := n.subject() + "\n" + n.text() text := n.subject() + "\n" + n.text()
switch format { switch format {
@@ -415,12 +462,25 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
return errors.New("enter one or more email addresses, separated by commas") return errors.New("enter one or more email addresses, separated by commas")
} }
} }
ch.Format, ch.AllowPrivate = "", false ch.Format, ch.Token, ch.Policy, ch.AllowPrivate = "", "", "", false
case "sandbar":
u, err := url.Parse(ch.Target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
}
if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" {
return errors.New("enter an API key from Sandbar's Account page")
}
ch.Policy, ch.Format = strings.TrimSpace(ch.Policy), ""
if ch.AllowPrivate && !trusted {
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
}
case "webhook": case "webhook":
u, err := url.Parse(ch.Target) u, err := url.Parse(ch.Target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("the webhook must be an http:// or https:// URL") return errors.New("the webhook must be an http:// or https:// URL")
} }
ch.Token, ch.Policy = "", ""
switch ch.Format { switch ch.Format {
case "json", "slack", "discord", "ntfy": case "json", "slack", "discord", "ntfy":
case "": case "":
@@ -432,7 +492,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization") return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
} }
default: default:
return errors.New("a channel is an email or a webhook") return errors.New("a channel is an email, a webhook or a Sandbar server")
} }
if ch.Name == "" { if ch.Name == "" {
ch.Name = ch.Kind ch.Name = ch.Kind
+6 -3
View File
@@ -23,7 +23,7 @@ type channelView struct {
} }
func preview(ch AlertChannel) string { func preview(ch AlertChannel) string {
if ch.Kind != "webhook" { if ch.Kind == "email" {
return ch.Target return ch.Target
} }
if u, err := url.Parse(ch.Target); err == nil { if u, err := url.Parse(ch.Target); err == nil {
@@ -47,7 +47,7 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
for _, c := range s.st.Channels { for _, c := range s.st.Channels {
if c.OrgID == sc.org { if c.OrgID == sc.org {
v := channelView{AlertChannel: *c, Preview: preview(*c)} v := channelView{AlertChannel: *c, Preview: preview(*c)}
v.Target = "" // webhook URLs often contain tokens v.Target, v.Token = "", "" // webhook URLs often contain tokens
if c.Kind == "email" { if c.Kind == "email" {
v.Target = c.Target v.Target = c.Target
} }
@@ -95,6 +95,9 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco
if in.Target == "" { if in.Target == "" {
in.Target = existing.Target // blank keeps the saved URL/address in.Target = existing.Target // blank keeps the saved URL/address
} }
if id != "" && in.Token == "" && in.Kind == "sandbar" {
in.Token = existing.Token // blank keeps the saved API key
}
} }
trusted := sc.trusted trusted := sc.trusted
if err := s.validateChannel(&in, trusted); err != nil { if err := s.validateChannel(&in, trusted); err != nil {
@@ -156,7 +159,7 @@ func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertS
return return
} }
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 { if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
fail(w, 400, errors.New("wait 1-120 minutes before alerting; remind every 0-168 hours (0 = never)")) fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
return return
} }
s.mu.Lock() s.mu.Lock()
+55 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"
) )
+3 -3
View File
@@ -9,8 +9,8 @@ import (
"sync" "sync"
"time" "time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model" "git.veillax.com/Veillax/reef/model"
"git.veillax.com/Veillax/reef/secretbox"
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed _ "modernc.org/sqlite" // pure Go, so no C compiler is needed
) )
@@ -113,7 +113,7 @@ func (d *DB) sealed(v any) any {
return &c return &c
case *AlertChannel: case *AlertChannel:
c := *x c := *x
c.Target = b.Seal(c.Target) c.Target, c.Token = b.Seal(c.Target), b.Seal(c.Token)
return &c return &c
case Settings: case Settings:
x.SMTP.Pass = b.Seal(x.SMTP.Pass) x.SMTP.Pass = b.Seal(x.SMTP.Pass)
@@ -144,7 +144,7 @@ func (d *DB) unseal(v any) error {
case *model.DKIMKey: case *model.DKIMKey:
return d.open1(&x.PrivateKey) return d.open1(&x.PrivateKey)
case *AlertChannel: case *AlertChannel:
return d.open1(&x.Target) return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
case *Settings: case *Settings:
return d.open1(&x.SMTP.Pass) return d.open1(&x.SMTP.Pass)
} }
+1 -1
View File
@@ -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)) {
+64
View File
@@ -0,0 +1,64 @@
# Hammerhead design language
The dashboard and the visitor sign-in page follow the Lightwell design language. The reference is `docs/design.md` in the Lightwell repository. This page records how that language is applied here and where Hammerhead needs its own pieces. When the two disagree, Lightwell's document wins.
## What is shared with Lightwell
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`.
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result, and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`fail` in `store.go`).
* **Status carries text.** A status is a badge with a label ("Online", "Listening", "Down"), never a colored dot on its own.
## Where it lives
| File | What it holds |
| --- | --- |
| `web/index.html` | The dashboard. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
| `web/resource.html` | The sign-in page that visitors see in front of a protected resource. Same token block. |
Both are single files with no build step, so the token block is repeated in each. Change a token in both.
## Components
Built from the helpers at the top of the script in `web/index.html`. Feature code uses these and does not style one-off.
| Helper | Use |
| --- | --- |
| `card`, `pageHeader` | Surfaces and the page title with its one primary action |
| `field`, `chk` | Label above the control, hint below |
| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral |
| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence |
| `modal`, `dialogForm` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener |
| `confirmDialog`, `ask` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone |
| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts |
| `skeleton` | Placeholder rows while a page loads, not a spinner |
| `updatePanel` | Progress bar and live log for an update |
## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `fin`, `mine`, `ports`, `sites`, `domains`, `access`, `logs`, `edges`, `orgs`, `users`, `idps`, `settings`, `audit`, `account`, `egress`, `alerts`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Patterns
* **One primary action per view.** It sits in the page header. Smaller forms inside a page use the default button.
* **List pages** show the page header, then the table in a card with no second title.
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 8 characters" are hints below them, not part of the label.
* **Dates** have the month written out ("Nov 2, 2026"), with `fmtDate` and `fmtTime`.
* **Empty states** say why the list is empty: nothing yet, or nothing matching the filters.
* **Long machine messages**, such as certificate errors, wrap and stop at three lines (`.clip`), with the full text in a tooltip.
## Layout
* Admin content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter.
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
## Checking a change
```
node scripts/lint-design.mjs
```
It fails on em and en dashes, emoji, and raw colors outside the token block, in the pages, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
+66
View File
@@ -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")
}
}
+2 -2
View File
@@ -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.8
git.veillax.com/Veillax/reef v0.1.7 git.veillax.com/Veillax/reef v0.1.9
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
+4 -24
View File
@@ -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.8 h1:TR4HfXlGwrNjCqOY+xUnJCJwESRTwFerb2wVhq/zbUk=
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg= git.veillax.com/Veillax/blacktip v0.1.8/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc=
git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0= git.veillax.com/Veillax/reef v0.1.9 h1:dmC+oPmRQIOVfx2kgS95QvRDn2wudwpZw7H9p0m6W8Y=
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI= git.veillax.com/Veillax/reef v0.1.9/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=
+37
View File
@@ -204,6 +204,43 @@ func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
writeJSON(w, 200, st) writeJSON(w, 200, st)
} }
// putSite renames a site and sets its tags. The built-in local site is shared between
// organizations, so it can't be edited here.
func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
tags, err := normalizeTags(in.Tags)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || st.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such site"))
return
}
if st.Local {
fail(w, 400, errors.New("the built-in local site can't be edited"))
return
}
st.Name, st.Tags = in.Name, tags
s.bump()
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
ok(w)
}
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) { func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id") id := r.PathValue("id")
s.mu.Lock() s.mu.Lock()
-87
View File
@@ -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
}
-51
View File
@@ -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")
}
}
+52 -6
View File
@@ -53,9 +53,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 {
@@ -87,7 +117,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)") return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
} }
if t.Port < 1 || t.Port > 65535 { if t.Port < 1 || t.Port > 65535 {
return errors.New("target ports must be 1-65535") return errors.New("Target ports must be between 1 and 65535.")
} }
} }
if m.BindIP == "" { if m.BindIP == "" {
@@ -99,7 +129,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
} }
m.BindIP = ip.String() m.BindIP = ip.String()
if m.Port < 1 || m.Port > 65535 { if m.Port < 1 || m.Port > 65535 {
return errors.New("public port must be 1-65535") return errors.New("The public port must be between 1 and 65535.")
} }
switch { switch {
case m.PortEnd == 0 || m.PortEnd == m.Port: case m.PortEnd == 0 || m.PortEnd == m.Port:
@@ -107,7 +137,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
case m.PortEnd < m.Port: case m.PortEnd < m.Port:
return errors.New("the end of a port range can't be before its start") return errors.New("the end of a port range can't be before its start")
case m.PortEnd > 65535: case m.PortEnd > 65535:
return errors.New("public ports must be 1-65535") return errors.New("Public ports must be between 1 and 65535.")
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP: case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
return errors.New("port ranges work for tcp and udp only") return errors.New("port ranges work for tcp and udp only")
case m.PortEnd-m.Port+1 > 1000: case m.PortEnd-m.Port+1 > 1000:
@@ -193,7 +223,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
} }
} }
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) { if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
return errors.New("health check interval must be 5-3600 seconds, or off") return errors.New("The health check interval must be 5 to 3600 seconds, or off.")
} }
for _, o := range s.st.Mappings { for _, o := range s.st.Mappings {
@@ -204,7 +234,23 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname { if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
continue // same listener, different hostname continue // same listener, different hostname
} }
return fmt.Errorf("%s port %s on %s is already used by %q", netClass(m.Proto), portLabel(*m), o.BindIP, o.Name) if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) {
continue // a website on every address steps aside for one that names an address
}
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
}
// an https resource with the redirect on also answers on port 80 at its address, so a raw port
// there would clash with it (and the other way round)
for _, o := range s.st.Mappings {
if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) {
continue
}
switch {
case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort():
return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name)
case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort():
return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name)
}
} }
if m.Name == "" { if m.Name == "" {
if m.Hostname != "" { if m.Hostname != "" {
@@ -226,7 +272,7 @@ func portLabel(m model.Mapping) string {
func checkPIN(p string) error { func checkPIN(p string) error {
if len(p) < 4 || len(p) > 12 { if len(p) < 4 || len(p) > 12 {
return errors.New("a PIN is 4-12 digits") return errors.New("A PIN is 4 to 12 digits.")
} }
for _, c := range p { for _, c := range p {
if c < '0' || c > '9' { if c < '0' || c > '9' {
+76
View File
@@ -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")
}
+1 -1
View File
@@ -349,7 +349,7 @@ func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) { if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
fail(w, 429, errors.New("too many attempts")) fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return return
} }
h := sha(in.Token) h := sha(in.Token)
+14 -14
View File
@@ -54,10 +54,10 @@ func (s *Server) protectedMapping(id string) *model.Mapping {
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) { func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("invalid return address") return nil, errors.New("This sign-in link is incomplete. Open the page you wanted again and you will be brought back here.")
} }
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") { if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
return nil, errors.New("return address uses the wrong scheme") return nil, errors.New("This sign-in link is not valid. Open the page you wanted again and you will be brought back here.")
} }
host := u.Hostname() host := u.Hostname()
port := u.Port() port := u.Port()
@@ -68,7 +68,7 @@ func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
} }
} }
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) { if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
return nil, errors.New("return address doesn't belong to this resource") return nil, errors.New("This sign-in link points somewhere else. Open the page you wanted again and you will be brought back here.")
} }
return u, nil return u, nil
} }
@@ -108,7 +108,7 @@ func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
defer s.mu.Unlock() defer s.mu.Unlock()
m := s.protectedMapping(r.PathValue("id")) m := s.protectedMapping(r.PathValue("id"))
if m == nil { if m == nil {
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist")) fail(w, 404, errors.New("This page does not need a sign-in, or it no longer exists. Check the address, or ask the person who shared it."))
return return
} }
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil { if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
@@ -141,7 +141,7 @@ func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping,
} }
s.mu.Unlock() s.mu.Unlock()
if key == "" { if key == "" {
fail(w, 500, errors.New("edge is not set up for logins")) fail(w, 500, errors.New("This server is not set up for sign-ins yet. Ask the administrator to finish setting it up."))
return return
} }
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()}) tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
@@ -158,7 +158,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
return return
} }
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) { if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes")) fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -169,7 +169,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
} }
s.mu.Unlock() s.mu.Unlock()
if pm == nil { if pm == nil {
fail(w, 404, errors.New("no such resource")) fail(w, 404, errors.New("That page does not exist. Check the address."))
return return
} }
back, err := checkReturn(&m, body["return"]) back, err := checkReturn(&m, body["return"])
@@ -191,7 +191,7 @@ func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *mod
} }
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) { if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong password")) fail(w, 401, errors.New("That password is not right. Check it and try again."))
return return
} }
s.grant(w, r, m, back, "password") s.grant(w, r, m, back, "password")
@@ -207,7 +207,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
} }
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) { if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong PIN")) fail(w, 401, errors.New("That PIN is not right. Check it and try again."))
return return
} }
s.grant(w, r, m, back, "pin") s.grant(w, r, m, back, "pin")
@@ -216,7 +216,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
p := s.principalFrom(r) p := s.principalFrom(r)
if p == nil || p.User.ID == "" || !m.Auth.SSO { if p == nil || p.User.ID == "" || !m.Auth.SSO {
fail(w, 401, errors.New("sign in first")) fail(w, 401, errors.New("Sign in first, then try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -224,7 +224,7 @@ func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Ma
allowed := u != nil && s.canAccess(u, m) allowed := u != nil && s.canAccess(u, m)
s.mu.Unlock() s.mu.Unlock()
if !allowed { if !allowed {
fail(w, 403, errors.New("your account doesn't have access to this resource")) fail(w, 403, errors.New("Your account does not have access to this page. Ask the person who shared it to add you."))
return return
} }
s.grant(w, r, m, back, p.User.Email) s.grant(w, r, m, back, p.User.Email)
@@ -243,7 +243,7 @@ func mailAllowed(list []string, email string) bool {
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
email := strings.ToLower(strings.TrimSpace(body["email"])) email := strings.ToLower(strings.TrimSpace(body["email"]))
if !m.Auth.Email || !s.mailConfigured() { if !m.Auth.Email || !s.mailConfigured() {
fail(w, 400, errors.New("email codes are not available for this resource")) fail(w, 400, errors.New("Email codes are not turned on for this page. Use another sign-in method."))
return return
} }
// always answer the same way so addresses can't be probed // always answer the same way so addresses can't be probed
@@ -279,7 +279,7 @@ func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *
} }
s.mu.Unlock() s.mu.Unlock()
if !good || !mailAllowed(m.Auth.AllowedMails, email) { if !good || !mailAllowed(m.Auth.AllowedMails, email) {
fail(w, 401, errors.New("wrong or expired code")) fail(w, 401, errors.New("That code is wrong or has expired. Request a new one and try again."))
return return
} }
s.grant(w, r, m, back, email) s.grant(w, r, m, back, email)
@@ -303,7 +303,7 @@ func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.
} }
s.mu.Unlock() s.mu.Unlock()
if !valid { if !valid {
fail(w, 401, errors.New("this share link is invalid, expired or used up")) fail(w, 401, errors.New("This share link is invalid, expired or already used up. Ask for a new one."))
return return
} }
if name == "" { if name == "" {
+57
View File
@@ -0,0 +1,57 @@
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
// token block of web/index.html. Run with: node scripts/lint-design.mjs
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative, sep } from "node:path";
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const targets = ["web", "docs", "README.md", "deploy", "scripts", "cmd", "."];
const skipDirs = new Set(["node_modules", ".git", "e2e", "internal"]);
const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".example"]);
const dash = /[–—−]/;
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
const seen = new Set();
function* walk(p, top) {
const st = statSync(p, { throwIfNoEntry: false });
if (!st || seen.has(p)) return;
if (st.isFile()) { seen.add(p); return yield p; }
for (const name of readdirSync(p)) {
if (skipDirs.has(name) || (top && name.startsWith("."))) continue;
const full = join(p, name);
// the "." target only contributes the files at the top level
if (p === root && statSync(full).isDirectory()) continue;
yield* walk(full, false);
}
}
const problems = [];
for (const t of targets) {
for (const file of walk(join(root, t), true)) {
if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue;
const rel = relative(root, file).split(sep).join("/");
if (rel.endsWith("go.sum") || rel === "scripts/lint-design.mjs") continue;
const isPage = rel.startsWith("web/") && rel.endsWith(".html");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
if (line.includes("tokens:begin")) inTokens = true;
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
at("raw color outside the token block. Use a token.");
}
if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
if (line.includes("tokens:end")) inTokens = false;
});
}
}
if (problems.length) {
console.error(problems.join("\n"));
console.error(`\n${problems.length} style problem(s).`);
process.exit(1);
}
console.log("Style checks passed.");
+1 -1
View File
@@ -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 {
+1
View File
@@ -127,6 +127,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey)) mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey)) mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite)) mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
mux.HandleFunc("PUT /api/orgs/{org}/sites/{id}", s.withOrg(true, s.putSite))
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite)) mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers)) mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain)) mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
+1 -1
View File
@@ -69,7 +69,7 @@ func (s *Server) smtpSend(to, subject, body string) error {
c := s.st.Settings.SMTP c := s.st.Settings.SMTP
s.mu.Unlock() s.mu.Unlock()
if c.Host == "" { if c.Host == "" {
return errors.New("email is not configured (Settings -> Email)") return errors.New("Email is not set up yet. Set it up under Server, Settings.")
} }
if c.Port == 0 { if c.Port == 0 {
c.Port = 587 c.Port = 587
+9 -2
View File
@@ -16,8 +16,10 @@ import (
"sync" "sync"
"time" "time"
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model" "git.veillax.com/Veillax/reef/model"
"git.veillax.com/Veillax/reef/secretbox"
"unicode"
"unicode/utf8"
) )
type Org struct { type Org struct {
@@ -433,8 +435,13 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
// fail sends an error to the browser. Messages are shown to people, so they start with a capital letter.
func fail(w http.ResponseWriter, code int, err error) { func fail(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()}) msg := err.Error()
if r, n := utf8.DecodeRuneInString(msg); n > 0 {
msg = string(unicode.ToUpper(r)) + msg[n:]
}
writeJSON(w, code, map[string]string{"error": msg})
} }
func failf(w http.ResponseWriter, code int, format string, a ...any) { func failf(w http.ResponseWriter, code int, format string, a ...any) {
+122
View File
@@ -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)
}
}
+499 -278
View File
File diff suppressed because it is too large. Load diff
+80 -54
View File
@@ -3,62 +3,81 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in</title> <title>Sign in</title>
<style> <style>
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */ /* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root { :root {
--accent:#0891b2; --accent-contrast:#fff; --accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3; --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b; --text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000); --accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface)); --accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000); --accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff); --focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14); --overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px; --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; --font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px; --text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease; --sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light; color-scheme:light;
} }
@media (prefers-color-scheme: dark) { :root { @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--accent:#22b8d4; --accent-contrast:#04222a; --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b; --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b; --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark; --success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} } } }
* { box-sizing:border-box; } :root[data-theme="dark"] {
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; } --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; } --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); } --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); } --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); } --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; } .sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; } label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; } input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); } input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); } :focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); } button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); } button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost, a.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); } button.ghost, a.ghost { background:var(--surface); border-color:var(--border-strong); color:var(--text); }
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); } button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
#err { color:var(--danger); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); } #err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); } #err:empty { display:none; }
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
[hidden] { display:none !important; } [hidden] { display:none !important; }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <main class="card">
<h1 id="title">Sign in</h1> <h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p> <p class="lead" id="sub"></p>
<div id="body"></div> <div id="body"></div>
<div id="err"></div> <div id="err" role="alert"></div>
</div> </main>
<script> <script>
const id = location.pathname.split('/')[3]; const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -77,6 +96,9 @@ function h(tag, props, ...kids) {
return el; return el;
} }
const err = m => $('#err').textContent = m || ''; const err = m => $('#err').textContent = m || '';
let n = 0;
// a visible label above the control, as the design language requires
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
async function post(path, data) { async function post(path, data) {
err(''); err('');
@@ -95,54 +117,57 @@ async function finish(path, data) {
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); } function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) { function passwordForm(kind, label, type) {
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null}); const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value }); const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go()); i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, i, h('button', {onclick:go}, 'Continue')); return h('div', {class:'sec'}, field(label, i), h('button', {onclick:go}, 'Continue'));
} }
function emailForm() { function emailForm() {
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'}); const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true}); const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const msg = h('div', {class:'ok'}); const codeField = field('6-digit code', code); codeField.hidden = true;
const msg = h('p', {class:'hint'});
const go = h('button', {}, 'Email me a code'); const go = h('button', {}, 'Email me a code');
go.onclick = async () => { go.onclick = async () => {
try { try {
if (code.hidden) { if (codeField.hidden) {
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret }); const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus(); msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
} else await finish('email/verify', { email: email.value, code: code.value }); } else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); } } catch (e) { err(e.message); }
}; };
return section('Email code', email, code, msg, go); return section('Email code', field('Email address', email), codeField, msg, go);
} }
function ssoForm(info) { function ssoForm(info) {
const kids = []; const kids = [];
if (info.user) { if (info.user) {
if (info.user.allowed) { if (info.user.allowed) {
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue')); kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
} else { } else {
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`)); kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
} }
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account')); kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids); return section('Account', ...kids);
} }
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'}); const email = h('input', {type:'email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'}); const pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true}); const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
codeField.hidden = true;
let ticket = null; let ticket = null;
const go = async () => { const go = async () => {
try { try {
let j; let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value }); if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
else j = await post('/api/login', { email: email.value, password: pw.value }); else j = await post('/api/login', { email: email.value, password: pw.value });
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; } if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
location.reload(); location.reload();
} catch (e) { err(e.message); } } catch (e) { err(e.message); }
}; };
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go())); [email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in')); kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
for (const p of info.providers || []) { for (const p of info.providers || []) {
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`; const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`)); kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
@@ -153,19 +178,20 @@ function ssoForm(info) {
async function main() { async function main() {
// share links skip the form // share links skip the form
const share = params.get('share'); const share = params.get('share');
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); } if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
try { try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`); const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json(); const info = await r.json();
if (!r.ok) throw new Error(info.error); if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name; $('#title').textContent = info.name;
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`; document.title = 'Sign in to ' + info.name;
$('#sub').textContent = `Sign in to open ${info.host}.`;
if (info.sso) body.append(ssoForm(info)); if (info.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password')); if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password')); if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm()); if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.'); if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); } } catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
} }
main(); main();
</script> </script>