Adds an authentication system.

* The new Rust utility "webusers" manages /opt/libreqos/webusers.toml.
* You can add/update/remove/list users from that tool.
* The "allow anonymous" option in webusers.toml permits access for
  unauthenticated users, but won't let them change anything. This is
  for payne demonstrations.
* All web APIs and pages should now be secured, requiring a login.
* The login requires cookies.

Signed-off-by: Herbert Wolverson <herberticus@gmail.com>
This commit is contained in:
Herbert Wolverson
2023-01-09 18:55:14 +00:00
parent 8060a50f0d
commit 165dae030b
26 changed files with 802 additions and 113 deletions

2
.gitignore vendored
View File

@@ -48,6 +48,7 @@ src/tinsStats.json
src/linux_tc.txt
src/lastRun.txt
src/liblqos_python.so
src/webusers.toml
# Ignore Rust build artifacts
src/rust/target
@@ -57,6 +58,7 @@ src/bin/lqtop
src/bin/xdp_iphash_to_cpu_cmdline
src/bin/xdp_pping
src/bin/lqos_node_manager
src/bin/webusers
src/bin/Rocket.toml

View File

@@ -7,7 +7,7 @@
# automatically.
#
# Don't forget to setup `/etc/lqos`
PROGS="lqosd lqtop xdp_iphash_to_cpu_cmdline xdp_pping lqos_node_manager"
PROGS="lqosd lqtop xdp_iphash_to_cpu_cmdline xdp_pping lqos_node_manager webusers"
mkdir -p bin/static
pushd rust
#cargo clean

52
src/rust/Cargo.lock generated
View File

@@ -205,6 +205,15 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.10.3"
@@ -377,7 +386,7 @@ dependencies = [
"hmac",
"percent-encoding",
"rand",
"sha2",
"sha2 0.10.6",
"subtle",
"time",
"version_check",
@@ -598,13 +607,22 @@ dependencies = [
"syn",
]
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array",
]
[[package]]
name = "digest"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f"
dependencies = [
"block-buffer",
"block-buffer 0.10.3",
"crypto-common",
"subtle",
]
@@ -912,7 +930,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.6",
]
[[package]]
@@ -1216,7 +1234,9 @@ dependencies = [
"ip_network",
"ip_network_table",
"serde",
"sha2 0.9.9",
"toml",
"uuid",
]
[[package]]
@@ -2078,6 +2098,19 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if 1.0.0",
"cpufeatures",
"digest 0.9.0",
"opaque-debug",
]
[[package]]
name = "sha2"
version = "0.10.6"
@@ -2086,7 +2119,7 @@ checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0"
dependencies = [
"cfg-if 1.0.0",
"cpufeatures",
"digest",
"digest 0.10.6",
]
[[package]]
@@ -2556,6 +2589,8 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "422ee0de9031b5b948b97a8fc04e3aa35230001a722ddd27943e0be31564ce4c"
dependencies = [
"getrandom",
"rand",
"serde",
]
@@ -2604,6 +2639,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "webusers"
version = "0.1.0"
dependencies = [
"anyhow",
"clap 4.0.29",
"lqos_config",
]
[[package]]
name = "which"
version = "3.1.1"

View File

@@ -20,4 +20,5 @@ members = [
"xdp_pping", # Rust port of cpumap's `xdp_pping` tool, for compatibility
"lqos_node_manager", # A lightweight web interface for management and local monitoring
"lqos_python", # Python bindings for using the Rust bus directly
"webusers", # CLI control for managing the web user list
]

View File

@@ -10,3 +10,5 @@ serde = { version = "1.0", features = [ "derive" ] }
csv = "1"
ip_network_table = "0"
ip_network = "0"
sha2 = "0"
uuid = { version = "1", features = ["v4", "fast-rng" ] }

View File

