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,307 changes: 536 additions & 771 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ edition = "2024"
homepage = "https://www.omnect.io/home"
license = "MIT OR Apache-2.0"
repository = "git@github.com:omnect/omnect-ui.git"
version = "1.2.5"
version = "1.2.6"
18 changes: 9 additions & 9 deletions src/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ typegen = ["crux_core/typegen", "crux_http/typegen", "crux_time/typegen"]
workspace = true

[dependencies]
base64 = { version = "0.22", default-features = false, features = ["alloc"] }
console_log = { version = "1.0", default-features = false }
crux_core = { version = "0.19", default-features = false }
crux_http = { version = "0.19", default-features = false }
base64 = { version = "0.23", default-features = false, features = ["alloc"] }
console_log = { version = "1.1", default-features = false }
crux_core = { version = "0.20", default-features = false }
crux_http = { version = "0.20", default-features = false }
crux_macros = { version = "0.10", default-features = false }
crux_time = { version = "0.17", default-features = false }
crux_time = { version = "0.18", default-features = false }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
hmac = { version = "0.13", default-features = false }
log = { version = "0.4", default-features = false }
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false }
serde_repr = { version = "0.1", default-features = false }
serde_valid = { version = "2.0", default-features = false }
serde_valid = { version = "3.2", default-features = false }
sha1 = { version = "0.11", default-features = false }
wasm-bindgen = { version = "0.2", default-features = false }

Expand All @@ -47,8 +47,8 @@ getrandom = { version = "0.3" }
[dev-dependencies]
# Enable the crux testing helpers (expect_*, ResponseBuilder). These moved
# behind feature flags in crux 0.19 and must not leak into the WASM build.
crux_core = { version = "0.19", default-features = false, features = ["testing"] }
crux_http = { version = "0.19", default-features = false, features = ["http-types"] }
crux_core = { version = "0.20", default-features = false, features = ["testing"] }
crux_http = { version = "0.20", default-features = false, features = ["http-types"] }

