From dcb978d3540bf210e27a07fa77581af5b9def63b Mon Sep 17 00:00:00 2001 From: Steven Rhoods Date: Wed, 12 Aug 2026 22:24:32 +0100 Subject: [PATCH 1/3] ansible: replace deprecated stdout_callback=yaml with result_format=yaml Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm --- ansible/ansible.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 0e245a9..d6cf1d2 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -3,7 +3,7 @@ inventory = inventories/example/hosts.ini roles_path = roles host_key_checking = False retry_files_enabled = False -stdout_callback = yaml +result_format = yaml [ssh_connection] pipelining = True From b23a0daaefb7e1089744eb6b2a23f8a748d1ce34 Mon Sep 17 00:00:00 2001 From: Steven Rhoods Date: Wed, 12 Aug 2026 22:24:41 +0100 Subject: [PATCH 2/3] ansible: remove drsync_agent mount pre-flight checks and vars Administrators configure source/destination volumes on agent hosts out of scope of drsync - the role has no business asserting anything about paths it doesn't own. Removes the three "Check the source/destination mount path exists" / "Assert both mount paths exist" tasks, the drsync_agent_source_mount/drsync_agent_dest_mount variables (defaults and group_vars), and the systemd unit's RequiresMountsFor= that depended on them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm --- ansible/README.md | 1 - .../inventories/example/group_vars/all.yml | 6 ---- ansible/roles/drsync_agent/defaults/main.yml | 2 -- ansible/roles/drsync_agent/tasks/main.yml | 30 ------------------- .../templates/drsync-agent.service.j2 | 1 - 5 files changed, 40 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index 6a81967..68233b6 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -115,7 +115,6 @@ defaults already set; override only what you need to change. | `coordinator_ip` | from inventory | Optional extra SAN / dial address | | `drsync_agent_port` | `7440` | Agent protocol listener | | `drsync_http_port` | `7441` | REST/WebUI/metrics listener | -| `drsync_agent_source_mount` / `drsync_agent_dest_mount` | `/mnt/src` / `/mnt/dst` | Paths every agent host must mount both filesystems at — must match every job spec's `source.path`/`destination.path` | | `drsync_tls_enabled` | `true` | Agent<->coordinator mTLS. Disabling runs the fleet in plaintext dev mode. | | `drsync_agent_walker_pct` / `drsync_agent_copy_pct` | `25` / `75` | Walker/copy thread split, as a % of detected vCPUs. Must sum to 100. | | `drsync_agent_uring_enabled` | `true` | `false` adds `-U` (force serial `fstatat`) | diff --git a/ansible/inventories/example/group_vars/all.yml b/ansible/inventories/example/group_vars/all.yml index 9a98477..9ef14ed 100644 --- a/ansible/inventories/example/group_vars/all.yml +++ b/ansible/inventories/example/group_vars/all.yml @@ -27,12 +27,6 @@ drsync_config_dir: /etc/drsync drsync_data_dir: /var/lib/drsync # coordinator: SQLite state store + journals drsync_pki_local_dir: "{{ drsync_build_dir }}/pki" # PKI staged on the controller before distribution -# Paths each agent host mounts BOTH filesystems at. Every job spec's -# source.path/destination.path must match these exactly across the whole -# fleet (docs/INSTALL.md §1). -drsync_agent_source_mount: /mnt/src -drsync_agent_dest_mount: /mnt/dst - # --- mTLS (agent<->coordinator control channel) ------------------------------- drsync_tls_enabled: true drsync_pki_ca_cn: drsync-ca diff --git a/ansible/roles/drsync_agent/defaults/main.yml b/ansible/roles/drsync_agent/defaults/main.yml index b9aa50e..7a42800 100644 --- a/ansible/roles/drsync_agent/defaults/main.yml +++ b/ansible/roles/drsync_agent/defaults/main.yml @@ -1,7 +1,5 @@ --- drsync_agent_port: 7440 -drsync_agent_source_mount: /mnt/src -drsync_agent_dest_mount: /mnt/dst drsync_tls_enabled: true # Walker/copy split as a percentage of ansible_processor_vcpus. Must sum to diff --git a/ansible/roles/drsync_agent/tasks/main.yml b/ansible/roles/drsync_agent/tasks/main.yml index c28b335..b471a5c 100644 --- a/ansible/roles/drsync_agent/tasks/main.yml +++ b/ansible/roles/drsync_agent/tasks/main.yml @@ -25,36 +25,6 @@ {{ drsync_agent_walkers }} walkers ({{ drsync_agent_walker_pct }}%) / {{ drsync_agent_copy_threads }} copy threads ({{ drsync_agent_copy_pct }}%) -# --- mounts: the agent must be able to see both trees before it can do -# anything useful, and systemd's RequiresMountsFor= only blocks agent startup -# on a MISSING mount — it says nothing about whether the paths exist at all -# (e.g. a fresh host with no mount unit configured yet). Fail loudly here -# rather than let the agent start and silently sit idle / error on first job. - -- name: Check the source mount path exists - stat: - path: "{{ drsync_agent_source_mount }}" - register: _src_mount - -- name: Check the destination mount path exists - stat: - path: "{{ drsync_agent_dest_mount }}" - register: _dst_mount - -- name: Assert both mount paths exist - assert: - that: - - _src_mount.stat.exists - - _src_mount.stat.isdir - - _dst_mount.stat.exists - - _dst_mount.stat.isdir - fail_msg: >- - {{ drsync_agent_source_mount }} and {{ drsync_agent_dest_mount }} must - both exist as directories on {{ inventory_hostname }} before the agent - can run jobs — mount the source and destination filesystems there - first (see docs/INSTALL.md §1: every agent host mounts both trees at - the exact absolute paths named in the job spec). - # --- io_uring: optional accelerator, not required for correctness ----------- - name: Check the io_uring kernel switch diff --git a/ansible/roles/drsync_agent/templates/drsync-agent.service.j2 b/ansible/roles/drsync_agent/templates/drsync-agent.service.j2 index 6fbb600..9208303 100644 --- a/ansible/roles/drsync_agent/templates/drsync-agent.service.j2 +++ b/ansible/roles/drsync_agent/templates/drsync-agent.service.j2 @@ -20,7 +20,6 @@ {% endif %} [Unit] Description=drsync agent -RequiresMountsFor={{ drsync_agent_source_mount }} {{ drsync_agent_dest_mount }} After=network-online.target remote-fs.target Wants=network-online.target From 16707d4b8117817047c347625fa51bd4906ff2b0 Mon Sep 17 00:00:00 2001 From: Steven Rhoods Date: Wed, 12 Aug 2026 22:24:51 +0100 Subject: [PATCH 3/3] webui: stop looping on "connecting..." when a token is required but no login is configured /api/v1/whoami's login_configured only reflected whether auth.yaml was present, saying nothing about a bearer token still being required. A coordinator with -api-token-file set (its own default path can be populated left over from an earlier deployment even when nobody intended token auth) but no auth.yaml reported login_configured=false - correct in isolation, but the WebUI (session-cookie login only, no bearer-token entry) read that as "nothing to authenticate," started polling, got 401'd on every request, and reloaded on each 401 - looping the console on "connecting..." forever with no explanation. Adds a token_required field to the whoami response (token set, no authenticator) and has the WebUI check it before ever starting the poll loop, showing a static explanation screen instead (there being no bearer-token UI to fall back to - it points at the CLI/DRSYNC_TOKEN instead). The same check also short-circuits onUnauthorized so a stray 401 later doesn't fall into the reload path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm --- coordinator/internal/api/auth_login.go | 16 ++++++ coordinator/internal/api/auth_login_test.go | 46 +++++++++++++++++ docs/ADMIN.md | 13 +++-- docs/DESIGN-coordinator.md | 14 ++++++ webui/console.html | 55 ++++++++++++++++++--- webui/test/console.test.mjs | 38 ++++++++++++++ 6 files changed, 171 insertions(+), 11 deletions(-) diff --git a/coordinator/internal/api/auth_login.go b/coordinator/internal/api/auth_login.go index abd88a4..e41a316 100644 --- a/coordinator/internal/api/auth_login.go +++ b/coordinator/internal/api/auth_login.go @@ -142,6 +142,21 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { // handleWhoAmI reports the caller's authenticated identity, letting the // WebUI show a username and decide whether to render the login page. +// +// login_configured=false means "no session/password login" — it says +// nothing about whether a bearer TOKEN is still required. auth()'s three +// passthrough conditions are token-empty-and-no-authenticator, valid +// bearer, or valid session cookie: a coordinator can have -api-token-file +// set (its default even points at a conventional path an operator may not +// realise is populated, e.g. left over from an earlier deployment) with no +// auth.yaml at all, in which case login_configured is correctly false (no +// session login exists) but every other endpoint still 401s a browser that +// never supplies that token — the WebUI has no bearer-token entry UI, only +// session-cookie login (docs/DESIGN-coordinator.md §6), so that state was +// previously indistinguishable from genuine "no auth at all" and the +// console looped 401 -> reload forever without ever explaining why. +// token_required makes that state visible so the frontend can show a clear +// message instead of retrying a request it can never satisfy. func (s *Server) handleWhoAmI(w http.ResponseWriter, r *http.Request) { username := "" if c, err := r.Cookie(authn.CookieName); err == nil && s.sessions != nil { @@ -152,6 +167,7 @@ func (s *Server) handleWhoAmI(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "username": username, "login_configured": s.authenticator != nil, + "token_required": s.token != "" && s.authenticator == nil, }) } diff --git a/coordinator/internal/api/auth_login_test.go b/coordinator/internal/api/auth_login_test.go index d0428b7..c8a7125 100644 --- a/coordinator/internal/api/auth_login_test.go +++ b/coordinator/internal/api/auth_login_test.go @@ -182,6 +182,52 @@ func TestWhoAmIWithoutSession(t *testing.T) { } } +// TestWhoAmIReportsTokenRequired is the WebUI-stuck-on-"connecting…" +// regression: a coordinator can have -api-token-file set (its default even +// names a conventional path an operator may not realise is populated, e.g. +// left over from an earlier deployment) with no auth.yaml at all — SetAuth +// is never called, so s.authenticator stays nil and login_configured is +// correctly false, but every route s.auth() protects still 401s a browser +// that supplies no bearer token. Before token_required existed, the WebUI +// could not tell this state apart from genuine "no auth at all" and looped +// silently (see webui/test/console.test.mjs's matching JS-side regression +// test). token_required must be true here specifically because a token IS +// configured and no authenticator is — the two other combinations +// (TestWhoAmIWithSession/WithoutSession above) must both report false. +func TestWhoAmIReportsTokenRequired(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + srv := New(st, nil, metrics.New(), nil, dir, "s3cr3t-token") // SetAuth deliberately never called + + r := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil) + w := httptest.NewRecorder() + srv.handleWhoAmI(w, r) + + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["login_configured"] != false { + t.Errorf("login_configured = %v, want false (no auth.yaml, SetAuth never called)", got["login_configured"]) + } + if got["token_required"] != true { + t.Errorf("token_required = %v, want true (a token is set with no authenticator to obtain a session through)", got["token_required"]) + } + + // And a protected route genuinely does 401 an unauthenticated request in + // this state — token_required=true is not just a label, it's true. + r2 := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil) + w2 := httptest.NewRecorder() + srv.auth(srv.listJobs)(w2, r2) + if w2.Code != http.StatusUnauthorized { + t.Fatalf("protected route status = %d, want 401 (token configured, none supplied)", w2.Code) + } +} + // TestAuthMiddlewareAcceptsSessionCookie exercises the middleware end to // end: a session cookie from login must be sufficient to pass s.auth() on a // protected route, without any bearer token. diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 49de78a..7936b58 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -794,9 +794,16 @@ allow: it elsewhere. - An absent `/etc/drsync/auth.yaml` (the default) disables interactive login entirely; the WebUI then connects straight through with no login screen - (open dev mode, matching prior behaviour) — the coordinator's REST API - itself may still be open or bearer-token-gated per `-api-token-file`, but - that token has no UI to enter it in. + (open dev mode, matching prior behaviour) — **unless** the coordinator's + REST API is still bearer-token-gated per `-api-token-file` (its own default + path, `/etc/drsync/api-token`, can be populated left over from an earlier + deployment even when nobody intended token auth to be active). That token + has no UI to enter it in, so in that specific combination — token + required, no `auth.yaml` — the WebUI shows a plain "this coordinator + requires a token; use the CLI/API instead" screen rather than attempting + to load (earlier versions instead looped silently on "connecting…" forever + — if you see that, either add `auth.yaml` for WebUI login, or remove the + stray token file if bearer-token auth was never intended). **HTTP(S) listener TLS** (`/etc/drsync/certs.yaml`, absent by default = plain `http://`): diff --git a/docs/DESIGN-coordinator.md b/docs/DESIGN-coordinator.md index 6ee3ae6..8b68feb 100644 --- a/docs/DESIGN-coordinator.md +++ b/docs/DESIGN-coordinator.md @@ -907,6 +907,20 @@ GET /api/v1/whoami current session identity + whether login delete-pass's protection is the in-body confirmation string, not a privilege tier. `login`/`logout`/`whoami` are themselves unauthenticated (you can't require a session to obtain one); `whoami` never 401s. + **Found live:** `whoami`'s `login_configured` field only reflects whether + `auth.yaml` is present — it said nothing about a bearer token being + required. A coordinator with `-api-token-file` set (its default even names + a conventional path, `/etc/drsync/api-token`, that can be populated left + over from an earlier deployment) but no `auth.yaml` reported + `login_configured=false` — correct in isolation, but the WebUI (which has + no bearer-token entry, only session-cookie login) read that as "nothing to + authenticate," went straight to polling, got 401'd on every request (the + token check in `auth()` still applies), and reloaded on each 401 — + looping the console on "connecting…" forever with no explanation. Fixed by + adding a `token_required` field (`s.token != "" && s.authenticator == + nil`) the WebUI checks before ever calling `startConsole()`, showing a + static explanation screen instead (there being no bearer-token UI to fall + back to, only a message pointing at the CLI/`DRSYNC_TOKEN`). - The listener is plain HTTP unless `/etc/drsync/certs.yaml` configures a cert/key pair, in which case it serves HTTPS and the session cookie is marked `Secure`. diff --git a/webui/console.html b/webui/console.html index e12d189..da00191 100644 --- a/webui/console.html +++ b/webui/console.html @@ -547,6 +547,25 @@ + + +
@@ -2125,12 +2144,19 @@