@@ -0,0 +1,205 @@
use anyhow::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{read_to_string, OpenOptions, remove_file},
io::Write,
path::{Path, PathBuf}, fmt::Display,
};
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum UserRole {
ReadOnly,
Admin,
}
impl From<&str> for UserRole {
fn from(s: &str) -> Self {
let s = s.to_lowercase();
if s == "admin" {
UserRole::Admin
} else {
UserRole::ReadOnly
}
}
}
impl Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserRole::Admin => write!(f, "admin"),
UserRole::ReadOnly => write!(f, "read-only"),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct WebUser {
username: String,
password_hash: String,
role: UserRole,
token: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WebUsers {
allow_unauthenticated_to_view: bool,
users: Vec<WebUser>,
}
impl Default for WebUsers {
fn default() -> Self {
Self {
users: Vec::new(),
allow_unauthenticated_to_view: false,
}
}
}
impl WebUsers {
fn path() -> Result<PathBuf> {
let base_path = crate::EtcLqos::load()?.lqos_directory;
let filename = Path::new(&base_path).join("webusers.toml");
Ok(filename)
}
fn save_to_disk(&self) -> Result<()> {
let path = Self::path()?;
let new_contents = toml::to_string(&self)?;
if path.exists() {
remove_file(&path)?;
}
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(&new_contents.as_bytes())?;
Ok(())
}
pub fn does_users_file_exist() -> Result<bool> {
Ok(Self::path()?.exists())
}
pub fn load_or_create() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
// Create a new users file, save it and return the
// empty file
let new_users = Self::default();
new_users.save_to_disk()?;
Ok(new_users)
} else {
// Load from disk
let raw = read_to_string(path)?;
let users = toml::from_str(&raw)?;
Ok(users)
}
}
fn hash_password(password: &str) -> String {
let salted = format!("!x{password}_LibreQosLikesPasswordsForDinner");
let mut sha256 = Sha256::new();
sha256.update(salted);
format!("{:X}", sha256.finalize())
}
pub fn add_or_update_user(
&mut self,
username: &str,
password: &str,
role: UserRole,
) -> Result<String> {
let token ; // Assigned in a branch
if let Some(mut user) = self.users.iter_mut().find(|u| u.username == username) {
user.password_hash = Self::hash_password(password);
user.role = role;
token = user.token.clone();
} else {
token = Uuid::new_v4().to_string();
let new_user = WebUser {
username: username.to_string(),
password_hash: Self::hash_password(password),
role,
token: token.clone(),
};
self.users.push(new_user);
}
self.save_to_disk()?;
Ok(token)
}
pub fn remove_user(&mut self, username: &str) -> Result<()> {
let old_len = self.users.len();
self.users.retain(|u| u.username != username);
if old_len == self.users.len() {
return Err(Error::msg(format!("User {} was not found", username)));
}
self.save_to_disk()?;
Ok(())
}
pub fn login(&self, username: &str, password: &str) -> Result<String> {
let hash = Self::hash_password(password);
if let Some(user) = self
.users
.iter()
.find(|u| u.username == username && u.password_hash == hash)
{
Ok(user.token.clone())
} else {
if self.allow_unauthenticated_to_view {
Ok("default".to_string())
} else {
Err(Error::msg("Invalid Login"))
}
}
}
pub fn get_role_from_token(&self, token: &str) -> Result<UserRole> {
if let Some(user) = self
.users
.iter()
.find(|u| u.token == token)
{
Ok(user.role)
} else {
if self.allow_unauthenticated_to_view {
Ok(UserRole::ReadOnly)
} else {
Err(Error::msg("Unknown user token"))
}
}
}
pub fn get_username(&self, token: &str) -> String {
if let Some(user) = self
.users
.iter()
.find(|u| u.token == token)
{
user.username.clone()
} else {
"Anonymous".to_string()
}
}
pub fn print_users(&self) -> Result<()> {
self
.users
.iter()
.for_each(|u| {
println!("{:<40} {:<10}", u.username, u.role.to_string());
});
Ok(())
}
pub fn allow_anonymous(&mut self, allow: bool) -> Result<()> {
self.allow_unauthenticated_to_view = allow;
self.save_to_disk()?;
Ok(())
}
pub fn do_we_allow_anonymous(&self) -> bool {
self.allow_unauthenticated_to_view
}
}

View File

@@ -2,8 +2,10 @@ mod etc;
mod libre_qos_config;
mod shaped_devices;
mod program_control;
mod authentication;
pub use libre_qos_config::LibreQoSConfig;
pub use shaped_devices::{ConfigShapedDevices, ShapedDevice};
pub use program_control::load_libreqos;
pub use etc::{EtcLqos, BridgeConfig, Tunables, BridgeInterface, BridgeVlan};
pub use authentication::{WebUsers, UserRole};

View File

@@ -0,0 +1,128 @@
use anyhow::Error;
use lazy_static::*;
use lqos_config::{UserRole, WebUsers};
use parking_lot::Mutex;
use rocket::{
http::{Status, CookieJar, Cookie},
request::{FromRequest, Outcome},
Request
};
use rocket::serde::{json::Json, Serialize, Deserialize};
lazy_static! {
static ref WEB_USERS: Mutex<Option<WebUsers>> = Mutex::new(None);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthGuard {
Admin,
ReadOnly,
FirstUse,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AuthGuard {
type Error = anyhow::Error; // Decorated because Error=Error looks odd
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let mut lock = WEB_USERS.lock();
if lock.is_none() {
if WebUsers::does_users_file_exist().unwrap() {
*lock = Some(WebUsers::load_or_create().unwrap());
} else {
// There is no user list, so we're redirecting to the
// new user page.
return Outcome::Success(AuthGuard::FirstUse);
}
}
if let Some(users) = &*lock {
if let Some(token) = request.cookies().get("User-Token") {
match users.get_role_from_token(token.value()) {
Ok(UserRole::Admin) => return Outcome::Success(AuthGuard::Admin),
Ok(UserRole::ReadOnly) => return Outcome::Success(AuthGuard::ReadOnly),
_ => {
return Outcome::Failure((
Status::Unauthorized,
Error::msg("Invalid token"),
))
}
}
} else {
// If no login, do we allow anonymous?
if users.do_we_allow_anonymous() {
return Outcome::Success(AuthGuard::ReadOnly);
}
}
}
Outcome::Failure((Status::Unauthorized, Error::msg("Access Denied")))
}
}
impl AuthGuard {}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(crate = "rocket::serde")]
pub struct FirstUser {
pub allow_anonymous: bool,
pub username: String,
pub password: String,
}
#[post("/api/create_first_user", data="<info>")]
pub fn create_first_user(cookies: &CookieJar, info: Json<FirstUser>) -> Json<String> {
if WebUsers::does_users_file_exist().unwrap() {
return Json("ERROR".to_string());
}
let mut lock = WEB_USERS.lock();
let mut users = WebUsers::load_or_create().unwrap();
users.allow_anonymous(info.allow_anonymous).unwrap();
let token = users.add_or_update_user(&info.username, &info.password, UserRole::Admin).unwrap();
cookies.add(Cookie::new("User-Token", token));
*lock = Some(users);
Json("OK".to_string())
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(crate = "rocket::serde")]
pub struct LoginAttempt {
pub username: String,
pub password: String,
}
#[post("/api/login", data="<info>")]
pub fn login(cookies: &CookieJar, info: Json<LoginAttempt>) -> Json<String> {
let mut lock = WEB_USERS.lock();
if lock.is_none() {
if WebUsers::does_users_file_exist().unwrap() {
*lock = Some(WebUsers::load_or_create().unwrap());
}
}
if let Some(users) = &*lock {
if let Ok(token) = users.login(&info.username, &info.password) {
cookies.add(Cookie::new("User-Token", token));
return Json("OK".to_string());
}
}
Json("ERROR".to_string())
}
#[get("/api/admin_check")]
pub fn admin_check(auth: AuthGuard) -> Json<bool> {
match auth {
AuthGuard::Admin => Json(true),
_ => Json(false)
}
}
#[get("/api/username")]
pub fn username(auth: AuthGuard, cookies: &CookieJar) -> Json<String> {
if let Some(token) = cookies.get("User-Token") {
let lock = WEB_USERS.lock();
if let Some(users) = &*lock {
return Json(users.get_username(token.value()));
}
}
Json("Anonymous".to_string())
}

