mirror of
https://github.com/dani-garcia/vaultwarden
synced 2024-11-24 21:03:05 +00:00
Admin token Argon2 hashing support
Added support for Argon2 hashing support for the `ADMIN_TOKEN` instead of only supporting a plain text string. The hash must be a PHC string which can be generated via the `argon2` CLI **or** via the also built-in hash command in Vaultwarden. You can simply run `vaultwarden hash` to generate a hash based upon a password the user provides them self. Added a warning during startup and within the admin settings panel is the `ADMIN_TOKEN` is not an Argon2 hash. Within the admin environment a user can ignore that warning and it will not be shown for at least 30 days. After that the warning will appear again unless the `ADMIN_TOKEN` has be converted to an Argon2 hash. I have also tested this on my RaspberryPi 2b and there the `Bitwarden` preset takes almost 4.5 seconds to generate/verify the Argon2 hash. Using the `OWASP` preset it is below 1 second, which I think should be fine for low-graded hardware. If it is needed people could use lower memory settings, but in those cases I even doubt Vaultwarden it self would run. They can always use the `argon2` CLI and generate a faster hash.
This commit is contained in:
parent
337cbfaf22
commit
de157b2654
8 changed files with 240 additions and 20 deletions
|
@ -259,9 +259,13 @@
|
||||||
## A comma-separated list means only those users can create orgs:
|
## A comma-separated list means only those users can create orgs:
|
||||||
# ORG_CREATION_USERS=admin1@example.com,admin2@example.com
|
# ORG_CREATION_USERS=admin1@example.com,admin2@example.com
|
||||||
|
|
||||||
## Token for the admin interface, preferably use a long random string
|
## Token for the admin interface, preferably an Argon2 PCH string
|
||||||
## One option is to use 'openssl rand -base64 48'
|
## Vaultwarden has a built-in generator by calling `vaultwarden hash`
|
||||||
|
## For details see: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token
|
||||||
## If not set, the admin panel is disabled
|
## If not set, the admin panel is disabled
|
||||||
|
## New Argon2 PHC string
|
||||||
|
# ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'
|
||||||
|
## Old plain text string (Will generate warnings in favor of Argon2)
|
||||||
# ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp
|
# ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp
|
||||||
|
|
||||||
## Enable this to bypass the admin panel security. This option is only
|
## Enable this to bypass the admin panel security. This option is only
|
||||||
|
|
60
Cargo.lock
generated
60
Cargo.lock
generated
|
@ -85,6 +85,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.0-pre.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0efde6c15a373abaefe544ddae9fc024eac3073798ba0c40043fd655f3535eb8"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
@ -324,6 +335,12 @@ version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
|
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "binascii"
|
name = "binascii"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
@ -336,6 +353,15 @@ version = "1.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
|
@ -2006,6 +2032,17 @@ dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0-pre.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d9d7f72dbf886af2c2a8d4a2ddfb4eea37e4d77ea3bde49f79af7c577e37908"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.11"
|
version = "1.0.11"
|
||||||
|
@ -2585,6 +2622,27 @@ dependencies = [
|
||||||
"uncased",
|
"uncased",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6678cf63ab3491898c0d021b493c94c9b221d91295294a2a5746eacbe5928322"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "034e22c514f5c0cb8a10ff341b9b048b5ceb21591f31c8f44c43b960f9b3524a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
|
@ -3425,6 +3483,7 @@ dependencies = [
|
||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cached",
|
"cached",
|
||||||
|
@ -3464,6 +3523,7 @@ dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rmpv",
|
"rmpv",
|
||||||
"rocket",
|
"rocket",
|
||||||
|
"rpassword",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
11
Cargo.toml
11
Cargo.toml
|
@ -157,8 +157,19 @@ semver = "1.0.16"
|
||||||
mimalloc = { version = "0.1.34", features = ["secure"], default-features = false, optional = true }
|
mimalloc = { version = "0.1.34", features = ["secure"], default-features = false, optional = true }
|
||||||
which = "4.4.0"
|
which = "4.4.0"
|
||||||
|
|
||||||
|
# Argon2 library with support for the PHC format
|
||||||
|
argon2 = "0.5.0-pre.0"
|
||||||
|
|
||||||
|
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||||
|
rpassword = "7.2"
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
# Also enable thin LTO for some optimizations
|
# Also enable thin LTO for some optimizations
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = "debuginfo"
|
strip = "debuginfo"
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
||||||
|
# Always build argon2 using opt-level 3
|
||||||
|
# This is a huge speed improvement during testing
|
||||||
|
[profile.dev.package.argon2]
|
||||||
|
opt-level = 3
|
||||||
|
|
|
@ -201,6 +201,19 @@ fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp
|
||||||
fn _validate_token(token: &str) -> bool {
|
fn _validate_token(token: &str) -> bool {
|
||||||
match CONFIG.admin_token().as_ref() {
|
match CONFIG.admin_token().as_ref() {
|
||||||
None => false,
|
None => false,
|
||||||
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
|
use argon2::password_hash::PasswordVerifier;
|
||||||
|
match argon2::password_hash::PasswordHash::new(t) {
|
||||||
|
Ok(h) => {
|
||||||
|
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
||||||
|
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()),
|
Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
|
||||||
|
|
||||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||||
Config::load().unwrap_or_else(|e| {
|
Config::load().unwrap_or_else(|e| {
|
||||||
println!("Error loading config:\n\t{e:?}\n");
|
println!("Error loading config:\n {e:?}\n");
|
||||||
exit(12)
|
exit(12)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -872,6 +872,23 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||||
err!("`EVENT_CLEANUP_SCHEDULE` is not a valid cron expression")
|
err!("`EVENT_CLEANUP_SCHEDULE` is not a valid cron expression")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !cfg.disable_admin_token {
|
||||||
|
match cfg.admin_token.as_ref() {
|
||||||
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
|
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
|
||||||
|
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
println!(
|
||||||
|
"[NOTICE] You are using a plain text `ADMIN_TOKEN` which is insecure.\n\
|
||||||
|
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.\n\
|
||||||
|
See: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
106
src/main.rs
106
src/main.rs
|
@ -118,14 +118,22 @@ async fn main() -> Result<(), Error> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const HELP: &str = "\
|
const HELP: &str = "\
|
||||||
Alternative implementation of the Bitwarden server API written in Rust
|
Alternative implementation of the Bitwarden server API written in Rust
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
vaultwarden
|
vaultwarden [FLAGS|COMMAND]
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
-h, --help Prints help information
|
||||||
|
-v, --version Prints the app version
|
||||||
|
|
||||||
|
COMMAND:
|
||||||
|
hash [--preset {bitwarden|owasp}] Generate an Argon2id PHC ADMIN_TOKEN
|
||||||
|
|
||||||
|
PRESETS: m= t= p=
|
||||||
|
bitwarden (default) 64MiB, 3 Iterations, 4 Threads
|
||||||
|
owasp 19MiB, 2 Iterations, 1 Thread
|
||||||
|
|
||||||
FLAGS:
|
|
||||||
-h, --help Prints help information
|
|
||||||
-v, --version Prints the app version
|
|
||||||
";
|
";
|
||||||
|
|
||||||
pub const VERSION: Option<&str> = option_env!("VW_VERSION");
|
pub const VERSION: Option<&str> = option_env!("VW_VERSION");
|
||||||
|
@ -142,24 +150,88 @@ fn parse_args() {
|
||||||
println!("vaultwarden {version}");
|
println!("vaultwarden {version}");
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if let Some(command) = pargs.subcommand().unwrap_or_default() {
|
||||||
|
if command == "hash" {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::SaltString, Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut argon2_params = ParamsBuilder::new();
|
||||||
|
let preset: Option<String> = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default();
|
||||||
|
let selected_preset;
|
||||||
|
match preset.as_deref() {
|
||||||
|
Some("owasp") => {
|
||||||
|
selected_preset = "owasp";
|
||||||
|
argon2_params.m_cost(19456);
|
||||||
|
argon2_params.t_cost(2);
|
||||||
|
argon2_params.p_cost(1);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Bitwarden preset is the default
|
||||||
|
selected_preset = "bitwarden";
|
||||||
|
argon2_params.m_cost(65540);
|
||||||
|
argon2_params.t_cost(3);
|
||||||
|
argon2_params.p_cost(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Generate an Argon2id PHC string using the '{selected_preset}' preset:\n");
|
||||||
|
|
||||||
|
let password = rpassword::prompt_password("Password: ").unwrap();
|
||||||
|
if password.len() < 8 {
|
||||||
|
println!("\nPassword must contain at least 8 characters");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_verify = rpassword::prompt_password("Confirm Password: ").unwrap();
|
||||||
|
if password != password_verify {
|
||||||
|
println!("\nPasswords do not match");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
|
||||||
|
let salt = SaltString::b64_encode(&crate::crypto::get_random_bytes::<32>()).unwrap();
|
||||||
|
|
||||||
|
let argon2_timer = tokio::time::Instant::now();
|
||||||
|
if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) {
|
||||||
|
println!(
|
||||||
|
"\n\
|
||||||
|
ADMIN_TOKEN='{password_hash}'\n\n\
|
||||||
|
Generation of the Argon2id PHC string took: {:?}",
|
||||||
|
argon2_timer.elapsed()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error!("Unable to generate Argon2id PHC hash.");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
fn launch_info() {
|
fn launch_info() {
|
||||||
println!("/--------------------------------------------------------------------\\");
|
println!(
|
||||||
println!("| Starting Vaultwarden |");
|
"\
|
||||||
|
/--------------------------------------------------------------------\\\n\
|
||||||
|
| Starting Vaultwarden |"
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(version) = VERSION {
|
if let Some(version) = VERSION {
|
||||||
println!("|{:^68}|", format!("Version {version}"));
|
println!("|{:^68}|", format!("Version {version}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("|--------------------------------------------------------------------|");
|
println!(
|
||||||
println!("| This is an *unofficial* Bitwarden implementation, DO NOT use the |");
|
"\
|
||||||
println!("| official channels to report bugs/features, regardless of client. |");
|
|--------------------------------------------------------------------|\n\
|
||||||
println!("| Send usage/configuration questions or feature requests to: |");
|
| This is an *unofficial* Bitwarden implementation, DO NOT use the |\n\
|
||||||
println!("| https://vaultwarden.discourse.group/ |");
|
| official channels to report bugs/features, regardless of client. |\n\
|
||||||
println!("| Report suspected bugs/issues in the software itself at: |");
|
| Send usage/configuration questions or feature requests to: |\n\
|
||||||
println!("| https://github.com/dani-garcia/vaultwarden/issues/new |");
|
| https://github.com/dani-garcia/vaultwarden/discussions or |\n\
|
||||||
println!("\\--------------------------------------------------------------------/\n");
|
| https://vaultwarden.discourse.group/ |\n\
|
||||||
|
| Report suspected bugs/issues in the software itself at: |\n\
|
||||||
|
| https://github.com/dani-garcia/vaultwarden/issues/new |\n\
|
||||||
|
\\--------------------------------------------------------------------/\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||||
|
|
37
src/static/scripts/admin_settings.js
vendored
37
src/static/scripts/admin_settings.js
vendored
|
@ -157,6 +157,41 @@ function masterCheck(check_id, inputs_query) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This will check if the ADMIN_TOKEN is not a Argon2 hashed value.
|
||||||
|
// Else it will show a warning, unless someone has closed it.
|
||||||
|
// Then it will not show this warning for 30 days.
|
||||||
|
function checkAdminToken() {
|
||||||
|
const admin_token = document.getElementById("input_admin_token");
|
||||||
|
const disable_admin_token = document.getElementById("input_disable_admin_token");
|
||||||
|
if (!disable_admin_token.checked && !admin_token.value.startsWith("$argon2")) {
|
||||||
|
// Check if the warning has been closed before and 30 days have passed
|
||||||
|
const admin_token_warning_closed = localStorage.getItem("admin_token_warning_closed");
|
||||||
|
if (admin_token_warning_closed !== null) {
|
||||||
|
const closed_date = new Date(parseInt(admin_token_warning_closed));
|
||||||
|
const current_date = new Date();
|
||||||
|
const thirtyDays = 1000*60*60*24*30;
|
||||||
|
if (current_date - closed_date < thirtyDays) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When closing the alert, store the current date/time in the browser
|
||||||
|
const admin_token_warning = document.getElementById("admin_token_warning");
|
||||||
|
admin_token_warning.addEventListener("closed.bs.alert", function() {
|
||||||
|
const d = new Date();
|
||||||
|
localStorage.setItem("admin_token_warning_closed", d.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display the warning
|
||||||
|
admin_token_warning.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will check for specific configured values, and when needed will show a warning div
|
||||||
|
function showWarnings() {
|
||||||
|
checkAdminToken();
|
||||||
|
}
|
||||||
|
|
||||||
const config_form = document.getElementById("config-form");
|
const config_form = document.getElementById("config-form");
|
||||||
|
|
||||||
// onLoad events
|
// onLoad events
|
||||||
|
@ -192,4 +227,6 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
config_form.addEventListener("submit", saveConfig);
|
config_form.addEventListener("submit", saveConfig);
|
||||||
|
|
||||||
|
showWarnings();
|
||||||
});
|
});
|
|
@ -1,4 +1,10 @@
|
||||||
<main class="container-xl">
|
<main class="container-xl">
|
||||||
|
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
|
||||||
|
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
You are using a plain text `ADMIN_TOKEN` which is insecure.<br>
|
||||||
|
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.<br>
|
||||||
|
See: <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token" target="_blank" rel="noopener noreferrer">Enabling admin page - Secure the `ADMIN_TOKEN`</a>
|
||||||
|
</div>
|
||||||
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
|
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-white mb-3">Configuration</h6>
|
<h6 class="text-white mb-3">Configuration</h6>
|
||||||
|
|
Loading…
Reference in a new issue