[build-dependencies]
crux_core = { version = "0.19.0", default-features = false, features = ["typegen"] }
crux_core = { version = "0.20.0", default-features = false, features = ["typegen"] }
25 changes: 10 additions & 15 deletions src/app/src/http_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,8 @@ pub fn process_json_response<T: serde::de::DeserializeOwned>(

#[must_use]
pub fn map_http_error(action: &str, e: &HttpError) -> String {
match e {
HttpError::Http {
body: Some(body), ..
} => match String::from_utf8(body.clone()) {
Ok(msg) => msg,
Err(_) => format!("{action} failed: {e}"),
},
match e.body().map(std::str::from_utf8) {
Some(Ok(msg)) => msg.to_string(),
_ => format!("{action} failed: {e}"),
}
}
Expand Down Expand Up @@ -214,15 +209,15 @@ mod tests {
}

#[test]
fn parse_json_response_any_status_parses_on_503() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct Info {
ok: bool,
}
fn map_http_error_returns_rejection_body() {
let err = crux_http::testing::rejection::<Vec<u8>>(503, "version mismatch").unwrap_err();
assert_eq!(map_http_error("test", &err), "version mismatch");
}

let mut response = make_response(503, b"{\"ok\":false}");
let result: Result<Info, String> = parse_json_response_any_status("test", &mut response);
assert_eq!(result.unwrap(), Info { ok: false });
#[test]
fn map_http_error_without_body_names_action() {
let err = crux_http::testing::rejection::<Vec<u8>>(503, "").unwrap_err();
assert!(map_http_error("test", &err).starts_with("test failed: "));
}

#[test]
Expand Down
5 changes: 2 additions & 3 deletions src/app/src/update/device/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,8 @@ fn build_initial_healthcheck_cmd() -> Command<Effect, Event> {
"Healthcheck",
&mut response,
),
Err(crux_http::HttpError::Http {
body: Some(body), ..
}) => serde_json::from_slice(&body)
Err(e) if e.body().is_some() => e
.body_json()
.map_err(|e| format!("Healthcheck: JSON parse error: {e}")),
Err(e) => Err(e.to_string()),
};
Expand Down
32 changes: 17 additions & 15 deletions src/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,41 @@ version.workspace = true
workspace = true

[dependencies]
actix-files = { version = "0.6", default-features = false }
actix-multipart = { version = "0.7", default-features = false, features = [
actix-files = { version = "0.7", default-features = false }
actix-http = { version = "3.18", default-features = false, features = [
"rustls-0_23",
] }
actix-multipart = { version = "0.8", default-features = false, features = [
"tempfile",
"derive",
] }
actix-server = { version = "2.6", default-features = false }
actix-server = { version = "2.9", default-features = false }
actix-service = { version = "2.0", default-features = false }
actix-session = { version = "0.11", features = ["cookie-session"] }
actix-web = { version = "4.13", default-features = false, features = [
actix-web = { version = "4.15", default-features = false, features = [
"macros",
"rustls-0_23",
] }
actix-web-httpauth = { version = "0.8", default-features = false }
actix-web-static-files = { version = "4.1", features = ["static-files-03"] }
actix-ws = { version = "0.4", default-features = false }
anyhow = { version = "1.0", default-features = false }
argon2 = { version = "0.5", default-features = false, features = [
argon2 = { version = "0.6", default-features = false, features = [
"password-hash",
"alloc",
"getrandom",
] }
base64 = { version = "0.22", default-features = false }
base64 = { version = "0.23", default-features = false }
env_logger = { version = "0.11", default-features = false }
futures-util = { version = "0.3", default-features = false }
jsonwebtoken = { version = "10.3", default-features = false, features = [
jsonwebtoken = { version = "11.1", default-features = false, features = [
"use_pem",
"rust_crypto",
] }
log = { version = "0.4", default-features = false }
log-panics = { version = "2.1", default-features = false, features = [
"with-backtrace",
] }
mockall = { version = "0.14", optional = true, default-features = false }
mockall = { version = "0.15", optional = true, default-features = false }
omnect-ui-core = { path = "../app" }
rand_core = { version = "0.10", default-features = false }
reqwest = { version = "0.13", default-features = false, features = [
Expand All @@ -58,7 +62,7 @@ rustls = { version = "0.23", default-features = false, features = [
"std",
"tls12",
] }
rustls-pki-types = { version = "1.14", default-features = false, features = [
rustls-pki-types = { version = "1.15", default-features = false, features = [
"std",
] }
semver = { version = "1.0", default-features = false }
Expand All @@ -67,9 +71,9 @@ serde_json = { version = "1.0", default-features = false, features = [
"raw_value",
] }
serde_repr = { version = "0.1", default-features = false }
serde_valid = { version = "2.0", default-features = false }
serde_valid = { version = "3.2", default-features = false }
static-files = { version = "0.3", default-features = false }
tokio = { version = "1.50", default-features = false, features = [
tokio = { version = "1.53", default-features = false, features = [
"fs",
"io-util",
"macros",
Expand All @@ -78,14 +82,12 @@ tokio = { version = "1.50", default-features = false, features = [
] }
trait-variant = { version = "0.1", default-features = false }
url = { version = "2.5", default-features = false }
uuid = { version = "1.22", default-features = false, features = ["v4"] }
uuid = { version = "1.27", default-features = false, features = ["v4"] }

[features]
mock = ["dep:mockall"]

[dev-dependencies]
actix-http = "3.12"
actix-service = "2.0"
mockall_double = "0.3"
tempfile = "3.27"

Expand Down
39 changes: 31 additions & 8 deletions src/backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ use crate::{
},
wifi_commissioning_client::{WifiAvailability, WifiCommissioningServiceClient},
};
use actix_http::HttpService;
use actix_multipart::form::MultipartFormConfig;
use actix_server::ServerHandle;
use actix_service::map_config;
use actix_session::{
SessionMiddleware,
config::{BrowserSession, CookieContentSecurity},
Expand All @@ -36,7 +38,7 @@ use anyhow::{Context, Result};
use env_logger::{Builder, Env, Target};
use log::{debug, error, info, warn};
use rustls::crypto::{CryptoProvider, ring::default_provider};
use std::{io::Write, sync::Mutex};
use std::{io::Write, sync::Mutex, time::Duration};
use tokio::{
signal::unix::{SignalKind, signal},
sync::broadcast,
Expand All @@ -45,6 +47,8 @@ use uuid::Uuid;

const UPLOAD_LIMIT_BYTES: usize = 1024 * 1024 * 1024;
const MULTIPART_CHUNK_SIZE_BYTES: usize = 512 * 1024;
// Same as HttpServer's default.
const CLIENT_DISCONNECT_TIMEOUT: Duration = Duration::from_secs(1);

// Cached common name (IP address) used for the current certificate
static CACHED_COMMON_NAME: Mutex<Option<String>> = Mutex::new(None);
Expand Down Expand Up @@ -360,7 +364,7 @@ async fn run_server(
let internal_task = tokio::spawn(internal_server);

// Main HTTPS server — serves UI, API, and WebSocket routes
let ui_server = HttpServer::new(move || {
let ui_app = move || {
App::new()
.app_data(
MultipartFormConfig::default()
Expand Down Expand Up @@ -517,12 +521,31 @@ async fn run_server(
),
)
.default_service(web::route().to(UiApi::index))
})
.workers(optimal_worker_count())
.bind_rustls_0_23(format!("0.0.0.0:{ui_port}"), tls_config)
.context("failed to bind HTTPS server")?
.disable_signals()
.run();
};

// TODO: remove me, as soon as actix-http depends on h2 >= 0.4.16; then go back to
// HttpServer::bind_rustls_0_23. HttpServer always offers HTTP/2 over TLS, and h2 0.3 has an
// unfixed DoS advisory, so this listener serves HTTP/1.1 only.
let builder = actix_server::Server::build();
let shutdown_signal = builder.graceful_shutdown_signal();
let ui_server = builder
.workers(optimal_worker_count())
.disable_signals()
.bind("omnect-ui", format!("0.0.0.0:{ui_port}"), move || {
let shutdown_signal = shutdown_signal.clone();
HttpService::build()
.client_disconnect_timeout(CLIENT_DISCONNECT_TIMEOUT)
.graceful_shutdown_signal(move || {
let signal = shutdown_signal.clone();
async move { signal.notified().await }
})
.finish(map_config(ui_app(), |()| {
actix_web::dev::AppConfig::default()
}))
.rustls_0_23(tls_config.clone())
})
.context("failed to bind HTTPS server")?
.run();

let ui_handle = ui_server.handle();
let ui_task = tokio::spawn(ui_server);
Expand Down
27 changes: 18 additions & 9 deletions src/backend/src/services/auth/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@

use crate::config::AppConfig;
use anyhow::{Context, Result, anyhow, ensure};
use argon2::{
Argon2, PasswordHash, PasswordVerifier,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use log::debug;
use std::{fs::File, io::Write};

Expand Down Expand Up @@ -68,11 +65,8 @@ impl PasswordService {
fn hash_password(password: &str) -> Result<String> {
debug!("hash_password() called");

let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();

argon2
.hash_password(password.as_bytes(), &salt)
Argon2::default()
.hash_password(password.as_bytes())
.map(|hash| hash.to_string())
.map_err(|e| anyhow!(e))
.context("failed to hash password")
Expand Down Expand Up @@ -166,4 +160,19 @@ mod tests {
// Cleanup
let _ = std::fs::remove_file(password_file);
}

#[test]
fn test_validate_password_from_previous_release() {
// Created by argon2 0.5; devices keep their stored hash across updates.
const STORED_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$HGHw67HpI+1WXkPHY9FtmQ$mfMwQkgUHtotw5oCvxSYGXxcVl2bm+gJ0uUmrRVQKfs";

let _lock = PasswordService::lock_for_test();
let password_file = &AppConfig::get().paths.password_file;
std::fs::write(password_file, STORED_HASH).expect("should write password file");

assert!(PasswordService::validate_password("legacy-password").is_ok());
assert!(PasswordService::validate_password("wrong-password").is_err());

let _ = std::fs::remove_file(password_file);
}
}
4 changes: 2 additions & 2 deletions src/shared_types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ workspace = true

[build-dependencies]
anyhow = "1.0"
crux_core = { version = "0.19", features = ["typegen"] }
crux_http = { version = "0.19", features = ["typegen"] }
crux_core = { version = "0.20", features = ["typegen"] }
crux_http = { version = "0.20", features = ["typegen"] }
omnect-ui-core = { path = "../app", features = ["typegen"] }
8 changes: 4 additions & 4 deletions src/ui/src/components/device/DeviceActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ const handleFactoryReset = () => execute(async () => {

<template>
<div class="flex flex-col gap-y-4 items-start w-full">
<div class="text-h4 text-secondary border-b pb-2 mb-4 w-full">Commands</div>
<div class="text-headline-large text-secondary border-b pb-2 mb-4 w-full">Commands</div>
<v-btn :prepend-icon="'mdi-restart'" color="primary" variant="flat" class="justify-start" density="compact" width="180">
Reboot
<v-dialog v-model="dialogs.reboot" activator="parent" max-width="340" :no-click-animation="true" persistent
@keydown.esc="dialogs.reboot = false">
<v-card>
<v-card-title class="text-h5">Reboot device</v-card-title>
<v-card-title class="text-headline-small">Reboot device</v-card-title>
<v-card-text>
Do you really want to restart the device?
</v-card-text>
Expand All @@ -51,11 +51,11 @@ const handleFactoryReset = () => execute(async () => {
<v-dialog v-model="dialogs.factoryReset" activator="parent" max-width="340" :no-click-animation="true"
persistent @keydown.esc="dialogs.factoryReset = false">
<v-card>
<v-card-title class="text-h5">Factory reset</v-card-title>
<v-card-title class="text-headline-small">Factory reset</v-card-title>
<v-card-text>
<div class="flex flex-col gap-2">
<div v-if="factoryResetKeys?.keys && factoryResetKeys.keys.length > 0">
<div class="text-subtitle-2 mb-2">Preserve options:</div>
<div class="text-title-small mb-2">Preserve options:</div>
<v-checkbox-btn v-for="(option, index) in factoryResetKeys.keys" :label="option"
v-model:model-value="selectedFactoryResetKeys" :value="option" :key="index"
density="compact"></v-checkbox-btn>
Expand Down
6 changes: 3 additions & 3 deletions src/ui/src/components/device/DeviceInfoCore.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ const displayItems = computed(() =>

<template>
<div class="flex flex-col gap-y-4 w-full">
<div class="text-h4 text-secondary border-b pb-2 mb-4">Common Info</div>
<div class="text-headline-large text-secondary border-b pb-2 mb-4">Common Info</div>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-4 w-full">
<div v-for="item of displayItems" :key="item.title" class="grid grid-cols-[max-content_1fr] items-baseline border-b pb-1 gap-x-4">
<dt class="text-subtitle-2 text-medium-emphasis">{{ item.title }}</dt>
<dd class="text-body-1 font-weight-medium text-gray-900 text-right">{{ item.value }}</dd>
<dt class="text-title-small text-medium-emphasis">{{ item.title }}</dt>
<dd class="text-body-large font-weight-medium text-gray-900 text-right">{{ item.value }}</dd>
</div>
</dl>
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/ui/src/components/feedback/OverlaySpinner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,22 @@ const formattedCountdown = computed(() => {

<template>
<v-overlay :persistent="true" :model-value="props.overlay" :no-click-animation="true"
class="align-center justify-center">
location="center center">
<div id="overlay" class="flex flex-col items-center">
<v-sheet class="flex flex-col gap-y-8 items-center p-8" :rounded="'lg'">
<div class="text-h4 text-center">{{ props.title }}</div>
<div class="text-headline-large text-center">{{ props.title }}</div>
<v-progress-circular v-if="!props.timedOut" color="secondary"
:indeterminate="props.progress === undefined"
:model-value="props.progress" size="100" width="5">
<template v-slot:default>
<span v-if="props.progress !== undefined" class="text-h6">{{ props.progress }}%</span>
<span v-if="props.progress !== undefined" class="text-title-large">{{ props.progress }}%</span>
</template>
</v-progress-circular>
<v-icon v-else icon="mdi-alert-circle-outline" color="warning" size="100" />
<p class="text-h6 m-t-4">{{ props.text }}</p>
<p class="text-title-large m-t-4">{{ props.text }}</p>
<div v-if="formattedCountdown && !props.timedOut" class="flex flex-col items-center gap-2">
<div class="text-subtitle-1 text-medium-emphasis">{{ props.countdownLabel }}</div>
<div class="text-h5 text-primary font-mono">
<div class="text-body-large text-medium-emphasis">{{ props.countdownLabel }}</div>
<div class="text-headline-small text-primary font-mono">
{{ formattedCountdown }}
</div>
</div>
Expand Down
Loading
Loading