From 5ca705c88c9471a684c6272eda437c4a566b4681 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 11 Sep 2026 09:05:44 +0000 Subject: [PATCH] feat: login page says a locked instance is suspended instead of drawing the form --- CLAUDE.md | 10 +++- server/internal/auth/instancehost.go | 44 +++++++++++---- .../internal/auth/instancehost_locked_test.go | 56 +++++++++++++++++++ server/internal/auth/local.go | 36 ++++++++++++ server/internal/auth/oidc.go | 4 ++ server/internal/services/instances.go | 15 +++++ web/app/login/page.tsx | 28 +++++++++- web/lib/api.ts | 3 + 8 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 server/internal/auth/instancehost_locked_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 329de15..67f9668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1002,9 +1002,13 @@ plane, each of which this codebase enforces: - **Disputes lock and purge instances.** HQ writes `instances.locked_at` when an account is disputed and `instances.purge_after` only once the dispute fails. `services.InstanceLocked` (60s cache) refuses sessions, API tokens - and agents on a locked instance, and `GetInstanceBySlug` hides it from the - host resolver. The check in `auth.Middleware` is explicit because the host - guard only runs when a host resolves. `ReapTerminatedInstances` purges once + and agents on a locked instance, and `InstanceForHost` and `SoleInstance` + resolve it to nothing. The check in `auth.Middleware` is explicit because the + host guard only runs when a host resolves. `HostLocked` is the one place that + tells "locked" from "unknown", so the login page can say access is suspended + rather than draw a form; `resolveLoginInstance` refuses a locked instance on + the single-instance fallback too, which otherwise served its sign-in page. + A locked instance's public status page stays an ordinary 404. `ReapTerminatedInstances` purges once both fields are present and `purge_after` has passed, independent of `FREE_INSTANCE_REAP_AFTER`, and `ReapFreeInstances` skips locked instances so a restore finds them intact. diff --git a/server/internal/auth/instancehost.go b/server/internal/auth/instancehost.go index be441af..26c1bda 100644 --- a/server/internal/auth/instancehost.go +++ b/server/internal/auth/instancehost.go @@ -68,17 +68,40 @@ func InstanceFromHost(c *gin.Context) (*models.Instance, bool) { return InstanceForHost(c.Request.Host) } +// instanceBySlug is a variable so tests can stub the Mongo read. +var instanceBySlug = services.GetInstanceBySlugIncludingLocked + // InstanceForHost is InstanceFromHost with the host supplied explicitly. +// +// An instance Vantage HQ has locked under a dispute resolves to nothing, so +// every caller refuses it without knowing locks exist. HostLocked is the one +// place that may tell the two apart, for the login page's message. func InstanceForHost(host string) (*models.Instance, bool) { - slug := hostSlug(host) - if slug == "" { + inst := slugInstance(hostSlug(host)) + if inst == nil || inst.LockedAt != nil { return nil, false } - if inst, hit := cachedInstanceFor(slug); hit { - return inst, inst != nil - } + return inst, true +} - inst, err := services.GetInstanceBySlug(slug) +// HostLocked reports whether the host names an instance Vantage HQ has locked. +// It shares InstanceForHost's cache, so a lock and an unlock are seen within +// the same 60 seconds by both. +func HostLocked(host string) bool { + inst := slugInstance(hostSlug(host)) + return inst != nil && inst.LockedAt != nil +} + +// slugInstance reads the instance a slug names, locked or not, through the +// cache. +func slugInstance(slug string) *models.Instance { + if slug == "" { + return nil + } + if inst, hit := cachedInstanceFor(slug); hit { + return inst + } + inst, err := instanceBySlug(slug) if err != nil || inst == nil { // Negative entries are cached too. Without them an unknown but // well-formed host costs a Mongo query per anonymous request, which @@ -86,10 +109,10 @@ func InstanceForHost(host string) (*models.Instance, bool) { // round trip is itself a timing oracle separating "no such instance" // from "instance exists, page does not". storeInstance(slug, nil) - return nil, false + return nil } storeInstance(slug, inst) - return inst, true + return inst } // SoleInstance resolves the one instance of a deployment that has exactly one. @@ -99,7 +122,7 @@ func InstanceForHost(host string) (*models.Instance, bool) { // more than one instance exists. func SoleInstance() (*models.Instance, bool) { if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit { - return inst, inst != nil + return inst, inst != nil && inst.LockedAt == nil } n, err := services.CountInstances() if err != nil || n != 1 { @@ -112,7 +135,8 @@ func SoleInstance() (*models.Instance, bool) { return nil, false } storeInstance(soleInstanceCacheKey, inst) - return inst, true + // A locked sole instance is refused like any other locked instance. + return inst, inst.LockedAt == nil } func cachedInstanceFor(key string) (*models.Instance, bool) { diff --git a/server/internal/auth/instancehost_locked_test.go b/server/internal/auth/instancehost_locked_test.go new file mode 100644 index 0000000..f3461e3 --- /dev/null +++ b/server/internal/auth/instancehost_locked_test.go @@ -0,0 +1,56 @@ +package auth + +import ( + "errors" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// stubSlugs replaces the slug lookup and empties the host cache for one test. +func stubSlugs(t *testing.T, bySlug map[string]*models.Instance) { + t.Helper() + prev := instanceBySlug + instanceBySlug = func(slug string) (*models.Instance, error) { + if inst, ok := bySlug[slug]; ok { + return inst, nil + } + return nil, errors.New("not found") + } + instanceCacheMu.Lock() + instanceCache = map[string]cachedInstance{} + instanceCacheMu.Unlock() + t.Cleanup(func() { + instanceBySlug = prev + instanceCacheMu.Lock() + instanceCache = map[string]cachedInstance{} + instanceCacheMu.Unlock() + }) +} + +func TestLockedHostResolvesToNothingButReportsLocked(t *testing.T) { + at := time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC) + stubSlugs(t, map[string]*models.Instance{ + "acme": {InstanceID: "i-acme", Slug: "acme", LockedAt: &at}, + "open": {InstanceID: "i-open", Slug: "open"}, + }) + + if _, ok := InstanceForHost("acme.vantage.example.com"); ok { + t.Fatal("a locked instance must not resolve") + } + if !HostLocked("acme.vantage.example.com") { + t.Fatal("a locked instance's host must report locked") + } + + if inst, ok := InstanceForHost("open.vantage.example.com"); !ok || inst.InstanceID != "i-open" { + t.Fatalf("unlocked instance: got %v, %v", inst, ok) + } + if HostLocked("open.vantage.example.com") { + t.Fatal("an unlocked instance is not locked") + } + + if HostLocked("missing.vantage.example.com") || HostLocked("example.com") { + t.Fatal("a host naming no instance is not locked") + } +} diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go index 36e0176..b20f795 100644 --- a/server/internal/auth/local.go +++ b/server/internal/auth/local.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "fmt" "net/http" @@ -31,10 +32,18 @@ func SetSessionCookie(c *gin.Context, sessionID string) { // // Anything else is refused rather than guessed. Picking an instance on someone's // behalf is how you sign them into the wrong tenant. +// +// An instance Vantage HQ has locked answers ErrInstanceLocked on both paths. +// Without the check on the fallback, a single-instance deployment whose host +// resolver hides the locked instance fell through to "the sole instance" and +// served its sign-in page anyway. func resolveLoginInstance(c *gin.Context) (string, error) { if inst, ok := InstanceFromHost(c); ok { return inst.InstanceID, nil } + if HostLocked(c.Request.Host) { + return "", ErrInstanceLocked + } n, err := services.CountInstances() if err != nil { return "", err @@ -48,9 +57,22 @@ func resolveLoginInstance(c *gin.Context) (string, error) { if err != nil { return "", err } + if inst.LockedAt != nil { + return "", ErrInstanceLocked + } return inst.InstanceID, nil } +// ErrInstanceLocked is a sign-in to an instance Vantage HQ has locked under an +// account dispute. The login page shows it as a message in place of the form. +var ErrInstanceLocked = errors.New("access to this instance is suspended") + +// loginLocked reports whether this request's sign-in target is locked. +func loginLocked(c *gin.Context) bool { + _, err := resolveLoginInstance(c) + return errors.Is(err, ErrInstanceLocked) +} + func HandleLocalLogin(c *gin.Context) { var body struct { Email string `json:"email"` @@ -61,6 +83,10 @@ func HandleLocalLogin(c *gin.Context) { return } instanceID, err := resolveLoginInstance(c) + if errors.Is(err, ErrInstanceLocked) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true}) + return + } if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -101,6 +127,10 @@ func HandleListPublicProviders(c *gin.Context) { out := []publicProvider{} instanceID, err := resolveLoginInstance(c) + if errors.Is(err, ErrInstanceLocked) { + c.JSON(http.StatusOK, gin.H{"local_enabled": false, "providers": out, "locked": true}) + return + } if err != nil { // An unresolvable instance is not an error the login page can act on: // it still has to render a password form. Answer the safe shape. @@ -127,6 +157,12 @@ func HandleBootstrapStatus(c *gin.Context) { err error instName string ) + // Checked first: a locked instance must not fall through to the global + // user count, which answers needs_setup false and draws the sign-in form. + if loginLocked(c) { + c.JSON(http.StatusOK, gin.H{"needs_setup": false, "locked": true}) + return + } if inst, ok := InstanceFromHost(c); ok { n, err = services.CountInstanceUsers(inst.InstanceID) instName = inst.Name diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 088d6b0..bfd1f34 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -88,6 +88,10 @@ func loadProvider(instanceID, providerID string) (*models.AuthProvider, string, func HandleSSOStart(c *gin.Context) { inst, ok := InstanceFromHost(c) if !ok { + if HostLocked(c.Request.Host) { + c.Redirect(http.StatusFound, "/login?error=instance_locked") + return + } c.Redirect(http.StatusFound, "/login?error=unknown_host") return } diff --git a/server/internal/services/instances.go b/server/internal/services/instances.go index 93f9457..eb4843c 100644 --- a/server/internal/services/instances.go +++ b/server/internal/services/instances.go @@ -35,6 +35,21 @@ func GetInstanceBySlug(slug string) (*models.Instance, error) { return &o, nil } +// GetInstanceBySlugIncludingLocked is GetInstanceBySlug without the lock +// filter. Only the host resolver calls it, so it can tell "no such instance" +// from "instance locked by Vantage HQ" and let the login page say which; it +// still refuses a locked instance itself. +func GetInstanceBySlugIncludingLocked(slug string) (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Instance + err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + func ListInstanceIDs() ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 7f4742c..7b5ef9c 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -22,6 +22,7 @@ const ERROR_MESSAGES: Record = { session_failed: "Could not start your session. Please try again.", state_failed: "Could not start sign-in. Please try again.", unknown_host: "This address does not name a known instance.", + instance_locked: "Access to this instance is suspended.", }; export default function LoginPage() { @@ -33,16 +34,24 @@ export default function LoginPage() { // usable rather than an empty card. const [localEnabled, setLocalEnabled] = useState(true); const [ssoError, setSsoError] = useState(""); + // Vantage HQ has locked this instance under an account dispute. The page + // says so instead of drawing a form that cannot sign anyone in. + const [locked, setLocked] = useState(false); useEffect(() => { const code = new URLSearchParams(window.location.search).get("error"); - if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again."); + if (code === "instance_locked") setLocked(true); + else if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again."); }, []); useEffect(() => { (async () => { try { const s = await auth.bootstrapStatus(); + if (s.locked) { + setLocked(true); + return; + } if (s.needs_setup) { window.location.href = "/setup"; return; @@ -51,6 +60,10 @@ export default function LoginPage() { } catch {} try { const p = await auth.providers(); + if (p.locked) { + setLocked(true); + return; + } setProviders(p.providers); setLocalEnabled(p.local_enabled); } catch {} @@ -86,11 +99,19 @@ export default function LoginPage() {
-

Sign in to Vantage

+

{locked ? "Instance unavailable" : "Sign in to Vantage"}

{/* A keyed label, not a heading: site/'s .tag treatment. */} -

{instanceName}

+ {!locked &&

{instanceName}

}
+ {locked ? ( + +
Access to this instance is suspended.
+

+ Nobody can sign in, and its servers are not being managed, until this is resolved. If you manage this account, check your email from Vantage for details. +

+
+ ) : ( {ssoError &&
{ssoError}
} @@ -155,6 +176,7 @@ export default function LoginPage() {
)} + )} ); diff --git a/web/lib/api.ts b/web/lib/api.ts index db12d77..cc4edfc 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -443,6 +443,8 @@ export interface MeResponse { export interface BootstrapStatus { needs_setup: boolean; instance_name?: string; + /** Vantage HQ has locked this instance; the login page shows a message instead of the form. */ + locked?: boolean; } export interface BootstrapResponse { @@ -479,6 +481,7 @@ export interface PublicProvider { export interface ProvidersResponse { local_enabled: boolean; providers: PublicProvider[]; + locked?: boolean; } export interface AuthProvider {