gate: password rules, HMAC cookie keyed off the hash, limiter and login page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+659
−0
internal/gate/cookie.go+59 −0internal/gate/gate.go+166 −0internal/gate/gate_test.go+274 −0internal/gate/limiter.go+64 −0internal/gate/login.html+55 −0internal/gate/page.go+41 −0
diff --git a/internal/gate/cookie.go b/internal/gate/cookie.go| new file mode 100644 |
| index 000000000..88f15480b |
| --- /dev/null |
| +++ b/internal/gate/cookie.go |
| @@ -0,0 +1,59 @@ |
| +package gate |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))> |
| +// with key derived from the stored bcrypt hash and nothing else. That is |
| +// what makes the design stateless and gives it its one rotation rule: |
| +// change or clear the password and every cookie minted under the old one |
| +// stops verifying at once, on every box, with no key to mint or mirror. |
| +// Anyone who can read the hash can mint a cookie — root on the box and a |
| +// bucket reader, who already hold the tenant's sealed secrets (spec §4.3). |
| + |
| +const cookieVersion = "v1" |
| + |
| +func cookieKey(hash string) []byte { |
| + m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1")) |
| + m.Write([]byte(hash)) |
| + return m.Sum(nil) |
| +} |
| + |
| +func cookieMAC(key []byte, host string, expiry int64) []byte { |
| + m := hmac.New(sha256.New, key) |
| + m.Write([]byte(host)) |
| + m.Write([]byte{'\n'}) |
| + m.Write([]byte(strconv.FormatInt(expiry, 10))) |
| + return m.Sum(nil) |
| +} |
| + |
| +// Mint produces a cookie value for host, valid until expiry. |
| +func Mint(host, hash string, expiry time.Time) string { |
| + exp := expiry.Unix() |
| + mac := cookieMAC(cookieKey(hash), host, exp) |
| + return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac) |
| +} |
| + |
| +// Verify reports whether value was minted for host under hash and has not |
| +// expired at now. One HMAC and a constant-time compare; no bcrypt. |
| +func Verify(value, host, hash string, now time.Time) bool { |
| + parts := strings.Split(value, ".") |
| + if len(parts) != 3 || parts[0] != cookieVersion { |
| + return false |
| + } |
| + exp, err := strconv.ParseInt(parts[1], 10, 64) |
| + if err != nil || exp <= now.Unix() { |
| + return false |
| + } |
| + got, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil { |
| + return false |
| + } |
| + return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp)) |
| +} |
diff --git a/internal/gate/gate.go b/internal/gate/gate.go| new file mode 100644 |
| index 000000000..160921145 |
| --- /dev/null |
| +++ b/internal/gate/gate.go |
| @@ -0,0 +1,166 @@ |
| +// Package gate is the password curtain the edge holds in front of a gated |
| +// host: one shared password per hostname, a neutral page that asks for it, |
| +// and a cookie that remembers the answer for a week. Spec: |
| +// docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| +// |
| +// The edge calls Admit once per request to a route whose registry row |
| +// carries a gate hash. Everything else here — the rules the console |
| +// applies before hashing, the cookie's shape, the attempt limiter — is |
| +// owned by this package so the two console doors and the edge cannot |
| +// drift from one another. |
| +package gate |
| + |
| +import ( |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "golang.org/x/crypto/bcrypt" |
| +) |
| + |
| +const ( |
| + // CookieName is the browser cookie a passed gate sets; stripped from |
| + // the request before it reaches the app. |
| + CookieName = "carlos_gate" |
| + // LoginPath is the form's action on a gated host. Ordinary on every |
| + // other host and for every other method. |
| + LoginPath = "/.carlos/gate" |
| + // Lifetime is how long a cookie stays valid; the expiry is inside the |
| + // value, so a stolen cookie is bounded even if the browser keeps it. |
| + Lifetime = 7 * 24 * time.Hour |
| + |
| + bcryptCost = 10 |
| + minPassword = 8 |
| + maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated |
| + maxFormBody = 4 << 10 |
| +) |
| + |
| +// CheckPassword is the door's rule, applied by the console before hashing. |
| +func CheckPassword(p string) error { |
| + if len(p) < minPassword { |
| + return fmt.Errorf("password must be at least %d characters", minPassword) |
| + } |
| + if len(p) > maxPassword { |
| + return fmt.Errorf("password must be at most %d bytes", maxPassword) |
| + } |
| + return nil |
| +} |
| + |
| +// Hash is bcrypt at cost 10. The console calls it once per set; the edge |
| +// never does (it compares on the login POST and verifies an HMAC cookie |
| +// on every other request). |
| +func Hash(p string) (string, error) { |
| + if err := CheckPassword(p); err != nil { |
| + return "", err |
| + } |
| + b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
| + |
| +func comparePassword(hash, password string) bool { |
| + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil |
| +} |
| + |
| +// Gate is the per-edge instance: one limiter shared by every gated host on |
| +// the box (keys carry the host), a clock and a comparer for tests. |
| +type Gate struct { |
| + limiter *Limiter |
| + now func() time.Time |
| + compare func(hash, password string) bool |
| +} |
| + |
| +// New is the edge's constructor. There is no configuration. |
| +func New() *Gate { |
| + g := &Gate{now: time.Now, compare: comparePassword} |
| + g.limiter = NewLimiter(func() time.Time { return g.now() }) |
| + return g |
| +} |
| + |
| +// Admit decides one request to a gated host. host is the route's own |
| +// hostname (the row's, not the request's — an alias and a path-routed |
| +// prefix must agree on which key signs), hash the row's gate. |
| +// |
| +// true: the request carries a valid cookie, now stripped from the Cookie |
| +// header; the caller proxies it exactly as it would have. false: Admit has |
| +// written the whole response — the login page (401), the redirect after a |
| +// successful POST (303), or the too-many-attempts page (429). |
| +func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool { |
| + if r.Method == http.MethodPost && r.URL.Path == LoginPath { |
| + g.login(w, r, host, hash) |
| + return false |
| + } |
| + if c, err := r.Cookie(CookieName); err == nil && Verify(c.Value, host, hash, g.now()) { |
| + stripCookie(r) |
| + return true |
| + } |
| + writePage(w, r, host, http.StatusUnauthorized, "", safeNext(r.URL.RequestURI())) |
| + return false |
| +} |
| + |
| +func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) { |
| + r.Body = http.MaxBytesReader(w, r.Body, maxFormBody) |
| + if err := r.ParseForm(); err != nil { |
| + writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/") |
| + return |
| + } |
| + next := safeNext(r.PostForm.Get("next")) |
| + if !g.limiter.Allow(clientKey(r) + "\x1f" + host) { |
| + w.Header().Set("Retry-After", "60") |
| + writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next) |
| + return |
| + } |
| + if !g.compare(hash, r.PostForm.Get("password")) { |
| + writePage(w, r, host, http.StatusUnauthorized, msgWrong, next) |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: CookieName, |
| + Value: Mint(host, hash, g.now().Add(Lifetime)), |
| + Path: "/", |
| + MaxAge: int(Lifetime / time.Second), |
| + HttpOnly: true, |
| + Secure: true, |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, next, http.StatusSeeOther) |
| +} |
| + |
| +// safeNext is the entire open-redirect surface: a path on this host, or "/". |
| +func safeNext(next string) string { |
| + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { |
| + return "/" |
| + } |
| + return next |
| +} |
| + |
| +// clientKey is the remote address without its port. A backhauled request's |
| +// address is the sibling edge's, which folds every hop from that edge into |
| +// one bucket — stricter, never looser, and named in the spec (§4.2). |
| +func clientKey(r *http.Request) string { |
| + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| + return h |
| + } |
| + return r.RemoteAddr |
| +} |
| + |
| +// stripCookie removes the gate cookie from the request so the app behind |
| +// the curtain never sees it. Other cookies pass through in their order. |
| +func stripCookie(r *http.Request) { |
| + cookies := r.Cookies() |
| + r.Header.Del("Cookie") |
| + parts := make([]string, 0, len(cookies)) |
| + for _, c := range cookies { |
| + if c.Name != CookieName { |
| + parts = append(parts, c.Name+"="+c.Value) |
| + } |
| + } |
| + if len(parts) > 0 { |
| + r.Header.Set("Cookie", strings.Join(parts, "; ")) |
| + } |
| +} |
diff --git a/internal/gate/gate_test.go b/internal/gate/gate_test.go| new file mode 100644 |
| index 000000000..879763d09 |
| --- /dev/null |
| +++ b/internal/gate/gate_test.go |
| @@ -0,0 +1,274 @@ |
| +package gate |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCheckPassword(t *testing.T) { |
| + if err := CheckPassword("short7!"); err == nil { |
| + t.Fatal("7 chars accepted") |
| + } |
| + if err := CheckPassword(strings.Repeat("x", 73)); err == nil { |
| + t.Fatal("73 bytes accepted — bcrypt would silently truncate") |
| + } |
| + if err := CheckPassword("longenough"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestHashThenVerifyPassword(t *testing.T) { |
| + h, err := Hash("correct horse") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(h, "$2a$10$") { |
| + t.Fatalf("hash = %q, want bcrypt cost 10", h) |
| + } |
| + if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") { |
| + t.Fatal("comparePassword disagrees with Hash") |
| + } |
| +} |
| + |
| +func TestCookieRoundTrip(t *testing.T) { |
| + now := time.Unix(1_800_000_000, 0) |
| + v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) |
| + if !Verify(v, "site.example", "$2a$10$hash", now) { |
| + t.Fatal("fresh cookie refused") |
| + } |
| + if Verify(v, "other.example", "$2a$10$hash", now) { |
| + t.Fatal("cookie accepted for another host") |
| + } |
| + if Verify(v, "site.example", "$2a$10$rotated", now) { |
| + t.Fatal("cookie accepted after the password changed") |
| + } |
| + if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) { |
| + t.Fatal("expired cookie accepted") |
| + } |
| + if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) { |
| + t.Fatal("malformed cookie accepted") |
| + } |
| +} |
| + |
| +func TestLimiterTenPerMinute(t *testing.T) { |
| + now := time.Unix(0, 0) |
| + l := NewLimiter(func() time.Time { return now }) |
| + for i := 0; i < 10; i++ { |
| + if !l.Allow("k") { |
| + t.Fatalf("attempt %d refused", i+1) |
| + } |
| + } |
| + if l.Allow("k") { |
| + t.Fatal("11th attempt allowed") |
| + } |
| + if !l.Allow("other") { |
| + t.Fatal("another key shares the bucket") |
| + } |
| + now = now.Add(6 * time.Second) |
| + if !l.Allow("k") { |
| + t.Fatal("no refill after 6s") |
| + } |
| +} |
| + |
| +func testGate(hash string) *Gate { |
| + g := New() |
| + g.now = func() time.Time { return time.Unix(1_800_000_000, 0) } |
| + return g |
| +} |
| + |
| +func TestAdmitNoCookieIsTheLoginPage(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} { |
| + rec := httptest.NewRecorder() |
| + r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil) |
| + if g.Admit(rec, r, "site.example", h) { |
| + t.Fatalf("%s without a cookie admitted", method) |
| + } |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("%s status = %d, want 401", method, rec.Code) |
| + } |
| + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Fatalf("Cache-Control = %q", cc) |
| + } |
| + if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" { |
| + t.Fatalf("X-Robots-Tag = %q", rt) |
| + } |
| + body := rec.Body.String() |
| + if method == http.MethodHead { |
| + if body != "" { |
| + t.Fatal("HEAD carried a body") |
| + } |
| + continue |
| + } |
| + if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") { |
| + t.Fatalf("page missing copy: %s", body) |
| + } |
| + if !strings.Contains(body, `name="next" value="/docs?x=1"`) { |
| + t.Fatalf("page does not carry next: %s", body) |
| + } |
| + if !strings.Contains(body, `action="/.carlos/gate"`) { |
| + t.Fatalf("form action wrong: %s", body) |
| + } |
| + } |
| +} |
| + |
| +func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + form := url.Values{"password": {password}, "next": {next}} |
| + r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.RemoteAddr = "203.0.113.9:4242" |
| + g.Admit(rec, r, host, hash) |
| + return rec |
| +} |
| + |
| +func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1") |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" { |
| + t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location")) |
| + } |
| + var c *http.Cookie |
| + for _, k := range rec.Result().Cookies() { |
| + if k.Name == CookieName { |
| + c = k |
| + } |
| + } |
| + if c == nil { |
| + t.Fatal("no gate cookie set") |
| + } |
| + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) { |
| + t.Fatalf("cookie attributes: %+v", c) |
| + } |
| + if !Verify(c.Value, "site.example", h, g.now()) { |
| + t.Fatal("minted cookie does not verify") |
| + } |
| + // And the cookie is admitted, and stripped from what the app sees. |
| + rec2 := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) |
| + r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1") |
| + if !g.Admit(rec2, r, "site.example", h) { |
| + t.Fatal("valid cookie refused") |
| + } |
| + if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" { |
| + t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got) |
| + } |
| +} |
| + |
| +func TestAdmitBadNextBecomesRoot(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} { |
| + rec := postLogin(g, "site.example", h, "correct horse", next) |
| + if loc := rec.Header().Get("Location"); loc != "/" { |
| + t.Fatalf("next %q -> Location %q, want /", next, loc) |
| + } |
| + } |
| +} |
| + |
| +func TestAdmitWrongPasswordThenLimit(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + compares := 0 |
| + g.compare = func(hash, password string) bool { compares++; return false } |
| + for i := 0; i < 10; i++ { |
| + rec := postLogin(g, "site.example", h, "nope nope", "/") |
| + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") { |
| + t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String()) |
| + } |
| + } |
| + rec := postLogin(g, "site.example", h, "nope nope", "/") |
| + if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" { |
| + t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After")) |
| + } |
| + if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") { |
| + t.Fatalf("429 page copy: %s", rec.Body.String()) |
| + } |
| + if compares != 10 { |
| + t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares) |
| + } |
| +} |
| + |
| +func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + rec := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil) |
| + if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code) |
| + } |
| +} |
| + |
| +// safeNext is the whole open-redirect surface, so pin it directly rather |
| +// than only through the three shapes the POST test happens to send. |
| +func TestSafeNextIsAPathOnThisHostOrRoot(t *testing.T) { |
| + for _, bad := range []string{"//evil.example/", `/\evil.example/`, `/\/evil.example/`, "https://evil.example/", "javascript:alert(1)", `\\evil.example\`, "", "docs", "?x=1", "#frag"} { |
| + if got := safeNext(bad); got != "/" { |
| + t.Errorf("safeNext(%q) = %q, want /", bad, got) |
| + } |
| + } |
| + for _, ok := range []string{"/", "/docs?x=1", "/a/b#c"} { |
| + if got := safeNext(ok); got != ok { |
| + t.Errorf("safeNext(%q) = %q, want it unchanged", ok, got) |
| + } |
| + } |
| +} |
| + |
| +// The MAC covers the expiry, so pushing the expiry out on a stolen cookie |
| +// must not buy another week. |
| +func TestCookieForgedExpiryAndTamperedMAC(t *testing.T) { |
| + now := time.Unix(1_800_000_000, 0) |
| + v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) |
| + parts := strings.Split(v, ".") |
| + if Verify(parts[0]+".9999999999."+parts[2], "site.example", "$2a$10$hash", now) { |
| + t.Fatal("expiry moved forward without re-signing was accepted") |
| + } |
| + if Verify(v+".x", "site.example", "$2a$10$hash", now) { |
| + t.Fatal("a fourth field was accepted") |
| + } |
| + if Verify("v2."+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) { |
| + t.Fatal("another version was accepted") |
| + } |
| + if Verify(parts[0]+"."+parts[1]+".!!!", "site.example", "$2a$10$hash", now) { |
| + t.Fatal("a non-base64 MAC was accepted") |
| + } |
| +} |
| + |
| +func TestStripCookieDropsTheHeaderWhenTheGateCookieIsTheOnlyOne(t *testing.T) { |
| + r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) |
| + r.Header.Set("Cookie", CookieName+"=x") |
| + stripCookie(r) |
| + if _, ok := r.Header["Cookie"]; ok { |
| + t.Fatalf("Cookie header survived as %q", r.Header.Get("Cookie")) |
| + } |
| +} |
| + |
| +// A body over maxFormBody fails to parse; that must cost no bcrypt and no |
| +// limiter token, so a flood of junk cannot lock a tenant out of their own |
| +// site. |
| +func TestOversizedFormBodyIsRefusedWithoutSpendingAnAttempt(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + compares := 0 |
| + g.compare = func(hash, password string) bool { compares++; return true } |
| + rec := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodPost, "https://site.example"+LoginPath, strings.NewReader("password="+strings.Repeat("a", 64<<10))) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.RemoteAddr = "203.0.113.9:4242" |
| + g.Admit(rec, r, "site.example", h) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("oversized body -> %d, want 401", rec.Code) |
| + } |
| + if compares != 0 { |
| + t.Fatal("an unparseable body reached bcrypt") |
| + } |
| + if !g.limiter.Allow(clientKey(r) + "\x1f" + "site.example") { |
| + t.Fatal("an unparseable body spent an attempt") |
| + } |
| +} |
diff --git a/internal/gate/limiter.go b/internal/gate/limiter.go| new file mode 100644 |
| index 000000000..a96294b08 |
| --- /dev/null |
| +++ b/internal/gate/limiter.go |
| @@ -0,0 +1,64 @@ |
| +package gate |
| + |
| +import ( |
| + "sync" |
| + "time" |
| +) |
| + |
| +const ( |
| + limitBurst = 10 |
| + limitPeriod = time.Minute |
| + limitPrune = 10_000 |
| +) |
| + |
| +// Limiter is a per-key token bucket: limitBurst attempts, refilling at |
| +// limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of |
| +// milliseconds a comparison; this exists so the CPU is spent on people and |
| +// not on a script. |
| +type Limiter struct { |
| + mu sync.Mutex |
| + now func() time.Time |
| + buckets map[string]*bucket |
| +} |
| + |
| +type bucket struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +func NewLimiter(now func() time.Time) *Limiter { |
| + return &Limiter{now: now, buckets: map[string]*bucket{}} |
| +} |
| + |
| +// Allow spends one token for key, reporting whether one was available. |
| +func (l *Limiter) Allow(key string) bool { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + now := l.now() |
| + b, ok := l.buckets[key] |
| + if !ok { |
| + if len(l.buckets) >= limitPrune { |
| + l.prune(now) |
| + } |
| + b = &bucket{tokens: limitBurst, last: now} |
| + l.buckets[key] = b |
| + } |
| + refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds()) |
| + b.tokens = min(limitBurst, b.tokens+refill) |
| + b.last = now |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
| + |
| +// prune drops every bucket that has been idle long enough to be full again; |
| +// keeping it would change no answer. |
| +func (l *Limiter) prune(now time.Time) { |
| + for k, b := range l.buckets { |
| + if now.Sub(b.last) >= limitPeriod { |
| + delete(l.buckets, k) |
| + } |
| + } |
| +} |
diff --git a/internal/gate/login.html b/internal/gate/login.html| new file mode 100644 |
| index 000000000..670bdd09c |
| --- /dev/null |
| +++ b/internal/gate/login.html |
| @@ -0,0 +1,55 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<meta name="robots" content="noindex, nofollow"> |
| +<title>Password required</title> |
| +<style> |
| +:root { |
| + --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85; |
| + --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a; |
| +} |
| +@media (prefers-color-scheme: dark) { |
| + :root { |
| + --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd; |
| + --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76; |
| + } |
| +} |
| +* { margin: 0; padding: 0; box-sizing: border-box; } |
| +body { |
| + background: var(--bg); color: var(--ink); |
| + font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; |
| + min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem; |
| +} |
| +main { width: 100%; max-width: 22rem; } |
| +h1 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
| +p { color: var(--muted); margin-bottom: 1.25rem; } |
| +p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; } |
| +label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; } |
| +input[type=password] { |
| + width: 100%; font: inherit; color: var(--ink); background: var(--bg); |
| + border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem; |
| +} |
| +input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } |
| +button { |
| + width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent); |
| + border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer; |
| +} |
| +.error { color: var(--bad); margin: 0 0 0.9rem; } |
| +</style> |
| +</head> |
| +<body> |
| +<main> |
| + <h1>Password required</h1> |
| + <p><code>{{.Host}}</code> is private. Enter the password to continue.</p> |
| + <form method="post" action="{{.Action}}"> |
| + <input type="hidden" name="next" value="{{.Next}}"> |
| + {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| + <label for="password">Password</label> |
| + <input type="password" id="password" name="password" autocomplete="current-password" autofocus required> |
| + <button type="submit">Continue</button> |
| + </form> |
| +</main> |
| +</body> |
| +</html> |
diff --git a/internal/gate/page.go b/internal/gate/page.go| new file mode 100644 |
| index 000000000..973c28c36 |
| --- /dev/null |
| +++ b/internal/gate/page.go |
| @@ -0,0 +1,41 @@ |
| +package gate |
| + |
| +import ( |
| + _ "embed" |
| + "html/template" |
| + "log" |
| + "net/http" |
| +) |
| + |
| +//go:embed login.html |
| +var loginHTML string |
| + |
| +var loginTmpl = template.Must(template.New("login").Parse(loginHTML)) |
| + |
| +const ( |
| + msgWrong = "That password is not right." |
| + msgTooMany = "Too many attempts. Try again in a minute." |
| +) |
| + |
| +type pageData struct { |
| + Host string |
| + Error string |
| + Next string |
| + Action string |
| +} |
| + |
| +// writePage renders the login page at status. Every page this package |
| +// writes is no-store and noindex: it is a curtain, not content. |
| +func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) { |
| + h := w.Header() |
| + h.Set("Cache-Control", "no-store") |
| + h.Set("X-Robots-Tag", "noindex, nofollow") |
| + h.Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + if r.Method == http.MethodHead { |
| + return |
| + } |
| + if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil { |
| + log.Printf("gate: render login page for %s: %v", host, err) |
| + } |
| +} |