Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
2 changes: 1 addition & 1 deletion ansible/ansible.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 0 additions & 6 deletions ansible/inventories/example/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions ansible/roles/drsync_agent/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 0 additions & 30 deletions ansible/roles/drsync_agent/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions coordinator/internal/api/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
})
}

Expand Down
46 changes: 46 additions & 0 deletions coordinator/internal/api/auth_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions docs/ADMIN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://`):
Expand Down
14 changes: 14 additions & 0 deletions docs/DESIGN-coordinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
55 changes: 47 additions & 8 deletions webui/console.html
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,25 @@
</div>
</div>

<!-- Shown instead of #login-screen when the coordinator requires a bearer
token (-api-token-file set) but has no session/password login (auth.yaml
absent) — the WebUI has no bearer-token entry, so there is nothing this
page can prompt for; explain that plainly rather than looping the
console through repeated silent 401s (the bug this fixes). -->
<div id="token-required-screen" class="login-screen" hidden role="dialog" aria-label="Bearer token required" aria-modal="true">
<div class="login-card">
<div class="login-brand"><span class="logo">dr<span class="flow">sync</span></span></div>
<div class="login-tag">cluster console</div>
<div class="login-msg err">
This coordinator requires an API bearer token, but has no interactive
login configured (<code>auth.yaml</code>) — the web console has no way
to accept a token. Use the <code>drsync</code> CLI or set
<code>DRSYNC_TOKEN</code>, or ask an operator to configure
<code>auth.yaml</code> for web login.
</div>
</div>
</div>

<div class="wrap">

<div id="view-overview">
Expand Down Expand Up @@ -2125,19 +2144,27 @@ <h3 id="jmodal-title">New job</h3>
// 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;
loginMsg.textContent = msg || ""; loginMsg.className = "login-msg" + (msg ? " err" : "");
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;
Expand All @@ -2147,11 +2174,16 @@ <h3 id="jmodal-title">New job</h3>
}

// 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();
}
Expand Down Expand Up @@ -2199,16 +2231,23 @@ <h3 id="jmodal-title">New job</h3>
// 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();
Expand Down
38 changes: 38 additions & 0 deletions webui/test/console.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});