New job

// GET /api/v1/whoami (always unauthenticated) tells us whether login is // configured on this coordinator at all: if not, there's nothing to sign // into and the console proceeds straight through (dev mode / a coordinator - // with no auth configured at all). + // with no auth configured at all) — UNLESS token_required is also true (a + // bearer token IS required, just with no session login to obtain one + // through), in which case #token-required-screen explains that instead of + // silently attempting requests that can only ever 401. Previously + // login_configured=false was treated as "nothing needed" unconditionally, + // so that state 401-looped the console forever with no explanation — see + // #token-required-screen's own comment and onUnauthorized below. const loginScreen = $("#login-screen"), loginForm = $("#login-form"), loginUser = $("#login-user"), loginPass = $("#login-pass"), loginMsg = $("#login-msg"), loginSubmit = $("#login-submit"), - userChip = $("#user-chip"), userName = $("#user-name"), logoutBtn = $("#logout"); - let loginConfigured = false; + userChip = $("#user-chip"), userName = $("#user-name"), logoutBtn = $("#logout"), + tokenRequiredScreen = $("#token-required-screen"); + let loginConfigured = false, tokenRequired = false; function showLogin(msg) { loginScreen.hidden = false; @@ -2138,6 +2164,7 @@

New job

loginUser.focus(); } function hideLogin() { loginScreen.hidden = true; } + function showTokenRequired() { tokenRequiredScreen.hidden = false; } function setUserChip(username) { if (username) { userName.textContent = username; userChip.hidden = false; logoutBtn.hidden = false; @@ -2147,11 +2174,16 @@

New job

} // onUnauthorized runs on any 401 from the API: send the operator back to - // the login screen. If login isn't configured at all, a 401 means the - // coordinator's auth setup changed out from under an open session — reload - // is the only sane recovery, since there is no credential UI to offer. + // the login screen. token_required with no login configured means there is + // no credential UI at all — show the explanation screen once rather than + // reloading (a reload would just re-run the identical whoami -> 401 -> + // reload cycle forever, the bug this fixes). Otherwise, if login isn't + // configured, a 401 means the coordinator's auth setup changed out from + // under an open session — reload is the sane recovery there, since a + // session-login coordinator can always re-establish one. function onUnauthorized() { setUserChip(""); + if (tokenRequired && !loginConfigured) { showTokenRequired(); return; } if (loginConfigured) showLogin("session expired — please sign in again"); else location.reload(); } @@ -2199,16 +2231,23 @@

New job

// whoami never 401s, so this alone decides the startup screen: an // established session shows the console immediately; a coordinator with // login configured but no session shows the login screen (never the raw - // console flashing 401s); a coordinator with no login configured at all - // goes straight to the console. + // console flashing 401s); a coordinator with no login configured AND no + // token required goes straight to the console; a coordinator with a + // token required but no login configured shows the explanation screen + // instead of starting the console (every request would just 401 — + // previously this looped silently forever instead of ever saying so). try { const who = await api("/api/v1/whoami"); loginConfigured = !!who.login_configured; + tokenRequired = !!who.token_required; if (who.username) { setUserChip(who.username); } else if (loginConfigured) { showLogin(""); return; + } else if (tokenRequired) { + showTokenRequired(); + return; } } catch (_) { /* whoami unreachable — fall through to normal connect/401 handling */ } startConsole(); diff --git a/webui/test/console.test.mjs b/webui/test/console.test.mjs index facb775..5b974eb 100644 --- a/webui/test/console.test.mjs +++ b/webui/test/console.test.mjs @@ -381,3 +381,41 @@ test("the page declares a theme-aware favicon", () => { test("the page ran without uncaught script errors", () => { assert.deepEqual(c.scriptErrors, []); }); + +// -------------------------------------------------------------------------- +// Auth: token required but no session login configured +// -------------------------------------------------------------------------- +// Regression: a coordinator can have -api-token-file set (its default even +// names a conventional path an operator may not realise is populated) with +// no auth.yaml — /api/v1/whoami's login_configured is correctly false (no +// session login exists), but every other endpoint still requires a bearer +// token the WebUI has no UI to supply. Before the fix, that state was +// indistinguishable from genuine "no auth at all": the console called +// startConsole(), every poll 401'd, onUnauthorized() reloaded the page, and +// the reload re-ran the identical whoami -> startConsole -> 401 cycle +// forever — "connecting…" and never anything else. token_required in the +// whoami response is what breaks that loop. + +test("a token-required coordinator with no login shows an explanation, not an infinite reload loop", async () => { + const c2 = await boot({ + routeOverrides: path => { + if (path === "/api/v1/whoami") { + return { json: { username: "", login_configured: false, token_required: true } }; + } + // Every other endpoint behaves as the real coordinator would in this + // state: 401, since the browser has no bearer token to send. + return { status: 401, json: { error: "invalid or missing credentials" } }; + }, + }); + await c2.tick(300); + assert.equal(c2.$("#token-required-screen").hidden, false, + "token-required explanation screen was not shown"); + assert.equal(c2.$("#login-screen").hidden, true, + "the password login screen should not show — there is nothing to log into"); + assert.match(c2.text("#token-required-screen"), /token/i); + assert.match(c2.text("#hz"), /connecting/, + "no successful poll ever completes in this state, so the header legitimately still reads connecting"); + assert.deepEqual(c2.scriptErrors, [], + "no script errors — in particular no unhandled reload/navigation loop"); + c2.dom.window.close(); +});