What's New in v2.7
Four reported issues, and three more bugs found while fixing them. Full details are in CHANGELOG.md.
MySQL storage
sentinel.MySQL was exported but Mount had no case for it, so an application configured for MySQL fell through to in-memory storage: the dashboard looked healthy and every threat, block, and audit entry disappeared on restart. The same silent fallback hid a misspelled driver name.
1sentinel.Mount(r, nil, sentinel.Config{2 Storage: sentinel.StorageConfig{3 Driver: sentinel.MySQL,4 // parseTime=True is required: without it the driver returns []byte5 // where Sentinel expects time.Time.6 DSN: "user:pass@tcp(127.0.0.1:3306)/sentinel?charset=utf8mb4&parseTime=True",7 },8})
The adapter reuses the same GORM models as SQLite and Postgres, so the schema and feature set are identical. A driver Mount does not recognise is now a startup error rather than a quiet downgrade to memory.
Two things MySQL needed that the others didn't
An untagged string column becomes LONGTEXT in MySQL, which cannot be indexed, so the adapter bounds them and the columns holding paths, user agents, and JSON blobs are explicitly text.
The driver's default timestamp column is datetime(3), which truncated the timestamps the audit log's hash chain covers — a chain written to MySQL failed its own verification, with every entry reading back as modified. The adapter asks for datetime(6).
A lockout no longer bars everyone sharing an address
AuthShield counted failed sign-ins per client address and locked that address. Behind NAT or CGNAT an address is an office, a university, an apartment block, or a mobile carrier: five mistakes by one person barred everybody sharing that egress address for fifteen minutes. Worse, it needed no access to the target — failing sign-ins against any username from a victim's egress address locked whoever happened to share it.
The default is now the (address, account) pair. The person who failed is stopped; their neighbours are not.
1AuthShield: sentinel.AuthShieldConfig{2 Enabled: true,3 LoginRoute: "/api/login",45 // "pair" (default) bars that account from that address.6 // "ip" is the pre-v2.7 behaviour; "account" bars it everywhere.7 LockoutKey: sentinel.LockoutPair,89 // Never locked by address, however you key lockouts: office NAT,10 // CGNAT, a VPN gateway.11 ExemptIPs: []string{"203.0.113.0/24"},12},
A per-account lockout cannot see one machine working through many accounts, so an address that accumulates AddressFailureBudget failures (default MaxFailedAttempts × 4) has every attempt refused for AddressCooldown — one minute by default. Deliberately short, because an address may be thousands of people, and an attacker's throughput still collapses. Refusals now carry Retry-After, and the dashboard lists the accounts locked from each address. See What a lockout bars.
A CAPTCHA tier that actually fires
CAPTCHAThreshold was given a value whether or not a CAPTCHA provider existed, and validation only complained about more than one provider. So the default state of every deployment that enabled AuthShield without CAPTCHA was a tier between "allow" and "locked out" that could never fire and said nothing about it. Config that reads as a defence and does nothing is worse than no config.
The threshold now defaults to 0 unless a provider is configured, and ValidateConfig warns when one is set without a provider. This matters more than it used to: with lockouts keyed on the pair, the CAPTCHA tier is the escalation path for a suspicious shared address.
The dashboard login budget holds across replicas
v2.5 moved rate limits and AuthShield onto a shared counter store, but the dashboard's own limiter — ten login attempts per address per quarter hour — stayed in process memory. Four replicas therefore gave an attacker forty guesses at the dashboard password. It now uses the same Config.Counters store, and fails open on a store outage rather than locking everyone out of the dashboard.
Found while fixing those
- Replicas starting together failed their migrations. They all run
AutoMigrateagainst the one shared database, and the losers of that race died with "table already exists" — three of four replicas in a test. Migrations are retried once. Postgres had the same exposure. WAF.ExcludeIPshonours CIDR ranges. Documented as "IP addresses or CIDR ranges" since v2.0, but entries were compared as exact strings: a10.0.0.0/8entry exempted exactly one client — the one whose address was that literal string.- An empty Postgres or MySQL DSN is reported.
ApplyDefaultsused to hand every driver the SQLite filename, so a config with no DSN looked complete and failed later at connect time.
Upgrading
Nothing is required, but three defaults changed:
| Change | If you want the old behaviour |
|---|---|
| A lockout bars the (address, account) pair, not the address | AuthShield.LockoutKey: sentinel.LockoutIP — ideally with ExemptIPs for your shared egress ranges |
CAPTCHAThreshold defaults to 0 without a provider | Configure a provider secret, or set the threshold explicitly |
An unrecognised Storage.Driver fails the mount | Nothing — this one was hiding data loss. Check the driver name if a mount starts failing. |
If your login handler does not report an account name (credentials in a header, or a field name of your own), add it to AuthShield.UsernameFields or set sentinel_username in the handler. Without one, a pair lockout has nothing to key on and the address is locked instead — what every application got before v2.7.