browserrot(gitea): M-B4 LIVE-REAL — first real site-side password rotation
ci / build-test (push) Waiting to run
ci / build-test (push) Waiting to run
Rotated the self-owned Gitea login password end-to-end via the new lab/rung4-gitea-browser harness: backup-gated (Hard Rule 1) -> pre-change login(OLD) -> change-form submit -> new stored to gopass -> verified by browser re-login AND independent API re-auth. GiteaSite promoted to ProofLiveReal (proof-as-data). Three real-site fixes surfaced by the live-fire: - sites.go: Gitea login + "Update Password" buttons carry no type=submit ATTRIBUTE (only the default DOM property), so CSS [type=submit] misses; select by class / the account form's sole button instead. - rod.go: Click now waits for the post-submit navigation to settle before the success text is read (a remote round-trip, unlike the instant in-process test) — else Text() resolves against the pre-submit page. - rod.go: new RodDriver.Insecure (--ignore-certificate-errors) for the self-signed / Tailscale-fronted host (browser analog of curl -k). Suite green + -race clean; docs (BROWSER-ROTATION.md §9, BROWSERROT-PLAN.md) and OVERSEER-STATUS updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -352,9 +352,10 @@ func TestGiteaSite_recipeShape(t *testing.T) {
|
||||
if s.Login.URL == "" || s.Form.CurrentSel == "" || s.Form.NewSel == "" || s.Form.SubmitSel == "" {
|
||||
t.Fatalf("recipe has empty required selectors: %+v", s)
|
||||
}
|
||||
// A seed recipe is UNPROVEN until actually run against the real instance (proof-as-data).
|
||||
if s.Proof != rotate.ProofUnproven {
|
||||
t.Fatalf("a fresh curated recipe must be ProofUnproven, got %v", s.Proof)
|
||||
// Promoted to LIVE-REAL: the recipe drove a real self-owned Gitea password
|
||||
// rotation on 2026-07-21 (lab/rung4-gitea-browser, M-B4). Proof-as-data.
|
||||
if s.Proof != rotate.ProofLiveReal {
|
||||
t.Fatalf("GiteaSite recipe should be ProofLiveReal after the M-B4 live-fire, got %v", s.Proof)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ type RodDriver struct {
|
||||
Bin string // chromium binary; "" lets go-rod find/download one
|
||||
Headless bool // default true in production; false to watch it locally
|
||||
Timeout time.Duration // per-operation timeout; defaults to 30s
|
||||
// Insecure accepts self-signed / untrusted TLS certs (Chromium
|
||||
// --ignore-certificate-errors). Needed for self-hosted targets behind a private
|
||||
// CA or a Tailscale-fronted service (e.g. a personal Gitea on 100.x). This is the
|
||||
// browser analog of `curl -k` in the rung harnesses; the connection is still TLS,
|
||||
// we just don't verify the chain. Off by default — opt in per run for a host you
|
||||
// own and whose cert you already trust out-of-band.
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// Name identifies the backend in logs/plans.
|
||||
@@ -32,6 +39,11 @@ func (d *RodDriver) Name() string { return "go-rod" }
|
||||
func (d *RodDriver) Open(ctx context.Context) (Session, error) {
|
||||
l := launcher.New().Headless(d.Headless).
|
||||
Set("no-sandbox").Set("disable-dev-shm-usage")
|
||||
if d.Insecure {
|
||||
// Accept the self-signed cert of a self-owned host. TLS still encrypts; only
|
||||
// chain verification is skipped. Scoped to this launched browser instance.
|
||||
l = l.Set("ignore-certificate-errors")
|
||||
}
|
||||
if d.Bin != "" {
|
||||
l = l.Bin(d.Bin)
|
||||
}
|
||||
@@ -93,7 +105,21 @@ func (s *rodSession) Click(ctx context.Context, selector string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return el.Click(proto.InputMouseButtonLeft, 1)
|
||||
// A submit/login click triggers a navigation (login POST -> 302 -> dashboard,
|
||||
// change-form POST -> flash). Against a remote host that round-trip has real
|
||||
// latency, so we must wait for the NEW document to commit before returning —
|
||||
// otherwise the next Text() read resolves against the PRE-submit page (the login
|
||||
// form still showing) and mis-reports the outcome. Arm the navigation wait BEFORE
|
||||
// clicking, then block until the network is almost idle. Bounded by the page
|
||||
// timeout; if a click does not navigate this returns when the timeout elapses.
|
||||
wait := s.page.Timeout(s.to).WaitNavigation(proto.PageLifecycleEventNameNetworkAlmostIdle)
|
||||
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
wait()
|
||||
// Belt-and-suspenders: let any post-navigation DOM (flash render) settle.
|
||||
_ = s.page.Timeout(s.to).WaitStable(300 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rodSession) Text(ctx context.Context, selector string) (string, error) {
|
||||
|
||||
@@ -19,9 +19,10 @@ import (
|
||||
// Gitea reaches the change-password controls only inside an authenticated session
|
||||
// (/user/settings/account 302s to /user/login otherwise), so LoginBeforeChange is on.
|
||||
// The password form on the account page carries old_password / password / retype; the
|
||||
// result renders in a flash message. These selectors match current Gitea markup but are
|
||||
// UNPROVEN against any specific instance/version — VERIFY on first run (self-heal will
|
||||
// rewrite any that drift). The SuccessText markers are the one part self-heal cannot fix
|
||||
// result renders in a flash message. Every selector here was VERIFIED against a live
|
||||
// Gitea instance on 2026-07-21 (login, the three change fields, and the submit button)
|
||||
// during the M-B4 live-fire; other instances/versions may drift, and self-heal will
|
||||
// rewrite any that do. The SuccessText markers are the one part self-heal cannot fix
|
||||
// (they are content, not selectors): a wrong marker only makes Rotate fail SAFE (old
|
||||
// secret kept, human flagged), never a silent bad rotation.
|
||||
func GiteaSite(baseURL, username string) Site {
|
||||
@@ -30,10 +31,15 @@ func GiteaSite(baseURL, username string) Site {
|
||||
Host: strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"),
|
||||
ChangeURL: base + "/user/settings/account",
|
||||
Form: Form{
|
||||
CurrentSel: "#old_password",
|
||||
NewSel: "#password",
|
||||
ConfirmSel: "#retype",
|
||||
SubmitSel: "form[action$='/user/settings/account'] button[type=submit]",
|
||||
CurrentSel: "#old_password",
|
||||
NewSel: "#password",
|
||||
ConfirmSel: "#retype",
|
||||
// Gitea's "Update Password" button carries NO type=submit ATTRIBUTE — it is
|
||||
// a bare <button> whose DOM .type property defaults to "submit". A CSS
|
||||
// [type=submit] matches the attribute, not the property, so it MISSES here
|
||||
// (confirmed live 2026-07-21). Select the account-password form's only
|
||||
// button instead; the page has exactly one form posting to this action.
|
||||
SubmitSel: "form[action$='/user/settings/account'] button",
|
||||
SuccessSel: ".ui.positive.message, .flash-success",
|
||||
SuccessText: "has been updated",
|
||||
},
|
||||
@@ -52,6 +58,11 @@ func GiteaSite(baseURL, username string) Site {
|
||||
SuccessText: username,
|
||||
},
|
||||
LoginBeforeChange: true,
|
||||
Proof: rotate.ProofUnproven,
|
||||
// LIVE-REAL: this recipe drove a real password rotation of a self-owned Gitea
|
||||
// account (trill-technician @ 100.117.177.50:3030) on 2026-07-21 via
|
||||
// lab/rung4-gitea-browser (M-B4) — backup-gated, verified by API re-auth. Same
|
||||
// proof-as-data discipline as the API drivers (rotate/proofs.go): proven at the
|
||||
// mechanism level against a real instance, not a blanket "works on every Gitea".
|
||||
Proof: rotate.ProofLiveReal,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user