From 3359e6722cec9e79ef794c9e7b5f0d914fe1c430 Mon Sep 17 00:00:00 2001
From: Herbert Wolverson
Date: Fri, 15 May 2026 12:20:54 -0500
Subject: [PATCH] Remove anonymous WebUI access
---
src/rust/lqos_config/src/authentication.rs | 77 ++++++++++++++-----
src/rust/lqosd/src/node_manager/auth.rs | 45 +++++------
.../node_manager/js_build/src/first-run.js | 2 -
.../src/node_manager/static2/first-run.html | 21 ++---
4 files changed, 84 insertions(+), 61 deletions(-)
diff --git a/src/rust/lqos_config/src/authentication.rs b/src/rust/lqos_config/src/authentication.rs
index ba457db5..7bd29fb3 100644
--- a/src/rust/lqos_config/src/authentication.rs
+++ b/src/rust/lqos_config/src/authentication.rs
@@ -110,8 +110,6 @@ pub struct WebUsers {
#[serde(default = "default_auth_epoch")]
auth_epoch: u64,
#[serde(default)]
- allow_unauthenticated_to_view: bool,
- #[serde(default)]
users: Vec,
#[serde(skip)]
base_path_override: Option,
@@ -122,7 +120,6 @@ impl Default for WebUsers {
Self {
version: AUTH_FILE_VERSION,
auth_epoch: INITIAL_AUTH_EPOCH,
- allow_unauthenticated_to_view: false,
users: Vec::new(),
base_path_override: None,
}
@@ -240,7 +237,11 @@ impl WebUsers {
Ok(())
}
- fn migrate_if_needed(&mut self, loaded_from: &Path) -> Result<(), AuthenticationError> {
+ fn migrate_if_needed(
+ &mut self,
+ loaded_from: &Path,
+ removed_anonymous_setting_present: bool,
+ ) -> Result<(), AuthenticationError> {
let current_path = Self::primary_path_for(&self.resolved_base_path()?);
let loaded_from_legacy_path = loaded_from != current_path;
let mut needs_rewrite = false;
@@ -259,6 +260,10 @@ impl WebUsers {
needs_rewrite = true;
}
+ if removed_anonymous_setting_present {
+ needs_rewrite = true;
+ }
+
if needs_rewrite {
self.bump_auth_epoch();
self.save_to_disk()?;
@@ -279,12 +284,13 @@ impl WebUsers {
error!("Unable to read auth file {:?}: {e}", path);
AuthenticationError::UnableToRead
})?;
+ let removed_anonymous_setting_present = auth_file_has_removed_anonymous_setting(&raw);
let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| {
error!("Unable to deserialize auth file {:?}: {e}", path);
AuthenticationError::UnableToParse
})?;
users.base_path_override = Some(Self::default_base_path()?);
- users.migrate_if_needed(&path)?;
+ users.migrate_if_needed(&path, removed_anonymous_setting_present)?;
Ok(users)
} else {
let new_users = Self::default();
@@ -311,12 +317,13 @@ impl WebUsers {
error!("Unable to read auth file {:?}: {e}", path);
AuthenticationError::UnableToRead
})?;
+ let removed_anonymous_setting_present = auth_file_has_removed_anonymous_setting(&raw);
let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| {
error!("Unable to deserialize auth file {:?}: {e}", path);
AuthenticationError::UnableToParse
})?;
users.base_path_override = Some(base_path.to_path_buf());
- users.migrate_if_needed(&path)?;
+ users.migrate_if_needed(&path, removed_anonymous_setting_present)?;
Ok(users)
} else {
let new_users = Self {
@@ -483,21 +490,12 @@ impl WebUsers {
pub fn get_users(&self) -> Vec {
self.users.clone()
}
+}
- /// Sets the "allow unauthenticated users" field. If true,
- /// unauthenticated users gain read-only access. This is useful
- /// for demonstration purposes.
- pub fn allow_anonymous(&mut self, allow: bool) -> Result<(), AuthenticationError> {
- self.allow_unauthenticated_to_view = allow;
- self.bump_auth_epoch();
- self.save_to_disk()?;
- Ok(())
- }
-
- /// Do we allow unauthenticated users to read site data?
- pub fn do_we_allow_anonymous(&self) -> bool {
- self.allow_unauthenticated_to_view
- }
+fn auth_file_has_removed_anonymous_setting(raw: &str) -> bool {
+ raw.parse::()
+ .map(|document| document.contains_key("allow_unauthenticated_to_view"))
+ .unwrap_or(false)
}
/// Errors that can occur while managing web-UI authentication.
@@ -530,3 +528,42 @@ pub enum AuthenticationError {
#[error("Invalid Login")]
InvalidLogin,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ fn temp_auth_dir(test_name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("libreqos-auth-{test_name}-{}", Uuid::new_v4()))
+ }
+
+ #[test]
+ fn load_removes_legacy_anonymous_access_setting() {
+ let dir = temp_auth_dir("anonymous-setting");
+ fs::create_dir_all(&dir).expect("create auth test directory");
+ let path = dir.join(CURRENT_AUTH_FILE_NAME);
+ fs::write(
+ &path,
+ r#"version = 2
+auth_epoch = 1
+allow_unauthenticated_to_view = true
+
+[[users]]
+username = "support"
+password_hash = "legacy"
+role = "ReadOnly"
+"#,
+ )
+ .expect("write auth file");
+
+ let users = WebUsers::load_or_create_in(&dir).expect("load auth file");
+ assert_eq!(users.auth_epoch(), 2);
+ assert_eq!(users.get_users().len(), 1);
+
+ let saved = fs::read_to_string(&path).expect("read migrated auth file");
+ assert!(!saved.contains("allow_unauthenticated_to_view"));
+
+ fs::remove_dir_all(&dir).expect("remove auth test directory");
+ }
+}
diff --git a/src/rust/lqosd/src/node_manager/auth.rs b/src/rust/lqosd/src/node_manager/auth.rs
index 68a1d137..716fa887 100644
--- a/src/rust/lqosd/src/node_manager/auth.rs
+++ b/src/rust/lqosd/src/node_manager/auth.rs
@@ -55,7 +55,6 @@ struct CachedAuthSnapshot {
struct AuthSnapshot {
bootstrap_state: AuthBootstrapState,
auth_epoch: u64,
- allow_anonymous: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -334,7 +333,6 @@ fn auth_snapshot() -> AuthSnapshot {
return AuthSnapshot {
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
auth_epoch: 0,
- allow_anonymous: false,
};
}
};
@@ -350,7 +348,6 @@ fn auth_snapshot() -> AuthSnapshot {
None => AuthSnapshot {
bootstrap_state: AuthBootstrapState::MissingUsersFile,
auth_epoch: 0,
- allow_anonymous: false,
},
Some(_) => match WebUsers::load_or_create() {
Ok(users) => AuthSnapshot {
@@ -360,14 +357,12 @@ fn auth_snapshot() -> AuthSnapshot {
AuthBootstrapState::Ready
},
auth_epoch: users.auth_epoch(),
- allow_anonymous: users.do_we_allow_anonymous(),
},
Err(e) => {
warn!("Unable to load auth state: {e}");
AuthSnapshot {
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
auth_epoch: 0,
- allow_anonymous: false,
}
}
},
@@ -597,14 +592,14 @@ pub async fn get_username(jar: &CookieJar) -> String {
}
}
-#[derive(Copy, Clone, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum LoginResult {
Admin,
ReadOnly,
Denied,
}
-fn login_result_for_session(user: Option, allow_anonymous: bool) -> LoginResult {
+fn login_result_for_session(user: Option) -> LoginResult {
match user {
Some(SessionUser {
role: UserRole::Admin,
@@ -614,7 +609,6 @@ fn login_result_for_session(user: Option, allow_anonymous: bool) ->
role: UserRole::ReadOnly,
..
}) => LoginResult::ReadOnly,
- None if allow_anonymous => LoginResult::ReadOnly,
None => LoginResult::Denied,
}
}
@@ -649,7 +643,7 @@ fn runtime_onboarding_exempt_path(path: &str) -> bool {
/// Checks an incoming request for a `User-Token` cookie. If found,
/// it validates the request against the signed session and current auth epoch.
/// Missing or empty auth state redirects to first-run; invalid sessions redirect
-/// to login unless anonymous read-only is enabled.
+/// to login.
pub async fn auth_layer(
jar: CookieJar,
mut req: axum::extract::Request,
@@ -667,7 +661,7 @@ pub async fn auth_layer(
}
let login_result = match session_from_cookie(&jar, &snapshot) {
- Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
+ Ok(user) => login_result_for_session(user),
Err(status) => return (status, "Unable to validate session").into_response(),
};
@@ -703,7 +697,7 @@ pub async fn login_from_token(token: &str) -> LoginResult {
};
let login_result = match verify_signed_session(&key, token, &snapshot) {
- Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
+ Ok(user) => login_result_for_session(user),
Err(e) => {
warn!("Unable to verify websocket session token: {e}");
LoginResult::Denied
@@ -881,7 +875,6 @@ pub async fn try_login(
pub struct FirstUser {
username: String,
password: String,
- allow_anonymous: bool,
}
pub async fn first_user(
@@ -924,19 +917,6 @@ pub async fn first_user(
}),
)
})?;
- users
- .allow_anonymous(new_user.allow_anonymous)
- .map_err(|e| {
- warn!("Unable to set anonymous auth policy: {e}");
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(LoginResponse {
- ok: false,
- reason: Some("auth_corrupt"),
- message: Some("Unable to update auth settings.".to_string()),
- }),
- )
- })?;
users
.add_or_update_user(&new_user.username, &new_user.password, UserRole::Admin)
.map_err(|e| {
@@ -995,6 +975,21 @@ pub async fn first_user(
mod tests {
use super::*;
+ #[test]
+ fn missing_session_is_denied() {
+ assert_eq!(login_result_for_session(None), LoginResult::Denied);
+ }
+
+ #[test]
+ fn authenticated_read_only_session_keeps_read_only_role() {
+ let user = SessionUser {
+ username: "support".to_string(),
+ role: UserRole::ReadOnly,
+ };
+
+ assert_eq!(login_result_for_session(Some(user)), LoginResult::ReadOnly);
+ }
+
#[test]
fn login_rate_limiter_blocks_after_failed_attempt_limit() {
let mut limiter = LoginRateLimiter::new();
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/first-run.js b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js
index a605adb1..d435fe6b 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/first-run.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js
@@ -1,7 +1,6 @@
$("#btnCreateUser").on('click', () => {
let username = ($("#username").val() || "").trim();
let password = $("#password").val();
- let anon = document.getElementById("allowAnonymous").checked;
if (username === "") {
alert("You must enter a username");
return;
@@ -12,7 +11,6 @@ $("#btnCreateUser").on('click', () => {
}
let login = {
- allow_anonymous: anon,
username: username,
password: password
}
diff --git a/src/rust/lqosd/src/node_manager/static2/first-run.html b/src/rust/lqosd/src/node_manager/static2/first-run.html
index 7f4c89f2..3ca744f9 100644
--- a/src/rust/lqosd/src/node_manager/static2/first-run.html
+++ b/src/rust/lqosd/src/node_manager/static2/first-run.html
@@ -42,23 +42,16 @@
This site will use a cookie to store your identification. If that's not ok,
please don't use the site.
- Let's create a new user, and set some parameters:
+ Let's create a new admin user: