feat: login page says a locked instance is suspended instead of drawing the form
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 4m4s

This commit is contained in:
2026-09-11 09:05:44 +00:00
parent 2d96cb4224
commit 5ca705c88c
8 changed files with 180 additions and 16 deletions
+7 -3
View File
@@ -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.
+34 -10
View File
@@ -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) {
@@ -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")
}
}
+36
View File
@@ -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
+4
View File
@@ -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
}
+15
View File
@@ -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()
+25 -3
View File
@@ -22,6 +22,7 @@ const ERROR_MESSAGES: Record<string, string> = {
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() {
<div className="relative w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<Logo className="h-11 w-auto text-logo" />
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Sign in to Vantage</h1>
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">{locked ? "Instance unavailable" : "Sign in to Vantage"}</h1>
{/* A keyed label, not a heading: site/'s .tag treatment. */}
<p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>
{!locked && <p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>}
</div>
{locked ? (
<Card>
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">Access to this instance is suspended.</div>
<p className="mt-4 text-sm text-text-secondary">
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.
</p>
</Card>
) : (
<Card>
{ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
@@ -155,6 +176,7 @@ export default function LoginPage() {
</div>
)}
</Card>
)}
</div>
</div>
);
+3
View File
@@ -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 {