View File

@@ -1,17 +1,17 @@
use default_net::get_interfaces;
use lqos_config::{LibreQoSConfig, EtcLqos};
use rocket::{fs::NamedFile, serde::json::Json};
use crate::cache_control::NoCache;
use crate::{cache_control::NoCache, auth_guard::AuthGuard};
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/config")]
pub async fn config_page<'a>() -> NoCache<Option<NamedFile>> {
pub async fn config_page<'a>(_auth: AuthGuard) -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/config.html").await.ok())
}
#[get("/api/list_nics")]
pub async fn get_nic_list<'a>() -> NoCache<Json<Vec<(String, String, String)>>> {
pub async fn get_nic_list<'a>(_auth: AuthGuard) -> NoCache<Json<Vec<(String, String, String)>>> {
let mut result = Vec::new();
for eth in get_interfaces().iter() {
let mac = if let Some(mac) = &eth.mac_addr {
@@ -29,14 +29,14 @@ pub async fn get_nic_list<'a>() -> NoCache<Json<Vec<(String, String, String)>>>
}
#[get("/api/python_config")]
pub async fn get_current_python_config() -> NoCache<Json<LibreQoSConfig>> {
pub async fn get_current_python_config(_auth: AuthGuard) -> NoCache<Json<LibreQoSConfig>> {
let config = lqos_config::LibreQoSConfig::load().unwrap();
println!("{:#?}", config);
NoCache::new(Json(config))
}
#[get("/api/lqosd_config")]
pub async fn get_current_lqosd_config() -> NoCache<Json<EtcLqos>> {
pub async fn get_current_lqosd_config(_auth: AuthGuard) -> NoCache<Json<EtcLqos>> {
let config = lqos_config::EtcLqos::load().unwrap();
println!("{:#?}", config);
NoCache::new(Json(config))

View File

@@ -8,6 +8,7 @@ mod cache_control;
use rocket_async_compression::Compression;
mod queue_info;
mod config_control;
mod auth_guard;
#[launch]
fn rocket() -> _ {
@@ -18,6 +19,7 @@ fn rocket() -> _ {
rocket::tokio::spawn(tracker::update_tracking());
})
}))
.register("/", catchers![static_pages::login])
.mount("/", routes![
static_pages::index,
static_pages::shaped_devices_csv_page,
@@ -54,6 +56,11 @@ fn rocket() -> _ {
config_control::get_nic_list,
config_control::get_current_python_config,
config_control::get_current_lqosd_config,
auth_guard::create_first_user,
auth_guard::login,
auth_guard::admin_check,
static_pages::login_page,
auth_guard::username,
// Supporting files
static_pages::bootsrap_css,

View File

@@ -3,12 +3,13 @@ use rocket::response::content::RawJson;
use rocket::serde::json::Json;
use rocket::tokio::io::{AsyncWriteExt, AsyncReadExt};
use rocket::tokio::net::TcpStream;
use crate::auth_guard::AuthGuard;
use crate::cache_control::NoCache;
use crate::tracker::SHAPED_DEVICES;
use std::net::IpAddr;
#[get("/api/circuit_name/<circuit_id>")]
pub async fn circuit_name(circuit_id: String) -> NoCache<Json<String>> {
pub async fn circuit_name(circuit_id: String, _auth: AuthGuard) -> NoCache<Json<String>> {
if let Some(device) = SHAPED_DEVICES.read().devices.iter().find(|d| d.circuit_id == circuit_id) {
NoCache::new(Json(device.circuit_name.clone()))
} else {
@@ -18,7 +19,7 @@ pub async fn circuit_name(circuit_id: String) -> NoCache<Json<String>> {
}
#[get("/api/circuit_throughput/<circuit_id>")]
pub async fn current_circuit_throughput(circuit_id: String) -> NoCache<Json<Vec<(String, u64, u64)>>> {
pub async fn current_circuit_throughput(circuit_id: String, _auth: AuthGuard) -> NoCache<Json<Vec<(String, u64, u64)>>> {
let mut result = Vec::new();
// Get a list of host counts
// This is really inefficient, but I'm struggling to find a better way.
@@ -65,7 +66,7 @@ pub async fn current_circuit_throughput(circuit_id: String) -> NoCache<Json<Vec<
}
#[get("/api/raw_queue_by_circuit/<circuit_id>")]
pub async fn raw_queue_by_circuit(circuit_id: String) -> NoCache<RawJson<String>> {
pub async fn raw_queue_by_circuit(circuit_id: String, _auth: AuthGuard) -> NoCache<RawJson<String>> {
let mut stream = TcpStream::connect(BUS_BIND_ADDRESS).await.unwrap();
let test = BusSession {
auth_cookie: 1234,

View File

@@ -3,6 +3,7 @@ use lqos_config::ShapedDevice;
use rocket::serde::json::Json;
use rocket::tokio::io::{AsyncWriteExt, AsyncReadExt};
use rocket::tokio::net::TcpStream;
use crate::auth_guard::AuthGuard;
use crate::cache_control::NoCache;
use crate::tracker::SHAPED_DEVICES;
use lazy_static::*;
@@ -13,24 +14,24 @@ lazy_static! {
}
#[get("/api/all_shaped_devices")]
pub fn all_shaped_devices() -> NoCache<Json<Vec<ShapedDevice>>> {
pub fn all_shaped_devices(_auth: AuthGuard) -> NoCache<Json<Vec<ShapedDevice>>> {
NoCache::new(Json(SHAPED_DEVICES.read().devices.clone()))
}
#[get("/api/shaped_devices_count")]
pub fn shaped_devices_count() -> NoCache<Json<usize>> {
pub fn shaped_devices_count(_auth: AuthGuard) -> NoCache<Json<usize>> {
NoCache::new(Json(SHAPED_DEVICES.read().devices.len()))
}
#[get("/api/shaped_devices_range/<start>/<end>")]
pub fn shaped_devices_range(start: usize, end: usize) -> NoCache<Json<Vec<ShapedDevice>>> {
pub fn shaped_devices_range(start: usize, end: usize, _auth: AuthGuard) -> NoCache<Json<Vec<ShapedDevice>>> {
let reader = SHAPED_DEVICES.read();
let result: Vec<ShapedDevice> = reader.devices.iter().skip(start).take(end).cloned().collect();
NoCache::new(Json(result))
}
#[get("/api/shaped_devices_search/<term>")]
pub fn shaped_devices_search(term: String) -> NoCache<Json<Vec<ShapedDevice>>> {
pub fn shaped_devices_search(term: String, _auth: AuthGuard) -> NoCache<Json<Vec<ShapedDevice>>> {
let term = term.trim().to_lowercase();
let reader = SHAPED_DEVICES.read();
let result: Vec<ShapedDevice> = reader
@@ -51,7 +52,10 @@ pub fn reload_required() -> NoCache<Json<bool>> {
}
#[get("/api/reload_libreqos")]
pub async fn reload_libreqos() -> NoCache<Json<String>> {
pub async fn reload_libreqos(auth: AuthGuard) -> NoCache<Json<String>> {
if auth != AuthGuard::Admin {
return NoCache::new(Json("Not authorized".to_string()));
}
// Send request to lqosd
let mut stream = TcpStream::connect(BUS_BIND_ADDRESS).await.unwrap();
let test = BusSession {

View File

@@ -1,38 +1,55 @@
use rocket::fs::NamedFile;
use crate::cache_control::{LongCache, NoCache};
use crate::{cache_control::{LongCache, NoCache}, auth_guard::AuthGuard};
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/")]
pub async fn index<'a>() -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/main.html").await.ok())
pub async fn index<'a>(auth: AuthGuard) -> NoCache<Option<NamedFile>> {
match auth {
AuthGuard::FirstUse => NoCache::new(NamedFile::open("static/first_run.html").await.ok()),
_ => NoCache::new(NamedFile::open("static/main.html").await.ok())
}
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[catch(401)]
pub async fn login<'a>() -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/login.html").await.ok())
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/login")]
pub async fn login_page<'a>() -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/login.html").await.ok())
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/shaped")]
pub async fn shaped_devices_csv_page<'a>() -> NoCache<Option<NamedFile>> {
pub async fn shaped_devices_csv_page<'a>(_auth: AuthGuard) -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/shaped.html").await.ok())
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/circuit_queue")]
pub async fn circuit_queue<'a>() -> NoCache<Option<NamedFile>> {
pub async fn circuit_queue<'a>(_auth: AuthGuard) -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/circuit_queue.html").await.ok())
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/unknown")]
pub async fn unknown_devices_page<'a>() -> NoCache<Option<NamedFile>> {
pub async fn unknown_devices_page<'a>(_auth: AuthGuard) -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/unknown-ip.html").await.ok())
}
// Note that NoCache can be replaced with a cache option
// once the design work is complete.
#[get("/shaped-add")]
pub async fn shaped_devices_add_page<'a>() -> NoCache<Option<NamedFile>> {
pub async fn shaped_devices_add_page<'a>(_auth: AuthGuard) -> NoCache<Option<NamedFile>> {
NoCache::new(NamedFile::open("static/shaped-add.html").await.ok())
}

View File

@@ -5,7 +5,7 @@ pub use cache_manager::update_tracking;
use std::net::IpAddr;
use lqos_bus::{IpStats, TcHandle};
use rocket::serde::{json::Json, Serialize, Deserialize};
use crate::tracker::cache::ThroughputPerSecond;
use crate::{tracker::cache::ThroughputPerSecond, auth_guard::AuthGuard};
use self::cache::{CURRENT_THROUGHPUT, THROUGHPUT_BUFFER, CPU_USAGE, MEMORY_USAGE, TOP_10_DOWNLOADERS, WORST_10_RTT, RTT_HISTOGRAM, HOST_COUNTS};
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -49,50 +49,50 @@ impl From<&IpStats> for IpStatsWithPlan {
}
#[get("/api/current_throughput")]
pub fn current_throughput() -> Json<ThroughputPerSecond> {
pub fn current_throughput(_auth: AuthGuard) -> Json<ThroughputPerSecond> {
let result = CURRENT_THROUGHPUT.read().clone();
Json(result)
}
#[get("/api/throughput_ring")]
pub fn throughput_ring() -> Json<Vec<ThroughputPerSecond>> {
pub fn throughput_ring(_auth: AuthGuard) -> Json<Vec<ThroughputPerSecond>> {
let result = THROUGHPUT_BUFFER.read().get_result();
Json(result)
}
#[get("/api/cpu")]
pub fn cpu_usage() -> Json<Vec<f32>> {
pub fn cpu_usage(_auth: AuthGuard) -> Json<Vec<f32>> {
let cpu_usage = CPU_USAGE.read().clone();
Json(cpu_usage)
}
#[get("/api/ram")]
pub fn ram_usage() -> Json<Vec<u64>> {
pub fn ram_usage(_auth: AuthGuard) -> Json<Vec<u64>> {
let ram_usage = MEMORY_USAGE.read().clone();
Json(ram_usage)
}
#[get("/api/top_10_downloaders")]
pub fn top_10_downloaders() -> Json<Vec<IpStatsWithPlan>> {
pub fn top_10_downloaders(_auth: AuthGuard) -> Json<Vec<IpStatsWithPlan>> {
let tt : Vec<IpStatsWithPlan> = TOP_10_DOWNLOADERS.read().iter().map(|tt| tt.into()).collect();
Json(tt)
}
#[get("/api/worst_10_rtt")]
pub fn worst_10_rtt() -> Json<Vec<IpStatsWithPlan>> {
pub fn worst_10_rtt(_auth: AuthGuard) -> Json<Vec<IpStatsWithPlan>> {
let tt : Vec<IpStatsWithPlan> = WORST_10_RTT.read().iter().map(|tt| tt.into()).collect();
Json(tt)
}
#[get("/api/rtt_histogram")]
pub fn rtt_histogram() -> Json<Vec<u32>> {
pub fn rtt_histogram(_auth: AuthGuard) -> Json<Vec<u32>> {
Json(RTT_HISTOGRAM.read().clone())
}
#[get("/api/host_counts")]
pub fn host_counts() -> Json<(u32, u32)> {
pub fn host_counts(_auth: AuthGuard) -> Json<(u32, u32)> {
let shaped_reader = SHAPED_DEVICES.read();
let n_devices = shaped_reader.devices.len();
let host_counts = HOST_COUNTS.read();

View File

@@ -1,19 +1,19 @@
use lqos_bus::IpStats;
use rocket::serde::json::Json;
use crate::{cache_control::NoCache, tracker::UNKNOWN_DEVICES};
use crate::{cache_control::NoCache, tracker::UNKNOWN_DEVICES, auth_guard::AuthGuard};
#[get("/api/all_unknown_devices")]
pub fn all_unknown_devices() -> NoCache<Json<Vec<IpStats>>> {
pub fn all_unknown_devices(_auth: AuthGuard) -> NoCache<Json<Vec<IpStats>>> {
NoCache::new(Json(UNKNOWN_DEVICES.read().clone()))
}
#[get("/api/unknown_devices_count")]
pub fn unknown_devices_count() -> NoCache<Json<usize>> {
pub fn unknown_devices_count(_auth: AuthGuard) -> NoCache<Json<usize>> {
NoCache::new(Json(UNKNOWN_DEVICES.read().len()))
}
#[get("/api/unknown_devices_range/<start>/<end>")]
pub fn unknown_devices_range(start: usize, end: usize) -> NoCache<Json<Vec<IpStats>>> {
pub fn unknown_devices_range(start: usize, end: usize, _auth: AuthGuard) -> NoCache<Json<Vec<IpStats>>> {
let reader = UNKNOWN_DEVICES.read();
let result: Vec<IpStats> = reader.iter().skip(start).take(end).cloned().collect();
NoCache::new(Json(result))

View File

@@ -23,6 +23,7 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>

View File

@@ -23,7 +23,7 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>
@@ -56,7 +56,7 @@
<div class="card-body">
<h5 class="card-title"><i class="fa fa-users"></i> Configuration</h5>
<div class="col-sm-8 mx-auto" style="padding: 4px; margin-bottom: 4px;">
<div class="col-sm-8 mx-auto" style="padding: 4px; margin-bottom: 4px;" id="controls">
<a href="#" class="btn btn-primary"><i class="fa fa-save"></i> Save ispConfig.py</a>&nbsp;
<a href="#" class="btn btn-danger"><i class="fa fa-save"></i> Save /etc/lqos</a>&nbsp;
<a href="#" class="btn btn-primary"><i class="fa fa-refresh"></i> Reload LibreQoS</a>&nbsp;
@@ -71,6 +71,7 @@
<button class="nav-link" id="v-pills-tuning-tab" data-bs-toggle="pill" data-bs-target="#v-pills-tuning" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false"><i class="fa fa-warning"></i> Tuning</button>
<button class="nav-link" id="v-pills-spylnx-tab" data-bs-toggle="pill" data-bs-target="#v-pills-spylnx" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false"><i class="fa fa-eye"></i> Spylnx</button>
<button class="nav-link" id="v-pills-uisp-tab" data-bs-toggle="pill" data-bs-target="#v-pills-uisp" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false"><i class="fa fa-eye"></i> UISP</button>
<button class="nav-link" id="v-pills-users-tab" data-bs-toggle="pill" data-bs-target="#v-pills-users" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false"><i class="fa fa-users"></i> LibreQoS Users</button>
</div>
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
@@ -353,6 +354,10 @@
UISP Settings
...
</div>
<div class="tab-pane fade" id="v-pills-users" role="tabpanel" aria-labelledby="v-pills-users-tab">
<h2><i class="fa fa-users"></i> LibreQos Web Interface Users</h2>
<div id="userManager"></div>
</div>
</div>
</div>
@@ -374,84 +379,100 @@
function start() {
colorReloadButton();
updateHostCounts();
$.get("/api/python_config", (data) => {
python_config = data;
$.get("/api/lqosd_config", (data) => {
lqosd_config = data;
$.get("/api/list_nics", (data) => {
nics = data;
console.log(nics);
fillNicList("nicCore", python_config.isp_interface);
fillNicList("nicInternet", python_config.internet_interface);
$.get("/api/admin_check", (is_admin) => {
if (!is_admin) {
$("#controls").html("<p class='alert alert-danger' role='alert'>You have to be an administrative user to change configuration.");
$("#userManager").html("<p class='alert alert-danger' role='alert'>Only administrators can see/change user information.");
}
$.get("/api/python_config", (data) => {
python_config = data;
$.get("/api/lqosd_config", (data) => {
lqosd_config = data;
$.get("/api/list_nics", (data) => {
nics = data;
console.log(nics);
fillNicList("nicCore", python_config.isp_interface);
fillNicList("nicInternet", python_config.internet_interface);
$("#onAStick").prop('checked', python_config.on_a_stick_mode);
$("#StickVLANCore").val(python_config.stick_vlans[0]);
$("#StickVLANInternet").val(python_config.stick_vlans[1]);
if (lqosd_config.bridge != null) {
$("#useKernelBridge").prop('checked', lqosd_config.bridge.use_kernel_bridge);
$("#onAStick").prop('checked', python_config.on_a_stick_mode);
$("#StickVLANCore").val(python_config.stick_vlans[0]);
$("#StickVLANInternet").val(python_config.stick_vlans[1]);
if (lqosd_config.bridge != null) {
$("#useKernelBridge").prop('checked', lqosd_config.bridge.use_kernel_bridge);
// Map Bifrost Interfaces
let html = "<h4>Interface Mapping</h4>";
html += "<table class='table'>";
html += "<thead><th>Input Interface</th><th>Output Interface</th><th>Scan VLANs?</th></thead>";
html += "<tbody>";
for (let i=0; i<lqosd_config.bridge.interface_mapping.length; i++) {
html += "<tr>";
html += "<td>" + buildNICList('bfIn_' + i, lqosd_config.bridge.interface_mapping[i].name) + "</td>";
html += "<td>" + buildNICList('bfOut_' + i, lqosd_config.bridge.interface_mapping[i].redirect_to) + "</td>";
html += "<td><input type='checkbox' class='form-check-input' id='bfScanVLAN_" + i + "'";
if (lqosd_config.bridge.interface_mapping[i].scan_vlans) {
html += ' checked';
// Map Bifrost Interfaces
let html = "<h4>Interface Mapping</h4>";
html += "<table class='table'>";
html += "<thead><th>Input Interface</th><th>Output Interface</th><th>Scan VLANs?</th></thead>";
html += "<tbody>";
for (let i=0; i<lqosd_config.bridge.interface_mapping.length; i++) {
html += "<tr>";
html += "<td>" + buildNICList('bfIn_' + i, lqosd_config.bridge.interface_mapping[i].name) + "</td>";
html += "<td>" + buildNICList('bfOut_' + i, lqosd_config.bridge.interface_mapping[i].redirect_to) + "</td>";
html += "<td><input type='checkbox' class='form-check-input' id='bfScanVLAN_" + i + "'";
if (lqosd_config.bridge.interface_mapping[i].scan_vlans) {
html += ' checked';
}
html += "/></td>";
html += "</tr>";
}
html += "/></td>";
html += "</tr>";
}
html += "</tbody></table>";
$("#bifrostInterfaces").html(html);
html += "</tbody></table>";
$("#bifrostInterfaces").html(html);
// Map Bifrost VLAN mappings
html = "<h4>VLAN Mapping</h4>";
html += "<table class='table'>";
html += "<thead><th>Parent Interface</th><th>Input Tag</th><th>Remapped Tag</th></thead>";
html += "<tbody>";
for (let i=0; i<lqosd_config.bridge.vlan_mapping.length; i++) {
html += "<tr>";
html += "<td>" + buildNICList('bfvlanif_' + i, lqosd_config.bridge.vlan_mapping[i].parent) + "</td>";
html += "<td><input id='bfvlantag_" + i + "' type='number' min='0' max='4094' value='" + lqosd_config.bridge.vlan_mapping[i].tag + "' /></td>";
html += "<td><input id='bfvlanout_" + i + "' type='number' min='0' max='4094' value='" + lqosd_config.bridge.vlan_mapping[i].redirect_to + "' /></td>";
html += "</tr>";
// Map Bifrost VLAN mappings
html = "<h4>VLAN Mapping</h4>";
html += "<table class='table'>";
html += "<thead><th>Parent Interface</th><th>Input Tag</th><th>Remapped Tag</th></thead>";
html += "<tbody>";
for (let i=0; i<lqosd_config.bridge.vlan_mapping.length; i++) {
html += "<tr>";
html += "<td>" + buildNICList('bfvlanif_' + i, lqosd_config.bridge.vlan_mapping[i].parent) + "</td>";
html += "<td><input id='bfvlantag_" + i + "' type='number' min='0' max='4094' value='" + lqosd_config.bridge.vlan_mapping[i].tag + "' /></td>";
html += "<td><input id='bfvlanout_" + i + "' type='number' min='0' max='4094' value='" + lqosd_config.bridge.vlan_mapping[i].redirect_to + "' /></td>";
html += "</tr>";
}
html += "</tbody></table>";
$("#bifrostVlans").html(html);
}
html += "</tbody></table>";
$("#bifrostVlans").html(html);
}
$("#sqmMode option[value='" + python_config.sqm + "']").prop("selected", true);
$("#maxDownload").val(python_config.total_download_mbps);
$("#maxUpload").val(python_config.total_upload_mbps);
$("#monitorMode").prop('checked', python_config.monitor_mode);
$("#generatedDownload").val(python_config.generated_download_mbps);
$("#generatedUpload").val(python_config.generated_upload_mbps);
$("#binpacking").prop('checked', python_config.use_binpacking);
$("#queuecheckms").val(lqosd_config.queue_check_period_ms);
$("#actualShellCommands").prop('checked', python_config.enable_shell_commands);
$("#useSudo").prop('checked', python_config.run_as_sudo);
$("#overrideQueues").val(python_config.override_queue_count);
$("#stopIrqBalance").prop('checked', lqosd_config.tuning.stop_irq_balance);
$("#netDevUsec").val(lqosd_config.tuning.netdev_budget_usecs);
$("#netDevPackets").val(lqosd_config.tuning.netdev_budget_packets);
$("#rxUsecs").val(lqosd_config.tuning.rx_usecs);
$("#txUsecs").val(lqosd_config.tuning.tx_usecs);
$("#disableRxVlan").prop('checked', lqosd_config.tuning.disable_rxvlan);
$("#disableTxVlan").prop('checked', lqosd_config.tuning.disable_txvlan);
let offloads = "";
for (let i=0; i<lqosd_config.tuning.disable_offload.length; i++) {
offloads += lqosd_config.tuning.disable_offload[i] + " ";
}
$("#disableOffloadList").val(offloads);
});
});
$("#sqmMode option[value='" + python_config.sqm + "']").prop("selected", true);
$("#maxDownload").val(python_config.total_download_mbps);
$("#maxUpload").val(python_config.total_upload_mbps);
$("#monitorMode").prop('checked', python_config.monitor_mode);
$("#generatedDownload").val(python_config.generated_download_mbps);
$("#generatedUpload").val(python_config.generated_upload_mbps);
$("#binpacking").prop('checked', python_config.use_binpacking);
$("#queuecheckms").val(lqosd_config.queue_check_period_ms);
$("#actualShellCommands").prop('checked', python_config.enable_shell_commands);
$("#useSudo").prop('checked', python_config.run_as_sudo);
$("#overrideQueues").val(python_config.override_queue_count);
$("#stopIrqBalance").prop('checked', lqosd_config.tuning.stop_irq_balance);
$("#netDevUsec").val(lqosd_config.tuning.netdev_budget_usecs);
$("#netDevPackets").val(lqosd_config.tuning.netdev_budget_packets);
$("#rxUsecs").val(lqosd_config.tuning.rx_usecs);
$("#txUsecs").val(lqosd_config.tuning.tx_usecs);
$("#disableRxVlan").prop('checked', lqosd_config.tuning.disable_rxvlan);
$("#disableTxVlan").prop('checked', lqosd_config.tuning.disable_txvlan);
let offloads = "";
for (let i=0; i<lqosd_config.tuning.disable_offload.length; i++) {
offloads += lqosd_config.tuning.disable_offload[i] + " ";
}
$("#disableOffloadList").val(offloads);
// User management
if (is_admin) {
userManager();
}
});
});
});
});
}
function userManager() {
let html = "<p>For now, please use <em>bin/webusers</em> to manage users.</p>";
$("#userManager").html(html);
}
function fillNicList(id, selected) {
let select = $("#" + id);
let html = "";

View File

@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/vendor/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/vendor/solid.min.css">
<title>LibreQoS - Local Node Manager</title>
<script src="/lqos.js"></script>
<script src="/vendor/plotly-2.16.1.min.js"></script>
<script src="/vendor/jquery.min.js"></script>
</head>
<body class="bg-secondary">
<div id="container" style="padding: 4px;">
<div class="row">
<div class="col-sm-4"></div>
<div class="col-sm-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">First Login</h5>
<p>
No <em>webusers.toml</em> file was found. This is probably the first time you've run
the LibreQoS web system. If it isn't, then please check permissions on that file and
use the "bin/webusers" command to verify that your system is working.
</p>
<p class="alert alert-warning" role="alert">
This site will use a cookie to store your identification. If that's not ok,
please don't use the site.
</p>
<p>Let's create a new user, and set some parameters:</p>
<table class="table">
<tr>
<td colspan="2">
<input class="form-check-input" type="checkbox" value="" id="allowAnonymous">
<label class="form-check-label" for="allowAnonymous">
Allow anonymous users to view (but not change) settings.
</label>
</td>
</tr><tr>
<td>
Your Username
</td>
<td>
<input type="text" id="username" />
</td>
</tr>
<tr>
<td>Your password</td>
<td><input type="password" id="password" /></td>
</tr>
</table>
<a class="btn btn-primary" id="btnCreateUser">Create User Account</a>
</div>
</div>
</div>
<div class="col-sm-4"></div>
</div>
</div>
<footer>Copyright (c) 2022, LibreQoE LLC</footer>
<script>
function start() {
$("#btnCreateUser").on('click', (data) => {
let newUser = {
allow_anonymous: $("#allowAnonymous").prop('checked'),
username: $("#username").val(),
password: $("#password").val(),
};
$.ajax({
type: "POST",
url: "/api/create_first_user",
data: JSON.stringify(newUser),
success: (data) => {
if (data == "ERROR") {
alert("Unable to create a first user.")
} else {
window.location.href = "/";
}
}
})
});
}
$(document).ready(start);
</script>
<!-- Leave to last -->
<script src="vendor/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/vendor/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/vendor/solid.min.css">
<title>LibreQoS - Local Node Manager</title>
<script src="/lqos.js"></script>
<script src="/vendor/plotly-2.16.1.min.js"></script>
<script src="/vendor/jquery.min.js"></script>
</head>
<body class="bg-secondary">
<div id="container" style="padding: 4px;">
<div class="row">
<div class="col-sm-4"></div>
<div class="col-sm-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p>Please enter a username and password to access LibreQoS.</p>
<p>You can control access locally with <em>bin/webusers</em> from the console.</p>
<table class="table">
<tr>
<td>Username</td>
<td><input type="text" id="username" /></td>
</tr>
<tr>
<td>Password</td>
<td><input type="password" id="password" /></td>
</tr>
</table>
<a class="btn btn-primary" id="btnLogin">Login</a>
</div>
</div>
</div>
<div class="col-sm-4"></div>
</div>
</div>
<footer>Copyright (c) 2022, LibreQoE LLC</footer>
<script>
function start() {
$("#btnLogin").on('click', (data) => {
let newUser = {
username: $("#username").val(),
password: $("#password").val(),
};
$.ajax({
type: "POST",
url: "/api/login",
data: JSON.stringify(newUser),
success: (data) => {
if (data == "ERROR") {
alert("Invalid login")
} else {
window.location.href = "/";
}
}
})
});
}
$(document).ready(start);
</script>
<!-- Leave to last -->
<script src="vendor/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -44,12 +44,33 @@ function bindColorToggle() {
});
}
function deleteAllCookies() {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
}
window.location.reload();
}
function updateHostCounts() {
$.get("/api/host_counts", (hc) => {
$("#shapedCount").text(hc[0]);
$("#unshapedCount").text(hc[1]);
setTimeout(updateHostCounts, 5000);
});
$.get("/api/username", (un) => {
let html = "";
if (un == "Anonymous") {
html = "<a class='nav-link' href='/login'><i class='fa fa-user'></i> Login</a>";
} else {
html = "<a class='nav-link' onclick='deleteAllCookies();'><i class='fa fa-user'></i> Logout " + un + "</a>";
}
$("#currentLogin").html(html);
})
}
function colorReloadButton() {

View File

@@ -23,6 +23,7 @@
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>

View File

@@ -23,7 +23,7 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>

View File

@@ -23,7 +23,7 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>

View File

@@ -23,7 +23,7 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="fa fa-home"></i> Dashboard</a>
</li>
<li class="nav-item" id="currentLogin"></li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-globe"></i> Network Layout</a>
</li>

View File

@@ -0,0 +1,9 @@
[package]
name = "webusers"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
lqos_config = { path = "../lqos_config" }
anyhow = "1"

View File

@@ -0,0 +1,59 @@
use std::process::exit;
use clap::{Parser, Subcommand};
use anyhow::Result;
use lqos_config::{WebUsers, UserRole};
#[derive(Parser)]
#[command()]
struct Args {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Add or update a user
Add {
/// Username
#[arg(long)]
username: String,
/// Role
#[arg(long)]
role: UserRole,
/// CPU id to connect
#[arg(long)]
password: String,
},
/// Remove a user
Del {
/// Username to remove
username: String,
},
/// List all mapped IPs.
List,
}
fn main() -> Result<()> {
let cli = Args::parse();
let mut users = WebUsers::load_or_create()?;
match cli.command {
Some(Commands::Add { username, role, password }) => {
users.add_or_update_user(&username, &password, role)?;
}
Some(Commands::Del { username }) => {
users.remove_user(&username)?;
}
Some(Commands::List) => {
println!("All Users\n");
users.print_users()?;
}
None => {
println!("Run with --help to see instructions");
exit(0);
}
}
Ok(())
}