From 3cdf7c62d509fca970e30f35fb973c9d6b523e24 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:46:21 +0200 Subject: [PATCH 001/119] [Add] basic split --- Cargo.lock | 177 ++++++- Cargo.toml | 58 +-- decentralized/Cargo.toml | 56 ++ .../src}/communities/community.rs | 0 .../src}/communities/community_connection.rs | 0 .../src}/communities/community_manager.rs | 0 .../communities/interactables/category.rs | 0 .../communities/interactables/interactable.rs | 0 .../communities/interactables/registry.rs | 0 .../communities/interactables/text_chat.rs | 0 .../communities/interactables/voice_chat.rs | 0 {src => decentralized/src}/communities/mod.rs | 0 .../src}/communities/perms/permission.rs | 0 src/gui/tui.rs => decentralized/src/lib.rs | 0 .../src/local-auth}/auth_user.rs | 0 .../src/local-auth}/local_auth.rs | 0 .../src/local-auth}/mod.rs | 0 iota-cli/Cargo.toml | 57 ++ iota-cli/src/app_state.rs | 1 + .../src}/elements/console_card.rs | 0 .../gui => iota-cli/src}/elements/elements.rs | 0 .../src}/elements/graph_card.rs | 0 .../gui => iota-cli/src}/elements/log_card.rs | 0 {src/gui => iota-cli/src}/input_handler.rs | 2 +- .../src}/interaction_result.rs | 2 +- .../src}/langu/language_creator.rs | 0 .../src}/langu/language_manager.rs | 0 {src => iota-cli/src}/langu/mod.rs | 0 src/gui/mod.rs => iota-cli/src/lib.rs | 0 .../src}/screens/main_screen.rs | 2 +- .../gui => iota-cli/src}/screens/md_viewer.rs | 2 +- {src/gui => iota-cli/src}/screens/screens.rs | 2 +- .../src}/screens/terms_checker.rs | 14 +- .../src}/screens/terms_updater.rs | 13 +- iota-cli/src/tui.rs | 0 {src/gui => iota-cli/src}/ui.rs | 7 +- {src/gui => iota-cli/src}/util/borders.rs | 0 iota-core/Cargo.toml | 17 + {src => iota-core/src}/main.rs | 36 +- {src => iota-core/src}/terms/buttons.rs | 0 {src => iota-core/src}/terms/doc.rs | 0 {src => iota-core/src}/terms/focus.rs | 0 {src => iota-core/src}/terms/mod.rs | 1 - {src => iota-core/src}/terms/terms_getter.rs | 0 iota-logger/Cargo.toml | 6 + iota-logger/src/lib.rs | 14 + iota-state/Cargo.toml | 12 + src/gui/app_state.rs => iota-state/src/lib.rs | 77 +-- iota-storage/Cargo.toml | 27 + iota-storage/src/lib.rs | 2 + {src => iota-storage/src}/users/contact.rs | 0 {src => iota-storage/src}/users/mod.rs | 0 .../src}/users/user_community_util.rs | 0 .../src}/users/user_manager.rs | 0 .../src}/users/user_profile.rs | 0 {src => iota-storage/src}/util/chat_files.rs | 0 {src => iota-storage/src}/util/chats_util.rs | 0 .../src}/util/communities_util.rs | 0 {src => iota-storage/src}/util/config_util.rs | 0 .../src}/util/crypto_helper.rs | 0 {src => iota-storage/src}/util/crypto_util.rs | 0 {src => iota-storage/src}/util/db.rs | 0 {src => iota-storage/src}/util/file_util.rs | 0 {src => iota-storage/src}/util/logger.rs | 0 {src => iota-storage/src}/util/mod.rs | 0 omikron-connector/Cargo.toml | 13 + .../mod.rs => omikron-connector/src/lib.rs | 0 .../src}/omikron_connection.rs | 0 .../src}/ping_pong_task.rs | 0 src/terms/consent_state.rs | 491 ------------------ web-server/Cargo.toml | 9 + web-server/src/lib.rs | 0 web-ui/Cargo.toml | 56 ++ {src/server => web-ui/src}/api.rs | 0 src/server/mod.rs => web-ui/src/lib.rs | 0 {src/server => web-ui/src}/server.rs | 0 {src/server => web-ui/src}/web_path_parser.rs | 0 77 files changed, 506 insertions(+), 648 deletions(-) create mode 100644 decentralized/Cargo.toml rename {src => decentralized/src}/communities/community.rs (100%) rename {src => decentralized/src}/communities/community_connection.rs (100%) rename {src => decentralized/src}/communities/community_manager.rs (100%) rename {src => decentralized/src}/communities/interactables/category.rs (100%) rename {src => decentralized/src}/communities/interactables/interactable.rs (100%) rename {src => decentralized/src}/communities/interactables/registry.rs (100%) rename {src => decentralized/src}/communities/interactables/text_chat.rs (100%) rename {src => decentralized/src}/communities/interactables/voice_chat.rs (100%) rename {src => decentralized/src}/communities/mod.rs (100%) rename {src => decentralized/src}/communities/perms/permission.rs (100%) rename src/gui/tui.rs => decentralized/src/lib.rs (100%) rename {src/auth => decentralized/src/local-auth}/auth_user.rs (100%) rename {src/auth => decentralized/src/local-auth}/local_auth.rs (100%) rename {src/auth => decentralized/src/local-auth}/mod.rs (100%) create mode 100644 iota-cli/Cargo.toml create mode 100755 iota-cli/src/app_state.rs rename {src/gui => iota-cli/src}/elements/console_card.rs (100%) rename {src/gui => iota-cli/src}/elements/elements.rs (100%) rename {src/gui => iota-cli/src}/elements/graph_card.rs (100%) rename {src/gui => iota-cli/src}/elements/log_card.rs (100%) rename {src/gui => iota-cli/src}/input_handler.rs (98%) rename {src/gui => iota-cli/src}/interaction_result.rs (97%) rename {src => iota-cli/src}/langu/language_creator.rs (100%) rename {src => iota-cli/src}/langu/language_manager.rs (100%) rename {src => iota-cli/src}/langu/mod.rs (100%) rename src/gui/mod.rs => iota-cli/src/lib.rs (100%) rename {src/gui => iota-cli/src}/screens/main_screen.rs (99%) rename {src/gui => iota-cli/src}/screens/md_viewer.rs (99%) rename {src/gui => iota-cli/src}/screens/screens.rs (89%) rename {src/gui => iota-cli/src}/screens/terms_checker.rs (97%) rename {src/gui => iota-cli/src}/screens/terms_updater.rs (98%) create mode 100644 iota-cli/src/tui.rs rename {src/gui => iota-cli/src}/ui.rs (96%) rename {src/gui => iota-cli/src}/util/borders.rs (100%) create mode 100644 iota-core/Cargo.toml rename {src => iota-core/src}/main.rs (81%) rename {src => iota-core/src}/terms/buttons.rs (100%) rename {src => iota-core/src}/terms/doc.rs (100%) rename {src => iota-core/src}/terms/focus.rs (100%) rename {src => iota-core/src}/terms/mod.rs (74%) rename {src => iota-core/src}/terms/terms_getter.rs (100%) create mode 100644 iota-logger/Cargo.toml create mode 100644 iota-logger/src/lib.rs create mode 100644 iota-state/Cargo.toml rename src/gui/app_state.rs => iota-state/src/lib.rs (76%) mode change 100755 => 100644 create mode 100644 iota-storage/Cargo.toml create mode 100644 iota-storage/src/lib.rs rename {src => iota-storage/src}/users/contact.rs (100%) rename {src => iota-storage/src}/users/mod.rs (100%) rename {src => iota-storage/src}/users/user_community_util.rs (100%) rename {src => iota-storage/src}/users/user_manager.rs (100%) rename {src => iota-storage/src}/users/user_profile.rs (100%) rename {src => iota-storage/src}/util/chat_files.rs (100%) rename {src => iota-storage/src}/util/chats_util.rs (100%) rename {src => iota-storage/src}/util/communities_util.rs (100%) rename {src => iota-storage/src}/util/config_util.rs (100%) rename {src => iota-storage/src}/util/crypto_helper.rs (100%) rename {src => iota-storage/src}/util/crypto_util.rs (100%) rename {src => iota-storage/src}/util/db.rs (100%) rename {src => iota-storage/src}/util/file_util.rs (100%) rename {src => iota-storage/src}/util/logger.rs (100%) rename {src => iota-storage/src}/util/mod.rs (100%) create mode 100644 omikron-connector/Cargo.toml rename src/omikron/mod.rs => omikron-connector/src/lib.rs (100%) rename {src/omikron => omikron-connector/src}/omikron_connection.rs (100%) rename {src/omikron => omikron-connector/src}/ping_pong_task.rs (100%) delete mode 100644 src/terms/consent_state.rs create mode 100644 web-server/Cargo.toml create mode 100644 web-server/src/lib.rs create mode 100644 web-ui/Cargo.toml rename {src/server => web-ui/src}/api.rs (100%) rename src/server/mod.rs => web-ui/src/lib.rs (100%) rename {src/server => web-ui/src}/server.rs (100%) rename {src/server => web-ui/src}/web_path_parser.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index cd56dfb..334fe02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,53 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "decentralized" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.5", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1028,9 +1075,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" [[package]] name = "fiat-crypto" @@ -1723,6 +1770,14 @@ dependencies = [ [[package]] name = "iota" version = "0.1.0" +dependencies = [ + "ttp-core", + "ttp-native", +] + +[[package]] +name = "iota-cli" +version = "0.1.0" dependencies = [ "actix-web", "actix-web-actors", @@ -1738,6 +1793,7 @@ dependencies = [ "hkdf", "hyper", "hyper-util", + "iota-state", "json", "lazy_static", "once_cell", @@ -1767,6 +1823,64 @@ dependencies = [ "zip", ] +[[package]] +name = "iota-core" +version = "0.1.0" +dependencies = [ + "dashmap", + "iota-state", + "json", + "once_cell", + "pnet", + "ratatui", + "reqwest", + "tokio", + "ttp-core", + "ttp-native", +] + +[[package]] +name = "iota-logger" +version = "0.1.0" + +[[package]] +name = "iota-state" +version = "0.1.0" +dependencies = [ + "dashmap", + "json", + "once_cell", + "sysinfo", + "tokio", + "ttp-core", +] + +[[package]] +name = "iota-storage" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "hex", + "hkdf", + "json", + "once_cell", + "rand 0.8.5", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "sha2", + "sysinfo", + "tokio", + "ttp-core", + "ttp-native", + "uuid", + "walkdir", + "x448", + "zip", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2257,6 +2371,18 @@ dependencies = [ "asn1-rs", ] +[[package]] +name = "omikron-connector" +version = "0.1.0" +dependencies = [ + "dashmap", + "json", + "tokio", + "ttp-core", + "ttp-native", + "uuid", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4224,6 +4350,53 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-ui" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.5", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "webpki-root-certs" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 8767b56..7d4ef81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,56 +1,2 @@ -[package] -name = "iota" -version = "0.1.0" -edition = "2024" - -[dependencies] -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -base64 = "0.22.1" -crossterm = "*" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper-util = { version = "*" } -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -json = "*" -once_cell = "1.21.3" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -reqwest = "0.13.2" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -sha2 = "0.10.9" -sysinfo = "0.38.3" -tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -rustls-pemfile = "2.2.0" -async-trait = "0.1.89" -zip = "6.0.0" -pnet = "0.35.0" -dashmap = "6.1.0" -strum = "0.27.2" -strum_macros = "0.27.2" -ratatui = "0.30.0" -open = "5.3.3" -chrono = "0.4.43" -serde_json = "1.0.149" -rusqlite = "0.39.0" -lazy_static = "1.5.0" +[workspace] +members = ["iota-storage", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "decentralized", "iota-logger"] diff --git a/decentralized/Cargo.toml b/decentralized/Cargo.toml new file mode 100644 index 0000000..9c21cdf --- /dev/null +++ b/decentralized/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "decentralized" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/src/communities/community.rs b/decentralized/src/communities/community.rs similarity index 100% rename from src/communities/community.rs rename to decentralized/src/communities/community.rs diff --git a/src/communities/community_connection.rs b/decentralized/src/communities/community_connection.rs similarity index 100% rename from src/communities/community_connection.rs rename to decentralized/src/communities/community_connection.rs diff --git a/src/communities/community_manager.rs b/decentralized/src/communities/community_manager.rs similarity index 100% rename from src/communities/community_manager.rs rename to decentralized/src/communities/community_manager.rs diff --git a/src/communities/interactables/category.rs b/decentralized/src/communities/interactables/category.rs similarity index 100% rename from src/communities/interactables/category.rs rename to decentralized/src/communities/interactables/category.rs diff --git a/src/communities/interactables/interactable.rs b/decentralized/src/communities/interactables/interactable.rs similarity index 100% rename from src/communities/interactables/interactable.rs rename to decentralized/src/communities/interactables/interactable.rs diff --git a/src/communities/interactables/registry.rs b/decentralized/src/communities/interactables/registry.rs similarity index 100% rename from src/communities/interactables/registry.rs rename to decentralized/src/communities/interactables/registry.rs diff --git a/src/communities/interactables/text_chat.rs b/decentralized/src/communities/interactables/text_chat.rs similarity index 100% rename from src/communities/interactables/text_chat.rs rename to decentralized/src/communities/interactables/text_chat.rs diff --git a/src/communities/interactables/voice_chat.rs b/decentralized/src/communities/interactables/voice_chat.rs similarity index 100% rename from src/communities/interactables/voice_chat.rs rename to decentralized/src/communities/interactables/voice_chat.rs diff --git a/src/communities/mod.rs b/decentralized/src/communities/mod.rs similarity index 100% rename from src/communities/mod.rs rename to decentralized/src/communities/mod.rs diff --git a/src/communities/perms/permission.rs b/decentralized/src/communities/perms/permission.rs similarity index 100% rename from src/communities/perms/permission.rs rename to decentralized/src/communities/perms/permission.rs diff --git a/src/gui/tui.rs b/decentralized/src/lib.rs similarity index 100% rename from src/gui/tui.rs rename to decentralized/src/lib.rs diff --git a/src/auth/auth_user.rs b/decentralized/src/local-auth/auth_user.rs similarity index 100% rename from src/auth/auth_user.rs rename to decentralized/src/local-auth/auth_user.rs diff --git a/src/auth/local_auth.rs b/decentralized/src/local-auth/local_auth.rs similarity index 100% rename from src/auth/local_auth.rs rename to decentralized/src/local-auth/local_auth.rs diff --git a/src/auth/mod.rs b/decentralized/src/local-auth/mod.rs similarity index 100% rename from src/auth/mod.rs rename to decentralized/src/local-auth/mod.rs diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml new file mode 100644 index 0000000..029e7e0 --- /dev/null +++ b/iota-cli/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "iota-cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-state = { path = "../iota-state" } +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/iota-cli/src/app_state.rs b/iota-cli/src/app_state.rs new file mode 100755 index 0000000..cfb4b3b --- /dev/null +++ b/iota-cli/src/app_state.rs @@ -0,0 +1 @@ +pub use iota_state::*; diff --git a/src/gui/elements/console_card.rs b/iota-cli/src/elements/console_card.rs similarity index 100% rename from src/gui/elements/console_card.rs rename to iota-cli/src/elements/console_card.rs diff --git a/src/gui/elements/elements.rs b/iota-cli/src/elements/elements.rs similarity index 100% rename from src/gui/elements/elements.rs rename to iota-cli/src/elements/elements.rs diff --git a/src/gui/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs similarity index 100% rename from src/gui/elements/graph_card.rs rename to iota-cli/src/elements/graph_card.rs diff --git a/src/gui/elements/log_card.rs b/iota-cli/src/elements/log_card.rs similarity index 100% rename from src/gui/elements/log_card.rs rename to iota-cli/src/elements/log_card.rs diff --git a/src/gui/input_handler.rs b/iota-cli/src/input_handler.rs similarity index 98% rename from src/gui/input_handler.rs rename to iota-cli/src/input_handler.rs index b2557c5..92b899c 100644 --- a/src/gui/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,4 +1,4 @@ -use crate::gui::ui::{UI, UNIQUE}; +use crate::ui::{UI, UNIQUE}; use crate::{RELOAD, SHUTDOWN}; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; diff --git a/src/gui/interaction_result.rs b/iota-cli/src/interaction_result.rs similarity index 97% rename from src/gui/interaction_result.rs rename to iota-cli/src/interaction_result.rs index 462149a..f9ba7e0 100644 --- a/src/gui/interaction_result.rs +++ b/iota-cli/src/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::gui::screens::screens::Screen; +use crate::screens::screens::Screen; #[allow(unused)] pub enum InteractionResult { diff --git a/src/langu/language_creator.rs b/iota-cli/src/langu/language_creator.rs similarity index 100% rename from src/langu/language_creator.rs rename to iota-cli/src/langu/language_creator.rs diff --git a/src/langu/language_manager.rs b/iota-cli/src/langu/language_manager.rs similarity index 100% rename from src/langu/language_manager.rs rename to iota-cli/src/langu/language_manager.rs diff --git a/src/langu/mod.rs b/iota-cli/src/langu/mod.rs similarity index 100% rename from src/langu/mod.rs rename to iota-cli/src/langu/mod.rs diff --git a/src/gui/mod.rs b/iota-cli/src/lib.rs similarity index 100% rename from src/gui/mod.rs rename to iota-cli/src/lib.rs diff --git a/src/gui/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs similarity index 99% rename from src/gui/screens/main_screen.rs rename to iota-cli/src/screens/main_screen.rs index 85dbbaa..e10947d 100644 --- a/src/gui/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -1,4 +1,4 @@ -use crate::gui::{ +use crate::{ elements::{ console_card::ConsoleCard, elements::{InteractableElement, JoinableElement}, diff --git a/src/gui/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs similarity index 99% rename from src/gui/screens/md_viewer.rs rename to iota-cli/src/screens/md_viewer.rs index 10eabce..2faa2f1 100644 --- a/src/gui/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use std::{any::Any, time::Duration}; -use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{interaction_result::InteractionResult, screens::screens::Screen}; pub struct FileViewer { title: String, diff --git a/src/gui/screens/screens.rs b/iota-cli/src/screens/screens.rs similarity index 89% rename from src/gui/screens/screens.rs rename to iota-cli/src/screens/screens.rs index 9676fbb..7606521 100644 --- a/src/gui/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -3,7 +3,7 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect}; -use crate::gui::interaction_result::InteractionResult; +use crate::interaction_result::InteractionResult; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavDirection { diff --git a/src/gui/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs similarity index 97% rename from src/gui/screens/terms_checker.rs rename to iota-cli/src/screens/terms_checker.rs index 26bb7c6..0808ac5 100644 --- a/src/gui/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -1,15 +1,7 @@ use crate::{ - gui::{ - interaction_result::InteractionResult, - screens::{md_viewer::FileViewer, screens::Screen}, - ui::UI, - }, - terms::{ - buttons::{checkbox, draw_buttons}, - consent_state::UserChoice, - focus::Focus, - terms_getter::{Type, get_link, get_terms}, - }, + interaction_result::InteractionResult, + screens::{md_viewer::FileViewer, screens::Screen}, + ui::UI, }; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ diff --git a/src/gui/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs similarity index 98% rename from src/gui/screens/terms_updater.rs rename to iota-cli/src/screens/terms_updater.rs index 35da364..909da8d 100644 --- a/src/gui/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -1,15 +1,6 @@ use crate::{ - gui::{ - interaction_result::InteractionResult, - screens::{md_viewer::FileViewer, screens::Screen}, - }, - terms::{ - buttons::{checkbox, draw_buttons}, - consent_state::{UpdateDecision, UserChoice}, - doc::Doc, - focus::Focus, - terms_getter::{Type, get_newest_link, get_terms}, - }, + interaction_result::InteractionResult, + screens::{md_viewer::FileViewer, screens::Screen}, }; use chrono::{Local, TimeZone, Utc}; use crossterm::event::{KeyCode, KeyEvent}; diff --git a/iota-cli/src/tui.rs b/iota-cli/src/tui.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/ui.rs b/iota-cli/src/ui.rs similarity index 96% rename from src/gui/ui.rs rename to iota-cli/src/ui.rs index 5903a4b..71e9847 100644 --- a/src/gui/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,9 +1,6 @@ use crate::{ - ACTIVE_TASKS, SHUTDOWN, - gui::{ - input_handler::setup_input_handler, interaction_result::InteractionResult, - screens::screens::Screen, - }, + input_handler::setup_input_handler, interaction_result::InteractionResult, + screens::screens::Screen, }; use crossterm::event::KeyEvent; use once_cell::sync::Lazy; diff --git a/src/gui/util/borders.rs b/iota-cli/src/util/borders.rs similarity index 100% rename from src/gui/util/borders.rs rename to iota-cli/src/util/borders.rs diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml new file mode 100644 index 0000000..7db34a2 --- /dev/null +++ b/iota-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "iota-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-state = { path = "../iota-state" } +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +dashmap = "6.1.0" +json = "*" +once_cell = "1.21.3" +pnet = "0.35.0" +ratatui = "0.30.0" +reqwest = "0.13.2" +tokio = { version = "1.50.0", features = ["full"] } diff --git a/src/main.rs b/iota-core/src/main.rs similarity index 81% rename from src/main.rs rename to iota-core/src/main.rs index 767106f..f8f4847 100644 --- a/src/main.rs +++ b/iota-core/src/main.rs @@ -1,39 +1,7 @@ -use dashmap::DashSet; -use once_cell::sync::Lazy; use pnet::datalink::NetworkInterface; -use std::sync::Arc; -use std::sync::LazyLock; -use std::sync::Mutex; -use tokio::sync::RwLock; use tokio::time::{Duration, sleep}; -mod auth; -mod gui; -mod langu; -mod omikron; -mod terms; -mod users; -mod util; - -use crate::gui::app_state; -use crate::gui::app_state::AppState; -use crate::gui::screens::main_screen::MainScreen; -use crate::gui::ui::start_tui; -use crate::langu::language_creator; -use crate::omikron::omikron_connection::OmikronConnection; -use crate::terms::consent_state; -use crate::users::user_manager; -use crate::util::config_util::CONFIG; -use crate::util::file_util::download_and_extract_zip; -use crate::util::file_util::has_dir; -use crate::util::logger; - -pub static APP_STATE: LazyLock>> = - LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); - -pub static SHUTDOWN: Lazy> = Lazy::new(|| RwLock::new(false)); -pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); -pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); +use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] #[allow(unused_must_use, dead_code)] @@ -73,7 +41,7 @@ async fn main() { println!("You can find this at 'agreements'!"); return; } - app_state::setup(); + iota_state::setup(); let main_screen = MainScreen::new(ui.clone()).await; ui.set_screen(Box::new(main_screen)).await; diff --git a/src/terms/buttons.rs b/iota-core/src/terms/buttons.rs similarity index 100% rename from src/terms/buttons.rs rename to iota-core/src/terms/buttons.rs diff --git a/src/terms/doc.rs b/iota-core/src/terms/doc.rs similarity index 100% rename from src/terms/doc.rs rename to iota-core/src/terms/doc.rs diff --git a/src/terms/focus.rs b/iota-core/src/terms/focus.rs similarity index 100% rename from src/terms/focus.rs rename to iota-core/src/terms/focus.rs diff --git a/src/terms/mod.rs b/iota-core/src/terms/mod.rs similarity index 74% rename from src/terms/mod.rs rename to iota-core/src/terms/mod.rs index 4a11008..947fc13 100644 --- a/src/terms/mod.rs +++ b/iota-core/src/terms/mod.rs @@ -1,5 +1,4 @@ pub mod buttons; -pub mod consent_state; pub mod doc; pub mod focus; pub mod terms_getter; diff --git a/src/terms/terms_getter.rs b/iota-core/src/terms/terms_getter.rs similarity index 100% rename from src/terms/terms_getter.rs rename to iota-core/src/terms/terms_getter.rs diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml new file mode 100644 index 0000000..a07b745 --- /dev/null +++ b/iota-logger/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "iota-logger" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/iota-logger/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml new file mode 100644 index 0000000..eff1cb7 --- /dev/null +++ b/iota-state/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "iota-state" +version = "0.1.0" +edition = "2024" + +[dependencies] +dashmap = "6.1.0" +once_cell = "1.21.3" +tokio = { version = "1.50.0", features = ["full"] } +json = "*" +sysinfo = "0.38.3" +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } diff --git a/src/gui/app_state.rs b/iota-state/src/lib.rs old mode 100755 new mode 100644 similarity index 76% rename from src/gui/app_state.rs rename to iota-state/src/lib.rs index a161226..5d8d351 --- a/src/gui/app_state.rs +++ b/iota-state/src/lib.rs @@ -1,7 +1,33 @@ -use crate::{ACTIVE_TASKS, APP_STATE, SHUTDOWN, gui::elements::log_card::UiLogEntry}; +use dashmap::DashSet; use json::{JsonValue, object}; -use std::{collections::VecDeque, thread, time::Duration}; +use once_cell::sync::Lazy; +use std::collections::VecDeque; +use std::sync::{Arc, LazyLock, Mutex}; +use std::thread; +use std::time::Duration; use sysinfo::{RefreshKind, System}; +use tokio::sync::RwLock; + +pub const MAX_POINTS: usize = 1000; +pub const MAX_LOGS: usize = 100; + +#[derive(Clone, Debug)] +pub struct UiLogEntry { + pub timestamp_ms: u128, + pub sender: String, + pub message: String, + pub is_error: bool, +} + +impl UiLogEntry { + pub fn format_timestamp(&self) -> String { + let secs = (self.timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } +} #[derive(Clone)] pub struct AppState { @@ -13,8 +39,6 @@ pub struct AppState { pub net_down: Vec<(f64, f64)>, pub sys_info: String, } -const MAX_POINTS: usize = 1000; -const MAX_LOGS: usize = 100; impl AppState { pub fn new() -> Self { @@ -74,34 +98,17 @@ impl AppState { self.net_down.remove(0); } } + pub fn to_json(&self) -> JsonValue { - let json = object! { - "cpu" => self.cpu - .iter() - .map(|(_, y)| *y) - .collect::>(), - "ram" => self.ram - .iter() - .map(|(_, y)| *y) - .collect::>(), - "ping" => self - .ping - .iter() - .map(|(_, y)| *y) - .collect::>(), - "net_up" => self - .net_up - .iter() - .map(|(_, y)| *y) - .collect::>(), - "net_down" => self - .net_down - .iter() - .map(|(_, y)| *y) - .collect::>(), - }; - json + object! { + "cpu" => self.cpu.iter().map(|(_, y)| *y).collect::>(), + "ram" => self.ram.iter().map(|(_, y)| *y).collect::>(), + "ping" => self.ping.iter().map(|(_, y)| *y).collect::>(), + "net_up" => self.net_up.iter().map(|(_, y)| *y).collect::>(), + "net_down" => self.net_down.iter().map(|(_, y)| *y).collect::>(), + } } + pub fn with_width(&self, width: u16) -> Self { let mut new = self.clone(); new.cpu = Self::downsample_to_fit_width(&new.cpu, width); @@ -128,10 +135,9 @@ impl AppState { .first() .map(|(x, _)| x - (dx * pad_len as f64)) .unwrap_or(0.0); - let _ = data.first().map(|(_, y)| *y).unwrap_or(0.0); for i in 0..pad_len { - result.push((start_x + i as f64 * dx, -1 as f64)); + result.push((start_x + i as f64 * dx, -1.0)); } result.extend_from_slice(data); @@ -140,6 +146,13 @@ impl AppState { } } +pub static APP_STATE: LazyLock>> = + LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); + +pub static SHUTDOWN: Lazy> = Lazy::new(|| RwLock::new(false)); +pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); +pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); + pub fn setup() { ACTIVE_TASKS.insert("System info loader".to_string()); tokio::spawn(async move { diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml new file mode 100644 index 0000000..b62ac11 --- /dev/null +++ b/iota-storage/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "iota-storage" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +aes-gcm = "0.10.3" +base64 = "0.22.1" +hex = "*" +hkdf = "0.12.4" +json = "*" +once_cell = "1.21.3" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +sha2 = "0.10.9" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs new file mode 100644 index 0000000..778de0f --- /dev/null +++ b/iota-storage/src/lib.rs @@ -0,0 +1,2 @@ +mod users; +mod util; diff --git a/src/users/contact.rs b/iota-storage/src/users/contact.rs similarity index 100% rename from src/users/contact.rs rename to iota-storage/src/users/contact.rs diff --git a/src/users/mod.rs b/iota-storage/src/users/mod.rs similarity index 100% rename from src/users/mod.rs rename to iota-storage/src/users/mod.rs diff --git a/src/users/user_community_util.rs b/iota-storage/src/users/user_community_util.rs similarity index 100% rename from src/users/user_community_util.rs rename to iota-storage/src/users/user_community_util.rs diff --git a/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs similarity index 100% rename from src/users/user_manager.rs rename to iota-storage/src/users/user_manager.rs diff --git a/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs similarity index 100% rename from src/users/user_profile.rs rename to iota-storage/src/users/user_profile.rs diff --git a/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs similarity index 100% rename from src/util/chat_files.rs rename to iota-storage/src/util/chat_files.rs diff --git a/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs similarity index 100% rename from src/util/chats_util.rs rename to iota-storage/src/util/chats_util.rs diff --git a/src/util/communities_util.rs b/iota-storage/src/util/communities_util.rs similarity index 100% rename from src/util/communities_util.rs rename to iota-storage/src/util/communities_util.rs diff --git a/src/util/config_util.rs b/iota-storage/src/util/config_util.rs similarity index 100% rename from src/util/config_util.rs rename to iota-storage/src/util/config_util.rs diff --git a/src/util/crypto_helper.rs b/iota-storage/src/util/crypto_helper.rs similarity index 100% rename from src/util/crypto_helper.rs rename to iota-storage/src/util/crypto_helper.rs diff --git a/src/util/crypto_util.rs b/iota-storage/src/util/crypto_util.rs similarity index 100% rename from src/util/crypto_util.rs rename to iota-storage/src/util/crypto_util.rs diff --git a/src/util/db.rs b/iota-storage/src/util/db.rs similarity index 100% rename from src/util/db.rs rename to iota-storage/src/util/db.rs diff --git a/src/util/file_util.rs b/iota-storage/src/util/file_util.rs similarity index 100% rename from src/util/file_util.rs rename to iota-storage/src/util/file_util.rs diff --git a/src/util/logger.rs b/iota-storage/src/util/logger.rs similarity index 100% rename from src/util/logger.rs rename to iota-storage/src/util/logger.rs diff --git a/src/util/mod.rs b/iota-storage/src/util/mod.rs similarity index 100% rename from src/util/mod.rs rename to iota-storage/src/util/mod.rs diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml new file mode 100644 index 0000000..89aa2d4 --- /dev/null +++ b/omikron-connector/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "omikron-connector" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +dashmap = "6.1.0" +json = "*" +tokio = { version = "1.50.0", features = ["full"] } +uuid = { version = "*", features = ["v4"] } diff --git a/src/omikron/mod.rs b/omikron-connector/src/lib.rs similarity index 100% rename from src/omikron/mod.rs rename to omikron-connector/src/lib.rs diff --git a/src/omikron/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs similarity index 100% rename from src/omikron/omikron_connection.rs rename to omikron-connector/src/omikron_connection.rs diff --git a/src/omikron/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs similarity index 100% rename from src/omikron/ping_pong_task.rs rename to omikron-connector/src/ping_pong_task.rs diff --git a/src/terms/consent_state.rs b/src/terms/consent_state.rs deleted file mode 100644 index 1cc6e0a..0000000 --- a/src/terms/consent_state.rs +++ /dev/null @@ -1,491 +0,0 @@ -use tokio::sync::oneshot; - -use crate::{ - gui::{ - screens::{terms_checker::TermsCheckerScreen, terms_updater::TermsUpdaterScreen}, - ui::UI, - }, - terms::{ - doc::Doc, - terms_getter::{Type, get_current_docs, get_newest_docs}, - }, - util::file_util::{load_file, save_file}, -}; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub async fn check(ui: Arc) -> (bool, bool) { - let mut state = ConsentState::load_state(); - - if ensure_initial_consent(ui.clone(), &mut state) - .await - .is_err() - { - return (false, false); - } - if ensure_updates(ui, &mut state).await.is_err() { - return (false, false); - }; - - state = state.sanitize(); - state.save_state(); - - (state.accepted_eula, state.accepted_tos && state.accepted_pp) -} - -async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { - if state.accepted_eula { - return Ok(()); - } - - let (tx, rx) = oneshot::channel(); - - ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) - .await; - - let result = rx.await.unwrap_or(UserChoice::Deny); - - match result { - UserChoice::AcceptEULA | UserChoice::AcceptAll => { - if let Some((eula, tos, privacy)) = get_current_docs().await { - state.accepted_eula = true; - state.eula = Some(eula); - - if matches!(result, UserChoice::AcceptAll) { - state.accepted_tos = true; - state.accepted_pp = true; - state.tos = Some(tos); - state.privacy = Some(privacy); - } - } - - let _ = &state.save_state(); - Ok(()) - } - UserChoice::Deny => Err(()), - } -} -async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { - let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { - return Ok(()); - }; - - let is_forced = matches!(eula_update, UpdateDecision::Forced(_)) - || matches!(tos_update, UpdateDecision::Forced(_)) - || matches!(privacy_update, UpdateDecision::Forced(_)); - - let (tx, rx) = oneshot::channel(); - - ui.set_screen(Box::new(TermsUpdaterScreen::new( - eula_update.clone(), - tos_update.clone(), - privacy_update.clone(), - Some(tx), - ))) - .await; - - let result = rx.await.unwrap_or(UserChoice::Deny); - - if is_forced { - match result { - UserChoice::AcceptAll => { - state.accepted_eula = true; - state.accepted_tos = true; - state.accepted_pp = true; - } - UserChoice::AcceptEULA => { - state.accepted_eula = true; - } - UserChoice::Deny => return Err(()), - } - } else { - apply_future_updates(state, result, eula_update, tos_update, privacy_update); - } - - state.save_state(); - Ok(()) -} -fn apply_future_updates( - state: &mut ConsentState, - result: UserChoice, - eula_update: UpdateDecision, - tos_update: UpdateDecision, - privacy_update: UpdateDecision, -) { - match result { - UserChoice::AcceptAll => { - if let UpdateDecision::Future { newest } = eula_update { - state.future_eula = Some(newest); - } - if let UpdateDecision::Future { newest } = tos_update { - state.future_tos = Some(newest); - } - if let UpdateDecision::Future { newest } = privacy_update { - state.future_privacy = Some(newest); - } - } - UserChoice::AcceptEULA => { - if let UpdateDecision::Future { newest } = eula_update { - state.future_eula = Some(newest); - } - } - UserChoice::Deny => {} - } -} - -async fn get_updates() -> Option<( - // Ok(None) indicates no update - // Ok(Some) Indicates a future update - // Err indicates a update that has to be accepted before the programm can continue - UpdateDecision, - UpdateDecision, - UpdateDecision, -)> { - if let ( - Some((current_eula, current_tos, current_privacy)), - Some((newest_eula, newest_tos, newest_privacy)), - ) = (get_current_docs().await, get_newest_docs().await) - { - let file = load_file("", "agreements"); - let accepted_state = ConsentState::from_str(&file).sanitize(); - save_file("", "agreements", &accepted_state.to_string()); - - let eula_update: UpdateDecision = if current_eula.equals_some(&accepted_state.eula) { - if current_eula.equals(&newest_eula) { - UpdateDecision::NoChange - } else { - if newest_eula.equals_some(&accepted_state.future_eula) { - UpdateDecision::NoChange - } else { - UpdateDecision::Future { - newest: newest_eula, - } - } - } - } else if newest_eula.equals_some(&accepted_state.eula) { - UpdateDecision::NoChange - } else { - UpdateDecision::Forced(current_eula) - }; - - let tos_update: UpdateDecision = - if !accepted_state.accepted_tos || newest_tos.equals_some(&accepted_state.tos) { - UpdateDecision::NoChange - } else if accepted_state.accepted_tos && current_tos.equals_some(&accepted_state.tos) { - if current_tos.equals(&newest_tos) { - UpdateDecision::NoChange - } else { - if newest_tos.equals_some(&accepted_state.future_tos) { - UpdateDecision::NoChange - } else { - UpdateDecision::Future { newest: newest_tos } - } - } - } else { - UpdateDecision::Forced(current_tos) - }; - - let privacy_update: UpdateDecision = if !accepted_state.accepted_pp - || newest_privacy.equals_some(&accepted_state.privacy) - { - UpdateDecision::NoChange - } else if accepted_state.accepted_pp && current_privacy.equals_some(&accepted_state.privacy) - { - if current_privacy.equals(&newest_privacy) { - UpdateDecision::NoChange - } else { - if newest_privacy.equals_some(&accepted_state.future_privacy) { - UpdateDecision::NoChange - } else { - UpdateDecision::Future { - newest: newest_privacy, - } - } - } - } else { - UpdateDecision::Forced(current_privacy) - }; - match (&eula_update, &tos_update, &privacy_update) { - (&UpdateDecision::NoChange, &UpdateDecision::NoChange, &UpdateDecision::NoChange) => { - None - } - _ => Some((eula_update, tos_update, privacy_update)), - } - } else { - None - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum UserChoice { - Deny, - AcceptEULA, - AcceptAll, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum UpdateDecision { - NoChange, - Future { newest: Doc }, - Forced(Doc), -} - -#[derive(Debug, Clone)] -pub struct ConsentState { - pub eula: Option, - pub accepted_eula: bool, - pub future_eula: Option, - - pub tos: Option, - pub accepted_tos: bool, - pub future_tos: Option, - - pub privacy: Option, - pub accepted_pp: bool, - pub future_privacy: Option, -} - -impl ConsentState { - fn sanitize(mut self) -> Self { - let current_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - if let Some(future_eula) = self.future_eula.clone() { - if future_eula.get_time() < current_secs { - self.eula = Some(future_eula); - self.future_eula = None; - } - } - if let Some(future_tos) = self.future_tos.clone() { - if future_tos.get_time() < current_secs { - self.tos = Some(future_tos); - self.future_tos = None; - } - } - if let Some(future_privacy) = self.future_privacy.clone() { - if future_privacy.get_time() < current_secs { - self.privacy = Some(future_privacy); - self.future_privacy = None; - } - } - - if !self.accepted_eula { - self.accepted_tos = false; - self.accepted_pp = false; - } - self - } - - pub fn load_state() -> ConsentState { - let file = load_file("", "agreements"); - ConsentState::from_str(&file).sanitize() - } - - pub fn save_state(&self) { - save_file("", "agreements", &self.to_string()); - } - - fn to_string(&self) -> String { - let current_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let mut file_out: String = format!( - "This file reflects the current consent state used by the application.\ - \nIt may be regenerated or overwritten by the application.\ - \nThis file was last edited by Tensamin at:\ - \nUNIX-SECOND={}", - current_secs - ); - - if let Some(eula) = &self.eula { - file_out.push_str(&format!("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\ - \nEULA={}\ - \nEULA-VERSION={}\ - \nEULA-HASH={}\ - ", self.accepted_eula, eula.get_version(), eula.get_hash())); - - if self.accepted_tos - && let Some(tos) = &self.tos - { - file_out.push_str(&format!("\ - \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.tensamin.net/tos/\ - \nTerms-of-Service={}\ - \nTerms-of-Service-VERSION={}\ - \nTerms-of-Service-HASH={}\ - ", self.accepted_tos, tos.get_version(), tos.get_hash())); - } - if self.accepted_pp - && let Some(pp) = &self.privacy - { - file_out.push_str(&format!("\ - \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy/\ - \nPrivacy-Policy={}\ - \nPrivacy-Policy-VERSION={}\ - \nPrivacy-Policy-HASH={}\ - ", self.accepted_pp, pp.get_version(), pp.get_hash())); - } - } else { - file_out.push_str("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.tensamin.net/eula/\ - \nEULA=false\ - "); - } - - if let Some(eula) = &self.future_eula { - file_out.push_str(&format!( - "\ - \nFUTURE-EULA-VERSION={}\ - \nFUTURE-EULA-HASH={}\ - \nFUTURE-EULA-TIME={}\ - ", - eula.get_version(), - eula.get_hash(), - eula.get_time() - )); - } - if let Some(tos) = &self.future_tos { - file_out.push_str(&format!( - "\ - \nFUTURE-Terms-of-Service-VERSION={}\ - \nFUTURE-Terms-of-Service-HASH={}\ - \nFUTURE-Terms-of-Service-TIME={}\ - ", - tos.get_version(), - tos.get_hash(), - tos.get_time() - )); - } - if let Some(pp) = &self.future_privacy { - file_out.push_str(&format!( - "\ - \nFUTURE-Privacy-Policy-VERSION={}\ - \nFUTURE-Privacy-Policy-HASH={}\ - \nFUTURE-Privacy-Policy-TIME={}\ - ", - pp.get_version(), - pp.get_hash(), - pp.get_time() - )); - } - - file_out - } - - fn from_str(s: &str) -> Self { - let mut eula = false; - let mut eula_version = String::new(); - let mut eula_hash = String::new(); - let mut pp = false; - let mut pp_version = String::new(); - let mut pp_hash = String::new(); - let mut tos = false; - let mut tos_version = String::new(); - let mut tos_hash = String::new(); - - let mut future_eula_version = String::new(); - let mut future_eula_hash = String::new(); - let mut future_eula_time = String::new(); - - let mut future_tos_version = String::new(); - let mut future_tos_hash = String::new(); - let mut future_tos_time = String::new(); - - let mut future_pp_version = String::new(); - let mut future_pp_hash = String::new(); - let mut future_pp_time = String::new(); - - let mut unix = String::new(); - - for line in s.lines() { - if let Some(v) = line.strip_prefix("EULA=") { - eula = v == "true"; - } else if let Some(v) = line.strip_prefix("EULA-VERSION=") { - eula_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("EULA-HASH=") { - eula_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("Terms-of-Service=") { - tos = v == "true"; - } else if let Some(v) = line.strip_prefix("Terms-of-Service-VERSION=") { - tos_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("Terms-of-Service-HASH=") { - tos_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("Privacy-Policy=") { - pp = v == "true"; - } else if let Some(v) = line.strip_prefix("Privacy-Policy-VERSION=") { - pp_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("Privacy-Policy-HASH=") { - pp_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("UNIX-SECOND=") { - unix = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-EULA-VERSION=") { - future_eula_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-EULA-HASH=") { - future_eula_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-EULA-TIME=") { - future_eula_time = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-VERSION=") { - future_tos_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-HASH=") { - future_tos_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-TIME=") { - future_tos_time = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-VERSION=") { - future_pp_version = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-HASH=") { - future_pp_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-TIME=") { - future_pp_time = v.to_string(); - } - } - let unix: u64 = unix.parse::().unwrap_or(0); - let future_eula_time = future_eula_time.parse::().unwrap_or(0); - let future_tos_time = future_tos_time.parse::().unwrap_or(0); - let future_pp_time = future_pp_time.parse::().unwrap_or(0); - let state = Self { - accepted_eula: eula, - eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)), - accepted_pp: pp, - privacy: Some(Doc::new(pp_version, pp_hash, Type::PP, unix)), - accepted_tos: tos, - tos: Some(Doc::new(tos_version, tos_hash, Type::TOS, unix)), - future_eula: if !future_eula_version.is_empty() { - Some(Doc::new( - future_eula_version, - future_eula_hash, - Type::EULA, - future_eula_time, - )) - } else { - None - }, - future_tos: if !future_tos_version.is_empty() { - Some(Doc::new( - future_tos_version, - future_tos_hash, - Type::TOS, - future_tos_time, - )) - } else { - None - }, - future_privacy: if !future_pp_version.is_empty() { - Some(Doc::new( - future_pp_version, - future_pp_hash, - Type::PP, - future_pp_time, - )) - } else { - None - }, - } - .sanitize(); - - state - } -} diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml new file mode 100644 index 0000000..fdeeb44 --- /dev/null +++ b/web-server/Cargo.toml @@ -0,0 +1,9 @@ + +[package] +name = "iota" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml new file mode 100644 index 0000000..4d2ef71 --- /dev/null +++ b/web-ui/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "web-ui" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/src/server/api.rs b/web-ui/src/api.rs similarity index 100% rename from src/server/api.rs rename to web-ui/src/api.rs diff --git a/src/server/mod.rs b/web-ui/src/lib.rs similarity index 100% rename from src/server/mod.rs rename to web-ui/src/lib.rs diff --git a/src/server/server.rs b/web-ui/src/server.rs similarity index 100% rename from src/server/server.rs rename to web-ui/src/server.rs diff --git a/src/server/web_path_parser.rs b/web-ui/src/web_path_parser.rs similarity index 100% rename from src/server/web_path_parser.rs rename to web-ui/src/web_path_parser.rs From 71ac8d97fa1ac40b83090f5ce97f276f4aa39c13 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:43:54 +0200 Subject: [PATCH 002/119] ... --- Cargo.lock | 1 + Cargo.toml | 2 +- iota-cli/Cargo.toml | 3 + iota-cli/src/lib.rs | 2 + .../terms => iota-cli/src/util}/buttons.rs | 2 +- .../src/util/terms_focus.rs | 0 iota-logger/src/lib.rs | 440 +++++++++++++++++- iota-storage/src/util/logger.rs | 432 ----------------- iota-terms/Cargo.toml | 10 + .../src/terms => iota-terms/src}/doc.rs | 0 .../src/terms/mod.rs => iota-terms/src/lib.rs | 2 - .../terms => iota-terms/src}/terms_getter.rs | 0 12 files changed, 447 insertions(+), 447 deletions(-) rename {iota-core/src/terms => iota-cli/src/util}/buttons.rs (99%) rename iota-core/src/terms/focus.rs => iota-cli/src/util/terms_focus.rs (100%) delete mode 100755 iota-storage/src/util/logger.rs create mode 100644 iota-terms/Cargo.toml rename {iota-core/src/terms => iota-terms/src}/doc.rs (100%) rename iota-core/src/terms/mod.rs => iota-terms/src/lib.rs (52%) rename {iota-core/src/terms => iota-terms/src}/terms_getter.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 334fe02..1b603bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1793,6 +1793,7 @@ dependencies = [ "hkdf", "hyper", "hyper-util", + "iota-core", "iota-state", "json", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 7d4ef81..f9744a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["iota-storage", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "decentralized", "iota-logger"] +members = ["iota-storage", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "decentralized", "iota-logger"] diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 029e7e0..a963a07 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" [dependencies] iota-state = { path = "../iota-state" } +iota-core = { path = "../iota-terms" } + + ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index b355046..aaa23ef 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -13,6 +13,8 @@ pub mod screens { } pub mod util { pub mod borders; + pub mod buttons; + pub mod terms_focus; } pub mod app_state; pub mod input_handler; diff --git a/iota-core/src/terms/buttons.rs b/iota-cli/src/util/buttons.rs similarity index 99% rename from iota-core/src/terms/buttons.rs rename to iota-cli/src/util/buttons.rs index c545a4c..6c93cce 100644 --- a/iota-core/src/terms/buttons.rs +++ b/iota-cli/src/util/buttons.rs @@ -5,7 +5,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; -use crate::terms::focus::Focus; +use crate::util::terms_focus::Focus; #[allow(mismatched_lifetime_syntaxes)] pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line { diff --git a/iota-core/src/terms/focus.rs b/iota-cli/src/util/terms_focus.rs similarity index 100% rename from iota-core/src/terms/focus.rs rename to iota-cli/src/util/terms_focus.rs diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index b93cf3f..a5feb02 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -1,14 +1,432 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use std::{ + collections::BTreeMap, + fs::{self, OpenOptions}, + io::Write, + path::Path, + sync::{OnceLock, atomic::Ordering, mpsc}, + thread, + time::{SystemTime, UNIX_EPOCH}, +}; + +use ratatui::style::Color; +use ttp_core::{CommunicationValue, DataTypes, DataValue}; + +use crate::{ + APP_STATE, + gui::{elements::log_card::LogEntry, ui::UNIQUE}, + langu::language_manager, +}; + +static LOGGER: OnceLock> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[allow(unused)] +pub enum PrintType { + Call, + Client, + Iota, + Omikron, + Omega, + General, + Command, } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); +impl PrintType { + pub fn prefix_color(self) -> Color { + match self { + PrintType::Call => Color::Magenta, + PrintType::Client => Color::Green, + PrintType::Iota => Color::Yellow, + PrintType::Omikron => Color::Blue, + PrintType::Omega => Color::Cyan, + PrintType::General => Color::LightCyan, + PrintType::Command => Color::LightGreen, + } } } + +struct LogMessage { + timestamp_ms: u128, + prefix: String, + kind: PrintType, + is_error: bool, + translation_key: Option, + format_args: Vec, + message: Option, +} + +pub fn startup() { + let (tx, rx) = mpsc::channel::(); + LOGGER.set(tx).expect("Logger already initialized"); + + thread::spawn(move || { + let log_dir = Path::new("logs"); + fs::create_dir_all(log_dir).expect("Failed to create log directory"); + + let start_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let path = log_dir.join(format!("log_{}.txt", start_ts)); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open log file"); + + for msg in rx { + let resolved_message = if let Some(key) = msg.translation_key { + let args: Vec<&str> = msg.format_args.iter().map(|s| s.as_str()).collect(); + language_manager::format(&key, &args) + } else { + msg.message.unwrap_or_default() + }; + + let timestamp = format_timestamp_inline(msg.timestamp_ms); + let entry = LogEntry::new(msg.kind, resolved_message, msg.is_error); + + let prefix = if msg.prefix.is_empty() { + String::new() + } else { + format!("{} ", msg.prefix) + }; + + let _ = writeln!( + file, + "{} {}{}", + fixed_box(&msg.timestamp_ms.to_string(), 13), + prefix, + entry.message + ); + + let _ = writeln!(file, " {}", timestamp); + + let mut state = APP_STATE.lock().unwrap(); + state.push_log(entry.into()); + } + }); +} + +fn format_timestamp_inline(timestamp_ms: u128) -> String { + let secs = (timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds) +} + +fn fixed_box(content: &str, width: usize) -> String { + let s: String = content.chars().take(width).collect(); + let len = s.chars().count(); + if len < width { + format!("[{}{}]", " ".repeat(width - len), s) + } else { + s + } +} + +fn colorize(kind: PrintType, is_error: bool) -> Color { + if is_error { + return Color::Red; + } + + match kind { + PrintType::Call => Color::Magenta, + PrintType::Client => Color::Green, + PrintType::Iota => Color::Yellow, + PrintType::Omikron => Color::Blue, + PrintType::Omega => Color::Cyan, + PrintType::General => Color::LightCyan, + PrintType::Command => Color::LightGreen, + } +} + +pub fn log_internal_translated( + kind: PrintType, + prefix: String, + is_error: bool, + key: &str, + args: Vec, +) { + if let Some(tx) = LOGGER.get() { + UNIQUE.store(true, Ordering::Relaxed); + let _ = tx.send(LogMessage { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + prefix, + kind, + is_error, + translation_key: Some(key.to_string()), + format_args: args, + message: None, + }); + } +} + +pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: String) { + if let Some(tx) = LOGGER.get() { + UNIQUE.store(true, Ordering::Relaxed); + let _ = tx.send(LogMessage { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + prefix, + kind, + is_error, + translation_key: None, + format_args: Vec::new(), + message: Some(message), + }); + } +} + +#[macro_export] +macro_rules! log_t { + ($key:expr) => { + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, + "".to_string(), + false, + $key, + vec![] + ) + }; + + ($key:expr, $($arg:expr),+) => { + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, + "".to_string(), + false, + $key, + vec![$($arg),+] + ) + }; +} + +#[macro_export] +macro_rules! log_t_err { + ($key:expr) => { + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, + "".to_string(), + true, + $key, + vec![] + ) + }; + + ($key:expr, $($arg:expr),+) => { + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, + "".to_string(), + true, + $key, + vec![$($arg.to_string()),+] + ) + }; +} + +/// Log a command message. +#[macro_export] +macro_rules! log_command { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::Command, + "".to_string(), + false, + format!($($arg)*) + ) + }; +} + +/// Log a general informational message. +#[macro_export] +macro_rules! log { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, + "".to_string(), + false, + format!($($arg)*) + ) + }; +} + +/// Log an inbound message (`>`). +#[macro_export] +macro_rules! log_in { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, + ">".to_string(), + false, + format!($($arg)*) + ) + }; +} + +/// Log an outbound message (`<`). +#[macro_export] +macro_rules! log_out { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, + "<".to_string(), + false, + format!($($arg)*) + ) + }; +} + +/// Log an error message (`>>`). +#[macro_export] +macro_rules! log_err { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, + ">>".to_string(), + true, + format!($($arg)*) + ) + }; +} + +// ******** COMMUNICATION VALUES ******** +pub fn log_cv_internal( + prefix: &'static str, + cv: &CommunicationValue, + print_type: Option, +) { + let formatted = format_cv(cv); + + log_internal( + print_type.unwrap_or(PrintType::General), + prefix.to_string(), + false, + formatted, + ); +} + +pub fn format_cv(cv: &CommunicationValue) -> String { + let mut parts = Vec::new(); + + let sender = cv.get_sender(); + let receiver = cv.get_receiver(); + + if sender > 0 && receiver > 0 { + parts.push(format!("{} > {}", sender, receiver)); + } else if sender > 0 { + parts.push(format!("{}", sender)); + } else if receiver > 0 { + parts.push(format!("> {}", receiver)); + } + + let comm_type = cv.get_type().to_string(); + parts.push(format!("{}", comm_type)); + + let data: &BTreeMap = cv.get_data_container(); + + let formated_data = + format_data_container(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); + + parts.push(format!("{}", formated_data)); + + parts.join(": ") +} + +fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { + let parts: Vec = data + .into_iter() + .map(|(key, value)| { + let key_str = key.to_string(); + + match value { + DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), + + DataValue::Container(inner) => { + let inner_formatted = format_data_container(inner); + format!("{}={{ {} }}", key_str, inner_formatted) + } + + DataValue::Array(arr) => { + let arr_formatted = format_array(arr); + format!("{}=[{}]", key_str, arr_formatted) + } + + DataValue::Bool(b) => format!("{}={}", key_str, b), + + DataValue::BoolTrue => format!("{}=true", key_str), + DataValue::BoolFalse => format!("{}=false", key_str), + + DataValue::Number(num) => format!("{}={}", key_str, num), + + _ => "".to_string(), + } + }) + .collect(); + + parts.join(", ") +} + +fn format_array(arr: Vec) -> String { + let parts: Vec = arr + .into_iter() + .map(|value| match value { + DataValue::Str(s) => format!("\"{}\"", s), + + DataValue::Container(inner) => { + let inner_formatted = format_data_container(inner); + format!("{{ {} }}", inner_formatted) + } + + DataValue::Array(inner_arr) => { + let formatted = format_array(inner_arr); + format!("[{}]", formatted) + } + + DataValue::Bool(b) => b.to_string(), + + DataValue::BoolTrue => "true".to_string(), + DataValue::BoolFalse => "false".to_string(), + + DataValue::Number(num) => num.to_string(), + + _ => String::new(), + }) + .collect(); + + parts.join(", ") +} + +#[macro_export] +macro_rules! log_cv { + ($kind:expr, $cv:expr) => { + $crate::util::logger::log_cv_internal("", &$cv, Some($kind)) + }; + ($cv:expr) => { + $crate::util::logger::log_cv_internal("", &$cv, None) + }; +} + +#[macro_export] +macro_rules! log_cv_in { + ($kind:expr, $cv:expr) => { + $crate::util::logger::log_cv_internal("> ", &$cv, Some($kind)) + }; + ($cv:expr) => { + $crate::util::logger::log_cv_internal("> ", &$cv, None) + }; +} + +#[macro_export] +macro_rules! log_cv_out { + ($kind:expr, $cv:expr) => { + $crate::util::logger::log_cv_internal("< ", &$cv, Some($kind)) + }; + ($cv:expr) => { + $crate::util::logger::log_cv_internal("< ", &$cv, None) + }; +} diff --git a/iota-storage/src/util/logger.rs b/iota-storage/src/util/logger.rs deleted file mode 100755 index a5feb02..0000000 --- a/iota-storage/src/util/logger.rs +++ /dev/null @@ -1,432 +0,0 @@ -use std::{ - collections::BTreeMap, - fs::{self, OpenOptions}, - io::Write, - path::Path, - sync::{OnceLock, atomic::Ordering, mpsc}, - thread, - time::{SystemTime, UNIX_EPOCH}, -}; - -use ratatui::style::Color; -use ttp_core::{CommunicationValue, DataTypes, DataValue}; - -use crate::{ - APP_STATE, - gui::{elements::log_card::LogEntry, ui::UNIQUE}, - langu::language_manager, -}; - -static LOGGER: OnceLock> = OnceLock::new(); - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[allow(unused)] -pub enum PrintType { - Call, - Client, - Iota, - Omikron, - Omega, - General, - Command, -} -impl PrintType { - pub fn prefix_color(self) -> Color { - match self { - PrintType::Call => Color::Magenta, - PrintType::Client => Color::Green, - PrintType::Iota => Color::Yellow, - PrintType::Omikron => Color::Blue, - PrintType::Omega => Color::Cyan, - PrintType::General => Color::LightCyan, - PrintType::Command => Color::LightGreen, - } - } -} - -struct LogMessage { - timestamp_ms: u128, - prefix: String, - kind: PrintType, - is_error: bool, - translation_key: Option, - format_args: Vec, - message: Option, -} - -pub fn startup() { - let (tx, rx) = mpsc::channel::(); - LOGGER.set(tx).expect("Logger already initialized"); - - thread::spawn(move || { - let log_dir = Path::new("logs"); - fs::create_dir_all(log_dir).expect("Failed to create log directory"); - - let start_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let path = log_dir.join(format!("log_{}.txt", start_ts)); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .expect("Failed to open log file"); - - for msg in rx { - let resolved_message = if let Some(key) = msg.translation_key { - let args: Vec<&str> = msg.format_args.iter().map(|s| s.as_str()).collect(); - language_manager::format(&key, &args) - } else { - msg.message.unwrap_or_default() - }; - - let timestamp = format_timestamp_inline(msg.timestamp_ms); - let entry = LogEntry::new(msg.kind, resolved_message, msg.is_error); - - let prefix = if msg.prefix.is_empty() { - String::new() - } else { - format!("{} ", msg.prefix) - }; - - let _ = writeln!( - file, - "{} {}{}", - fixed_box(&msg.timestamp_ms.to_string(), 13), - prefix, - entry.message - ); - - let _ = writeln!(file, " {}", timestamp); - - let mut state = APP_STATE.lock().unwrap(); - state.push_log(entry.into()); - } - }); -} - -fn format_timestamp_inline(timestamp_ms: u128) -> String { - let secs = (timestamp_ms / 1000) as i64; - let hours = (secs / 3600) % 24; - let minutes = (secs / 60) % 60; - let seconds = secs % 60; - format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds) -} - -fn fixed_box(content: &str, width: usize) -> String { - let s: String = content.chars().take(width).collect(); - let len = s.chars().count(); - if len < width { - format!("[{}{}]", " ".repeat(width - len), s) - } else { - s - } -} - -fn colorize(kind: PrintType, is_error: bool) -> Color { - if is_error { - return Color::Red; - } - - match kind { - PrintType::Call => Color::Magenta, - PrintType::Client => Color::Green, - PrintType::Iota => Color::Yellow, - PrintType::Omikron => Color::Blue, - PrintType::Omega => Color::Cyan, - PrintType::General => Color::LightCyan, - PrintType::Command => Color::LightGreen, - } -} - -pub fn log_internal_translated( - kind: PrintType, - prefix: String, - is_error: bool, - key: &str, - args: Vec, -) { - if let Some(tx) = LOGGER.get() { - UNIQUE.store(true, Ordering::Relaxed); - let _ = tx.send(LogMessage { - timestamp_ms: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(), - prefix, - kind, - is_error, - translation_key: Some(key.to_string()), - format_args: args, - message: None, - }); - } -} - -pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: String) { - if let Some(tx) = LOGGER.get() { - UNIQUE.store(true, Ordering::Relaxed); - let _ = tx.send(LogMessage { - timestamp_ms: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(), - prefix, - kind, - is_error, - translation_key: None, - format_args: Vec::new(), - message: Some(message), - }); - } -} - -#[macro_export] -macro_rules! log_t { - ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, - "".to_string(), - false, - $key, - vec![] - ) - }; - - ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, - "".to_string(), - false, - $key, - vec![$($arg),+] - ) - }; -} - -#[macro_export] -macro_rules! log_t_err { - ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, - "".to_string(), - true, - $key, - vec![] - ) - }; - - ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, - "".to_string(), - true, - $key, - vec![$($arg.to_string()),+] - ) - }; -} - -/// Log a command message. -#[macro_export] -macro_rules! log_command { - ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::Command, - "".to_string(), - false, - format!($($arg)*) - ) - }; -} - -/// Log a general informational message. -#[macro_export] -macro_rules! log { - ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, - "".to_string(), - false, - format!($($arg)*) - ) - }; -} - -/// Log an inbound message (`>`). -#[macro_export] -macro_rules! log_in { - ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, - ">".to_string(), - false, - format!($($arg)*) - ) - }; -} - -/// Log an outbound message (`<`). -#[macro_export] -macro_rules! log_out { - ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, - "<".to_string(), - false, - format!($($arg)*) - ) - }; -} - -/// Log an error message (`>>`). -#[macro_export] -macro_rules! log_err { - ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, - ">>".to_string(), - true, - format!($($arg)*) - ) - }; -} - -// ******** COMMUNICATION VALUES ******** -pub fn log_cv_internal( - prefix: &'static str, - cv: &CommunicationValue, - print_type: Option, -) { - let formatted = format_cv(cv); - - log_internal( - print_type.unwrap_or(PrintType::General), - prefix.to_string(), - false, - formatted, - ); -} - -pub fn format_cv(cv: &CommunicationValue) -> String { - let mut parts = Vec::new(); - - let sender = cv.get_sender(); - let receiver = cv.get_receiver(); - - if sender > 0 && receiver > 0 { - parts.push(format!("{} > {}", sender, receiver)); - } else if sender > 0 { - parts.push(format!("{}", sender)); - } else if receiver > 0 { - parts.push(format!("> {}", receiver)); - } - - let comm_type = cv.get_type().to_string(); - parts.push(format!("{}", comm_type)); - - let data: &BTreeMap = cv.get_data_container(); - - let formated_data = - format_data_container(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); - - parts.push(format!("{}", formated_data)); - - parts.join(": ") -} - -fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { - let parts: Vec = data - .into_iter() - .map(|(key, value)| { - let key_str = key.to_string(); - - match value { - DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), - - DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); - format!("{}={{ {} }}", key_str, inner_formatted) - } - - DataValue::Array(arr) => { - let arr_formatted = format_array(arr); - format!("{}=[{}]", key_str, arr_formatted) - } - - DataValue::Bool(b) => format!("{}={}", key_str, b), - - DataValue::BoolTrue => format!("{}=true", key_str), - DataValue::BoolFalse => format!("{}=false", key_str), - - DataValue::Number(num) => format!("{}={}", key_str, num), - - _ => "".to_string(), - } - }) - .collect(); - - parts.join(", ") -} - -fn format_array(arr: Vec) -> String { - let parts: Vec = arr - .into_iter() - .map(|value| match value { - DataValue::Str(s) => format!("\"{}\"", s), - - DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); - format!("{{ {} }}", inner_formatted) - } - - DataValue::Array(inner_arr) => { - let formatted = format_array(inner_arr); - format!("[{}]", formatted) - } - - DataValue::Bool(b) => b.to_string(), - - DataValue::BoolTrue => "true".to_string(), - DataValue::BoolFalse => "false".to_string(), - - DataValue::Number(num) => num.to_string(), - - _ => String::new(), - }) - .collect(); - - parts.join(", ") -} - -#[macro_export] -macro_rules! log_cv { - ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("", &$cv, Some($kind)) - }; - ($cv:expr) => { - $crate::util::logger::log_cv_internal("", &$cv, None) - }; -} - -#[macro_export] -macro_rules! log_cv_in { - ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, Some($kind)) - }; - ($cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, None) - }; -} - -#[macro_export] -macro_rules! log_cv_out { - ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, Some($kind)) - }; - ($cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, None) - }; -} diff --git a/iota-terms/Cargo.toml b/iota-terms/Cargo.toml new file mode 100644 index 0000000..070687c --- /dev/null +++ b/iota-terms/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "iota-terms" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-state = { path = "../iota-state" } + +json = "*" +reqwest = "0.13.2" diff --git a/iota-core/src/terms/doc.rs b/iota-terms/src/doc.rs similarity index 100% rename from iota-core/src/terms/doc.rs rename to iota-terms/src/doc.rs diff --git a/iota-core/src/terms/mod.rs b/iota-terms/src/lib.rs similarity index 52% rename from iota-core/src/terms/mod.rs rename to iota-terms/src/lib.rs index 947fc13..b03efc9 100644 --- a/iota-core/src/terms/mod.rs +++ b/iota-terms/src/lib.rs @@ -1,4 +1,2 @@ -pub mod buttons; pub mod doc; -pub mod focus; pub mod terms_getter; diff --git a/iota-core/src/terms/terms_getter.rs b/iota-terms/src/terms_getter.rs similarity index 100% rename from iota-core/src/terms/terms_getter.rs rename to iota-terms/src/terms_getter.rs From 05763e7dd39e8579d36bd959ff6b765e65111a64 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:22:46 +0200 Subject: [PATCH 003/119] ... --- Cargo.lock | 52 +++++++++++++++---- iota-cli/Cargo.toml | 6 ++- iota-core/Cargo.toml | 9 ++++ iota-util/Cargo.toml | 15 ++++++ .../util => iota-util/src}/crypto_helper.rs | 0 .../src/util => iota-util/src}/crypto_util.rs | 0 .../src/util => iota-util/src}/file_util.rs | 0 iota-util/src/lib.rs | 0 web-server/Cargo.toml | 2 +- 9 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 iota-util/Cargo.toml rename {iota-storage/src/util => iota-util/src}/crypto_helper.rs (100%) rename {iota-storage/src/util => iota-util/src}/crypto_util.rs (100%) rename {iota-storage/src/util => iota-util/src}/file_util.rs (100%) create mode 100644 iota-util/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1b603bc..d48cc69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,14 +1767,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "iota" -version = "0.1.0" -dependencies = [ - "ttp-core", - "ttp-native", -] - [[package]] name = "iota-cli" version = "0.1.0" @@ -1793,10 +1785,14 @@ dependencies = [ "hkdf", "hyper", "hyper-util", - "iota-core", + "iota-logger", "iota-state", + "iota-storage", + "iota-terms", + "iota-util", "json", "lazy_static", + "omikron-connector", "once_cell", "open", "pnet", @@ -1829,8 +1825,14 @@ name = "iota-core" version = "0.1.0" dependencies = [ "dashmap", + "iota-cli", + "iota-logger", "iota-state", + "iota-storage", + "iota-terms", + "iota-util", "json", + "omikron-connector", "once_cell", "pnet", "ratatui", @@ -1838,6 +1840,8 @@ dependencies = [ "tokio", "ttp-core", "ttp-native", + "web-server", + "web-ui", ] [[package]] @@ -1882,6 +1886,28 @@ dependencies = [ "zip", ] +[[package]] +name = "iota-terms" +version = "0.1.0" +dependencies = [ + "iota-state", + "json", + "reqwest", +] + +[[package]] +name = "iota-util" +version = "0.1.0" +dependencies = [ + "json", + "pnet", + "ratatui", + "reqwest", + "tokio", + "ttp-core", + "ttp-native", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4331,6 +4357,14 @@ dependencies = [ "semver", ] +[[package]] +name = "web-server" +version = "0.1.0" +dependencies = [ + "ttp-core", + "ttp-native", +] + [[package]] name = "web-sys" version = "0.3.94" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index a963a07..99e2051 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -4,8 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } -iota-core = { path = "../iota-terms" } +iota-storage = { path = "../iota-storage" } +iota-terms = { path = "../iota-terms" } +iota-util = { path = "../iota-util" } +omikron-connector = { path = "../omikron-connector" } ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 7db34a2..5ecc90a 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -4,7 +4,16 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-cli = { path = "../iota-cli" } +iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } +iota-storage = { path = "../iota-storage" } +iota-terms = { path = "../iota-terms" } +iota-util = { path = "../iota-util" } +omikron-connector = { path = "../omikron-connector" } +web-server = { path = "../web-server" } +web-ui = { path = "../web-ui" } + ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml new file mode 100644 index 0000000..369ad84 --- /dev/null +++ b/iota-util/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "iota-util" +version = "0.1.0" +edition = "2024" + +[dependencies] + +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +json = "*" +pnet = "0.35.0" +ratatui = "0.30.0" +reqwest = "0.13.2" +tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-storage/src/util/crypto_helper.rs b/iota-util/src/crypto_helper.rs similarity index 100% rename from iota-storage/src/util/crypto_helper.rs rename to iota-util/src/crypto_helper.rs diff --git a/iota-storage/src/util/crypto_util.rs b/iota-util/src/crypto_util.rs similarity index 100% rename from iota-storage/src/util/crypto_util.rs rename to iota-util/src/crypto_util.rs diff --git a/iota-storage/src/util/file_util.rs b/iota-util/src/file_util.rs similarity index 100% rename from iota-storage/src/util/file_util.rs rename to iota-util/src/file_util.rs diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index fdeeb44..0e99c39 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "iota" +name = "web-server" version = "0.1.0" edition = "2024" From 1ea0b97f6dcc8ac7e9a6474f63d694e089279219 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:03:19 +0200 Subject: [PATCH 004/119] ... --- Cargo.lock | 83 ++- Cargo.toml | 2 +- decentralized/src/lib.rs | 2 + .../{local-auth => local_auth}/auth_user.rs | 0 .../{local-auth => local_auth}/local_auth.rs | 2 +- .../src/{local-auth => local_auth}/mod.rs | 0 .../{Cargo.toml => unused-Cargo.toml} | 4 + iota-cli/src/elements/console_card.rs | 28 +- iota-cli/src/elements/elements.rs | 2 +- iota-cli/src/elements/graph_card.rs | 12 +- iota-cli/src/elements/log_card.rs | 29 +- iota-cli/src/input_handler.rs | 4 +- iota-cli/src/screens/terms_checker.rs | 24 +- iota-cli/src/screens/terms_updater.rs | 25 +- iota-cli/src/ui.rs | 2 +- iota-core/src/consent_state.rs | 473 ++++++++++++++++++ iota-core/src/main.rs | 11 + iota-logger/Cargo.toml | 4 + iota-logger/src/lib.rs | 67 +-- iota-state/src/lib.rs | 4 +- iota-storage/Cargo.toml | 4 + iota-storage/src/lib.rs | 4 +- iota-storage/src/users/user_community_util.rs | 2 +- iota-storage/src/users/user_manager.rs | 88 +--- iota-storage/src/users/user_profile.rs | 2 +- iota-storage/src/util/chat_files.rs | 2 +- iota-storage/src/util/config_util.rs | 2 +- iota-storage/src/util/db.rs | 2 +- iota-storage/src/util/mod.rs | 4 - iota-terms/Cargo.toml | 1 + iota-terms/src/doc.rs | 3 +- iota-terms/src/lib.rs | 14 +- iota-terms/src/terms_getter.rs | 2 +- iota-util/Cargo.toml | 12 + iota-util/src/file_util.rs | 26 +- .../src/langu/language_creator.rs | 2 +- .../src/langu/language_manager.rs | 2 +- {iota-cli => iota-util}/src/langu/mod.rs | 0 iota-util/src/lib.rs | 4 + omikron-connector/Cargo.toml | 9 + omikron-connector/src/lib.rs | 1 + omikron-connector/src/omikron_connection.rs | 20 +- omikron-connector/src/ping_pong_task.rs | 5 +- omikron-connector/src/user_ops.rs | 93 ++++ web-ui/Cargo.toml | 5 + web-ui/src/api.rs | 49 +- web-ui/src/server.rs | 20 +- web-ui/src/socket.rs | 0 web-ui/src/web_path_parser.rs | 2 +- 49 files changed, 869 insertions(+), 289 deletions(-) rename decentralized/src/{local-auth => local_auth}/auth_user.rs (100%) rename decentralized/src/{local-auth => local_auth}/local_auth.rs (91%) rename decentralized/src/{local-auth => local_auth}/mod.rs (100%) rename decentralized/{Cargo.toml => unused-Cargo.toml} (88%) create mode 100644 iota-core/src/consent_state.rs rename {iota-cli => iota-util}/src/langu/language_creator.rs (98%) rename {iota-cli => iota-util}/src/langu/language_manager.rs (98%) rename {iota-cli => iota-util}/src/langu/mod.rs (100%) create mode 100644 omikron-connector/src/user_ops.rs create mode 100644 web-ui/src/socket.rs diff --git a/Cargo.lock b/Cargo.lock index d48cc69..a9dcfc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,53 +847,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" -[[package]] -name = "decentralized" -version = "0.1.0" -dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf", - "hyper", - "hyper-util", - "json", - "lazy_static", - "once_cell", - "open", - "pnet", - "rand 0.8.5", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2", - "strum 0.27.2", - "strum_macros 0.27.2", - "sysinfo", - "tokio", - "tokio-tungstenite", - "ttp-core", - "ttp-native", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] - [[package]] name = "deflate64" version = "0.1.12" @@ -1847,6 +1800,12 @@ dependencies = [ [[package]] name = "iota-logger" version = "0.1.0" +dependencies = [ + "iota-state", + "iota-util", + "ratatui", + "ttp-core", +] [[package]] name = "iota-state" @@ -1868,6 +1827,9 @@ dependencies = [ "base64", "hex", "hkdf", + "iota-logger", + "iota-state", + "iota-util", "json", "once_cell", "rand 0.8.5", @@ -1891,6 +1853,7 @@ name = "iota-terms" version = "0.1.0" dependencies = [ "iota-state", + "iota-util", "json", "reqwest", ] @@ -1899,13 +1862,25 @@ dependencies = [ name = "iota-util" version = "0.1.0" dependencies = [ + "aes-gcm", + "base64", + "hex", + "hkdf", "json", + "once_cell", "pnet", + "rand_core 0.6.4", "ratatui", "reqwest", + "sha2", + "sysinfo", "tokio", "ttp-core", "ttp-native", + "uuid", + "walkdir", + "x448", + "zip", ] [[package]] @@ -2402,12 +2377,21 @@ dependencies = [ name = "omikron-connector" version = "0.1.0" dependencies = [ + "base64", "dashmap", + "hex", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", "json", + "rand_core 0.6.4", + "sha2", "tokio", "ttp-core", "ttp-native", "uuid", + "x448", ] [[package]] @@ -4403,8 +4387,13 @@ dependencies = [ "hkdf", "hyper", "hyper-util", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", "json", "lazy_static", + "omikron-connector", "once_cell", "open", "pnet", diff --git a/Cargo.toml b/Cargo.toml index f9744a3..30a7a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["iota-storage", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "decentralized", "iota-logger"] +members = ["iota-storage", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] diff --git a/decentralized/src/lib.rs b/decentralized/src/lib.rs index e69de29..e7896d5 100644 --- a/decentralized/src/lib.rs +++ b/decentralized/src/lib.rs @@ -0,0 +1,2 @@ +pub mod communities; +pub mod local_auth; diff --git a/decentralized/src/local-auth/auth_user.rs b/decentralized/src/local_auth/auth_user.rs similarity index 100% rename from decentralized/src/local-auth/auth_user.rs rename to decentralized/src/local_auth/auth_user.rs diff --git a/decentralized/src/local-auth/local_auth.rs b/decentralized/src/local_auth/local_auth.rs similarity index 91% rename from decentralized/src/local-auth/local_auth.rs rename to decentralized/src/local_auth/local_auth.rs index 112ee2b..f9c0fd1 100644 --- a/decentralized/src/local-auth/local_auth.rs +++ b/decentralized/src/local_auth/local_auth.rs @@ -1,6 +1,6 @@ use json::JsonValue; -use crate::util::file_util::load_file; +use iota_iota_util::file_util::load_file; // NOT USED AT MOMENT pub fn is_private_key_valid(user_id: &i64, key_hash: &str) -> bool { let file_contents = load_file("", "users.json"); diff --git a/decentralized/src/local-auth/mod.rs b/decentralized/src/local_auth/mod.rs similarity index 100% rename from decentralized/src/local-auth/mod.rs rename to decentralized/src/local_auth/mod.rs diff --git a/decentralized/Cargo.toml b/decentralized/unused-Cargo.toml similarity index 88% rename from decentralized/Cargo.toml rename to decentralized/unused-Cargo.toml index 9c21cdf..2c9c97c 100644 --- a/decentralized/Cargo.toml +++ b/decentralized/unused-Cargo.toml @@ -6,6 +6,10 @@ edition = "2024" [dependencies] ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +iota-logger = { path = "../iota-logger" } +iota-util = { path = "../iota-util" } +iota-storage = { path = "../iota-storage" } +iota-state = { path = "../iota-state" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index b189bd4..f7b0c8f 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,4 +1,9 @@ use crossterm::event::{KeyCode, KeyEvent}; +use iota_logger::{log, log_command, log_cv}; +use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; +use iota_storage::users::{user_manager, user_profile::UserProfile}; +use iota_util::file_util; +use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ Frame, layout::Rect, @@ -9,19 +14,6 @@ use ratatui::{ use ttp_core::{CommunicationType, CommunicationValue}; use uuid::Uuid; -use crate::{ - ACTIVE_TASKS, RELOAD, SHUTDOWN, - gui::{ - elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, - ui::FPS, - util::borders::draw_block_joins, - }, - log, log_command, log_cv, - omikron::omikron_connection::OMIKRON_CONNECTION, - users::{user_manager, user_profile::UserProfile}, - util::file_util, -}; use std::{ any::Any, sync::{Arc, Mutex}, @@ -29,6 +21,13 @@ use std::{ }; use tokio::time::Instant; +use crate::{ + elements::elements::{Element, InteractableElement, JoinableElement}, + interaction_result::InteractionResult, + ui::FPS, + util::borders::draw_block_joins, +}; + pub struct ConsoleCard { focused: bool, pub title: String, @@ -425,7 +424,8 @@ pub async fn run_command(command: &str) { ping(time).await; } ["user", "add", username] => { - if let (Some(user), Some(_)) = user_manager::create_user(username).await { + if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await + { log!("Created user {}", user.user_id); } else { log!("Failed to create user"); diff --git a/iota-cli/src/elements/elements.rs b/iota-cli/src/elements/elements.rs index 3429e52..abe9ab9 100644 --- a/iota-cli/src/elements/elements.rs +++ b/iota-cli/src/elements/elements.rs @@ -3,7 +3,7 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::Borders}; -use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{interaction_result::InteractionResult, screens::screens::Screen}; #[allow(unused)] pub trait Element: Send + Sync + Any { diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index d95f937..c7ec2d6 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -1,6 +1,7 @@ use std::{any::Any, sync::Arc}; use crossterm::event::KeyEvent; +use iota_state::APP_STATE; use ratatui::{ Frame, layout::Rect, @@ -12,13 +13,10 @@ use ratatui::{ }; use crate::{ - APP_STATE, - gui::{ - elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, - ui::UI, - util::borders::draw_block_joins, - }, + elements::elements::{Element, InteractableElement, JoinableElement}, + interaction_result::InteractionResult, + ui::UI, + util::borders::draw_block_joins, }; pub enum GRAPHS { diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index e2d4ba1..a0ce8fd 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,9 +1,9 @@ -use crate::APP_STATE; -use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement}; -use crate::gui::interaction_result::InteractionResult; -use crate::gui::util::borders::draw_block_joins; -use crate::util::logger::PrintType; +use crate::app_state::APP_STATE; +use crate::elements::elements::{Element, InteractableElement, JoinableElement}; +use crate::interaction_result::InteractionResult; +use crate::util::borders::draw_block_joins; use crossterm::event::{KeyCode, KeyEvent}; +use iota_logger::PrintType; use ratatui::{ Frame, layout::Rect, @@ -90,7 +90,24 @@ impl LogCard { fn get_logs(&self) -> Vec { let state = APP_STATE.lock().unwrap(); - state.get_logs().iter().cloned().collect() + state + .get_logs() + .iter() + .map(|e| UiLogEntry { + timestamp_ms: e.timestamp_ms, + sender: match e.sender.as_str() { + "Call" => PrintType::Call, + "Client" => PrintType::Client, + "Iota" => PrintType::Iota, + "Omikron" => PrintType::Omikron, + "Omega" => PrintType::Omega, + "Command" => PrintType::Command, + _ => PrintType::General, + }, + message: e.message.clone(), + is_error: e.is_error, + }) + .collect() } fn find_split_point(s: &str, max_width: usize) -> usize { diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 92b899c..524ce2e 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,6 +1,6 @@ -use crate::ui::{UI, UNIQUE}; -use crate::{RELOAD, SHUTDOWN}; +use crate::ui::UI; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; +use iota_state::{RELOAD, SHUTDOWN, UNIQUE}; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 0808ac5..0d64187 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,8 +2,13 @@ use crate::{ interaction_result::InteractionResult, screens::{md_viewer::FileViewer, screens::Screen}, ui::UI, + util::{ + buttons::{checkbox, draw_buttons}, + terms_focus::Focus, + }, }; use crossterm::event::{KeyCode, KeyEvent}; +use iota_terms::{TermsType, get_link, get_terms}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -14,6 +19,13 @@ use ratatui::{ use std::{any::Any, pin::Pin, sync::Arc}; use tokio::sync::oneshot; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserChoice { + Deny, + AcceptEULA, + AcceptAll, +} + pub struct TermsCheckerScreen { ui: Arc, sender: Option>, @@ -268,9 +280,9 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(Type::EULA), - Focus::Tos => Some(Type::TOS), - Focus::Pp => Some(Type::PP), + Focus::Eula => Some(TermsType::EULA), + Focus::Tos => Some(TermsType::TOS), + Focus::Pp => Some(TermsType::PP), _ => None, }; if let Some(terms_type) = terms_type { @@ -288,15 +300,15 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_link(Type::EULA)); + let _ = open::that(get_link(TermsType::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_link(Type::TOS)); + let _ = open::that(get_link(TermsType::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_link(Type::PP)); + let _ = open::that(get_link(TermsType::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index 909da8d..ee91cba 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -1,9 +1,15 @@ +use crate::screens::terms_checker::UserChoice; use crate::{ interaction_result::InteractionResult, screens::{md_viewer::FileViewer, screens::Screen}, + util::{ + buttons::{checkbox, draw_buttons}, + terms_focus::Focus, + }, }; use chrono::{Local, TimeZone, Utc}; use crossterm::event::{KeyCode, KeyEvent}; +use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -14,6 +20,13 @@ use ratatui::{ use std::any::Any; use tokio::sync::oneshot; +#[derive(Debug, Clone)] +pub enum UpdateDecision { + NoChange, + Future { newest: Doc }, + Forced(Doc), +} + pub struct TermsUpdaterScreen { sender: Option>, @@ -635,9 +648,9 @@ impl Screen for TermsUpdaterScreen { }, KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(Type::EULA), - Focus::Tos => Some(Type::TOS), - Focus::Pp => Some(Type::PP), + Focus::Eula => Some(TermsType::EULA), + Focus::Tos => Some(TermsType::TOS), + Focus::Pp => Some(TermsType::PP), _ => None, }; @@ -655,15 +668,15 @@ impl Screen for TermsUpdaterScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_newest_link(Type::EULA)); + let _ = open::that(get_newest_link(TermsType::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_newest_link(Type::TOS)); + let _ = open::that(get_newest_link(TermsType::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_newest_link(Type::PP)); + let _ = open::that(get_newest_link(TermsType::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 71e9847..723babe 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -3,6 +3,7 @@ use crate::{ screens::screens::Screen, }; use crossterm::event::KeyEvent; +use iota_state::{ACTIVE_TASKS, SHUTDOWN, UNIQUE}; use once_cell::sync::Lazy; use ratatui::{Terminal, backend::CrosstermBackend, init}; use std::{ @@ -17,7 +18,6 @@ use std::{ use tokio::{sync::RwLock, time::Instant}; /// UI state and rendering -pub static UNIQUE: AtomicBool = AtomicBool::new(true); pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs new file mode 100644 index 0000000..723c5a7 --- /dev/null +++ b/iota-core/src/consent_state.rs @@ -0,0 +1,473 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::sync::oneshot; +use iota_cli::ui::UI; +use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; +use iota_cli::screens::terms_updater::{TermsUpdaterScreen, UpdateDecision}; +use iota_terms::{Doc, get_current_docs, get_newest_docs, TermsType as Type}; +use iota_util::file_util::{load_file, save_file}; + + +pub async fn check(ui: Arc) -> (bool, bool) { + let mut state = ConsentState::load_state(); + + if ensure_initial_consent(ui.clone(), &mut state) + .await + .is_err() + { + return (false, false); + } + if ensure_updates(ui, &mut state).await.is_err() { + return (false, false); + }; + + state = state.sanitize(); + state.save_state(); + + (state.accepted_eula, state.accepted_tos && state.accepted_pp) +} + +async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { + if state.accepted_eula { + return Ok(()); + } + + let (tx, rx) = oneshot::channel(); + + ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) + .await; + + let result = rx.await.unwrap_or(UserChoice::Deny); + + match result { + UserChoice::AcceptEULA | UserChoice::AcceptAll => { + if let Some((eula, tos, privacy)) = get_current_docs().await { + state.accepted_eula = true; + state.eula = Some(eula); + + if matches!(result, UserChoice::AcceptAll) { + state.accepted_tos = true; + state.accepted_pp = true; + state.tos = Some(tos); + state.privacy = Some(privacy); + } + } + + let _ = &state.save_state(); + Ok(()) + } + UserChoice::Deny => Err(()), + } +} +async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { + let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { + return Ok(()); + }; + + let is_forced = matches!(eula_update, UpdateDecision::Forced(_)) + || matches!(tos_update, UpdateDecision::Forced(_)) + || matches!(privacy_update, UpdateDecision::Forced(_)); + + let (tx, rx) = oneshot::channel(); + + ui.set_screen(Box::new(TermsUpdaterScreen::new( + eula_update.clone(), + tos_update.clone(), + privacy_update.clone(), + Some(tx), + ))) + .await; + + let result = rx.await.unwrap_or(UserChoice::Deny); + + if is_forced { + match result { + UserChoice::AcceptAll => { + state.accepted_eula = true; + state.accepted_tos = true; + state.accepted_pp = true; + } + UserChoice::AcceptEULA => { + state.accepted_eula = true; + } + UserChoice::Deny => return Err(()), + } + } else { + apply_future_updates(state, result, eula_update, tos_update, privacy_update); + } + + state.save_state(); + Ok(()) +} +fn apply_future_updates( + state: &mut ConsentState, + result: UserChoice, + eula_update: UpdateDecision, + tos_update: UpdateDecision, + privacy_update: UpdateDecision, +) { + match result { + UserChoice::AcceptAll => { + if let UpdateDecision::Future { newest } = eula_update { + state.future_eula = Some(newest); + } + if let UpdateDecision::Future { newest } = tos_update { + state.future_tos = Some(newest); + } + if let UpdateDecision::Future { newest } = privacy_update { + state.future_privacy = Some(newest); + } + } + UserChoice::AcceptEULA => { + if let UpdateDecision::Future { newest } = eula_update { + state.future_eula = Some(newest); + } + } + UserChoice::Deny => {} + } +} + +async fn get_updates() -> Option<( + // Ok(None) indicates no update + // Ok(Some) Indicates a future update + // Err indicates a update that has to be accepted before the programm can continue + UpdateDecision, + UpdateDecision, + UpdateDecision, +)> { + if let ( + Some((current_eula, current_tos, current_privacy)), + Some((newest_eula, newest_tos, newest_privacy)), + ) = (get_current_docs().await, get_newest_docs().await) + { + let file = load_file("", "agreements"); + let accepted_state = ConsentState::from_str(&file).sanitize(); + save_file("", "agreements", &accepted_state.to_string()); + + let eula_update: UpdateDecision = if current_eula.equals_some(&accepted_state.eula) { + if current_eula.equals(&newest_eula) { + UpdateDecision::NoChange + } else { + if newest_eula.equals_some(&accepted_state.future_eula) { + UpdateDecision::NoChange + } else { + UpdateDecision::Future { + newest: newest_eula, + } + } + } + } else if newest_eula.equals_some(&accepted_state.eula) { + UpdateDecision::NoChange + } else { + UpdateDecision::Forced(current_eula) + }; + + let tos_update: UpdateDecision = + if !accepted_state.accepted_tos || newest_tos.equals_some(&accepted_state.tos) { + UpdateDecision::NoChange + } else if accepted_state.accepted_tos && current_tos.equals_some(&accepted_state.tos) { + if current_tos.equals(&newest_tos) { + UpdateDecision::NoChange + } else { + if newest_tos.equals_some(&accepted_state.future_tos) { + UpdateDecision::NoChange + } else { + UpdateDecision::Future { newest: newest_tos } + } + } + } else { + UpdateDecision::Forced(current_tos) + }; + + let privacy_update: UpdateDecision = if !accepted_state.accepted_pp + || newest_privacy.equals_some(&accepted_state.privacy) + { + UpdateDecision::NoChange + } else if accepted_state.accepted_pp && current_privacy.equals_some(&accepted_state.privacy) + { + if current_privacy.equals(&newest_privacy) { + UpdateDecision::NoChange + } else { + if newest_privacy.equals_some(&accepted_state.future_privacy) { + UpdateDecision::NoChange + } else { + UpdateDecision::Future { + newest: newest_privacy, + } + } + } + } else { + UpdateDecision::Forced(current_privacy) + }; + match (&eula_update, &tos_update, &privacy_update) { + (&UpdateDecision::NoChange, &UpdateDecision::NoChange, &UpdateDecision::NoChange) => { + None + } + _ => Some((eula_update, tos_update, privacy_update)), + } + } else { + None + } +} + + +#[derive(Debug, Clone)] +pub struct ConsentState { + pub eula: Option, + pub accepted_eula: bool, + pub future_eula: Option, + + pub tos: Option, + pub accepted_tos: bool, + pub future_tos: Option, + + pub privacy: Option, + pub accepted_pp: bool, + pub future_privacy: Option, +} + +impl ConsentState { + fn sanitize(mut self) -> Self { + let current_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if let Some(future_eula) = self.future_eula.clone() { + if future_eula.get_time() < current_secs { + self.eula = Some(future_eula); + self.future_eula = None; + } + } + if let Some(future_tos) = self.future_tos.clone() { + if future_tos.get_time() < current_secs { + self.tos = Some(future_tos); + self.future_tos = None; + } + } + if let Some(future_privacy) = self.future_privacy.clone() { + if future_privacy.get_time() < current_secs { + self.privacy = Some(future_privacy); + self.future_privacy = None; + } + } + + if !self.accepted_eula { + self.accepted_tos = false; + self.accepted_pp = false; + } + self + } + + pub fn load_state() -> ConsentState { + let file = load_file("", "agreements"); + ConsentState::from_str(&file).sanitize() + } + + pub fn save_state(&self) { + save_file("", "agreements", &self.to_string()); + } + + fn to_string(&self) -> String { + let current_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut file_out: String = format!( + "This file reflects the current consent state used by the application.\ + \nIt may be regenerated or overwritten by the application.\ + \nThis file was last edited by Tensamin at:\ + \nUNIX-SECOND={}", + current_secs + ); + + if let Some(eula) = &self.eula { + file_out.push_str(&format!("\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\ + \nEULA={}\ + \nEULA-VERSION={}\ + \nEULA-HASH={}\ + ", self.accepted_eula, eula.get_version(), eula.get_hash())); + + if self.accepted_tos + && let Some(tos) = &self.tos + { + file_out.push_str(&format!("\ + \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.tensamin.net/tos/\ + \nTerms-of-Service={}\ + \nTerms-of-Service-VERSION={}\ + \nTerms-of-Service-HASH={}\ + ", self.accepted_tos, tos.get_version(), tos.get_hash())); + } + if self.accepted_pp + && let Some(pp) = &self.privacy + { + file_out.push_str(&format!("\ + \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy/\ + \nPrivacy-Policy={}\ + \nPrivacy-Policy-VERSION={}\ + \nPrivacy-Policy-HASH={}\ + ", self.accepted_pp, pp.get_version(), pp.get_hash())); + } + } else { + file_out.push_str("\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.tensamin.net/eula/\ + \nEULA=false\ + "); + } + + if let Some(eula) = &self.future_eula { + file_out.push_str(&format!( + "\ + \nFUTURE-EULA-VERSION={}\ + \nFUTURE-EULA-HASH={}\ + \nFUTURE-EULA-TIME={}\ + ", + eula.get_version(), + eula.get_hash(), + eula.get_time() + )); + } + if let Some(tos) = &self.future_tos { + file_out.push_str(&format!( + "\ + \nFUTURE-Terms-of-Service-VERSION={}\ + \nFUTURE-Terms-of-Service-HASH={}\ + \nFUTURE-Terms-of-Service-TIME={}\ + ", + tos.get_version(), + tos.get_hash(), + tos.get_time() + )); + } + if let Some(pp) = &self.future_privacy { + file_out.push_str(&format!( + "\ + \nFUTURE-Privacy-Policy-VERSION={}\ + \nFUTURE-Privacy-Policy-HASH={}\ + \nFUTURE-Privacy-Policy-TIME={}\ + ", + pp.get_version(), + pp.get_hash(), + pp.get_time() + )); + } + + file_out + } + + fn from_str(s: &str) -> Self { + let mut eula = false; + let mut eula_version = String::new(); + let mut eula_hash = String::new(); + let mut pp = false; + let mut pp_version = String::new(); + let mut pp_hash = String::new(); + let mut tos = false; + let mut tos_version = String::new(); + let mut tos_hash = String::new(); + + let mut future_eula_version = String::new(); + let mut future_eula_hash = String::new(); + let mut future_eula_time = String::new(); + + let mut future_tos_version = String::new(); + let mut future_tos_hash = String::new(); + let mut future_tos_time = String::new(); + + let mut future_pp_version = String::new(); + let mut future_pp_hash = String::new(); + let mut future_pp_time = String::new(); + + let mut unix = String::new(); + + for line in s.lines() { + if let Some(v) = line.strip_prefix("EULA=") { + eula = v == "true"; + } else if let Some(v) = line.strip_prefix("EULA-VERSION=") { + eula_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("EULA-HASH=") { + eula_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("Terms-of-Service=") { + tos = v == "true"; + } else if let Some(v) = line.strip_prefix("Terms-of-Service-VERSION=") { + tos_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("Terms-of-Service-HASH=") { + tos_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("Privacy-Policy=") { + pp = v == "true"; + } else if let Some(v) = line.strip_prefix("Privacy-Policy-VERSION=") { + pp_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("Privacy-Policy-HASH=") { + pp_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("UNIX-SECOND=") { + unix = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-EULA-VERSION=") { + future_eula_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-EULA-HASH=") { + future_eula_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-EULA-TIME=") { + future_eula_time = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-VERSION=") { + future_tos_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-HASH=") { + future_tos_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Terms-of-Service-TIME=") { + future_tos_time = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-VERSION=") { + future_pp_version = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-HASH=") { + future_pp_hash = v.to_string(); + } else if let Some(v) = line.strip_prefix("FUTURE-Privacy-Policy-TIME=") { + future_pp_time = v.to_string(); + } + } + let unix: u64 = unix.parse::().unwrap_or(0); + let future_eula_time = future_eula_time.parse::().unwrap_or(0); + let future_tos_time = future_tos_time.parse::().unwrap_or(0); + let future_pp_time = future_pp_time.parse::().unwrap_or(0); + let state = Self { + accepted_eula: eula, + eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)), + accepted_pp: pp, + privacy: Some(Doc::new(pp_version, pp_hash, Type::PP, unix)), + accepted_tos: tos, + tos: Some(Doc::new(tos_version, tos_hash, Type::TOS, unix)), + future_eula: if !future_eula_version.is_empty() { + Some(Doc::new( + future_eula_version, + future_eula_hash, + Type::EULA, + future_eula_time, + )) + } else { + None + }, + future_tos: if !future_tos_version.is_empty() { + Some(Doc::new( + future_tos_version, + future_tos_hash, + Type::TOS, + future_tos_time, + )) + } else { + None + }, + future_privacy: if !future_pp_version.is_empty() { + Some(Doc::new( + future_pp_version, + future_pp_hash, + Type::PP, + future_pp_time, + )) + } else { + None + }, + } + .sanitize(); + + state + } +} diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index f8f4847..6e4a035 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -1,8 +1,19 @@ +mod consent_state; use pnet::datalink::NetworkInterface; use tokio::time::{Duration, sleep}; use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; +use iota_cli::screens::main_screen::MainScreen; +use iota_cli::ui::start_tui; +use iota_logger as logger; +use iota_logger::{log, log_t}; +use iota_storage::users::user_manager; +use iota_storage::util::config_util::CONFIG; +use iota_util::file_util::{download_and_extract_zip, has_dir}; +use iota_util::langu::language_creator; +use omikron_connector as omikron; + #[tokio::main(flavor = "multi_thread", worker_threads = 16)] #[allow(unused_must_use, dead_code)] async fn main() { diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index a07b745..f03eac3 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -4,3 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +ratatui = "0.30.0" +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +iota-state = { path = "../iota-state" } +iota-util = { path = "../iota-util" } diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index a5feb02..8a4b8b0 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -11,11 +11,8 @@ use std::{ use ratatui::style::Color; use ttp_core::{CommunicationValue, DataTypes, DataValue}; -use crate::{ - APP_STATE, - gui::{elements::log_card::LogEntry, ui::UNIQUE}, - langu::language_manager, -}; +use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; +use iota_util::langu::language_manager; static LOGGER: OnceLock> = OnceLock::new(); @@ -83,7 +80,6 @@ pub fn startup() { }; let timestamp = format_timestamp_inline(msg.timestamp_ms); - let entry = LogEntry::new(msg.kind, resolved_message, msg.is_error); let prefix = if msg.prefix.is_empty() { String::new() @@ -96,13 +92,20 @@ pub fn startup() { "{} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), prefix, - entry.message + resolved_message ); let _ = writeln!(file, " {}", timestamp); + let entry = UiLogEntry { + timestamp_ms: msg.timestamp_ms, + sender: format!("{:?}", msg.kind), + message: resolved_message, + is_error: msg.is_error, + }; + let mut state = APP_STATE.lock().unwrap(); - state.push_log(entry.into()); + state.push_log(entry); } }); } @@ -186,8 +189,8 @@ pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: St #[macro_export] macro_rules! log_t { ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), false, $key, @@ -196,8 +199,8 @@ macro_rules! log_t { }; ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), false, $key, @@ -209,8 +212,8 @@ macro_rules! log_t { #[macro_export] macro_rules! log_t_err { ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), true, $key, @@ -219,8 +222,8 @@ macro_rules! log_t_err { }; ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), true, $key, @@ -233,8 +236,8 @@ macro_rules! log_t_err { #[macro_export] macro_rules! log_command { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::Command, + $crate::log_internal( + $crate::PrintType::Command, "".to_string(), false, format!($($arg)*) @@ -246,8 +249,8 @@ macro_rules! log_command { #[macro_export] macro_rules! log { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, "".to_string(), false, format!($($arg)*) @@ -259,8 +262,8 @@ macro_rules! log { #[macro_export] macro_rules! log_in { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, ">".to_string(), false, format!($($arg)*) @@ -272,8 +275,8 @@ macro_rules! log_in { #[macro_export] macro_rules! log_out { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, "<".to_string(), false, format!($($arg)*) @@ -285,8 +288,8 @@ macro_rules! log_out { #[macro_export] macro_rules! log_err { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, ">>".to_string(), true, format!($($arg)*) @@ -404,29 +407,29 @@ fn format_array(arr: Vec) -> String { #[macro_export] macro_rules! log_cv { ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("", &$cv, Some($kind)) + $crate::log_cv_internal("", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("", &$cv, None) + $crate::log_cv_internal("", &$cv, None) }; } #[macro_export] macro_rules! log_cv_in { ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, Some($kind)) + $crate::log_cv_internal("> ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, None) + $crate::log_cv_internal("> ", &$cv, None) }; } #[macro_export] macro_rules! log_cv_out { ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, Some($kind)) + $crate::log_cv_internal("< ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, None) + $crate::log_cv_internal("< ", &$cv, None) }; } diff --git a/iota-state/src/lib.rs b/iota-state/src/lib.rs index 5d8d351..1e5d1c2 100644 --- a/iota-state/src/lib.rs +++ b/iota-state/src/lib.rs @@ -2,7 +2,7 @@ use dashmap::DashSet; use json::{JsonValue, object}; use once_cell::sync::Lazy; use std::collections::VecDeque; -use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::{Arc, LazyLock, Mutex, atomic::AtomicBool}; use std::thread; use std::time::Duration; use sysinfo::{RefreshKind, System}; @@ -11,6 +11,8 @@ use tokio::sync::RwLock; pub const MAX_POINTS: usize = 1000; pub const MAX_LOGS: usize = 100; +pub static UNIQUE: AtomicBool = AtomicBool::new(true); + #[derive(Clone, Debug)] pub struct UiLogEntry { pub timestamp_ms: u128, diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index b62ac11..6bab29e 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-logger = { path = "../iota-logger" } +iota-state = { path = "../iota-state" } +iota-util = { path = "../iota-util" } + ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs index 778de0f..7f0e8ef 100644 --- a/iota-storage/src/lib.rs +++ b/iota-storage/src/lib.rs @@ -1,2 +1,2 @@ -mod users; -mod util; +pub mod users; +pub mod util; diff --git a/iota-storage/src/users/user_community_util.rs b/iota-storage/src/users/user_community_util.rs index 45b036c..6f01e74 100644 --- a/iota-storage/src/users/user_community_util.rs +++ b/iota-storage/src/users/user_community_util.rs @@ -1,4 +1,4 @@ -use crate::util::file_util::save_file; +use iota_util::file_util::save_file; use json::{self, Array, JsonValue}; use std::fs; use std::path::Path; diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index bcb5e26..add6ea4 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,12 +1,9 @@ -use crate::omikron::omikron_connection::OMIKRON_CONNECTION; use crate::users::user_profile::UserProfile; -use crate::util::crypto_helper::{self, public_key_to_base64}; -use crate::util::file_util::{load_file, save_file}; -use crate::util::logger::PrintType; -use crate::{RELOAD, SHUTDOWN}; -use crate::{log, log_cv}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use hex::{self}; +use iota_logger::{log, log_cv}; +use iota_util::crypto_helper::{self, public_key_to_base64}; +use iota_util::file_util::{load_file, save_file}; use json::JsonValue; use once_cell::sync::Lazy; use rand::Rng; @@ -48,84 +45,7 @@ pub async fn load_from_tu(username: &str) -> Result<(), ()> { Ok(()) } -pub async fn create_user(username: &str) -> (Option, Option) { - let register_cv = CommunicationValue::new(CommunicationType::get_register); - - let conn = OMIKRON_CONNECTION.clone(); - - let response_cv = match conn - .await_response(®ister_cv, Some(Duration::from_secs(20))) - .await - { - Ok(cv) => cv, - Err(_) => return (None, None), - }; - log_cv!(PrintType::Omega, response_cv); - - let user_id = match response_cv.get_data(DataTypes::user_id).as_number() { - Some(id) => id, - None => return (None, None), - }; - let mut buf = [0u8; 56]; - let mut rng = OsRng; - rng.fill_bytes(&mut buf); - let private_key = Secret::from_bytes(&buf).unwrap(); - let public_key = PublicKey::from(&private_key); - - let mut hasher = Sha256::new(); - hasher.update(&STANDARD.encode(&private_key.as_bytes()).as_bytes()); - let result = hasher.finalize(); - let private_key_hash = hex::encode(result); - - let mut bytes = [0u8; 192]; - OsRng.fill(bytes.as_mut()); - let reset_token = STANDARD.encode(&bytes); - - let up = UserProfile::new( - user_id, - username.to_string(), - None, - STANDARD.encode(&public_key.as_bytes()), - private_key_hash, - reset_token.clone(), - ); - - let cv = CommunicationValue::new(CommunicationType::complete_register_user) - .add_data(DataTypes::user_id, DataValue::Number(user_id)) - .add_data(DataTypes::username, DataValue::Str(username.to_string())) - .add_data( - DataTypes::public_key, - DataValue::Str(public_key_to_base64(&public_key)), - ) - .add_data(DataTypes::iota_id, DataValue::Number(user_id)) - .add_data(DataTypes::reset_token, DataValue::Str(reset_token)); - - let response_cv = conn - .await_response(&cv, Some(Duration::from_secs(20))) - .await; - - if let Ok(resp) = response_cv { - log_cv!(PrintType::Omega, resp); - if !resp.is_type(CommunicationType::success) { - return (None, None); - } - } else { - return (None, None); - } - *SHUTDOWN.write().await = true; - *RELOAD.write().await = true; - log!("Created User"); - save_file( - "", - &format!("{}.tu", username), - &format!("{}::{}", user_id, STANDARD.encode(&private_key.as_bytes())), - ); - - USERS.lock().unwrap().push(up.clone()); - save_users(); - (Some(up), Some(STANDARD.encode(&private_key.as_bytes()))) -} - +pub fn add_user(user: UserProfile) { USERS.lock().unwrap().push(user); } pub fn get_user_by_username(username: &str) -> Option { USERS .lock() diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index 5d60277..ba413ff 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -1,6 +1,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use crate::util::file_util::{has_file, load_file, used_dir_space}; +use iota_util::file_util::{has_file, load_file, used_dir_space}; use base64::{Engine as _, engine::general_purpose}; use json::{JsonValue, object}; use rand::Rng; diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index e54fe29..143aa14 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,5 +1,5 @@ -use crate::log; use crate::util::db; +use iota_logger::log; use json::{JsonValue, array, object}; use rusqlite::params; use std::io; diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 6330b59..0ad7827 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -1,4 +1,4 @@ -use crate::util::file_util::{load_file, save_file}; +use iota_util::file_util::{load_file, save_file}; use json::JsonValue; use once_cell::sync::Lazy; use tokio::sync::RwLock; diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 9fb40a0..eb48400 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -5,7 +5,7 @@ //! reuse. The goal is to centralize the "open and initialize" logic and //! provide small convenience helpers used by other util modules. -use crate::util::file_util::get_directory; +use iota_util::file_util::get_directory; use rusqlite::{Connection, Error as RusqliteError}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 47901f9..dff0d65 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -2,8 +2,4 @@ pub mod chat_files; pub mod chats_util; pub mod communities_util; pub mod config_util; -pub mod crypto_helper; -pub mod crypto_util; pub mod db; -pub mod file_util; -pub mod logger; diff --git a/iota-terms/Cargo.toml b/iota-terms/Cargo.toml index 070687c..607dce1 100644 --- a/iota-terms/Cargo.toml +++ b/iota-terms/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] iota-state = { path = "../iota-state" } +iota-util = { path = "../iota-util" } json = "*" reqwest = "0.13.2" diff --git a/iota-terms/src/doc.rs b/iota-terms/src/doc.rs index 2365ba3..0c2e0d2 100644 --- a/iota-terms/src/doc.rs +++ b/iota-terms/src/doc.rs @@ -1,6 +1,7 @@ use json::{JsonValue, object::Object}; -use crate::{terms::terms_getter::Type, util::file_util::load_file}; +use crate::terms_getter::Type; +use iota_util::file_util::load_file; #[derive(Clone, Debug, PartialEq, Eq)] #[allow(unused)] diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index b03efc9..4d99cc6 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -1,2 +1,14 @@ -pub mod doc; + + pub mod terms_getter; + +pub use terms_getter::Type as TermsType; +pub use terms_getter::get_current_docs; +pub use terms_getter::get_link; +pub use terms_getter::get_newest_docs; +pub use terms_getter::get_newest_link; +pub use terms_getter::get_terms; + +pub mod doc; + +pub use doc::Doc; diff --git a/iota-terms/src/terms_getter.rs b/iota-terms/src/terms_getter.rs index a0fa53b..65de9b1 100755 --- a/iota-terms/src/terms_getter.rs +++ b/iota-terms/src/terms_getter.rs @@ -1,6 +1,6 @@ use json::JsonValue::Object; -use crate::terms::doc::Doc; +use crate::doc::Doc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Type { diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 369ad84..77ecde1 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -13,3 +13,15 @@ pnet = "0.35.0" ratatui = "0.30.0" reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } +sysinfo = "0.38.3" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +zip = "6.0.0" +aes-gcm = "0.10.3" +base64 = "0.22.1" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +sha2 = "0.10.9" +x448 = { version = "*" } +hkdf = "0.12.4" +once_cell = "1.21.3" +hex = "*" diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 2d00718..1326feb 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use walkdir::WalkDir; use zip::ZipArchive; -use crate::log; + #[allow(dead_code)] pub fn delete_directory(path: &str) -> bool { @@ -23,7 +23,7 @@ fn delete_dir_recursive(directory: &Path) -> bool { return false; } if let Err(e) = fs::remove_dir_all(directory) { - log!( + println!( "[IMPORTANT] Couldn't delete directory {}: {}", directory.display(), e, @@ -97,7 +97,7 @@ pub fn load_file(path: &str, name: &str) -> String { if !dir.exists() { if let Err(e) = fs::create_dir_all(&dir) { - log!("[IMPORTANT] Couldn't create directories: {}", e); + println!("[IMPORTANT] Couldn't create directories: {}", e); return String::new(); } return String::new(); @@ -105,7 +105,7 @@ pub fn load_file(path: &str, name: &str) -> String { if !file_path.exists() { if let Err(e) = File::create(&file_path) { - log!("[IMPORTANT] Couldn't create file: {}", e); + println!("[IMPORTANT] Couldn't create file: {}", e); } return String::new(); } @@ -130,13 +130,13 @@ pub fn save_file(path: &str, name: &str, value: &str) { if !dir.exists() { if let Err(e) = fs::create_dir_all(&dir) { - log!("[IMPORTANT] Couldn't create directories: {}", e); + println!("[IMPORTANT] Couldn't create directories: {}", e); return; } } if let Err(e) = fs::write(&file_path, value) { - log!( + println!( "[IMPORTANT] Couldn't write file {}: {}", file_path.display(), e @@ -231,7 +231,7 @@ pub async fn download_zip(url: &str, zip_path: &Path) -> Result<(), Box true, Err(e) => { - log!("Error during ZIP extraction: {}", e); + println!("Error during ZIP extraction: {}", e); false } }; if let Err(e) = tokio::fs::remove_file(&zip_path).await { - log!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); + println!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); } else if successful { - log!("Downloaded and extracted ZIP file successfully."); + println!("Downloaded and extracted ZIP file successfully."); } } diff --git a/iota-cli/src/langu/language_creator.rs b/iota-util/src/langu/language_creator.rs similarity index 98% rename from iota-cli/src/langu/language_creator.rs rename to iota-util/src/langu/language_creator.rs index fc6d1c5..e740e5b 100644 --- a/iota-cli/src/langu/language_creator.rs +++ b/iota-util/src/langu/language_creator.rs @@ -1,4 +1,4 @@ -use crate::util::file_util::save_file; +use crate::file_util::save_file; use json::{self, JsonError, JsonValue}; pub fn create_languages() -> Result<(), JsonError> { diff --git a/iota-cli/src/langu/language_manager.rs b/iota-util/src/langu/language_manager.rs similarity index 98% rename from iota-cli/src/langu/language_manager.rs rename to iota-util/src/langu/language_manager.rs index c903886..0dcfa70 100644 --- a/iota-cli/src/langu/language_manager.rs +++ b/iota-util/src/langu/language_manager.rs @@ -1,4 +1,4 @@ -use crate::util::file_util::{self}; +use crate::file_util::{self}; use json::parse; use once_cell::sync::Lazy; use std::collections::HashMap; diff --git a/iota-cli/src/langu/mod.rs b/iota-util/src/langu/mod.rs similarity index 100% rename from iota-cli/src/langu/mod.rs rename to iota-util/src/langu/mod.rs diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index e69de29..d104f80 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -0,0 +1,4 @@ +pub mod crypto_helper; +pub mod crypto_util; +pub mod file_util; +pub mod langu; diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 89aa2d4..41f7d55 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-logger = { path = "../iota-logger" } +iota-state = { path = "../iota-state" } +iota-storage = { path = "../iota-storage" } +iota-util = { path = "../iota-util" } ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } @@ -11,3 +15,8 @@ dashmap = "6.1.0" json = "*" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } +base64 = "0.22.1" +hex = "*" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +sha2 = "0.10.9" +x448 = { version = "*" } diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index acb0f59..5d9124d 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -1,2 +1,3 @@ pub mod omikron_connection; pub mod ping_pong_task; +pub mod user_ops; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 240da6f..def2cc6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,12 +1,14 @@ -use crate::users::contact::Contact; -use crate::util::chat_files::{MessageState, change_message_state}; -use crate::util::chats_util::{get_user, mod_user}; -use crate::util::communities_util::CommunitiesUtil; -use crate::util::crypto_util::{DataFormat, SecurePayload}; -use crate::util::file_util::{get_children, load_file, save_file}; -use crate::util::{chat_files, chats_util}; -use crate::util::{config_util::CONFIG, crypto_helper}; -use crate::{ACTIVE_TASKS, SHUTDOWN, log, log_cv_in, log_cv_out, log_t}; +use iota_storage::users::contact::Contact; +use iota_storage::util::chat_files::{MessageState, change_message_state}; +use iota_storage::util::chats_util::{get_user, mod_user}; +use iota_storage::util::communities_util::CommunitiesUtil; +use iota_util::crypto_util::{DataFormat, SecurePayload}; +use iota_util::file_util::{get_children, load_file, save_file}; +use iota_storage::util::{chat_files, chats_util}; +use iota_storage::util::config_util::CONFIG; +use iota_util::crypto_helper; +use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use dashmap::DashMap; use json::JsonValue; use std::collections::HashMap; diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index f1a891f..26bec38 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,5 +1,6 @@ -use crate::omikron::omikron_connection::OmikronConnection; -use crate::{APP_STATE, log}; +use crate::omikron_connection::OmikronConnection; +use iota_state::APP_STATE; +use iota_logger::log; use dashmap::DashMap; use std::sync::LazyLock; use std::time::Instant; diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs new file mode 100644 index 0000000..1c34670 --- /dev/null +++ b/omikron-connector/src/user_ops.rs @@ -0,0 +1,93 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use hex; +use iota_logger::{PrintType, log, log_cv}; +use iota_state::{RELOAD, SHUTDOWN}; +use iota_storage::users::user_manager::{add_user, save_users}; +use iota_storage::users::user_profile::UserProfile; +use iota_util::crypto_helper::public_key_to_base64; +use iota_util::file_util::save_file; +use rand_core::{OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; +use x448::{PublicKey, Secret}; + +use crate::omikron_connection::OMIKRON_CONNECTION; + +pub async fn create_user(username: &str) -> (Option, Option) { + let register_cv = CommunicationValue::new(CommunicationType::get_register); + + let conn = OMIKRON_CONNECTION.clone(); + + let response_cv = match conn + .await_response(®ister_cv, Some(Duration::from_secs(20))) + .await + { + Ok(cv) => cv, + Err(_) => return (None, None), + }; + log_cv!(PrintType::Omega, response_cv); + + let user_id = match response_cv.get_data(DataTypes::user_id).as_number() { + Some(id) => id, + None => return (None, None), + }; + let mut buf = [0u8; 56]; + let mut rng = OsRng; + rng.fill_bytes(&mut buf); + let private_key = Secret::from_bytes(&buf).unwrap(); + let public_key = PublicKey::from(&private_key); + + let mut hasher = Sha256::new(); + hasher.update(&STANDARD.encode(&private_key.as_bytes()).as_bytes()); + let result = hasher.finalize(); + let private_key_hash = hex::encode(result); + + let mut bytes = [0u8; 192]; + OsRng.fill_bytes(&mut bytes); + let reset_token = STANDARD.encode(&bytes); + + let up = UserProfile::new( + user_id, + username.to_string(), + None, + STANDARD.encode(&public_key.as_bytes()), + private_key_hash, + reset_token.clone(), + ); + + let cv = CommunicationValue::new(CommunicationType::complete_register_user) + .add_data(DataTypes::user_id, DataValue::Number(user_id)) + .add_data(DataTypes::username, DataValue::Str(username.to_string())) + .add_data( + DataTypes::public_key, + DataValue::Str(public_key_to_base64(&public_key)), + ) + .add_data(DataTypes::iota_id, DataValue::Number(user_id)) + .add_data(DataTypes::reset_token, DataValue::Str(reset_token)); + + let response_cv = conn + .await_response(&cv, Some(Duration::from_secs(20))) + .await; + + if let Ok(resp) = response_cv { + log_cv!(PrintType::Omega, resp); + if !resp.is_type(CommunicationType::success) { + return (None, None); + } + } else { + return (None, None); + } + *SHUTDOWN.write().await = true; + *RELOAD.write().await = true; + log!("Created User"); + save_file( + "", + &format!("{}.tu", username), + &format!("{}::{}", user_id, STANDARD.encode(&private_key.as_bytes())), + ); + + add_user(up.clone()); + save_users(); + (Some(up), Some(STANDARD.encode(&private_key.as_bytes()))) +} diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 4d2ef71..6734582 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -4,6 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +omikron-connector = { path = "../omikron-connector" } +iota-storage = { path = "../iota-storage" } +iota-state = { path = "../iota-state" } +iota-util = { path = "../iota-util" } +iota-logger = { path = "../iota-logger" } ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index f9a87a4..26a0544 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -1,6 +1,6 @@ -use crate::server::server::is_local_network; -use crate::util::config_util::CONFIG; +use crate::server::is_local_network; use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use iota_storage::util::config_util::CONFIG; use serde_json::{Value, json}; use std::net::SocketAddr; use std::sync::Arc; @@ -56,15 +56,15 @@ async fn communities_get(req: HttpRequest, ssl: web::Data) -> impl Respond return forbidden(); } - let communities = crate::communities::community_manager::get_communities().await; + // let communities = decentralized::communities::community_manager::get_communities().await; - let mut list = Vec::new(); + let list: Vec = Vec::new(); - for c in communities { - let val = c.frontend().await; - let s_val: Value = serde_json::to_value(val.to_string()).unwrap(); - list.push(s_val); - } + // for c in communities { + // let val = c.frontend().await.to_string(); + // let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null); + // list.push(s_val); + // } HttpResponse::Ok().json(list) } @@ -77,12 +77,13 @@ async fn communities_add( return forbidden(); } - let name = payload["name"].as_str().unwrap_or("").to_string(); - let owner = payload["owner"].as_i64().unwrap_or(0); + // let name = payload["name"].as_str().unwrap_or("").to_string(); + // let owner = payload["owner"].as_i64().unwrap_or(0); - let community = Arc::new(crate::communities::community::Community::create(name, owner).await); + // let community = + // Arc::new(decentralized::communities::community::Community::create(name, owner).await); - crate::communities::community_manager::add_community(community).await; + // decentralized::communities::community_manager::add_community(community).await; success() } @@ -92,13 +93,13 @@ async fn users_get(req: HttpRequest, ssl: web::Data) -> impl Responder { return forbidden(); } - let users = crate::users::user_manager::get_users(); + let users = iota_storage::users::user_manager::get_users(); let list: Vec<_> = users .into_iter() .map(|u| { - let val = u.frontend(); - serde_json::to_value(val.to_string()).unwrap() + let val = u.frontend().to_string(); + serde_json::from_str(&val).unwrap_or(Value::Null) }) .collect(); @@ -116,8 +117,8 @@ async fn users_remove( let uuid = payload.get("uuid").and_then(|v| v.as_i64()).unwrap_or(0); - crate::users::user_manager::remove_user(uuid); - crate::users::user_manager::save_users(); + iota_storage::users::user_manager::remove_user(uuid); + iota_storage::users::user_manager::save_users(); success() } @@ -136,9 +137,9 @@ async fn users_add( _ => return error(), }; - if let (Some(user), Some(_)) = crate::users::user_manager::create_user(username).await { - let val = user.frontend(); - let s_val: Value = serde_json::to_value(val.to_string()).unwrap(); + if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await { + let val = user.frontend().to_string(); + let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null); HttpResponse::Ok().json(s_val) } else { error() @@ -150,7 +151,7 @@ async fn shutdown(req: HttpRequest, ssl: web::Data) -> impl Responder { return forbidden(); } - *crate::SHUTDOWN.write().await = true; + *iota_state::SHUTDOWN.write().await = true; success() } @@ -159,8 +160,8 @@ async fn reload(req: HttpRequest, ssl: web::Data) -> impl Responder { return forbidden(); } - *crate::SHUTDOWN.write().await = true; - *crate::RELOAD.write().await = true; + *iota_state::SHUTDOWN.write().await = true; + *iota_state::RELOAD.write().await = true; success() } diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index b55a50f..9902fe0 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -1,10 +1,9 @@ -use crate::log; -use crate::server::api::api_config; -use crate::server::web_path_parser; -use crate::util::file_util::load_file_buf; -use crate::{ACTIVE_TASKS, SHUTDOWN}; +use crate::api::api_config; +use crate::web_path_parser; use actix_web::{App, Error, HttpRequest, HttpServer, Responder, dev::ServerHandle, web}; -use actix_web_actors::ws; +use iota_logger::log; +use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_util::file_util::load_file_buf; use rustls::ServerConfig; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::{ @@ -15,13 +14,6 @@ use std::{ time::Duration, }; -async fn ws_handler(req: HttpRequest, stream: web::Payload) -> Result { - let path = req.path().to_string(); - log!("WS connection from {:?}", req.peer_addr()); - let session = WsSession::new(path); - ws::start(session, &req, stream) -} - use tokio::sync::oneshot; pub async fn start(port: u16) -> bool { @@ -36,7 +28,6 @@ pub async fn start(port: u16) -> bool { App::new() .app_data(web::Data::new(true)) .configure(api_config) - .service(web::resource("/ws/{path:.*}").route(web::get().to(ws_handler))) .default_service(web::to(web_path_parser::handle)) }) .bind(("0.0.0.0", port)) @@ -49,7 +40,6 @@ pub async fn start(port: u16) -> bool { App::new() .app_data(web::Data::new(false)) .configure(api_config) - .service(web::resource("/ws/{path:.*}").route(web::get().to(ws_handler))) .default_service(web::to(web_path_parser::handle)) }) .bind(("0.0.0.0", port)) diff --git a/web-ui/src/socket.rs b/web-ui/src/socket.rs new file mode 100644 index 0000000..e69de29 diff --git a/web-ui/src/web_path_parser.rs b/web-ui/src/web_path_parser.rs index a82c8db..2eaf3cf 100755 --- a/web-ui/src/web_path_parser.rs +++ b/web-ui/src/web_path_parser.rs @@ -1,7 +1,7 @@ use actix_web::{HttpRequest, HttpResponse}; use std::path::{Path, PathBuf}; -use crate::util::file_util::load_file_vec; +use iota_util::file_util::load_file_vec; fn codec_for_ext(ext: &str) -> &'static str { match ext { From c8a6d4104e01056cdcdb5608fbb34bde045c1080 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:27:04 +0200 Subject: [PATCH 005/119] ... --- iota-storage/src/util/config_util.rs | 20 ++++++++- iota-util/src/file_util.rs | 9 ---- omikron-connector/src/omikron_connection.rs | 50 ++++++++++----------- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 0ad7827..6d4082b 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -19,11 +19,26 @@ impl ConfigUtil { } pub fn clear(&mut self) { self.config = JsonValue::new_object(); + self.unique = false; } pub fn load(&mut self) { let s = load_file("", "config.json"); - if !s.is_empty() { - self.config = json::parse(&s).unwrap_or(JsonValue::new_object()); + if s.is_empty() { + // File might be missing or empty/being written. + // We don't want to wipe the current config if it already has data. + // But if it's the first load, it will stay empty. + return; + } + + match json::parse(&s) { + Ok(parsed) => { + self.config = parsed; + self.unique = false; + } + Err(e) => { + eprintln!("Failed to parse config.json: {}. Content: '{}'", e, s); + // Keep the current config rather than wiping it. + } } } @@ -55,6 +70,7 @@ impl ConfigUtil { pub fn update(&mut self) { if self.unique { save_file("", "config.json", &self.config.to_string()); + self.unique = false; } } } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 1326feb..b0cd3a3 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -9,8 +9,6 @@ use uuid::Uuid; use walkdir::WalkDir; use zip::ZipArchive; - - #[allow(dead_code)] pub fn delete_directory(path: &str) -> bool { let dir = Path::new(&get_directory()).join(path); @@ -96,17 +94,10 @@ pub fn load_file(path: &str, name: &str) -> String { let file_path = dir.join(name); if !dir.exists() { - if let Err(e) = fs::create_dir_all(&dir) { - println!("[IMPORTANT] Couldn't create directories: {}", e); - return String::new(); - } return String::new(); } if !file_path.exists() { - if let Err(e) = File::create(&file_path) { - println!("[IMPORTANT] Couldn't create file: {}", e); - } return String::new(); } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index def2cc6..48da9fb 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,15 +1,15 @@ +use dashmap::DashMap; +use iota_logger::{log, log_cv_in, log_cv_out, log_t}; +use iota_state::{ACTIVE_TASKS, SHUTDOWN}; use iota_storage::users::contact::Contact; use iota_storage::util::chat_files::{MessageState, change_message_state}; use iota_storage::util::chats_util::{get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; +use iota_storage::util::config_util::CONFIG; +use iota_storage::util::{chat_files, chats_util}; +use iota_util::crypto_helper; use iota_util::crypto_util::{DataFormat, SecurePayload}; use iota_util::file_util::{get_children, load_file, save_file}; -use iota_storage::util::{chat_files, chats_util}; -use iota_storage::util::config_util::CONFIG; -use iota_util::crypto_helper; -use iota_state::{ACTIVE_TASKS, SHUTDOWN}; -use iota_logger::{log, log_cv_in, log_cv_out, log_t}; -use dashmap::DashMap; use json::JsonValue; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; @@ -225,15 +225,15 @@ impl OmikronConnection { *self.sender.write().await = Some(sender_arc.clone()); *self.state.write().await = ConnectionState::Connected { identified: false }; - // Handle registration/identification - self.handle_authentication().await; - // Start read loop let read_self = self.clone(); let read_handle = tokio::spawn(async move { read_self.read_loop(&mut receiver).await; }); + // Handle registration/identification + self.handle_authentication().await; + // Start heartbeat let heartbeat_self = self.clone(); let heartbeat_handle = tokio::spawn(async move { @@ -280,26 +280,26 @@ impl OmikronConnection { let private_key = conf.get_private_key(); drop(conf); - if iota_id == 0 || public_key.is_none() || private_key.is_none() { + if iota_id == 0 { log_t!("iota_register_new"); - let key_pair = crypto_helper::generate_keypair(); - let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); - let _private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); + let (pub_k, priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { + (pk, sk) + } else { + let key_pair = crypto_helper::generate_keypair(); + let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); + let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); - let mut conf_write = CONFIG.write().await; - // NOTE: - // Intentionally not storing the generated private/public keys directly into the - // config file here to avoid persisting sensitive material in plaintext. If you - // want to persist them, uncomment the two lines below and accept the security - // implications (they will be saved by `conf_write.update()`). - // conf_write.change("public_key", DataValue::Str(public_key_base64.clone())); - // conf_write.change("private_key", DataValue::Str(private_key_base64)); - conf_write.update(); - drop(conf_write); + let mut conf_write = CONFIG.write().await; + conf_write.change("public_key", JsonValue::from(public_key_base64.clone())); + conf_write.change("private_key", JsonValue::from(private_key_base64.clone())); + conf_write.update(); + drop(conf_write); + (public_key_base64, private_key_base64) + }; let register_msg = CommunicationValue::new(CommunicationType::register_iota) - .add_data(DataTypes::public_key, DataValue::Str(public_key_base64)); + .add_data(DataTypes::public_key, DataValue::Str(pub_k)); let msg_id = register_msg.get_id(); @@ -311,7 +311,7 @@ impl OmikronConnection { return false; } - let iota_value = cv.get_data(DataTypes::register_id); + let iota_value = cv.get_data(DataTypes::iota_id); let iota_id = iota_value.as_number().unwrap_or(0); if iota_id != 0 { From d7f79d2f6e01f4b4825f0ec90e69992c2d728ceb Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:42:02 +0200 Subject: [PATCH 006/119] [Fix] Changed TTP to fetch from git.methanium.net --- Cargo.lock | 28 ++++++++++++++-------------- decentralized/unused-Cargo.toml | 4 ++-- iota-cli/Cargo.toml | 4 ++-- iota-core/Cargo.toml | 4 ++-- iota-logger/Cargo.toml | 2 +- iota-state/Cargo.toml | 2 +- iota-storage/Cargo.toml | 4 ++-- iota-util/Cargo.toml | 4 ++-- omikron-connector/Cargo.toml | 4 ++-- web-server/Cargo.toml | 4 ++-- web-ui/Cargo.toml | 4 ++-- 11 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9dcfc7..5d2a1ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,9 +552,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -1679,9 +1679,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3361,9 +3361,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3851,9 +3851,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -3868,9 +3868,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4010,7 +4010,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" +source = "git+https://git.methanium.net/Tensamin/TTP.git#82b71c1be1fa73aeb0f228c4af364206fda8d712" dependencies = [ "base64", "byteorder", @@ -4022,7 +4022,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" +source = "git+https://git.methanium.net/Tensamin/TTP.git#82b71c1be1fa73aeb0f228c4af364206fda8d712" dependencies = [ "quinn", "rustls", @@ -4966,9 +4966,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wtransport" diff --git a/decentralized/unused-Cargo.toml b/decentralized/unused-Cargo.toml index 2c9c97c..8af10a1 100644 --- a/decentralized/unused-Cargo.toml +++ b/decentralized/unused-Cargo.toml @@ -4,8 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 99e2051..60a0ce1 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -12,8 +12,8 @@ iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 5ecc90a..acb6c34 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -14,8 +14,8 @@ omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } web-ui = { path = "../web-ui" } -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } dashmap = "6.1.0" json = "*" diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index f03eac3..aa71144 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -5,6 +5,6 @@ edition = "2024" [dependencies] ratatui = "0.30.0" -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index eff1cb7..ce37258 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -9,4 +9,4 @@ once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" sysinfo = "0.38.3" -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 6bab29e..2baa309 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -8,8 +8,8 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } aes-gcm = "0.10.3" base64 = "0.22.1" diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 77ecde1..9a1d4cb 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" [dependencies] -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } json = "*" pnet = "0.35.0" diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 41f7d55..5b29a2f 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -8,8 +8,8 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } dashmap = "6.1.0" json = "*" diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index 0e99c39..b214806 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,5 +5,5 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 6734582..16a8974 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -9,8 +9,8 @@ iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } -ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" From 17f1653f87ca6bea3a7f669b34b2e03d16427463 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:41:57 +0200 Subject: [PATCH 007/119] [Add] New Client identification Logic --- Cargo.lock | 4 +- omikron-connector/src/omikron_connection.rs | 62 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d2a1ae..197184f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4010,7 +4010,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#82b71c1be1fa73aeb0f228c4af364206fda8d712" +source = "git+https://git.methanium.net/Tensamin/TTP.git#bacbcb0e38791cf1a38cf5851beb20d5645002ba" dependencies = [ "base64", "byteorder", @@ -4022,7 +4022,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#82b71c1be1fa73aeb0f228c4af364206fda8d712" +source = "git+https://git.methanium.net/Tensamin/TTP.git#bacbcb0e38791cf1a38cf5851beb20d5645002ba" dependencies = [ "quinn", "rustls", diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 48da9fb..88d51c7 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -424,6 +424,68 @@ impl OmikronConnection { return; } + if cv.is_type(CommunicationType::client_connected) { + let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + let session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; + + let mut contacts = chats_util::get_users(user_id); + let mut contacts_array = Vec::new(); + + for (i, contact) in contacts.iter().enumerate() { + let mut contact_container = Vec::new(); + contact_container.push((DataTypes::user_id, DataValue::Number(contact.user_id))); + contact_container.push(( + DataTypes::last_message_at, + DataValue::Number(contact.last_message_at.unwrap_or(0)), + )); + + if let Some(ref name) = contact.user_name { + contact_container.push((DataTypes::username, DataValue::Str(name.clone()))); + } + + let amount = if i < 10 { 20 } else { 1 }; + let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); + + let mut msg_array = Vec::new(); + for m in messages.members() { + let message_time = m["message_time"].as_i64().unwrap_or(0); + let content = m["content"].as_str().unwrap_or("").to_string(); + let sent_by_self = m["sent_by_self"].as_bool().unwrap_or(false); + let height = m["height"].as_i64().unwrap_or(0); + let message_state = m["message_state"].as_str().unwrap_or("").to_string(); + + let mut msg_container = Vec::new(); + msg_container.push((DataTypes::send_time, DataValue::Number(message_time))); + msg_container.push((DataTypes::content, DataValue::Str(content.clone()))); + msg_container.push((DataTypes::message_state, DataValue::Str(message_state))); + msg_container.push((DataTypes::height, DataValue::Number(height))); + msg_container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); + msg_array.push(DataValue::Container(msg_container)); + + if msg_array.len() == 1 { + let sender_id = if sent_by_self { + user_id + } else { + contact.user_id + }; + let mut last_msg = Vec::new(); + last_msg.push((DataTypes::content, DataValue::Str(content))); + last_msg.push((DataTypes::sender_id, DataValue::Number(sender_id))); + contact_container + .push((DataTypes::last_message, DataValue::Container(last_msg))); + } + } + contact_container.push((DataTypes::messages, DataValue::Array(msg_array))); + contacts_array.push(DataValue::Container(contact_container)); + } + + let resp = CommunicationValue::new(CommunicationType::client_connected) + .with_id(cv.get_id()) + .add_data(DataTypes::contacts, DataValue::Array(contacts_array)); + self.send_message(&resp).await; + return; + } + if cv.is_type(CommunicationType::identification_response) { if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { let mut state = self.state.write().await; From 1e714d1c2d3de5baa1e4875f36c0165bf18030fc Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:11:24 +0200 Subject: [PATCH 008/119] [Fix] correct ChatpartnerID in Message_state --- omikron-connector/src/omikron_connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 88d51c7..4e8af77 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -641,7 +641,7 @@ impl OmikronConnection { .with_sender(receiver_id as u64) .add_data( DataTypes::chat_partner_id, - DataValue::Number(sender_id as i64), + DataValue::Number(receiver_id as i64), ) .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) .add_data( From d79a625e322437f1ff531140900b40974d9735fd Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:37:01 +0200 Subject: [PATCH 009/119] [Fix] Dockerfile Iota bin was named wrong --- dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dockerfile b/dockerfile index ee00add..5f07197 100644 --- a/dockerfile +++ b/dockerfile @@ -13,8 +13,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/iota . +COPY --from=builder /app/target/release/iota-core . EXPOSE 1984 -CMD ["./iota"] \ No newline at end of file +CMD ["./iota-core"] From 8df387bea553b2920fa55221911b293625b44395 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:39:37 +0200 Subject: [PATCH 010/119] [Add] Auto updates --- Cargo.lock | 70 ++++++++++++++++-- iota-core/src/main.rs | 4 +- iota-util/Cargo.toml | 5 ++ iota-util/src/lib.rs | 1 + iota-util/src/update_util.rs | 134 +++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 iota-util/src/update_util.rs diff --git a/Cargo.lock b/Cargo.lock index 197184f..ec499cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "aster" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258989846dd255a1e0eeef92d425d345477c9999433cecc9f0879f4549d5e5c9" + [[package]] name = "async-trait" version = "0.1.89" @@ -1028,9 +1034,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fiat-crypto" @@ -1863,6 +1869,7 @@ name = "iota-util" version = "0.1.0" dependencies = [ "aes-gcm", + "anyhow", "base64", "hex", "hkdf", @@ -1872,8 +1879,12 @@ dependencies = [ "rand_core 0.6.4", "ratatui", "reqwest", + "semver", + "serde", + "serde_macros", "sha2", "sysinfo", + "tempfile", "tokio", "ttp-core", "ttp-native", @@ -2810,6 +2821,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quasi" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a532453b931a4483a5b2e40f0fe04aee35b6bc2c0eeec876f1bd2358a134d3" + +[[package]] +name = "quasi_codegen" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb4a9a5410fdbdacbeda8063ddb8add9838dfd4cf50ac486db98abb762d8bd6" +dependencies = [ + "aster", +] + +[[package]] +name = "quasi_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc2b36285ea5e54e4e267f83896267ff8c5aba4f66b2e7d186ed6d968f3715f" +dependencies = [ + "quasi_codegen", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3375,6 +3410,24 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_codegen" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da68810d845f8e33a80243c28794650397056cbe7aea4c9c7516f55d1061c94e" +dependencies = [ + "aster", + "quasi", + "quasi_macros", + "serde_codegen_internals", +] + +[[package]] +name = "serde_codegen_internals" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b0115c5c602e81c61b787fb0f0fa76a614f8dbe9100b2b59b7d590155672c80" + [[package]] name = "serde_core" version = "1.0.228" @@ -3408,6 +3461,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_macros" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3cf1c01933271e1e72bb788e0499d1bca8af2c09efcc3ddc0b04ff22d080b83" +dependencies = [ + "serde_codegen", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4010,7 +4072,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#bacbcb0e38791cf1a38cf5851beb20d5645002ba" +source = "git+https://git.methanium.net/Tensamin/TTP.git#2322bdced8183e970405d5017cd87ba805309a4c" dependencies = [ "base64", "byteorder", @@ -4022,7 +4084,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#bacbcb0e38791cf1a38cf5851beb20d5645002ba" +source = "git+https://git.methanium.net/Tensamin/TTP.git#2322bdced8183e970405d5017cd87ba805309a4c" dependencies = [ "quinn", "rustls", diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 67f72e2..209518f 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -1,4 +1,5 @@ mod consent_state; +use iota_util::update_util::check_update; use pnet::datalink::NetworkInterface; use tokio::time::{Duration, sleep}; @@ -52,6 +53,7 @@ async fn main() { println!("You can find this at 'agreements'!"); return; } + check_update(); iota_state::setup(); let main_screen = MainScreen::new(ui.clone()).await; @@ -118,7 +120,7 @@ async fn main() { } } } - /* Community port activation is used for activating the port for communities. + /* Community port activation is used for activating the port for communities. * Code is currently commented because communities have not been implemented yet. if start(port).await { log_t!("community_active", ip, port.to_string()); diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 9a1d4cb..41502e1 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -25,3 +25,8 @@ x448 = { version = "*" } hkdf = "0.12.4" once_cell = "1.21.3" hex = "*" +serde = "1.0.228" +tempfile = "3.27.0" +anyhow = "1.0.102" +semver = "1.0.28" +serde_macros = "0.8.9" diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index d104f80..ebef516 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -2,3 +2,4 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; pub mod langu; +pub mod update_util; diff --git a/iota-util/src/update_util.rs b/iota-util/src/update_util.rs new file mode 100644 index 0000000..2752008 --- /dev/null +++ b/iota-util/src/update_util.rs @@ -0,0 +1,134 @@ +/* This file is used for the auto update function for the Iota. + * It connects to the git server from methanium and checks if + * the version has updated inside the cargo.toml file. + * It is made by Yolokit and pasted in by AlexEmmet */ + +use anyhow::{Context, Result, anyhow}; +use semver::Version; +use serde::Deserialize; +use std::fs::File; +use std::io::copy; +use tempfile::NamedTempFile; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const API_BASE: &str = "https://git.methanium.net/api/v1"; +const OWNER: &str = "Tensamin"; +const REPO: &str = "Iota"; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +async fn latest_release() -> Result { + let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest"); + + let response = reqwest::get(&url) + .await + .context("failed to query latest release.")?; + + if !response.status().is_success() { + return Err(anyhow!("release API returned {}", response.status())); + } + + Ok(response + .json() + .await + .context("failed to parse release JSON")?) +} + +async fn parse_tag_version(tag: &str) -> Result { + let normalized = tag.strip_prefix('v').unwrap_or(tag); + Ok(Version::parse(normalized)?) +} + +async fn current_version() -> Result { + Ok(Version::parse(CURRENT_VERSION)?) +} + +async fn asset_name_for_current_platform() -> String { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => "iota-linux-x86_64".to_string(), + ("linux", "aarch64") => "iota-linux-aarch64".to_string(), + ("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(), + ("macos", "x86_64") => "iota-macos-x86_64".to_string(), + ("macos", "aarch64") => "iota-macos-aarch64".to_string(), + _ => panic!("unsupported platform: {os}/{arch}"), + } +} + +async fn download_asset(url: &str) -> Result { + let mut response = reqwest::get(url) + .await + .context("failed to download asset")?; + + if !response.status().is_success() { + return Err(anyhow!("asset download returned {}", response.status())); + } + + let tmp = NamedTempFile::new().context("failed to create temp file")?; + let mut out = File::create(tmp.path()).context("failed to open temp file")?; + + let bytes = response + .bytes() + .await + .context("failed to read response bytes")?; + + std::fs::write(tmp.path(), &bytes).context("failed to write file")?; + + Ok(tmp) +} + +async fn check_for_update() -> Result> { + let current = current_version().await?; + let release = latest_release().await?; + let latest = parse_tag_version(&release.tag_name).await?; + + if latest > current { + Ok(Some(release)) + } else { + Ok(None) + } +} + +async fn perform_update() -> Result { + let Some(release) = check_for_update().await? else { + return Ok(false); + }; + + let wanted_asset = asset_name_for_current_platform().await; + + let asset = release + .assets + .iter() + .find(|a| a.name == wanted_asset) + .ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?; + + log!("Downloading update: {}", asset.name); + + let downloaded = download_asset(&asset.browser_download_url).await?; + + self_replace::self_replace(downloaded.path()) + .context("failed to replace current executable")?; + + Ok(true) +} + +pub async fn check_update() -> Result { + if perform_update().await? { + return Ok(true); + } else { + return Ok(false); + } +} From 3c11800af5dadd0c5b40a2485a3b36ebba3119d2 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:19:45 +0200 Subject: [PATCH 011/119] [Fix] Local & Remote message handling is now propper --- iota-core/src/consent_state.rs | 8 +- iota-storage/src/users/user_manager.rs | 4 +- iota-storage/src/users/user_profile.rs | 2 +- iota-terms/src/lib.rs | 2 - omikron-connector/src/omikron_connection.rs | 256 +++++++++++++------- omikron-connector/src/ping_pong_task.rs | 4 +- 6 files changed, 175 insertions(+), 101 deletions(-) diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index 723c5a7..006de0c 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -1,13 +1,12 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::sync::oneshot; -use iota_cli::ui::UI; use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; use iota_cli::screens::terms_updater::{TermsUpdaterScreen, UpdateDecision}; -use iota_terms::{Doc, get_current_docs, get_newest_docs, TermsType as Type}; +use iota_cli::ui::UI; +use iota_terms::{Doc, TermsType as Type, get_current_docs, get_newest_docs}; use iota_util::file_util::{load_file, save_file}; - +use tokio::sync::oneshot; pub async fn check(ui: Arc) -> (bool, bool) { let mut state = ConsentState::load_state(); @@ -211,7 +210,6 @@ async fn get_updates() -> Option<( } } - #[derive(Debug, Clone)] pub struct ConsentState { pub eula: Option, diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index add6ea4..8315de4 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -45,7 +45,9 @@ pub async fn load_from_tu(username: &str) -> Result<(), ()> { Ok(()) } -pub fn add_user(user: UserProfile) { USERS.lock().unwrap().push(user); } +pub fn add_user(user: UserProfile) { + USERS.lock().unwrap().push(user); +} pub fn get_user_by_username(username: &str) -> Option { USERS .lock() diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index ba413ff..573320a 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -1,7 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use iota_util::file_util::{has_file, load_file, used_dir_space}; use base64::{Engine as _, engine::general_purpose}; +use iota_util::file_util::{has_file, load_file, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index 4d99cc6..216997b 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -1,5 +1,3 @@ - - pub mod terms_getter; pub use terms_getter::Type as TermsType; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 4e8af77..d6b0ff4 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -569,15 +569,19 @@ impl OmikronConnection { let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; - // persist message for the receiver (storage_owner = receiver_id) - chat_files::add_message( - timestamp_u128, - false, - receiver_id as i64, - sender_id as i64, - &content, - height, - ); + let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); + + if is_local { + // persist message for the receiver (storage_owner = receiver_id) + chat_files::add_message( + timestamp_u128, + false, + receiver_id as i64, + sender_id as i64, + &content, + height, + ); + } // persist message for the sender (storage_owner = sender_id) chat_files::add_message( @@ -595,96 +599,168 @@ impl OmikronConnection { .with_receiver(sender_id as u64); self.send_message(&conf_msg).await; - // Build a live-delivery message for the local client (recipient) - let user_forward = CommunicationValue::new(CommunicationType::message_live) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data(DataTypes::content, DataValue::Str(content.clone())) - .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) - .add_data(DataTypes::height, DataValue::Number(height)); + if !is_local { + let fw_msg = CommunicationValue::new(CommunicationType::message_other_iota) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .with_sender(sender_id as u64) + .add_data(DataTypes::height, DataValue::Number(height)) + .add_data(DataTypes::content, DataValue::Str(content)) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)); - // Attempt delivery and await a response from the local client - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) - .await; + let other_iota_resp = self + .clone() + .await_response(&fw_msg, Some(Duration::from_secs(10))) + .await; - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataTypes::message_state) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + if let Ok(resp) = other_iota_resp { + let ms_raw = resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - // update stored message state for receiver - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - sender_id as i64, - ms.clone(), - ); + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); - // update stored message state for sender - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - ms.clone(), - ); + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); - // notify original sender about the delivered/read state - self.send_message( - &CommunicationValue::new(CommunicationType::message_state) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), - ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; } else { - // Delivery failed or timed out; mark as Sent - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - sender_id as i64, - MessageState::Sent, - ); + // Build a live-delivery message for the local client (recipient) + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data(DataTypes::content, DataValue::Str(content.clone())) + .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) + .add_data(DataTypes::height, DataValue::Number(height)); - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - MessageState::Sent, - ); + // Attempt delivery and await a response from the local client + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; - // notify sender - self.send_message( - &CommunicationValue::new(CommunicationType::message_state) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(sender_id as i64), - ) - .add_data( - DataTypes::message_state, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + // update stored message state for receiver + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + ms.clone(), + ); + + // update stored message state for sender + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); + + // notify original sender about the delivered/read state + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery failed or timed out; mark as Sent + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + MessageState::Sent, + ); + + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); + + // notify sender + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; } - return; } if cv.is_type(CommunicationType::message_other_iota) { diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index 26bec38..fc21855 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,7 +1,7 @@ use crate::omikron_connection::OmikronConnection; -use iota_state::APP_STATE; -use iota_logger::log; use dashmap::DashMap; +use iota_logger::log; +use iota_state::APP_STATE; use std::sync::LazyLock; use std::time::Instant; use tokio::time::Duration; From b37ec732f7efe071a8a43282236b4b185ed3e02b Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:36:42 +0200 Subject: [PATCH 012/119] [Fix] moved language to logging --- iota-logger/Cargo.toml | 8 +++- .../src}/language_creator.rs | 3 +- .../src}/language_manager.rs | 2 +- iota-logger/src/lib.rs | 19 +-------- iota-updater/Cargo.toml | 0 .../update_util.rs => iota-updater/src/lib.rs | 41 ++++++++++++++++--- iota-util/src/langu/mod.rs | 2 - 7 files changed, 46 insertions(+), 29 deletions(-) rename {iota-util/src/langu => iota-logger/src}/language_creator.rs (98%) rename {iota-util/src/langu => iota-logger/src}/language_manager.rs (98%) create mode 100644 iota-updater/Cargo.toml rename iota-util/src/update_util.rs => iota-updater/src/lib.rs (79%) delete mode 100644 iota-util/src/langu/mod.rs diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index aa71144..bdfc84b 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -4,7 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] -ratatui = "0.30.0" -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } + +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } + +ratatui = "0.30.0" +json = "0.12.4" +once_cell = "1.21.4" diff --git a/iota-util/src/langu/language_creator.rs b/iota-logger/src/language_creator.rs similarity index 98% rename from iota-util/src/langu/language_creator.rs rename to iota-logger/src/language_creator.rs index e740e5b..2b9f109 100644 --- a/iota-util/src/langu/language_creator.rs +++ b/iota-logger/src/language_creator.rs @@ -1,4 +1,5 @@ -use crate::file_util::save_file; +use iota_util::file_util::save_file; + use json::{self, JsonError, JsonValue}; pub fn create_languages() -> Result<(), JsonError> { diff --git a/iota-util/src/langu/language_manager.rs b/iota-logger/src/language_manager.rs similarity index 98% rename from iota-util/src/langu/language_manager.rs rename to iota-logger/src/language_manager.rs index 0dcfa70..8cced10 100644 --- a/iota-util/src/langu/language_manager.rs +++ b/iota-logger/src/language_manager.rs @@ -1,4 +1,4 @@ -use crate::file_util::{self}; +use iota_util::file_util::{self}; use json::parse; use once_cell::sync::Lazy; use std::collections::HashMap; diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 8a4b8b0..5e45c1e 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -12,7 +12,8 @@ use ratatui::style::Color; use ttp_core::{CommunicationValue, DataTypes, DataValue}; use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; -use iota_util::langu::language_manager; +mod language_creator; +mod language_manager; static LOGGER: OnceLock> = OnceLock::new(); @@ -128,22 +129,6 @@ fn fixed_box(content: &str, width: usize) -> String { } } -fn colorize(kind: PrintType, is_error: bool) -> Color { - if is_error { - return Color::Red; - } - - match kind { - PrintType::Call => Color::Magenta, - PrintType::Client => Color::Green, - PrintType::Iota => Color::Yellow, - PrintType::Omikron => Color::Blue, - PrintType::Omega => Color::Cyan, - PrintType::General => Color::LightCyan, - PrintType::Command => Color::LightGreen, - } -} - pub fn log_internal_translated( kind: PrintType, prefix: String, diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml new file mode 100644 index 0000000..e69de29 diff --git a/iota-util/src/update_util.rs b/iota-updater/src/lib.rs similarity index 79% rename from iota-util/src/update_util.rs rename to iota-updater/src/lib.rs index 2752008..627b3eb 100644 --- a/iota-util/src/update_util.rs +++ b/iota-updater/src/lib.rs @@ -5,7 +5,6 @@ use anyhow::{Context, Result, anyhow}; use semver::Version; -use serde::Deserialize; use std::fs::File; use std::io::copy; use tempfile::NamedTempFile; @@ -16,13 +15,13 @@ const API_BASE: &str = "https://git.methanium.net/api/v1"; const OWNER: &str = "Tensamin"; const REPO: &str = "Iota"; -#[derive(Debug, Deserialize)] +#[derive(Debug)] struct Release { tag_name: String, assets: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug)] struct Asset { name: String, browser_download_url: String, @@ -39,10 +38,40 @@ async fn latest_release() -> Result { return Err(anyhow!("release API returned {}", response.status())); } - Ok(response - .json() + let text = response + .text() .await - .context("failed to parse release JSON")?) + .context("failed to read response text")?; + + let parsed = json::parse(&text).map_err(|e| anyhow!("failed to parse JSON: {}", e))?; + + let tag_name = parsed["tag_name"] + .as_str() + .ok_or_else(|| anyhow!("missing tag_name"))? + .to_string(); + + let assets_json = parsed["assets"].members().collect::>(); + + let mut assets = Vec::new(); + + for asset in assets_json { + let name = asset["name"] + .as_str() + .ok_or_else(|| anyhow!("missing asset name"))? + .to_string(); + + let browser_download_url = asset["browser_download_url"] + .as_str() + .ok_or_else(|| anyhow!("missing download url"))? + .to_string(); + + assets.push(Asset { + name, + browser_download_url, + }); + } + + Ok(Release { tag_name, assets }) } async fn parse_tag_version(tag: &str) -> Result { diff --git a/iota-util/src/langu/mod.rs b/iota-util/src/langu/mod.rs deleted file mode 100644 index 352d639..0000000 --- a/iota-util/src/langu/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod language_creator; -pub mod language_manager; From 17929bdad378ebc9fa46edd69cf9925860b3188d Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:41:03 +0200 Subject: [PATCH 013/119] [Clean] --- web-ui/src/api.rs | 1 - web-ui/src/server.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 26a0544..7092aba 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -3,7 +3,6 @@ use actix_web::{HttpRequest, HttpResponse, Responder, web}; use iota_storage::util::config_util::CONFIG; use serde_json::{Value, json}; use std::net::SocketAddr; -use std::sync::Arc; pub fn api_config(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index 9902fe0..edecfdd 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -1,6 +1,6 @@ use crate::api::api_config; use crate::web_path_parser; -use actix_web::{App, Error, HttpRequest, HttpServer, Responder, dev::ServerHandle, web}; +use actix_web::{App, HttpServer, dev::ServerHandle, web}; use iota_logger::log; use iota_state::{ACTIVE_TASKS, SHUTDOWN}; use iota_util::file_util::load_file_buf; From eff1c019a8ca8cfa2efd8e8ed1b05050757db2a7 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:44:47 +0200 Subject: [PATCH 014/119] [Add] Iota updater now with it's own crate --- Cargo.toml | 3 ++- iota-updater/Cargo.toml | 33 +++++++++++++++++++++++++++++++++ iota-updater/src/lib.rs | 8 ++++---- iota-util/Cargo.toml | 10 ---------- iota-util/src/lib.rs | 2 -- web-ui/Cargo.toml | 10 +--------- 6 files changed, 40 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 30a7a09..99c452d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,3 @@ [workspace] -members = ["iota-storage", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] +members = ["iota-storage", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] +resolver = "3" diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index e69de29..7c4ec88 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "iota-updater" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-logger = { path = "../iota-logger" } + +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } + +json = "*" +pnet = "0.35.0" +ratatui = "0.30.0" +reqwest = "0.13.2" +tokio = { version = "1.50.0", features = ["full"] } +sysinfo = "0.38.3" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +zip = "6.0.0" +aes-gcm = "0.10.3" +base64 = "0.22.1" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +sha2 = "0.10.9" +x448 = { version = "*" } +hkdf = "0.12.4" +once_cell = "1.21.3" +hex = "*" +serde = "1.0.228" +tempfile = "3.27.0" +anyhow = "1.0.102" +semver = "1.0.28" +self-replace = "1.5.0" diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index 627b3eb..4d8ddbb 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -4,9 +4,10 @@ * It is made by Yolokit and pasted in by AlexEmmet */ use anyhow::{Context, Result, anyhow}; +use iota_logger::log; +use self_replace::self_replace; use semver::Version; use std::fs::File; -use std::io::copy; use tempfile::NamedTempFile; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -98,7 +99,7 @@ async fn asset_name_for_current_platform() -> String { } async fn download_asset(url: &str) -> Result { - let mut response = reqwest::get(url) + let response = reqwest::get(url) .await .context("failed to download asset")?; @@ -148,8 +149,7 @@ async fn perform_update() -> Result { let downloaded = download_asset(&asset.browser_download_url).await?; - self_replace::self_replace(downloaded.path()) - .context("failed to replace current executable")?; + self_replace(downloaded.path()).context("failed to replace current executable")?; Ok(true) } diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 41502e1..51d9d49 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -4,13 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] - ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } -json = "*" -pnet = "0.35.0" -ratatui = "0.30.0" reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } sysinfo = "0.38.3" @@ -23,10 +19,4 @@ rand_core = { version = "0.6", features = ["getrandom", "std"] } sha2 = "0.10.9" x448 = { version = "*" } hkdf = "0.12.4" -once_cell = "1.21.3" hex = "*" -serde = "1.0.228" -tempfile = "3.27.0" -anyhow = "1.0.102" -semver = "1.0.28" -serde_macros = "0.8.9" diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index ebef516..2277b2b 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,5 +1,3 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; -pub mod langu; -pub mod update_util; diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 16a8974..e0debbd 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -24,15 +24,7 @@ futures = "*" futures-util = "*" hex = "*" hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } +hyper = { version = "1.8.1", features = ["capi", "client", "full", "http1", "http2", "nightly", "server"] } hyper-util = { version = "*" } json = "*" lazy_static = "1.5.0" From a60ea3d5d2eeb3d2a3f2062862913941cef133f4 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:46:26 +0200 Subject: [PATCH 015/119] [Add] Correct impl of Iota updater & Move of language creation in main --- Cargo.lock | 97 +++++++++++++++++------------------------- iota-core/Cargo.toml | 1 + iota-core/src/main.rs | 5 +-- iota-logger/src/lib.rs | 4 +- 4 files changed, 43 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec499cf..9ee4537 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,12 +381,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "aster" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258989846dd255a1e0eeef92d425d345477c9999433cecc9f0879f4549d5e5c9" - [[package]] name = "async-trait" version = "0.1.89" @@ -1789,6 +1783,7 @@ dependencies = [ "iota-state", "iota-storage", "iota-terms", + "iota-updater", "iota-util", "json", "omikron-connector", @@ -1809,6 +1804,8 @@ version = "0.1.0" dependencies = [ "iota-state", "iota-util", + "json", + "once_cell", "ratatui", "ttp-core", ] @@ -1865,7 +1862,7 @@ dependencies = [ ] [[package]] -name = "iota-util" +name = "iota-updater" version = "0.1.0" dependencies = [ "aes-gcm", @@ -1873,15 +1870,16 @@ dependencies = [ "base64", "hex", "hkdf", + "iota-logger", "json", "once_cell", "pnet", "rand_core 0.6.4", "ratatui", "reqwest", + "self-replace", "semver", "serde", - "serde_macros", "sha2", "sysinfo", "tempfile", @@ -1894,6 +1892,27 @@ dependencies = [ "zip", ] +[[package]] +name = "iota-util" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "hex", + "hkdf", + "rand_core 0.6.4", + "reqwest", + "sha2", + "sysinfo", + "tokio", + "ttp-core", + "ttp-native", + "uuid", + "walkdir", + "x448", + "zip", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2821,30 +2840,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quasi" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a532453b931a4483a5b2e40f0fe04aee35b6bc2c0eeec876f1bd2358a134d3" - -[[package]] -name = "quasi_codegen" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb4a9a5410fdbdacbeda8063ddb8add9838dfd4cf50ac486db98abb762d8bd6" -dependencies = [ - "aster", -] - -[[package]] -name = "quasi_macros" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc2b36285ea5e54e4e267f83896267ff8c5aba4f66b2e7d186ed6d968f3715f" -dependencies = [ - "quasi_codegen", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3394,6 +3389,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" @@ -3410,24 +3416,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_codegen" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da68810d845f8e33a80243c28794650397056cbe7aea4c9c7516f55d1061c94e" -dependencies = [ - "aster", - "quasi", - "quasi_macros", - "serde_codegen_internals", -] - -[[package]] -name = "serde_codegen_internals" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0115c5c602e81c61b787fb0f0fa76a614f8dbe9100b2b59b7d590155672c80" - [[package]] name = "serde_core" version = "1.0.228" @@ -3461,15 +3449,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_macros" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3cf1c01933271e1e72bb788e0499d1bca8af2c09efcc3ddc0b04ff22d080b83" -dependencies = [ - "serde_codegen", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index acb6c34..a1f6de1 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -9,6 +9,7 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-terms = { path = "../iota-terms" } +iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 209518f..af76140 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -1,5 +1,5 @@ mod consent_state; -use iota_util::update_util::check_update; +use iota_updater::check_update; use pnet::datalink::NetworkInterface; use tokio::time::{Duration, sleep}; @@ -7,12 +7,11 @@ use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; use iota_cli::screens::main_screen::MainScreen; use iota_cli::ui::start_tui; -use iota_logger as logger; +use iota_logger::{self as logger, language_creator}; use iota_logger::{log, log_t}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use iota_util::file_util::{download_and_extract_zip, has_dir}; -use iota_util::langu::language_creator; use omikron_connector as omikron; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 5e45c1e..afc9c0b 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -12,8 +12,8 @@ use ratatui::style::Color; use ttp_core::{CommunicationValue, DataTypes, DataValue}; use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; -mod language_creator; -mod language_manager; +pub mod language_creator; +pub mod language_manager; static LOGGER: OnceLock> = OnceLock::new(); From caaf4504874e6f29706d60be318d45f2c38adea4 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:06:38 +0200 Subject: [PATCH 016/119] [Clean] --- iota-storage/src/users/user_manager.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 8315de4..99cbaf5 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,19 +1,13 @@ use crate::users::user_profile::UserProfile; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use hex::{self}; -use iota_logger::{log, log_cv}; -use iota_util::crypto_helper::{self, public_key_to_base64}; +use iota_util::crypto_helper::{self}; use iota_util::file_util::{load_file, save_file}; use json::JsonValue; use once_cell::sync::Lazy; use rand::Rng; use rand_core::OsRng; -use rand_core::RngCore; -use sha2::{Digest, Sha256}; use std::io::{self}; use std::sync::Mutex; -use std::time::Duration; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; use x448::{PublicKey, Secret}; static USERS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); From 77e5072f20526334c0ebdd1f9c4b066bba3af748 Mon Sep 17 00:00:00 2001 From: Jonathan Wanke Date: Fri, 10 Apr 2026 00:14:41 +0200 Subject: [PATCH 017/119] Proper user error handling. --- iota-cli/src/elements/console_card.rs | 6 +- omikron-connector/src/user_ops.rs | 57 ++++++----- src/.DS_Store | Bin 0 -> 6148 bytes src/util/auto_update.rs | 135 ++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 src/.DS_Store create mode 100644 src/util/auto_update.rs diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index f7b0c8f..a394e96 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -428,7 +428,7 @@ pub async fn run_command(command: &str) { { log!("Created user {}", user.user_id); } else { - log!("Failed to create user"); + log!("User creation: Failed to create user. See errors above."); } } ["user", "remove", username] => { @@ -436,7 +436,7 @@ pub async fn run_command(command: &str) { user_manager::remove_user(user.user_id); log!("Removed user {}", user.user_id); } else { - log!("Failed to find user"); + log!("User removal: Username doesn't exist"); } } ["user", "list"] => { @@ -457,7 +457,7 @@ pub async fn run_command(command: &str) { user_manager::remove_user(user.user_id); log!("Removed user {}", user.user_id); } else { - log!("Failed to find user"); + log!("User info: Username doesn't exist"); } } ["reload"] | ["restart"] => { diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 1c34670..eb14bcc 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,6 +1,6 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use hex; -use iota_logger::{PrintType, log, log_cv}; +use iota_logger::{PrintType, log, log_cv, log_t}; use iota_state::{RELOAD, SHUTDOWN}; use iota_storage::users::user_manager::{add_user, save_users}; use iota_storage::users::user_profile::UserProfile; @@ -15,27 +15,36 @@ use x448::{PublicKey, Secret}; use crate::omikron_connection::OMIKRON_CONNECTION; pub async fn create_user(username: &str) -> (Option, Option) { - let register_cv = CommunicationValue::new(CommunicationType::get_register); + let register_communication_value = CommunicationValue::new(CommunicationType::get_register); - let conn = OMIKRON_CONNECTION.clone(); + let connection = OMIKRON_CONNECTION.clone(); - let response_cv = match conn - .await_response(®ister_cv, Some(Duration::from_secs(20))) + let response_communication_value = match connection + .await_response(®ister_communication_value, Some(Duration::from_secs(20))) .await { - Ok(cv) => cv, - Err(_) => return (None, None), + Ok(communication_value) => communication_value, + Err(e) => { + log_t!("User creation: {}", e); + return (None, None); + } }; - log_cv!(PrintType::Omega, response_cv); + log_cv!(PrintType::Omega, response_communication_value); - let user_id = match response_cv.get_data(DataTypes::user_id).as_number() { + let user_id = match response_communication_value + .get_data(DataTypes::user_id) + .as_number() + { Some(id) => id, - None => return (None, None), + None => { + log_t!("User creation: Response returned none"); + return (None, None); + } }; - let mut buf = [0u8; 56]; + let mut buffer = [0u8; 56]; let mut rng = OsRng; - rng.fill_bytes(&mut buf); - let private_key = Secret::from_bytes(&buf).unwrap(); + rng.fill_bytes(&mut buffer); + let private_key = Secret::from_bytes(&buffer).unwrap(); let public_key = PublicKey::from(&private_key); let mut hasher = Sha256::new(); @@ -47,7 +56,7 @@ pub async fn create_user(username: &str) -> (Option, Option OsRng.fill_bytes(&mut bytes); let reset_token = STANDARD.encode(&bytes); - let up = UserProfile::new( + let user_profile = UserProfile::new( user_id, username.to_string(), None, @@ -56,7 +65,7 @@ pub async fn create_user(username: &str) -> (Option, Option reset_token.clone(), ); - let cv = CommunicationValue::new(CommunicationType::complete_register_user) + let communication_value = CommunicationValue::new(CommunicationType::complete_register_user) .add_data(DataTypes::user_id, DataValue::Number(user_id)) .add_data(DataTypes::username, DataValue::Str(username.to_string())) .add_data( @@ -66,16 +75,17 @@ pub async fn create_user(username: &str) -> (Option, Option .add_data(DataTypes::iota_id, DataValue::Number(user_id)) .add_data(DataTypes::reset_token, DataValue::Str(reset_token)); - let response_cv = conn - .await_response(&cv, Some(Duration::from_secs(20))) + let response_communication_value = connection + .await_response(&communication_value, Some(Duration::from_secs(20))) .await; - if let Ok(resp) = response_cv { - log_cv!(PrintType::Omega, resp); - if !resp.is_type(CommunicationType::success) { + if let Ok(response) = response_communication_value { + log_cv!(PrintType::Omega, response); + if !response.is_type(CommunicationType::success) { return (None, None); } } else { + log_t!("User creation: Response returned none"); return (None, None); } *SHUTDOWN.write().await = true; @@ -87,7 +97,10 @@ pub async fn create_user(username: &str) -> (Option, Option &format!("{}::{}", user_id, STANDARD.encode(&private_key.as_bytes())), ); - add_user(up.clone()); + add_user(user_profile.clone()); save_users(); - (Some(up), Some(STANDARD.encode(&private_key.as_bytes()))) + ( + Some(user_profile), + Some(STANDARD.encode(&private_key.as_bytes())), + ) } diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9f676a4c84f34edbb631ab7a0ca0f57d1c55d095 GIT binary patch literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8zj35RBCIAV8Fop~hR0Kpbg3L%NFD^*R$xmWnVAu|o z8|)Ow?JNwX3?&Si3^~Z|Pb$dCEG{uHxW>rD%)-jX&cV*X%@G@%kzXEMl2}q&?37p( z4dR95=jSBB*ojGDnW^RR0wT`&c_oRNd8tJpCBc~~sY!`NG2xkcDf#72`K5U&#bCWq z2@XyU&UgXw>S{|f6CDK$%UT_UYD*If9R*8MquN?d4pC)&>!A4ToZP(pPDpq%GD2tu zUMLNtx)>N3;NB?)1ECdl9B}YSGGz3OO2r#m^1iLtaDoq^T1=Y2n`ZNJ5 w532nk)iJ1|Mr{NzK?W2hph`j2JxD8v23N(543L_9v>^Zsp;3A?1n3_E0OX}gUH||9 literal 0 HcmV?d00001 diff --git a/src/util/auto_update.rs b/src/util/auto_update.rs new file mode 100644 index 0000000..090c5a8 --- /dev/null +++ b/src/util/auto_update.rs @@ -0,0 +1,135 @@ +/* This file is used for the auto update function for the Iota. + * It connects to the git server from methanium and checks if + * the version has updated inside the cargo.toml file.*/ + +use anyhow::{Context, Result, anyhow}; +use semver::Version; +use serde::Deserialize; +use std::fs::File; +use std::io::copy; +use tempfile::NamedTempFile; + +use crate::log; // For logging messages into the iota + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const API_BASE: &str = "https://git.methanium.net/api/v1"; +const OWNER: &str = "Tensamin"; +const REPO: &str = "Iota"; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +async fn latest_release() -> Result { + let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest"); + + let response = reqwest::get(&url) + .await + .context("failed to query latest release.")?; + + if !response.status().is_success() { + return Err(anyhow!("release API returned {}", response.status())); + } + + Ok(response + .json() + .await + .context("failed to parse release JSON")?) +} + +async fn parse_tag_version(tag: &str) -> Result { + let normalized = tag.strip_prefix('v').unwrap_or(tag); + Ok(Version::parse(normalized)?) +} + +async fn current_version() -> Result { + Ok(Version::parse(CURRENT_VERSION)?) +} + +async fn asset_name_for_current_platform() -> String { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => "iota-linux-x86_64".to_string(), + ("linux", "aarch64") => "iota-linux-aarch64".to_string(), + ("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(), + ("macos", "x86_64") => "iota-macos-x86_64".to_string(), + ("macos", "aarch64") => "iota-macos-aarch64".to_string(), + _ => panic!("unsupported platform: {os}/{arch}"), + } +} + +async fn download_asset(url: &str) -> Result { + let mut response = reqwest::get(url) + .await + .context("failed to download asset")?; + + if !response.status().is_success() { + return Err(anyhow!("asset download returned {}", response.status())); + } + + let tmp = NamedTempFile::new().context("failed to create temp file")?; + let mut out = File::create(tmp.path()).context("failed to open temp file")?; + + let bytes = response + .bytes() + .await + .context("failed to read response bytes")?; + + std::fs::write(tmp.path(), &bytes).context("failed to write file")?; + + Ok(tmp) +} + +async fn check_for_update() -> Result> { + let current = current_version().await?; + let release = latest_release().await?; + let latest = parse_tag_version(&release.tag_name).await?; + + if latest > current { + Ok(Some(release)) + } else { + Ok(None) + } +} + +async fn perform_update() -> Result { + let Some(release) = check_for_update().await? else { + return Ok(false); + }; + + let wanted_asset = asset_name_for_current_platform().await; + + let asset = release + .assets + .iter() + .find(|a| a.name == wanted_asset) + .ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?; + + log!("Downloading update: {}", asset.name); + + let downloaded = download_asset(&asset.browser_download_url).await?; + + self_replace::self_replace(downloaded.path()) + .context("failed to replace current executable")?; + + Ok(true) +} + +pub async fn check_update() -> Result { + if perform_update().await? { + return Ok(true); + } else { + return Ok(false); + } +} From 39b6351c85df96cd795e78cf4fa62089e9f0174f Mon Sep 17 00:00:00 2001 From: Jonathan Wanke Date: Fri, 10 Apr 2026 00:36:46 +0200 Subject: [PATCH 018/119] Added allows for unused things. --- iota-cli/src/screens/terms_checker.rs | 1 + iota-cli/src/ui.rs | 5 +---- iota-core/src/main.rs | 6 ++++-- iota-updater/src/lib.rs | 2 -- omikron-connector/src/omikron_connection.rs | 5 ++++- omikron-connector/src/ping_pong_task.rs | 1 - web-ui/src/api.rs | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 0d64187..c570769 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -26,6 +26,7 @@ pub enum UserChoice { AcceptAll, } +#[allow(dead_code)] // ui is unused pub struct TermsCheckerScreen { ui: Arc, sender: Option>, diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 723babe..a44ee74 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -9,10 +9,7 @@ use ratatui::{Terminal, backend::CrosstermBackend, init}; use std::{ collections::VecDeque, io::Stdout, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, + sync::{Arc, Mutex, atomic::Ordering}, time::Duration, }; use tokio::{sync::RwLock, time::Instant}; diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index af76140..6ae276a 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -15,7 +15,7 @@ use iota_util::file_util::{download_and_extract_zip, has_dir}; use omikron_connector as omikron; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] -#[allow(unused_must_use, dead_code)] +#[allow(unused_must_use, dead_code, unused_assignments)] async fn main() { while *RELOAD.read().await { *RELOAD.write().await = false; @@ -107,7 +107,9 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ + #[allow(unused_variables)] // port is unused let port = CONFIG.read().await.get_port(); + #[allow(unused_variables)] // ip is unused let mut ip = "0.0.0.0".to_string(); for iface in pnet::datalink::interfaces() { let iface: NetworkInterface = iface; @@ -115,7 +117,7 @@ async fn main() { let ipsv = format!("{}", iface.ips[0]); let ips: &str = ipsv.split('/').next().unwrap_or(""); if format!("{}", ips).starts_with("10.") || format!("{}", ips).starts_with("192.") { - ip = ips.to_string(); + ip = ips.to_string(); // Unused assignments. } } } diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index 4d8ddbb..af2982e 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -7,7 +7,6 @@ use anyhow::{Context, Result, anyhow}; use iota_logger::log; use self_replace::self_replace; use semver::Version; -use std::fs::File; use tempfile::NamedTempFile; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -108,7 +107,6 @@ async fn download_asset(url: &str) -> Result { } let tmp = NamedTempFile::new().context("failed to create temp file")?; - let mut out = File::create(tmp.path()).context("failed to open temp file")?; let bytes = response .bytes() diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index d6b0ff4..9aa7f15 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -79,6 +79,7 @@ impl ConnectionState { // Omikron Connection (Client-side with auto-reconnect) // ============================================================================ +#[allow(dead_code)] // message_send_times is unused. pub struct OmikronConnection { state: Arc>, sender: Arc>>>, @@ -283,6 +284,7 @@ impl OmikronConnection { if iota_id == 0 { log_t!("iota_register_new"); + #[allow(unused_variables)] // priv_k is unused let (pub_k, priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { (pk, sk) } else { @@ -426,9 +428,10 @@ impl OmikronConnection { if cv.is_type(CommunicationType::client_connected) { let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + #[allow(unused_variables)] // session_id is unused. let session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; - let mut contacts = chats_util::get_users(user_id); + let contacts = chats_util::get_users(user_id); let mut contacts_array = Vec::new(); for (i, contact) in contacts.iter().enumerate() { diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index fc21855..18e651f 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,6 +1,5 @@ use crate::omikron_connection::OmikronConnection; use dashmap::DashMap; -use iota_logger::log; use iota_state::APP_STATE; use std::sync::LazyLock; use std::time::Instant; diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 7092aba..3c43bf6 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -70,7 +70,7 @@ async fn communities_get(req: HttpRequest, ssl: web::Data) -> impl Respond async fn communities_add( req: HttpRequest, ssl: web::Data, - payload: web::Json, + #[allow(unused_variables)] payload: web::Json, ) -> impl Responder { if !is_allowed_req(&req, *ssl.get_ref()) { return forbidden(); From 3b218411d01d6ceedd93cb269211802cdeac2a31 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:37:51 +0200 Subject: [PATCH 019/119] [Add] App storage --- Cargo.lock | 92 +++++----- iota-storage/src/users/user_manager.rs | 20 ++ iota-storage/src/users/user_profile.rs | 20 +- omikron-connector/src/omikron_connection.rs | 191 ++++++++++++++++++++ 4 files changed, 279 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ee4537..7fec03a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.4", "sha1", "smallvec", "tokio", @@ -552,9 +552,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -1344,6 +1344,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.11.0" @@ -1493,15 +1499,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1679,12 +1684,12 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2028,9 +2033,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -2087,9 +2092,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libsqlite3-sys" @@ -2449,9 +2454,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -2481,9 +2486,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -2678,9 +2683,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pnet" @@ -2870,7 +2875,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -2930,9 +2935,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3240,9 +3245,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -3314,9 +3319,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", @@ -3892,9 +3897,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -4051,11 +4056,12 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#2322bdced8183e970405d5017cd87ba805309a4c" +source = "git+https://git.methanium.net/Tensamin/TTP.git#ac3c0e1cf9e1957b554f0b19f0b0d7cf9cca46c1" dependencies = [ "base64", "byteorder", "rand 0.8.5", + "serde_json", "strum 0.28.0", "strum_macros 0.28.0", ] @@ -4063,7 +4069,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#2322bdced8183e970405d5017cd87ba805309a4c" +source = "git+https://git.methanium.net/Tensamin/TTP.git#ac3c0e1cf9e1957b554f0b19f0b0d7cf9cca46c1" dependencies = [ "quinn", "rustls", @@ -4086,7 +4092,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", ] @@ -4295,9 +4301,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -4308,9 +4314,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -4318,9 +4324,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4328,9 +4334,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -4341,9 +4347,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -4392,9 +4398,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 99cbaf5..9d0fbf1 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -42,6 +42,14 @@ pub async fn load_from_tu(username: &str) -> Result<(), ()> { pub fn add_user(user: UserProfile) { USERS.lock().unwrap().push(user); } + +pub fn update_user(user: UserProfile) { + let mut users = USERS.lock().unwrap(); + if let Some(pos) = users.iter().position(|u| u.user_id == user.user_id) { + users[pos] = user; + } + *UNIQUE.lock().unwrap() = true; +} pub fn get_user_by_username(username: &str) -> Option { USERS .lock() @@ -111,3 +119,15 @@ pub async fn load_users() -> io::Result<()> { pub fn set_unique(val: bool) { *UNIQUE.lock().unwrap() = val; } + +pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { + let path = format!("users/{}/apps", user_id); + let name = format!("{}.json", app_identifier); + save_file(&path, &name, data); +} + +pub fn load_app_data(user_id: i64, app_identifier: &str) -> String { + let path = format!("users/{}/apps", user_id); + let name = format!("{}.json", app_identifier); + load_file(&path, &name) +} diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index 573320a..c0ae20e 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -16,6 +16,7 @@ pub struct UserProfile { pub reset_token: String, pub created_at: i64, pub display_name: Option, + pub trusted_apps: std::collections::HashMap, } impl UserProfile { @@ -38,17 +39,24 @@ impl UserProfile { .unwrap() .as_millis() as i64, reset_token, + trusted_apps: std::collections::HashMap::new(), } } pub fn to_json(&self) -> JsonValue { + let mut trusted_apps_obj = json::JsonValue::new_object(); + for (k, v) in &self.trusted_apps { + trusted_apps_obj[k] = v.clone().into(); + } + let mut obj = object! { "uuid" => self.user_id, "username" => self.username.clone(), "public_key" => self.public_key.clone(), "private_key_hash" => self.private_key_hash.clone(), "created_at" => self.created_at, - "reset_token" => self.reset_token.clone() + "reset_token" => self.reset_token.clone(), + "trusted_apps" => trusted_apps_obj, }; if let Some(d) = &self.display_name { obj["display_name"] = d.clone().into(); @@ -82,6 +90,15 @@ impl UserProfile { let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); + let mut trusted_apps = std::collections::HashMap::new(); + if j["trusted_apps"].is_object() { + for (key, value) in j["trusted_apps"].entries() { + if let Some(s) = value.as_str() { + trusted_apps.insert(key.to_string(), s.to_string()); + } + } + } + let up = UserProfile { user_id, username, @@ -90,6 +107,7 @@ impl UserProfile { private_key_hash, created_at, reset_token, + trusted_apps, }; // TODO: Migrate to Omikron / Wss diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index d6b0ff4..3b08e15 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -91,6 +91,8 @@ pub struct OmikronConnection { pub connection_id: Uuid, shutdown_tx: Arc>>>, reconnect_on_close: Arc>, + pub app_challenges: Arc>>, + pub app_sessions: Arc>>, } impl OmikronConnection { @@ -113,6 +115,8 @@ impl OmikronConnection { connection_id: Uuid::new_v4(), shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), + app_challenges: Arc::new(RwLock::new(HashMap::new())), + app_sessions: Arc::new(RwLock::new(HashMap::new())), } } @@ -424,6 +428,193 @@ impl OmikronConnection { return; } + if cv.is_type(CommunicationType::app_identification) { + let sender_id = cv.get_sender(); + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataTypes::app_public_key) + .as_str() + .unwrap_or("") + .to_string(); + let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + + let mut trusted = false; + if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { + if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { + if pub_k == &app_public_key { + trusted = true; + } + } + } + + if trusted { + use iota_util::crypto_util::{DataFormat, SecurePayload}; + + let challenge = Uuid::new_v4().to_string(); + + self.app_challenges + .write() + .await + .insert(sender_id, challenge.clone()); + self.app_sessions + .write() + .await + .insert(sender_id, (user_id, app_identifier.clone())); + + if let Some(pub_key) = iota_util::crypto_helper::load_public_key(&app_public_key) { + let conf = CONFIG.read().await; + let priv_k_str = conf.get_private_key().unwrap_or_default(); + let pub_k_str = conf.get_public_key().unwrap_or_default(); + drop(conf); + + if let Some(priv_key) = iota_util::crypto_helper::load_secret_key(&priv_k_str) { + let encrypted_challenge = + SecurePayload::new(challenge.as_bytes(), DataFormat::Raw, priv_key) + .unwrap() + .encrypt_x448(pub_key) + .unwrap() + .export(DataFormat::Base64); + + let res = CommunicationValue::new(CommunicationType::app_challange) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_data(DataTypes::public_key, DataValue::Str(pub_k_str)) + .add_data(DataTypes::challenge, DataValue::Str(encrypted_challenge)); + + self.send_message(&res).await; + return; + } + } + } + + let res = CommunicationValue::new(CommunicationType::error) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::app_challange_response) { + let sender_id = cv.get_sender(); + let mut challenges = self.app_challenges.write().await; + if let Some(expected) = challenges.remove(&sender_id) { + if let DataValue::Str(response) = cv.get_data(DataTypes::challenge) { + if expected == *response { + let res = + CommunicationValue::new(CommunicationType::app_identification_response) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + } + } + let res = CommunicationValue::new(CommunicationType::error) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::save_app_data) { + let sender_id = cv.get_sender(); + let app_data = cv + .get_data(DataTypes::app_data) + .as_str() + .unwrap_or("") + .to_string(); + + let sessions = self.app_sessions.read().await; + if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { + iota_storage::users::user_manager::save_app_data( + *user_id, + app_identifier, + &app_data, + ); + } + + let res = CommunicationValue::new(CommunicationType::save_app_data) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::load_app_data) { + let sender_id = cv.get_sender(); + let mut app_data = String::new(); + + let sessions = self.app_sessions.read().await; + if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { + app_data = + iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); + } + + let res = CommunicationValue::new(CommunicationType::load_app_data) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_data(DataTypes::app_data, DataValue::Str(app_data)); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::create_app) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataTypes::app_public_key) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() && !app_public_key.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if !user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.insert(app_identifier, app_public_key); + iota_storage::users::user_manager::update_user(user); + } + } + } + + let res = CommunicationValue::new(CommunicationType::create_app) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::delete_app) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.remove(&app_identifier); + iota_storage::users::user_manager::update_user(user); + } + } + } + + let res = CommunicationValue::new(CommunicationType::delete_app) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&res).await; + return; + } + if cv.is_type(CommunicationType::client_connected) { let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; let session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; From 401d4890dc5c9fcfc333ff7d7d869ab3ba6c8601 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:15:37 +0200 Subject: [PATCH 020/119] [Clean & Fix] --- Cargo.lock | 255 ++++++++++++++------ iota-cli/src/screens/terms_checker.rs | 4 +- iota-core/src/main.rs | 6 +- iota-updater/src/lib.rs | 2 +- omikron-connector/src/omikron_connection.rs | 34 ++- omikron-connector/src/ping_pong_task.rs | 1 - web-ui/src/api.rs | 2 +- 7 files changed, 205 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fec03a..943a560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" dependencies = [ "actix-rt", - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "crossbeam-channel", "futures-core", @@ -31,7 +31,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-sink", @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.0" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" dependencies = [ "actix-codec", "actix-rt", @@ -54,7 +54,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.11.0", + "bitflags 2.11.1", "brotli", "bytes", "bytestring", @@ -73,8 +73,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.4", - "sha1", + "rand 0.10.1", + "sha1 0.11.0", "smallvec", "tokio", "tokio-util", @@ -259,7 +259,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -271,7 +271,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -415,9 +415,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -426,9 +426,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -465,9 +465,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -478,6 +478,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "brotli" version = "8.0.2" @@ -580,6 +589,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -599,7 +619,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -636,6 +656,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -697,6 +723,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -742,7 +777,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -774,6 +809,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -922,11 +966,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1260,6 +1315,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1371,7 +1427,7 @@ dependencies = [ "http 1.4.0", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -1410,7 +1466,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1475,6 +1531,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1499,9 +1564,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.8" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", @@ -1754,7 +1819,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "ratatui", "reqwest", @@ -1840,7 +1905,7 @@ dependencies = [ "iota-util", "json", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "ratatui", "reqwest", @@ -2086,9 +2151,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libbz2-rs-sys" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" @@ -2112,7 +2177,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -2167,9 +2232,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -2288,7 +2353,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -2380,7 +2445,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -2443,9 +2508,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ "is-wsl", "libc", @@ -2458,7 +2523,7 @@ version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -2540,7 +2605,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -2630,7 +2695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2785,7 +2850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2924,9 +2989,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2943,6 +3008,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2987,6 +3063,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -3007,7 +3089,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3059,7 +3141,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.16.1", "indoc", "instability", @@ -3091,7 +3173,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -3197,7 +3279,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3236,7 +3318,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -3319,9 +3401,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -3377,7 +3459,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3473,8 +3555,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -3484,8 +3577,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3571,9 +3664,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" dependencies = [ "cc", "js-sys", @@ -3706,7 +3799,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3763,7 +3856,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.11.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3897,9 +3990,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -3991,7 +4084,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http 1.4.0", @@ -4056,11 +4149,11 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#ac3c0e1cf9e1957b554f0b19f0b0d7cf9cca46c1" +source = "git+https://git.methanium.net/Tensamin/TTP.git#4c8cacf8e8b58d917c24fa6baecefefbce2294b3" dependencies = [ "base64", "byteorder", - "rand 0.8.5", + "rand 0.8.6", "serde_json", "strum 0.28.0", "strum_macros 0.28.0", @@ -4069,7 +4162,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#ac3c0e1cf9e1957b554f0b19f0b0d7cf9cca46c1" +source = "git+https://git.methanium.net/Tensamin/TTP.git#4c8cacf8e8b58d917c24fa6baecefefbce2294b3" dependencies = [ "quinn", "rustls", @@ -4093,15 +4186,15 @@ dependencies = [ "log", "native-tls", "rand 0.9.4", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -4156,7 +4249,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -4198,9 +4291,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "atomic", "getrandom 0.4.2", @@ -4283,11 +4376,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4296,7 +4389,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -4382,7 +4475,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4444,7 +4537,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "ratatui", "reqwest", @@ -4470,9 +4563,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -4932,6 +5025,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4981,7 +5080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -5245,7 +5344,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.10.6", "time", "zeroize", "zopfli", diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 0d64187..6526a5f 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -27,7 +27,7 @@ pub enum UserChoice { } pub struct TermsCheckerScreen { - ui: Arc, + _ui: Arc, sender: Option>, eula: bool, @@ -40,7 +40,7 @@ pub struct TermsCheckerScreen { impl TermsCheckerScreen { pub fn new(ui: Arc, sender: Option>) -> Self { Self { - ui, + _ui: ui, sender, eula: false, tos: false, diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index af76140..f06143f 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -107,15 +107,15 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ - let port = CONFIG.read().await.get_port(); - let mut ip = "0.0.0.0".to_string(); + let _port = CONFIG.read().await.get_port(); + let mut _ip = "0.0.0.0".to_string(); for iface in pnet::datalink::interfaces() { let iface: NetworkInterface = iface; if iface.ips.len() > 0 { let ipsv = format!("{}", iface.ips[0]); let ips: &str = ipsv.split('/').next().unwrap_or(""); if format!("{}", ips).starts_with("10.") || format!("{}", ips).starts_with("192.") { - ip = ips.to_string(); + _ip = ips.to_string(); } } } diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index 4d8ddbb..c93e9d3 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -108,7 +108,7 @@ async fn download_asset(url: &str) -> Result { } let tmp = NamedTempFile::new().context("failed to create temp file")?; - let mut out = File::create(tmp.path()).context("failed to open temp file")?; + let _out = File::create(tmp.path()).context("failed to open temp file")?; let bytes = response .bytes() diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 3b08e15..3bf212e 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -87,7 +87,6 @@ pub struct OmikronConnection { port: u16, pub last_ping: Arc>, heartbeat_handle: Arc>>>, - message_send_times: Arc>>, pub connection_id: Uuid, shutdown_tx: Arc>>>, reconnect_on_close: Arc>, @@ -111,7 +110,6 @@ impl OmikronConnection { port, last_ping: Arc::new(Mutex::new(-1)), heartbeat_handle: Arc::new(Mutex::new(None)), - message_send_times: Arc::new(Mutex::new(HashMap::new())), connection_id: Uuid::new_v4(), shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), @@ -287,7 +285,7 @@ impl OmikronConnection { if iota_id == 0 { log_t!("iota_register_new"); - let (pub_k, priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { + let (pub_k, _priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { (pk, sk) } else { let key_pair = crypto_helper::generate_keypair(); @@ -617,9 +615,9 @@ impl OmikronConnection { if cv.is_type(CommunicationType::client_connected) { let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; - let session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; + let _session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; - let mut contacts = chats_util::get_users(user_id); + let contacts = chats_util::get_users(user_id); let mut contacts_array = Vec::new(); for (i, contact) in contacts.iter().enumerate() { @@ -865,10 +863,15 @@ impl OmikronConnection { let user_forward = CommunicationValue::new(CommunicationType::message_live) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data(DataTypes::content, DataValue::Str(content.clone())) .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) - .add_data(DataTypes::height, DataValue::Number(height)); + .add_data( + DataTypes::message, + DataValue::Container(vec![ + (DataTypes::content, DataValue::Str(content.clone())), + (DataTypes::send_time, DataValue::Number(timestamp_i64)), + (DataTypes::height, DataValue::Number(height)), + ]), + ); // Attempt delivery and await a response from the local client let user_resp = self @@ -941,7 +944,7 @@ impl OmikronConnection { .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) .add_data( DataTypes::chat_partner_id, - DataValue::Number(sender_id as i64), + DataValue::Number(receiver_id as i64), ) .add_data( DataTypes::message_state, @@ -994,10 +997,15 @@ impl OmikronConnection { let user_forward = CommunicationValue::new(CommunicationType::message_live) .with_id(cv.get_id()) .with_receiver(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data(DataTypes::content, DataValue::Str(content.clone())) .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) - .add_data(DataTypes::height, DataValue::Number(height)); + .add_data( + DataTypes::message, + DataValue::Container(vec![ + (DataTypes::content, DataValue::Str(content.clone())), + (DataTypes::send_time, DataValue::Number(timestamp)), + (DataTypes::height, DataValue::Number(height)), + ]), + ); let user_resp = self .clone() @@ -1051,7 +1059,7 @@ impl OmikronConnection { .add_data(DataTypes::send_time, DataValue::Number(timestamp)) .add_data( DataTypes::chat_partner_id, - DataValue::Number(*sender_id as i64), + DataValue::Number(*receiver_id as i64), ) .add_data( DataTypes::message_state, diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index fc21855..18e651f 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,6 +1,5 @@ use crate::omikron_connection::OmikronConnection; use dashmap::DashMap; -use iota_logger::log; use iota_state::APP_STATE; use std::sync::LazyLock; use std::time::Instant; diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 7092aba..6cbdf78 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -70,7 +70,7 @@ async fn communities_get(req: HttpRequest, ssl: web::Data) -> impl Respond async fn communities_add( req: HttpRequest, ssl: web::Data, - payload: web::Json, + _payload: web::Json, ) -> impl Responder { if !is_allowed_req(&req, *ssl.get_ref()) { return forbidden(); From bfb238d7a02abbf2c0a49705759b09657b17e587 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 3 May 2026 15:01:47 +0200 Subject: [PATCH 021/119] [Fix] Spelling, Offline Legal --- Cargo.lock | 265 ++++++++------------ iota-cli/src/screens/terms_checker.rs | 22 +- iota-cli/src/screens/terms_updater.rs | 12 +- iota-core/src/consent_state.rs | 45 ++-- iota-core/src/main.rs | 15 +- iota-updater/src/lib.rs | 1 + omikron-connector/src/omikron_connection.rs | 4 +- web-server/src/lib.rs | 6 + 8 files changed, 174 insertions(+), 196 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 943a560..41d5e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,9 +534,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ "bytes", ] @@ -561,9 +561,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -571,12 +571,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -743,9 +737,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -887,9 +881,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deflate64" @@ -1533,9 +1527,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "typenum", ] @@ -1733,9 +1727,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1827,7 +1821,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2", + "sha2 0.10.9", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -1910,7 +1904,7 @@ dependencies = [ "ratatui", "reqwest", "rusqlite", - "sha2", + "sha2 0.10.9", "sysinfo", "tokio", "ttp-core", @@ -1950,7 +1944,7 @@ dependencies = [ "self-replace", "semver", "serde", - "sha2", + "sha2 0.10.9", "sysinfo", "tempfile", "tokio", @@ -1972,7 +1966,7 @@ dependencies = [ "hkdf", "rand_core 0.6.4", "reqwest", - "sha2", + "sha2 0.10.9", "sysinfo", "tokio", "ttp-core", @@ -2044,27 +2038,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", ] [[package]] @@ -2098,9 +2097,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -2157,9 +2156,9 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libsqlite3-sys" @@ -2252,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ "crc", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2486,7 +2485,7 @@ dependencies = [ "iota-util", "json", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "tokio", "ttp-core", "ttp-native", @@ -2519,9 +2518,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.77" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -2551,9 +2550,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.113" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -2665,7 +2664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3163,7 +3162,7 @@ dependencies = [ "aws-lc-rs", "rustls-pki-types", "time", - "x509-parser 0.18.1", + "x509-parser", "yasna", ] @@ -3213,9 +3212,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -3327,9 +3326,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -3364,9 +3363,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -3374,9 +3373,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3401,9 +3400,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -3581,6 +3580,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3624,6 +3634,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.2" @@ -3873,7 +3899,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -4149,7 +4175,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#4c8cacf8e8b58d917c24fa6baecefefbce2294b3" +source = "git+https://git.methanium.net/Tensamin/TTP.git#9bd66c762f2de5ebee6ab0e591702b0b992f041e" dependencies = [ "base64", "byteorder", @@ -4162,7 +4188,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#4c8cacf8e8b58d917c24fa6baecefefbce2294b3" +source = "git+https://git.methanium.net/Tensamin/TTP.git#9bd66c762f2de5ebee6ab0e591702b0b992f041e" dependencies = [ "quinn", "rustls", @@ -4394,9 +4420,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -4407,9 +4433,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -4417,9 +4443,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4427,9 +4453,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -4440,9 +4466,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -4491,9 +4517,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -4545,7 +4571,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2", + "sha2 0.10.9", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -4588,7 +4614,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4785,15 +4811,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4821,21 +4838,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -4878,12 +4880,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4896,12 +4892,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4914,12 +4904,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4944,12 +4928,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4962,12 +4940,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4980,12 +4952,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4998,12 +4964,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5118,9 +5078,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wtransport" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56b611f195638f3790e4e5a41e9d777643a6c324ae4ffd1f0f53f2738e7678c" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" dependencies = [ "bytes", "pem", @@ -5129,22 +5089,22 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pki-types", - "sha2", - "socket2 0.5.10", + "sha2 0.11.0", + "socket2 0.6.3", "thiserror 2.0.18", "time", "tokio", "tracing", "url", "wtransport-proto", - "x509-parser 0.17.0", + "x509-parser", ] [[package]] name = "wtransport-proto" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1627c5b59450278e9771aab35275d72bfa2788128c197c5af1e4a820c8737ef4" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" dependencies = [ "httlib-huffman", "octets", @@ -5163,23 +5123,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "x509-parser" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - [[package]] name = "x509-parser" version = "0.18.1" diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 4e888e4..029dae2 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -287,13 +287,21 @@ impl Screen for TermsCheckerScreen { _ => None, }; if let Some(terms_type) = terms_type { - let fut: Pin> + Send>> = - Box::pin(async move { - let content = get_terms(terms_type.clone()).await.unwrap(); - let screen: FileViewer = - FileViewer::new(terms_type.to_string(), &content); - Box::new(screen) as Box - }); + let fut: Pin> + Send>> = Box::pin( + async move { + if let Some(content) = get_terms(terms_type.clone()).await { + let screen: FileViewer = + FileViewer::new(terms_type.to_string(), &content); + Box::new(screen) as Box + } else { + let screen: FileViewer = FileViewer::new( + "Error".to_string(), + "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", + ); + Box::new(screen) as Box + } + }, + ); InteractionResult::OpenFutureScreen { screen: fut } } else { InteractionResult::Unhandled diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index ee91cba..e6aad76 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -656,9 +656,15 @@ impl Screen for TermsUpdaterScreen { if let Some(terms_type) = terms_type { let fut = Box::pin(async move { - let content = get_terms(terms_type.clone()).await.unwrap(); - Box::new(FileViewer::new(terms_type.to_string(), &content)) - as Box + if let Some(content) = get_terms(terms_type.clone()).await { + Box::new(FileViewer::new(terms_type.to_string(), &content)) + as Box + } else { + Box::new(FileViewer::new( + "Error".to_string(), + "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", + )) as Box + } }); return InteractionResult::OpenFutureScreen { screen: fut }; diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index 006de0c..60e4515 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -8,30 +8,31 @@ use iota_terms::{Doc, TermsType as Type, get_current_docs, get_newest_docs}; use iota_util::file_util::{load_file, save_file}; use tokio::sync::oneshot; -pub async fn check(ui: Arc) -> (bool, bool) { +pub async fn check(ui: Arc) -> Result<(bool, bool), String> { let mut state = ConsentState::load_state(); - if ensure_initial_consent(ui.clone(), &mut state) - .await - .is_err() - { - return (false, false); - } + ensure_initial_consent(ui.clone(), &mut state).await?; if ensure_updates(ui, &mut state).await.is_err() { - return (false, false); + // We don't stop the program if updates fail, as long as we have initial consent }; state = state.sanitize(); state.save_state(); - (state.accepted_eula, state.accepted_tos && state.accepted_pp) + Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) } -async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { +async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), String> { if state.accepted_eula { return Ok(()); } + let docs = get_current_docs().await; + if docs.is_none() { + return Err("Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string()); + } + let (current_eula, current_tos, current_privacy) = docs.unwrap(); + let (tx, rx) = oneshot::channel(); ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) @@ -41,25 +42,23 @@ async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result match result { UserChoice::AcceptEULA | UserChoice::AcceptAll => { - if let Some((eula, tos, privacy)) = get_current_docs().await { - state.accepted_eula = true; - state.eula = Some(eula); + state.accepted_eula = true; + state.eula = Some(current_eula); - if matches!(result, UserChoice::AcceptAll) { - state.accepted_tos = true; - state.accepted_pp = true; - state.tos = Some(tos); - state.privacy = Some(privacy); - } + if matches!(result, UserChoice::AcceptAll) { + state.accepted_tos = true; + state.accepted_pp = true; + state.tos = Some(current_tos); + state.privacy = Some(current_privacy); } let _ = &state.save_state(); Ok(()) } - UserChoice::Deny => Err(()), + UserChoice::Deny => Ok(()), } } -async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { +async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { return Ok(()); }; @@ -90,7 +89,9 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> UserChoice::AcceptEULA => { state.accepted_eula = true; } - UserChoice::Deny => return Err(()), + UserChoice::Deny => { + return Err("Consent update was denied for a mandatory document.".to_string()); + } } } else { apply_future_updates(state, result, eula_update, tos_update, privacy_update); diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index d60f31a..7874057 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -23,7 +23,20 @@ async fn main() { let ui = start_tui(); - let (eula, tos_pp) = consent_state::check(ui.clone()).await; + let (eula, tos_pp) = match consent_state::check(ui.clone()).await { + Ok(v) => v, + Err(e) => { + *SHUTDOWN.write().await = true; + loop { + if ACTIVE_TASKS.is_empty() { + break; + } + sleep(Duration::from_millis(100)).await; + } + println!("{}", e); + return; + } + }; if !eula { *SHUTDOWN.write().await = true; diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index de74ef6..c93e9d3 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result, anyhow}; use iota_logger::log; use self_replace::self_replace; use semver::Version; +use std::fs::File; use tempfile::NamedTempFile; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 02b8aaf..5a25039 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -478,7 +478,7 @@ impl OmikronConnection { .unwrap() .export(DataFormat::Base64); - let res = CommunicationValue::new(CommunicationType::app_challange) + let res = CommunicationValue::new(CommunicationType::app_challenge) .with_id(cv.get_id()) .with_receiver(sender_id) .add_data(DataTypes::public_key, DataValue::Str(pub_k_str)) @@ -497,7 +497,7 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::app_challange_response) { + if cv.is_type(CommunicationType::app_challenge_response) { let sender_id = cv.get_sender(); let mut challenges = self.app_challenges.write().await; if let Some(expected) = challenges.remove(&sender_id) { diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index e69de29..e886571 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -0,0 +1,6 @@ +// The web server is a TTP host & identification system, +// it "upgrades" connections after identification to +// +// either Own User (Cut down version of the Omikron Connection), +// or Community (Custom Connection), +// or Iota (Custom Connection). From 3274471695ec7f999021a906269d822ac4d872ff Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 3 May 2026 21:58:53 +0200 Subject: [PATCH 022/119] [Fix] Now uses tensamin.net --- omikron-connector/src/omikron_connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 5a25039..11376fc 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -25,7 +25,7 @@ use uuid::Uuid; // Configuration // ============================================================================ -const OMIKRON_HOST_DEFAULT: &str = "methanium.net"; +const OMIKRON_HOST_DEFAULT: &str = "tensamin.net"; const OMIKRON_PORT_DEFAULT: u16 = 959; const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); From e5360088eb7cc2d9cb219f961e3def2738a83ef8 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 4 May 2026 17:04:58 +0200 Subject: [PATCH 023/119] [Fix] Split --- .../unused-Cargo.toml => client/Cargo.toml | 3 +- client/src/lib.rs | 1 + communities/Cargo.toml | 61 +++++++++++++++++++ .../src}/community.rs | 0 .../src}/community_connection.rs | 0 .../src}/community_manager.rs | 0 .../src}/interactables/category.rs | 0 .../src}/interactables/interactable.rs | 0 .../src}/interactables/registry.rs | 0 .../src}/interactables/text_chat.rs | 0 .../src}/interactables/voice_chat.rs | 0 .../mod.rs => communities/src/lib.rs | 26 ++++---- .../src}/perms/permission.rs | 0 decentralized/src/lib.rs | 2 - decentralized/src/local_auth/mod.rs | 2 - iota-auth/Cargo.toml | 58 ++++++++++++++++++ .../local_auth => iota-auth/src}/auth_user.rs | 0 iota-auth/src/lib.rs | 0 .../src}/local_auth.rs | 0 other_iota/Cargo.toml | 61 +++++++++++++++++++ other_iota/src/lib.rs | 1 + 21 files changed, 197 insertions(+), 18 deletions(-) rename decentralized/unused-Cargo.toml => client/Cargo.toml (96%) create mode 100644 client/src/lib.rs create mode 100644 communities/Cargo.toml rename {decentralized/src/communities => communities/src}/community.rs (100%) rename {decentralized/src/communities => communities/src}/community_connection.rs (100%) rename {decentralized/src/communities => communities/src}/community_manager.rs (100%) rename {decentralized/src/communities => communities/src}/interactables/category.rs (100%) rename {decentralized/src/communities => communities/src}/interactables/interactable.rs (100%) rename {decentralized/src/communities => communities/src}/interactables/registry.rs (100%) rename {decentralized/src/communities => communities/src}/interactables/text_chat.rs (100%) rename {decentralized/src/communities => communities/src}/interactables/voice_chat.rs (100%) rename decentralized/src/communities/mod.rs => communities/src/lib.rs (95%) rename {decentralized/src/communities => communities/src}/perms/permission.rs (100%) delete mode 100644 decentralized/src/lib.rs delete mode 100644 decentralized/src/local_auth/mod.rs create mode 100644 iota-auth/Cargo.toml rename {decentralized/src/local_auth => iota-auth/src}/auth_user.rs (100%) create mode 100644 iota-auth/src/lib.rs rename {decentralized/src/local_auth => iota-auth/src}/local_auth.rs (100%) create mode 100644 other_iota/Cargo.toml create mode 100644 other_iota/src/lib.rs diff --git a/decentralized/unused-Cargo.toml b/client/Cargo.toml similarity index 96% rename from decentralized/unused-Cargo.toml rename to client/Cargo.toml index 8af10a1..b930ed5 100644 --- a/decentralized/unused-Cargo.toml +++ b/client/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "decentralized" +name = "communities" version = "0.1.0" edition = "2024" @@ -10,6 +10,7 @@ iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } +iota-auth = { path = "../iota-auth" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1 @@ + diff --git a/communities/Cargo.toml b/communities/Cargo.toml new file mode 100644 index 0000000..b930ed5 --- /dev/null +++ b/communities/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "communities" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +iota-logger = { path = "../iota-logger" } +iota-util = { path = "../iota-util" } +iota-storage = { path = "../iota-storage" } +iota-state = { path = "../iota-state" } +iota-auth = { path = "../iota-auth" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/decentralized/src/communities/community.rs b/communities/src/community.rs similarity index 100% rename from decentralized/src/communities/community.rs rename to communities/src/community.rs diff --git a/decentralized/src/communities/community_connection.rs b/communities/src/community_connection.rs similarity index 100% rename from decentralized/src/communities/community_connection.rs rename to communities/src/community_connection.rs diff --git a/decentralized/src/communities/community_manager.rs b/communities/src/community_manager.rs similarity index 100% rename from decentralized/src/communities/community_manager.rs rename to communities/src/community_manager.rs diff --git a/decentralized/src/communities/interactables/category.rs b/communities/src/interactables/category.rs similarity index 100% rename from decentralized/src/communities/interactables/category.rs rename to communities/src/interactables/category.rs diff --git a/decentralized/src/communities/interactables/interactable.rs b/communities/src/interactables/interactable.rs similarity index 100% rename from decentralized/src/communities/interactables/interactable.rs rename to communities/src/interactables/interactable.rs diff --git a/decentralized/src/communities/interactables/registry.rs b/communities/src/interactables/registry.rs similarity index 100% rename from decentralized/src/communities/interactables/registry.rs rename to communities/src/interactables/registry.rs diff --git a/decentralized/src/communities/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs similarity index 100% rename from decentralized/src/communities/interactables/text_chat.rs rename to communities/src/interactables/text_chat.rs diff --git a/decentralized/src/communities/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs similarity index 100% rename from decentralized/src/communities/interactables/voice_chat.rs rename to communities/src/interactables/voice_chat.rs diff --git a/decentralized/src/communities/mod.rs b/communities/src/lib.rs similarity index 95% rename from decentralized/src/communities/mod.rs rename to communities/src/lib.rs index ecad116..237b709 100644 --- a/decentralized/src/communities/mod.rs +++ b/communities/src/lib.rs @@ -1,13 +1,13 @@ -pub mod community_manager; -pub mod interactables { - pub mod category; - pub mod interactable; - pub mod registry; - pub mod text_chat; - pub mod voice_chat; -} -pub mod community; -pub mod community_connection; -pub mod perms { - pub mod permission; -} +pub mod community_manager; +pub mod interactables { + pub mod category; + pub mod interactable; + pub mod registry; + pub mod text_chat; + pub mod voice_chat; +} +pub mod community; +pub mod community_connection; +pub mod perms { + pub mod permission; +} diff --git a/decentralized/src/communities/perms/permission.rs b/communities/src/perms/permission.rs similarity index 100% rename from decentralized/src/communities/perms/permission.rs rename to communities/src/perms/permission.rs diff --git a/decentralized/src/lib.rs b/decentralized/src/lib.rs deleted file mode 100644 index e7896d5..0000000 --- a/decentralized/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod communities; -pub mod local_auth; diff --git a/decentralized/src/local_auth/mod.rs b/decentralized/src/local_auth/mod.rs deleted file mode 100644 index 73d6442..0000000 --- a/decentralized/src/local_auth/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod auth_user; -pub mod local_auth; diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml new file mode 100644 index 0000000..56f6eac --- /dev/null +++ b/iota-auth/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "iota-auth" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +iota-logger = { path = "../iota-logger" } +iota-util = { path = "../iota-util" } +iota-storage = { path = "../iota-storage" } +iota-state = { path = "../iota-state" } + +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/decentralized/src/local_auth/auth_user.rs b/iota-auth/src/auth_user.rs similarity index 100% rename from decentralized/src/local_auth/auth_user.rs rename to iota-auth/src/auth_user.rs diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/decentralized/src/local_auth/local_auth.rs b/iota-auth/src/local_auth.rs similarity index 100% rename from decentralized/src/local_auth/local_auth.rs rename to iota-auth/src/local_auth.rs diff --git a/other_iota/Cargo.toml b/other_iota/Cargo.toml new file mode 100644 index 0000000..b930ed5 --- /dev/null +++ b/other_iota/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "communities" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +iota-logger = { path = "../iota-logger" } +iota-util = { path = "../iota-util" } +iota-storage = { path = "../iota-storage" } +iota-state = { path = "../iota-state" } +iota-auth = { path = "../iota-auth" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = "0.4.43" +crossterm = "*" +dashmap = "6.1.0" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } +hyper-util = { version = "*" } +json = "*" +lazy_static = "1.5.0" +once_cell = "1.21.3" +open = "5.3.3" +pnet = "0.35.0" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +ratatui = "0.30.0" +reqwest = "0.13.2" +rusqlite = "0.39.0" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2.0" +serde_json = "1.0.149" +sha2 = "0.10.9" +strum = "0.27.2" +strum_macros = "0.27.2" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +zip = "6.0.0" diff --git a/other_iota/src/lib.rs b/other_iota/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/other_iota/src/lib.rs @@ -0,0 +1 @@ + From 91d0e9ab76c46631125fe63b736090d6d605c9c3 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 4 May 2026 22:08:14 +0200 Subject: [PATCH 024/119] [Add] Some more split --- Cargo.lock | 153 +++ Cargo.toml | 2 +- client/Cargo.toml | 2 +- client/src/client_connection.rs | 1496 +++++++++++++++++++++++ client/src/client_connection_manager.rs | 0 client/src/lib.rs | 2 +- 6 files changed, 1652 insertions(+), 3 deletions(-) create mode 100644 client/src/client_connection.rs create mode 100644 client/src/client_connection_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 41d5e1b..0e1cb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -617,6 +617,58 @@ dependencies = [ "inout", ] +[[package]] +name = "client" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-auth", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "cmake" version = "0.1.58" @@ -636,6 +688,58 @@ dependencies = [ "memchr", ] +[[package]] +name = "communities" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-auth", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -1784,6 +1888,55 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "iota-auth" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "iota-cli" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 99c452d..34fa81d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["iota-storage", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] +members = ["iota-storage", "client", "communities", "iota-auth", "other-iota", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] resolver = "3" diff --git a/client/Cargo.toml b/client/Cargo.toml index b930ed5..f38c0b4 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "communities" +name = "client" version = "0.1.0" edition = "2024" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs new file mode 100644 index 0000000..b13311a --- /dev/null +++ b/client/src/client_connection.rs @@ -0,0 +1,1496 @@ +use dashmap::DashMap; +use iota_logger::{log, log_cv_in, log_cv_out, log_t}; +use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_storage::users::contact::Contact; +use iota_storage::util::chat_files::{MessageState, change_message_state}; +use iota_storage::util::chats_util::{get_user, mod_user}; +use iota_storage::util::communities_util::CommunitiesUtil; +use iota_storage::util::config_util::CONFIG; +use iota_storage::util::{chat_files, chats_util}; +use iota_util::crypto_helper; +use iota_util::crypto_util::{DataFormat, SecurePayload}; +use iota_util::file_util::{get_children, load_file, save_file}; +use json::JsonValue; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; +use ttp_native::{Receiver, Sender}; +use uuid::Uuid; + +// ============================================================================ +// Configuration +// ============================================================================ + +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); +const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); +const TASK_MAX_AGE: Duration = Duration::from_secs(60); + +// ============================================================================ +// Waiting Task System +// ============================================================================ + +pub struct WaitingTask { + pub task: Box, CommunicationValue) -> bool + Send + Sync>, + pub inserted_at: Instant, +} + +// ============================================================================ +// Connection State +// ============================================================================ + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected { identified: bool }, +} + +impl ConnectionState { + pub fn is_connected(&self) -> bool { + matches!(self, ConnectionState::Connected { .. }) + } + + pub fn is_identified(&self) -> bool { + matches!(self, ConnectionState::Connected { identified: true }) + } +} + +// ============================================================================ +// Omikron Connection (Client-side with auto-reconnect) +// ============================================================================ + +#[allow(dead_code)] // message_send_times is unused. +pub struct OmikronConnection { + state: Arc>, + sender: Arc>>>, + connection_loop_handle: Arc>>>, + host: String, + port: u16, + pub last_ping: Arc>, + heartbeat_handle: Arc>>>, + pub connection_id: Uuid, + shutdown_tx: Arc>>>, + reconnect_on_close: Arc>, + pub app_challenges: Arc>>, + pub app_sessions: Arc>>, +} + +impl OmikronConnection { + pub fn new() -> Self { + Self::with_host(OMIKRON_HOST_DEFAULT, OMIKRON_PORT_DEFAULT) + } + + pub fn with_host(host: &str, port: u16) -> Self { + let (shutdown_tx, _) = watch::channel(false); + + OmikronConnection { + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + sender: Arc::new(RwLock::new(None)), + connection_loop_handle: Arc::new(Mutex::new(None)), + host: host.to_string(), + port, + last_ping: Arc::new(Mutex::new(-1)), + heartbeat_handle: Arc::new(Mutex::new(None)), + connection_id: Uuid::new_v4(), + shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), + reconnect_on_close: Arc::new(RwLock::new(true)), + app_challenges: Arc::new(RwLock::new(HashMap::new())), + app_sessions: Arc::new(RwLock::new(HashMap::new())), + } + } + + // ------------------------------------------------------------------------- + // Connection Management + // ------------------------------------------------------------------------- + + pub async fn connect(self: &Arc) { + if self.connection_loop_handle.lock().await.is_none() { + self.clone().start().await; + } + } + + pub async fn start(self: Arc) { + if let Some(handle) = self.connection_loop_handle.lock().await.take() { + handle.abort(); + } + + *self.reconnect_on_close.write().await = true; + + let self_clone = self.clone(); + let handle = tokio::spawn(async move { + self_clone.connection_loop().await; + }); + + *self.connection_loop_handle.lock().await = Some(handle); + } + + pub async fn stop(&self) { + *self.reconnect_on_close.write().await = false; + + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + + if let Some(handle) = self.connection_loop_handle.lock().await.take() { + handle.abort(); + } + + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + handle.abort(); + } + + if let Some(sender) = self.sender.read().await.as_ref() { + sender.close(); + } + + *self.state.write().await = ConnectionState::Disconnected; + *self.sender.write().await = None; + } + + async fn connection_loop(self: Arc) { + let mut reconnect_delay = RECONNECT_DELAY; + let shutdown_rx = self.shutdown_tx.lock().await.as_ref().unwrap().subscribe(); + let mut shutdown_rx = shutdown_rx; + + loop { + if *shutdown_rx.borrow() || *SHUTDOWN.read().await { + log_t!("omikron_connection_loop_shutdown"); + break; + } + + if !*self.reconnect_on_close.read().await { + break; + } + + match self.clone().connect_once().await { + Ok(()) => { + if *self.reconnect_on_close.read().await { + log!("Connection lost, reconnecting in {:?}...", reconnect_delay); + } else { + break; + } + } + Err(e) => { + log!( + "Connection failed: {}, retrying in {:?}...", + e, + reconnect_delay + ); + } + } + + tokio::select! { + _ = sleep(reconnect_delay) => {} + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + + reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY); + } + } + + async fn connect_once(self: Arc) -> Result<(), String> { + *self.state.write().await = ConnectionState::Connecting; + log_t!("omikron_connecting"); + + let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); + + let (sender, mut receiver) = ttp_native::client::connect(&addr_str, None) + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + log_t!("omikron_connection_success"); + + let sender_arc = Arc::new(sender); + *self.sender.write().await = Some(sender_arc.clone()); + *self.state.write().await = ConnectionState::Connected { identified: false }; + + // Start read loop + let read_self = self.clone(); + let read_handle = tokio::spawn(async move { + read_self.read_loop(&mut receiver).await; + }); + + // Handle registration/identification + self.handle_authentication().await; + + // Start heartbeat + let heartbeat_self = self.clone(); + let heartbeat_handle = tokio::spawn(async move { + heartbeat_self.heartbeat_loop().await; + }); + *self.heartbeat_handle.lock().await = Some(heartbeat_handle); + + { + ACTIVE_TASKS.insert("Omikron Listener".to_string()); + } + + // Wait for read loop to complete + let result = read_handle.await; + *self.sender.write().await = None; + *self.state.write().await = ConnectionState::Disconnected; + { + ACTIVE_TASKS.remove("Omikron Listener"); + } + + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + handle.abort(); + } + + match result { + Ok(()) => { + if *self.reconnect_on_close.read().await { + Err("Connection closed, will reconnect".to_string()) + } else { + Ok(()) + } + } + Err(e) => Err(format!("Read loop error: {}", e)), + } + } + + // ------------------------------------------------------------------------- + // Authentication (Registration/Identification) + // ------------------------------------------------------------------------- + + async fn handle_authentication(&self) { + let conf = CONFIG.read().await; + let iota_id = conf.get_iota_id(); + let public_key = conf.get_public_key(); + let private_key = conf.get_private_key(); + drop(conf); + + if iota_id == 0 { + log_t!("iota_register_new"); + + let (pub_k, _priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { + (pk, sk) + } else { + let key_pair = crypto_helper::generate_keypair(); + let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); + let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); + + let mut conf_write = CONFIG.write().await; + conf_write.change("public_key", JsonValue::from(public_key_base64.clone())); + conf_write.change("private_key", JsonValue::from(private_key_base64.clone())); + conf_write.update(); + drop(conf_write); + (public_key_base64, private_key_base64) + }; + + let register_msg = CommunicationValue::new(CommunicationType::register_iota) + .add_data(DataTypes::public_key, DataValue::Str(pub_k)); + + let msg_id = register_msg.get_id(); + + WAITING_TASKS.insert( + msg_id, + WaitingTask { + task: Box::new(|selfc, cv| { + if !cv.is_type(CommunicationType::success) { + return false; + } + + let iota_value = cv.get_data(DataTypes::iota_id); + let iota_id = iota_value.as_number().unwrap_or(0); + + if iota_id != 0 { + tokio::spawn(async move { + let mut conf_write = CONFIG.write().await; + conf_write.change("iota_id", JsonValue::from(iota_id)); + conf_write.update(); + drop(conf_write); + log!("Registered with Iota-ID: {}", iota_id); + + // Send identification after registration + let identify_msg = + CommunicationValue::new(CommunicationType::identification) + .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + selfc.send_message(&identify_msg).await; + }); + } else { + log!("Iota registration failed."); + } + true + }), + inserted_at: Instant::now(), + }, + ); + + self.send_message(®ister_msg).await; + } else { + let identify_msg = CommunicationValue::new(CommunicationType::identification) + .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + self.send_message(&identify_msg).await; + } + } + + // ------------------------------------------------------------------------- + // Read Loop & Heartbeat + // ------------------------------------------------------------------------- + + async fn read_loop(self: Arc, receiver: &mut Receiver) { + loop { + let result = receiver.receive().await; + match result { + Ok(cv) => { + self.clone().handle_message(cv).await; + } + Err(e) => { + self.fail_all_waiting_tasks(format!( + "Connection receive error: {} (connection_id={})", + e, self.connection_id + )) + .await; + break; + } + } + if !receiver.is_open() { + self.fail_all_waiting_tasks(format!( + "Connection closed (connection_id={}, receiver_open=false)", + self.connection_id + )) + .await; + break; + } + } + } + + async fn heartbeat_loop(self: Arc) { + loop { + sleep(HEARTBEAT_INTERVAL).await; + + if !self.state.read().await.is_connected() { + break; + } + + if let Some(sender) = self.sender.read().await.as_ref() { + if !sender.is_open() { + break; + } + } else { + break; + } + + self.send_ping().await; + } + } + + // ------------------------------------------------------------------------- + // Message Handling (Preserved from original) + // ------------------------------------------------------------------------- + + pub async fn handle_message(self: Arc, cv: CommunicationValue) { + if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + log_cv_in!(&cv); + } + + let msg_id = cv.get_id(); + + // Dispatch waiting task for this message id + if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { + if (task.task)(self.clone(), cv.clone()) { + return; + } + } + + if cv.is_type(CommunicationType::pong) { + self.handle_pong(&cv).await; + return; + } + + if cv.is_type(CommunicationType::challenge) { + self.handle_challenge(&cv).await; + return; + } + + if cv.is_type(CommunicationType::app_identification) { + let sender_id = cv.get_sender(); + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataTypes::app_public_key) + .as_str() + .unwrap_or("") + .to_string(); + let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + + let mut trusted = false; + if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { + if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { + if pub_k == &app_public_key { + trusted = true; + } + } + } + + if trusted { + use iota_util::crypto_util::{DataFormat, SecurePayload}; + + let challenge = Uuid::new_v4().to_string(); + + self.app_challenges + .write() + .await + .insert(sender_id, challenge.clone()); + self.app_sessions + .write() + .await + .insert(sender_id, (user_id, app_identifier.clone())); + + if let Some(pub_key) = iota_util::crypto_helper::load_public_key(&app_public_key) { + let conf = CONFIG.read().await; + let priv_k_str = conf.get_private_key().unwrap_or_default(); + let pub_k_str = conf.get_public_key().unwrap_or_default(); + drop(conf); + + if let Some(priv_key) = iota_util::crypto_helper::load_secret_key(&priv_k_str) { + let encrypted_challenge = + SecurePayload::new(challenge.as_bytes(), DataFormat::Raw, priv_key) + .unwrap() + .encrypt_x448(pub_key) + .unwrap() + .export(DataFormat::Base64); + + let res = CommunicationValue::new(CommunicationType::app_challenge) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_data(DataTypes::public_key, DataValue::Str(pub_k_str)) + .add_data(DataTypes::challenge, DataValue::Str(encrypted_challenge)); + + self.send_message(&res).await; + return; + } + } + } + + let res = CommunicationValue::new(CommunicationType::error) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::app_challenge_response) { + let sender_id = cv.get_sender(); + let mut challenges = self.app_challenges.write().await; + if let Some(expected) = challenges.remove(&sender_id) { + if let DataValue::Str(response) = cv.get_data(DataTypes::challenge) { + if expected == *response { + let res = + CommunicationValue::new(CommunicationType::app_identification_response) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + } + } + let res = CommunicationValue::new(CommunicationType::error) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::save_app_data) { + let sender_id = cv.get_sender(); + let app_data = cv + .get_data(DataTypes::app_data) + .as_str() + .unwrap_or("") + .to_string(); + + let sessions = self.app_sessions.read().await; + if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { + iota_storage::users::user_manager::save_app_data( + *user_id, + app_identifier, + &app_data, + ); + } + + let res = CommunicationValue::new(CommunicationType::save_app_data) + .with_id(cv.get_id()) + .with_receiver(sender_id); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::load_app_data) { + let sender_id = cv.get_sender(); + let mut app_data = String::new(); + + let sessions = self.app_sessions.read().await; + if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { + app_data = + iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); + } + + let res = CommunicationValue::new(CommunicationType::load_app_data) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_data(DataTypes::app_data, DataValue::Str(app_data)); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::create_app) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataTypes::app_public_key) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() && !app_public_key.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if !user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.insert(app_identifier, app_public_key); + iota_storage::users::user_manager::update_user(user); + } + } + } + + let res = CommunicationValue::new(CommunicationType::create_app) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::delete_app) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataTypes::app_identifier) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.remove(&app_identifier); + iota_storage::users::user_manager::update_user(user); + } + } + } + + let res = CommunicationValue::new(CommunicationType::delete_app) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&res).await; + return; + } + + if cv.is_type(CommunicationType::client_connected) { + let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + let _session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; + + let contacts = chats_util::get_users(user_id); + let mut contacts_array = Vec::new(); + + for (i, contact) in contacts.iter().enumerate() { + let mut contact_container = Vec::new(); + contact_container.push((DataTypes::user_id, DataValue::Number(contact.user_id))); + contact_container.push(( + DataTypes::last_message_at, + DataValue::Number(contact.last_message_at.unwrap_or(0)), + )); + + if let Some(ref name) = contact.user_name { + contact_container.push((DataTypes::username, DataValue::Str(name.clone()))); + } + + let amount = if i < 10 { 20 } else { 1 }; + let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); + + let mut msg_array = Vec::new(); + for m in messages.members() { + let message_time = m["message_time"].as_i64().unwrap_or(0); + let content = m["content"].as_str().unwrap_or("").to_string(); + let sent_by_self = m["sent_by_self"].as_bool().unwrap_or(false); + let height = m["height"].as_i64().unwrap_or(0); + let message_state = m["message_state"].as_str().unwrap_or("").to_string(); + + let mut msg_container = Vec::new(); + msg_container.push((DataTypes::send_time, DataValue::Number(message_time))); + msg_container.push((DataTypes::content, DataValue::Str(content.clone()))); + msg_container.push((DataTypes::message_state, DataValue::Str(message_state))); + msg_container.push((DataTypes::height, DataValue::Number(height))); + msg_container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); + msg_array.push(DataValue::Container(msg_container)); + + if msg_array.len() == 1 { + let sender_id = if sent_by_self { + user_id + } else { + contact.user_id + }; + let mut last_msg = Vec::new(); + last_msg.push((DataTypes::content, DataValue::Str(content))); + last_msg.push((DataTypes::sender_id, DataValue::Number(sender_id))); + contact_container + .push((DataTypes::last_message, DataValue::Container(last_msg))); + } + } + contact_container.push((DataTypes::messages, DataValue::Array(msg_array))); + contacts_array.push(DataValue::Container(contact_container)); + } + + let resp = CommunicationValue::new(CommunicationType::client_connected) + .with_id(cv.get_id()) + .add_data(DataTypes::contacts, DataValue::Array(contacts_array)); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::identification_response) { + if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { + let mut state = self.state.write().await; + if let ConnectionState::Connected { identified: _ } = *state { + *state = ConnectionState::Connected { identified: true }; + } + } + return; + } + + // ************************************************ // + // Direct messages // + // ************************************************ // + + if cv.is_type(CommunicationType::message_state) { + let sender_id = &cv.get_sender(); + let receiver_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + Some(id) => id, + _ => return, + }; + + // Parse send_time robustly: accept numeric or string, fallback to current time + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp_i64 = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + *sender_id as i64, + MessageState::from_str( + cv.get_data(DataTypes::message_state).as_str().unwrap_or(""), + ), + ); + } + + // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. + if cv.is_type(CommunicationType::message_send) { + let sender_id: u64 = cv.get_sender(); + + // parse receiver_id (the storage owner for this incoming message) + let receiver_id: i64 = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() + { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { + s.parse::().unwrap_or(0) + } else { + 0 + }; + + // parse send_time robustly (number or string), fallback to now + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp_i64 = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + let timestamp_u128 = timestamp_i64 as u128; + + // content may be missing; default to empty string + let content = cv + .get_data(DataTypes::content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + + let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); + + if is_local { + // persist message for the receiver (storage_owner = receiver_id) + chat_files::add_message( + timestamp_u128, + false, + receiver_id as i64, + sender_id as i64, + &content, + height, + ); + } + + // persist message for the sender (storage_owner = sender_id) + chat_files::add_message( + timestamp_u128, + true, + sender_id as i64, + receiver_id as i64, + &content, + height, + ); + + // send confirmation back to sender + let conf_msg = CommunicationValue::new(CommunicationType::message_send) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&conf_msg).await; + + if !is_local { + let fw_msg = CommunicationValue::new(CommunicationType::message_other_iota) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .with_sender(sender_id as u64) + .add_data(DataTypes::height, DataValue::Number(height)) + .add_data(DataTypes::content, DataValue::Str(content)) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)); + + let other_iota_resp = self + .clone() + .await_response(&fw_msg, Some(Duration::from_secs(10))) + .await; + + if let Ok(resp) = other_iota_resp { + let ms_raw = resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } else { + // Build a live-delivery message for the local client (recipient) + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) + .add_data( + DataTypes::message, + DataValue::Container(vec![ + (DataTypes::content, DataValue::Str(content.clone())), + (DataTypes::send_time, DataValue::Number(timestamp_i64)), + (DataTypes::height, DataValue::Number(height)), + ]), + ); + + // Attempt delivery and await a response from the local client + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; + + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + // update stored message state for receiver + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + ms.clone(), + ); + + // update stored message state for sender + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); + + // notify original sender about the delivered/read state + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery failed or timed out; mark as Sent + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + MessageState::Sent, + ); + + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); + + // notify sender + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } + } + + if cv.is_type(CommunicationType::message_other_iota) { + let sender_id = &cv.get_sender(); + let receiver_id = &cv.get_receiver(); + + // parse send_time safely (number or string), fallback to now + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + + // content may be missing or non-string; default to empty string + let content = cv + .get_data(DataTypes::content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + + chat_files::add_message( + timestamp as u128, + false, + *receiver_id as i64, + *sender_id as i64, + &content, + height, + ); + + // Build user_forward using the parsed numeric timestamp and safe content string + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(*receiver_id) + .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) + .add_data( + DataTypes::message, + DataValue::Container(vec![ + (DataTypes::content, DataValue::Str(content.clone())), + (DataTypes::send_time, DataValue::Number(timestamp)), + (DataTypes::height, DataValue::Number(height)), + ]), + ); + + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; + + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + let _ = change_message_state( + timestamp, + *receiver_id as i64, + *sender_id as i64, + ms.clone(), + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(*sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery timed out/failed — update stored state and notify sender with numeric timestamp + let _ = chat_files::change_message_state( + timestamp, + *receiver_id as i64, + *sender_id as i64, + MessageState::Sent, + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(*receiver_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } + + if cv.is_type(CommunicationType::messages_get) { + let my_id = cv.get_sender(); + let partner_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0); + let offset = cv.get_data(DataTypes::offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataTypes::amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages(my_id as i64, partner_id, offset, amount); + let mut msg_array: Vec = Vec::new(); + for m in messages.members() { + let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); + let content: String = m["content"].as_str().unwrap_or("").to_string(); + let sent_by_self: bool = m["sent_by_self"].as_bool().unwrap_or(false); + let height: i64 = m["height"].as_i64().unwrap_or(0); + let sender_id: i64 = if sent_by_self { + my_id as i64 + } else { + if let Some(n) = cv.get_data(DataTypes::chat_partner_id).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::chat_partner_id).as_str() { + s.parse::().unwrap_or(partner_id as i64) + } else { + partner_id as i64 + } + }; + let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); + + let mut container = Vec::new(); + container.push((DataTypes::send_time, DataValue::Number(message_time))); + container.push((DataTypes::content, DataValue::Str(content))); + container.push((DataTypes::sender_id, DataValue::Number(sender_id))); + container.push((DataTypes::message_state, DataValue::Str(message_state))); + container.push((DataTypes::height, DataValue::Number(height))); + container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); + msg_array.push(DataValue::Container(container)); + } + + let resp = CommunicationValue::new(CommunicationType::messages_get) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::messages, DataValue::Array(msg_array)); + + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::get_chats) { + let user_id = cv.get_sender(); + let users = chats_util::get_users(user_id as i64); + let mut user_array = Vec::new(); + for user in users { + let mut container = Vec::new(); + container.push((DataTypes::user_id, DataValue::Number(user.user_id))); + if let Some(name) = user.user_name { + container.push((DataTypes::username, DataValue::Str(name))); + } + if let Some(ts) = user.last_message_at { + container.push((DataTypes::last_message_at, DataValue::Number(ts))); + } + user_array.push(DataValue::Container(container)); + } + let resp = CommunicationValue::new(CommunicationType::get_chats) + .with_id(cv.get_id()) + .with_receiver(user_id) + .add_data(DataTypes::user_ids, DataValue::Array(user_array)); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::add_conversation) { + let user_id = cv.get_sender(); + let other_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + Some(n) => n as i64, + None => cv + .get_data(DataTypes::chat_partner_id) + .as_str() + .unwrap_or("0") + .parse() + .unwrap_or(0), + }; + let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); + + if let Some(name) = cv.get_data(DataTypes::chat_partner_name).as_str() { + contact.user_name = Some(name.to_string()); + } + + contact.set_last_message_at( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64, + ); + mod_user(user_id as i64, &contact); + let resp = CommunicationValue::new(CommunicationType::add_conversation) + .with_id(cv.get_id()) + .with_receiver(user_id); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::add_community) { + CommunitiesUtil::add_community( + cv.get_sender() as i64, + cv.get_data(DataTypes::community_address) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataTypes::community_title) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataTypes::position) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::add_community) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::get_communities) { + let mut comm_array = Vec::new(); + for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { + let mut container: Vec<(DataTypes, DataValue)> = Vec::new(); + if let Some(address) = c["address"].as_str() { + container.push(( + DataTypes::community_address, + DataValue::Str(address.to_string()), + )); + } + if let Some(title) = c["title"].as_str() { + container.push(( + DataTypes::community_title, + DataValue::Str(title.to_string()), + )); + } + if let Some(position) = c["position"].as_str() { + container.push((DataTypes::position, DataValue::Str(position.to_string()))); + } + comm_array.push(DataValue::Container(container)); + } + + let resp = CommunicationValue::new(CommunicationType::get_communities) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_data(DataTypes::communities, DataValue::Array(comm_array)); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::remove_community) { + CommunitiesUtil::remove_community( + cv.get_sender() as i64, + cv.get_data(DataTypes::community_address) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::remove_community) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::settings_save) { + let my_id = cv.get_sender(); + let settings_name = cv.get_data(DataTypes::settings_name).as_str().unwrap(); + let settings_value = cv.get_data(DataTypes::payload).as_str().unwrap(); + + save_file( + &format!("users/{}/settings/", my_id), + &format!("{}.settings", settings_name), + &settings_value, + ); + + let response = CommunicationValue::new(CommunicationType::settings_save) + .with_receiver(my_id) + .with_id(cv.get_id()); + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::settings_load) { + let my_id = cv.get_sender(); + let settings_name = cv.get_data(DataTypes::settings_name).as_string().unwrap(); + let settings_value_str = load_file( + &format!("users/{}/settings/", my_id), + &format!("{}.settings", settings_name), + ); + let response = CommunicationValue::new(CommunicationType::settings_load) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::payload, DataValue::Str(settings_value_str)) + .add_data(DataTypes::settings_name, DataValue::Str(settings_name)); + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::settings_list) { + let my_id = cv.get_sender(); + let settings = get_children(&format!("users/{}/settings/", my_id)); + let mut settings_json = Vec::new(); + for s in settings { + let s = s.replace(".settings", ""); + if s.is_empty() { + continue; + } + let _ = settings_json.push(DataValue::Str(s)); + } + let response = CommunicationValue::new(CommunicationType::settings_list) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::settings, DataValue::Array(settings_json)); + + self.send_message(&response).await; + return; + } + } + + async fn handle_challenge(&self, cv: &CommunicationValue) { + let conf = CONFIG.read().await; + let private_key = conf.get_private_key().unwrap(); + drop(conf); + + let omikron_public_key = cv.get_data(DataTypes::public_key).as_str().unwrap(); + let encrypted_challenge = cv.get_data(DataTypes::challenge).as_str().unwrap(); + + let solved_challenge = { + if let Ok(decrypted) = SecurePayload::new( + encrypted_challenge, + DataFormat::Base64, + crypto_helper::load_secret_key(&private_key).unwrap(), + ) { + if let Ok(decrypted) = decrypted + .decrypt_x448(crypto_helper::load_public_key(omikron_public_key).unwrap()) + { + Some(decrypted) + } else { + None + } + } else { + None + } + }; + + if let Some(decrypted) = solved_challenge { + let solved = decrypted.export(DataFormat::Raw); + + let response = CommunicationValue::new(CommunicationType::challenge_response) + .with_id(cv.get_id()) + .add_data(DataTypes::challenge, DataValue::Str(solved)); + + self.send_message(&response).await; + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + pub async fn send_message(&self, cv: &CommunicationValue) { + if let Err(err) = self.send_message_result(cv).await { + log_t!("send_message_failed", err); + } + } + + async fn send_message_result(&self, cv: &CommunicationValue) -> Result<(), String> { + let sender_guard = self.sender.read().await; + if let Some(sender) = sender_guard.as_ref() { + if !sender.is_open() { + drop(sender_guard); + if let Some(sender) = self.sender.write().await.take() { + sender.close(); + } + self.fail_all_waiting_tasks(format!( + "Send failed: connection closed (connection_id={})", + self.connection_id + )) + .await; + return Err("connection closed".to_string()); + } + + let sender_clone = Arc::clone(sender); + drop(sender_guard); + + if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + log_cv_out!(&cv); + } + + if let Err(e) = sender_clone.send(cv).await { + self.fail_all_waiting_tasks(format!( + "Send failed: {} (connection_id={})", + e, self.connection_id + )) + .await; + return Err(e.to_string()); + } + + Ok(()) + } else { + Err("not connected".to_string()) + } + } + + async fn fail_all_waiting_tasks(&self, reason: String) { + let keys: Vec = WAITING_TASKS.iter().map(|entry| *entry.key()).collect(); + + for key in keys { + if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { + let response = CommunicationValue::new(CommunicationType::error) + .with_id(key) + .add_data(DataTypes::message, DataValue::Str(reason.clone())); + let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); + } + } + } + + pub async fn is_connected(&self) -> bool { + self.state.read().await.is_connected() + } + + pub async fn is_identified(&self) -> bool { + self.state.read().await.is_identified() + } + + pub async fn await_response( + &self, + cv: &CommunicationValue, + timeout_duration: Option, + ) -> Result { + let (tx, mut rx) = mpsc::channel(1); + let msg_id = cv.get_id(); + + WAITING_TASKS.insert( + msg_id, + WaitingTask { + task: Box::new(move |_, response_cv| { + let inner_tx = tx.clone(); + tokio::spawn(async move { + let _ = inner_tx.send(response_cv).await; + }); + true + }), + inserted_at: Instant::now(), + }, + ); + + if let Err(send_err) = self.send_message_result(cv).await { + WAITING_TASKS.remove(&msg_id); + return Err(format!( + "Request send failed (msg_id={}, reason={})", + msg_id, send_err + )); + } + + let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); + + match tokio::time::timeout(timeout, rx.recv()).await { + Ok(Some(response_cv)) => { + if response_cv.is_type(CommunicationType::error) { + let reason = response_cv + .get_data(DataTypes::message) + .as_str() + .unwrap_or("connection error") + .to_string(); + Err(format!( + "Request failed due to disconnect (msg_id={}, reason={})", + msg_id, reason + )) + } else { + Ok(response_cv) + } + } + Ok(_) => { + WAITING_TASKS.remove(&msg_id); + Err("Channel closed while awaiting response".to_string()) + } + Err(_) => { + let waiting_tasks_len = WAITING_TASKS.len(); + WAITING_TASKS.remove(&msg_id); + Err(format!( + "Request timed out (msg_id={}, timeout={}s, connected={}, waiting_tasks={})", + msg_id, + timeout.as_secs(), + self.is_connected().await, + waiting_tasks_len + )) + } + } + } + + pub async fn await_connection(&self, timeout_duration: Option) -> Result<(), String> { + if self.state.read().await.is_connected() { + return Ok(()); + } + + let timeout = timeout_duration.unwrap_or(CONNECTION_TIMEOUT); + let start = Instant::now(); + + loop { + if self.state.read().await.is_connected() { + return Ok(()); + } + + if start.elapsed() >= timeout { + return Err(format!( + "Connection not established within {} seconds", + timeout.as_secs() + )); + } + + sleep(Duration::from_millis(100)).await; + } + } +} + +// ============================================================================ +// Global Instance +// ============================================================================ + +pub static OMIKRON_CONNECTION: LazyLock> = LazyLock::new(|| { + let conn = Arc::new(OmikronConnection::new()); + + start_task_cleanup_loop(); + + conn +}); + +pub async fn get_omikron_connection() -> Arc { + let conn = OMIKRON_CONNECTION.clone(); + + conn.connect().await; + conn +} diff --git a/client/src/client_connection_manager.rs b/client/src/client_connection_manager.rs new file mode 100644 index 0000000..e69de29 diff --git a/client/src/lib.rs b/client/src/lib.rs index 8b13789..228eaa9 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1 +1 @@ - +mod client_connection; From 3ad5d2b3abe28a40490e1acc0afb6b998c6e76e1 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 7 May 2026 09:29:38 +0200 Subject: [PATCH 025/119] [Add] Correct Client connection (still a lot todo) --- Cargo.lock | 197 +++--- Cargo.toml | 2 +- client/src/client_connection.rs | 670 +++----------------- client/src/lib.rs | 1 + omikron-connector/src/omikron_connection.rs | 25 +- {other_iota => other-iota}/Cargo.toml | 2 +- {other_iota => other-iota}/src/lib.rs | 0 7 files changed, 202 insertions(+), 695 deletions(-) rename {other_iota => other-iota}/Cargo.toml (98%) rename {other_iota => other-iota}/src/lib.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 0e1cb25..deeca7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,58 +688,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "communities" -version = "0.1.0" -dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf", - "hyper", - "hyper-util", - "iota-auth", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "lazy_static", - "once_cell", - "open", - "pnet", - "rand 0.8.6", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.10.9", - "strum 0.27.2", - "strum_macros 0.27.2", - "sysinfo", - "tokio", - "tokio-tungstenite", - "ttp-core", - "ttp-native", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] - [[package]] name = "compact_str" version = "0.9.0" @@ -1071,9 +1019,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", @@ -1455,9 +1403,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1648,7 +1596,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body", "httparse", @@ -2145,16 +2093,6 @@ dependencies = [ "serde", ] -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -2250,9 +2188,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -2671,15 +2609,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2703,9 +2640,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -2722,6 +2659,58 @@ dependencies = [ "num-traits", ] +[[package]] +name = "other-iota" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-auth", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2874,18 +2863,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -3373,7 +3362,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body", "http-body-util", @@ -3719,7 +3708,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -3741,7 +3730,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -3805,9 +3794,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4169,9 +4158,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -4259,20 +4248,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http 1.4.0", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4328,7 +4317,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#9bd66c762f2de5ebee6ab0e591702b0b992f041e" +source = "git+https://git.methanium.net/Tensamin/TTP.git#db53f44d9323fddceb0eba6ec854c27a3f5e0da7" dependencies = [ "base64", "byteorder", @@ -4341,7 +4330,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#9bd66c762f2de5ebee6ab0e591702b0b992f041e" +source = "git+https://git.methanium.net/Tensamin/TTP.git#db53f44d9323fddceb0eba6ec854c27a3f5e0da7" dependencies = [ "quinn", "rustls", @@ -4522,9 +4511,9 @@ dependencies = [ [[package]] name = "warp" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d06d9202adc1f15d709c4f4a2069be5428aa912cc025d6f268ac441ab066b0" +checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" dependencies = [ "bytes", "futures-util", @@ -4573,9 +4562,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -4586,9 +4575,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -4596,9 +4585,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4606,9 +4595,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -4619,9 +4608,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -4670,9 +4659,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 34fa81d..c62208e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["iota-storage", "client", "communities", "iota-auth", "other-iota", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] +members = ["iota-storage", "client", "iota-auth", "other-iota", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] resolver = "3" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index b13311a..ea238a5 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -1,6 +1,6 @@ use dashmap::DashMap; -use iota_logger::{log, log_cv_in, log_cv_out, log_t}; -use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_logger::{log_cv_in, log_cv_out, log_t}; +use iota_state::SHUTDOWN; use iota_storage::users::contact::Contact; use iota_storage::util::chat_files::{MessageState, change_message_state}; use iota_storage::util::chats_util::{get_user, mod_user}; @@ -10,127 +10,73 @@ use iota_storage::util::{chat_files, chats_util}; use iota_util::crypto_helper; use iota_util::crypto_util::{DataFormat, SecurePayload}; use iota_util::file_util::{get_children, load_file, save_file}; -use json::JsonValue; -use std::collections::HashMap; -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tokio::task::JoinHandle; -use tokio::time::sleep; use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; use ttp_native::{Receiver, Sender}; use uuid::Uuid; -// ============================================================================ -// Configuration -// ============================================================================ - -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); -const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); -const TASK_MAX_AGE: Duration = Duration::from_secs(60); - // ============================================================================ // Waiting Task System // ============================================================================ -pub struct WaitingTask { - pub task: Box, CommunicationValue) -> bool + Send + Sync>, - pub inserted_at: Instant, -} - -// ============================================================================ -// Connection State -// ============================================================================ - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ConnectionState { - Disconnected, - Connecting, - Connected { identified: bool }, -} - -impl ConnectionState { - pub fn is_connected(&self) -> bool { - matches!(self, ConnectionState::Connected { .. }) - } - - pub fn is_identified(&self) -> bool { - matches!(self, ConnectionState::Connected { identified: true }) - } -} - -// ============================================================================ -// Omikron Connection (Client-side with auto-reconnect) -// ============================================================================ - -#[allow(dead_code)] // message_send_times is unused. -pub struct OmikronConnection { - state: Arc>, +#[allow(dead_code)] +pub struct ClientConnection { sender: Arc>>>, + receiver: Receiver, connection_loop_handle: Arc>>>, - host: String, - port: u16, - pub last_ping: Arc>, - heartbeat_handle: Arc>>>, + pub ping: Arc>, pub connection_id: Uuid, shutdown_tx: Arc>>>, - reconnect_on_close: Arc>, - pub app_challenges: Arc>>, - pub app_sessions: Arc>>, + pub waiting_tasks: + DashMap, CommunicationValue) -> bool + Send + Sync>>, } -impl OmikronConnection { - pub fn new() -> Self { - Self::with_host(OMIKRON_HOST_DEFAULT, OMIKRON_PORT_DEFAULT) - } - - pub fn with_host(host: &str, port: u16) -> Self { - let (shutdown_tx, _) = watch::channel(false); - - OmikronConnection { - state: Arc::new(RwLock::new(ConnectionState::Disconnected)), - sender: Arc::new(RwLock::new(None)), - connection_loop_handle: Arc::new(Mutex::new(None)), - host: host.to_string(), - port, - last_ping: Arc::new(Mutex::new(-1)), - heartbeat_handle: Arc::new(Mutex::new(None)), - connection_id: Uuid::new_v4(), - shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), - reconnect_on_close: Arc::new(RwLock::new(true)), - app_challenges: Arc::new(RwLock::new(HashMap::new())), - app_sessions: Arc::new(RwLock::new(HashMap::new())), +impl ClientConnection { + pub fn new( + sender: Arc>>>, + receiver: Receiver, + connection_loop_handle: Arc>>>, + ping: Arc>, + connection_id: Uuid, + shutdown_tx: Arc>>>, + waiting_tasks: DashMap< + u32, + Box, CommunicationValue) -> bool + Send + Sync>, + >, + ) -> Self { + Self { + sender, + receiver, + connection_loop_handle, + ping, + connection_id, + shutdown_tx, + waiting_tasks, } } - // ------------------------------------------------------------------------- - // Connection Management - // ------------------------------------------------------------------------- - - pub async fn connect(self: &Arc) { - if self.connection_loop_handle.lock().await.is_none() { - self.clone().start().await; - } - } - - pub async fn start(self: Arc) { - if let Some(handle) = self.connection_loop_handle.lock().await.take() { - handle.abort(); - } - - *self.reconnect_on_close.write().await = true; - + pub fn start(self: Arc) { let self_clone = self.clone(); - let handle = tokio::spawn(async move { - self_clone.connection_loop().await; - }); + tokio::spawn(async move { + while let Ok(cv) = self_clone.receiver.receive().await { + if *SHUTDOWN.read().await { + return; + } - *self.connection_loop_handle.lock().await = Some(handle); + self.clone().handle_message(cv).await; + + if !self_clone.receiver.is_open() { + break; + } + } + // Handle Close + }); } pub async fn stop(&self) { - *self.reconnect_on_close.write().await = false; - if let Some(tx) = self.shutdown_tx.lock().await.take() { let _ = tx.send(true); } @@ -139,270 +85,44 @@ impl OmikronConnection { handle.abort(); } - if let Some(handle) = self.heartbeat_handle.lock().await.take() { - handle.abort(); - } - if let Some(sender) = self.sender.read().await.as_ref() { sender.close(); } - *self.state.write().await = ConnectionState::Disconnected; *self.sender.write().await = None; } - async fn connection_loop(self: Arc) { - let mut reconnect_delay = RECONNECT_DELAY; - let shutdown_rx = self.shutdown_tx.lock().await.as_ref().unwrap().subscribe(); - let mut shutdown_rx = shutdown_rx; - - loop { - if *shutdown_rx.borrow() || *SHUTDOWN.read().await { - log_t!("omikron_connection_loop_shutdown"); - break; - } - - if !*self.reconnect_on_close.read().await { - break; - } - - match self.clone().connect_once().await { - Ok(()) => { - if *self.reconnect_on_close.read().await { - log!("Connection lost, reconnecting in {:?}...", reconnect_delay); - } else { - break; - } - } - Err(e) => { - log!( - "Connection failed: {}, retrying in {:?}...", - e, - reconnect_delay - ); - } - } - - tokio::select! { - _ = sleep(reconnect_delay) => {} - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - break; - } - } - } - - reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY); + // ------------------------------------------------------------------------- + // Message Handling + // ------------------------------------------------------------------------- + async fn handle_ping(self: Arc, cv: CommunicationValue) { + // Update our ping if provided + if let DataValue::Number(last_ping) = cv.get_data(DataTypes::last_ping) { + let current = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let mut ping_guard = self.ping.write().await; + *ping_guard = current as i64 - last_ping; } + + // Send pong response + let response = CommunicationValue::new(CommunicationType::pong) + .with_id(cv.get_id()) + .add_data(DataTypes::ping_iota, DataValue::Number(0)); + + self.send_message(&response).await; } - async fn connect_once(self: Arc) -> Result<(), String> { - *self.state.write().await = ConnectionState::Connecting; - log_t!("omikron_connecting"); - - let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); - - let (sender, mut receiver) = ttp_native::client::connect(&addr_str, None) - .await - .map_err(|e| format!("Connection failed: {}", e))?; - - log_t!("omikron_connection_success"); - - let sender_arc = Arc::new(sender); - *self.sender.write().await = Some(sender_arc.clone()); - *self.state.write().await = ConnectionState::Connected { identified: false }; - - // Start read loop - let read_self = self.clone(); - let read_handle = tokio::spawn(async move { - read_self.read_loop(&mut receiver).await; - }); - - // Handle registration/identification - self.handle_authentication().await; - - // Start heartbeat - let heartbeat_self = self.clone(); - let heartbeat_handle = tokio::spawn(async move { - heartbeat_self.heartbeat_loop().await; - }); - *self.heartbeat_handle.lock().await = Some(heartbeat_handle); - - { - ACTIVE_TASKS.insert("Omikron Listener".to_string()); - } - - // Wait for read loop to complete - let result = read_handle.await; - *self.sender.write().await = None; - *self.state.write().await = ConnectionState::Disconnected; - { - ACTIVE_TASKS.remove("Omikron Listener"); - } - - if let Some(handle) = self.heartbeat_handle.lock().await.take() { - handle.abort(); - } - - match result { - Ok(()) => { - if *self.reconnect_on_close.read().await { - Err("Connection closed, will reconnect".to_string()) - } else { - Ok(()) - } - } - Err(e) => Err(format!("Read loop error: {}", e)), - } - } - - // ------------------------------------------------------------------------- - // Authentication (Registration/Identification) - // ------------------------------------------------------------------------- - - async fn handle_authentication(&self) { - let conf = CONFIG.read().await; - let iota_id = conf.get_iota_id(); - let public_key = conf.get_public_key(); - let private_key = conf.get_private_key(); - drop(conf); - - if iota_id == 0 { - log_t!("iota_register_new"); - - let (pub_k, _priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { - (pk, sk) - } else { - let key_pair = crypto_helper::generate_keypair(); - let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); - let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); - - let mut conf_write = CONFIG.write().await; - conf_write.change("public_key", JsonValue::from(public_key_base64.clone())); - conf_write.change("private_key", JsonValue::from(private_key_base64.clone())); - conf_write.update(); - drop(conf_write); - (public_key_base64, private_key_base64) - }; - - let register_msg = CommunicationValue::new(CommunicationType::register_iota) - .add_data(DataTypes::public_key, DataValue::Str(pub_k)); - - let msg_id = register_msg.get_id(); - - WAITING_TASKS.insert( - msg_id, - WaitingTask { - task: Box::new(|selfc, cv| { - if !cv.is_type(CommunicationType::success) { - return false; - } - - let iota_value = cv.get_data(DataTypes::iota_id); - let iota_id = iota_value.as_number().unwrap_or(0); - - if iota_id != 0 { - tokio::spawn(async move { - let mut conf_write = CONFIG.write().await; - conf_write.change("iota_id", JsonValue::from(iota_id)); - conf_write.update(); - drop(conf_write); - log!("Registered with Iota-ID: {}", iota_id); - - // Send identification after registration - let identify_msg = - CommunicationValue::new(CommunicationType::identification) - .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); - selfc.send_message(&identify_msg).await; - }); - } else { - log!("Iota registration failed."); - } - true - }), - inserted_at: Instant::now(), - }, - ); - - self.send_message(®ister_msg).await; - } else { - let identify_msg = CommunicationValue::new(CommunicationType::identification) - .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); - self.send_message(&identify_msg).await; - } - } - - // ------------------------------------------------------------------------- - // Read Loop & Heartbeat - // ------------------------------------------------------------------------- - - async fn read_loop(self: Arc, receiver: &mut Receiver) { - loop { - let result = receiver.receive().await; - match result { - Ok(cv) => { - self.clone().handle_message(cv).await; - } - Err(e) => { - self.fail_all_waiting_tasks(format!( - "Connection receive error: {} (connection_id={})", - e, self.connection_id - )) - .await; - break; - } - } - if !receiver.is_open() { - self.fail_all_waiting_tasks(format!( - "Connection closed (connection_id={}, receiver_open=false)", - self.connection_id - )) - .await; - break; - } - } - } - - async fn heartbeat_loop(self: Arc) { - loop { - sleep(HEARTBEAT_INTERVAL).await; - - if !self.state.read().await.is_connected() { - break; - } - - if let Some(sender) = self.sender.read().await.as_ref() { - if !sender.is_open() { - break; - } - } else { - break; - } - - self.send_ping().await; - } - } - - // ------------------------------------------------------------------------- - // Message Handling (Preserved from original) - // ------------------------------------------------------------------------- - pub async fn handle_message(self: Arc, cv: CommunicationValue) { if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { log_cv_in!(&cv); } - let msg_id = cv.get_id(); + let _msg_id = cv.get_id(); - // Dispatch waiting task for this message id - if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { - if (task.task)(self.clone(), cv.clone()) { - return; - } - } - - if cv.is_type(CommunicationType::pong) { - self.handle_pong(&cv).await; + if cv.is_type(CommunicationType::ping) { + self.handle_ping(cv).await; return; } @@ -411,115 +131,14 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::app_identification) { - let sender_id = cv.get_sender(); - let app_identifier = cv - .get_data(DataTypes::app_identifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataTypes::app_public_key) - .as_str() - .unwrap_or("") - .to_string(); - let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; - - let mut trusted = false; - if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { - if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { - if pub_k == &app_public_key { - trusted = true; - } - } - } - - if trusted { - use iota_util::crypto_util::{DataFormat, SecurePayload}; - - let challenge = Uuid::new_v4().to_string(); - - self.app_challenges - .write() - .await - .insert(sender_id, challenge.clone()); - self.app_sessions - .write() - .await - .insert(sender_id, (user_id, app_identifier.clone())); - - if let Some(pub_key) = iota_util::crypto_helper::load_public_key(&app_public_key) { - let conf = CONFIG.read().await; - let priv_k_str = conf.get_private_key().unwrap_or_default(); - let pub_k_str = conf.get_public_key().unwrap_or_default(); - drop(conf); - - if let Some(priv_key) = iota_util::crypto_helper::load_secret_key(&priv_k_str) { - let encrypted_challenge = - SecurePayload::new(challenge.as_bytes(), DataFormat::Raw, priv_key) - .unwrap() - .encrypt_x448(pub_key) - .unwrap() - .export(DataFormat::Base64); - - let res = CommunicationValue::new(CommunicationType::app_challenge) - .with_id(cv.get_id()) - .with_receiver(sender_id) - .add_data(DataTypes::public_key, DataValue::Str(pub_k_str)) - .add_data(DataTypes::challenge, DataValue::Str(encrypted_challenge)); - - self.send_message(&res).await; - return; - } - } - } - - let res = CommunicationValue::new(CommunicationType::error) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::app_challenge_response) { - let sender_id = cv.get_sender(); - let mut challenges = self.app_challenges.write().await; - if let Some(expected) = challenges.remove(&sender_id) { - if let DataValue::Str(response) = cv.get_data(DataTypes::challenge) { - if expected == *response { - let res = - CommunicationValue::new(CommunicationType::app_identification_response) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - } - } - let res = CommunicationValue::new(CommunicationType::error) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - if cv.is_type(CommunicationType::save_app_data) { let sender_id = cv.get_sender(); - let app_data = cv + let _app_data = cv .get_data(DataTypes::app_data) .as_str() .unwrap_or("") .to_string(); - let sessions = self.app_sessions.read().await; - if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { - iota_storage::users::user_manager::save_app_data( - *user_id, - app_identifier, - &app_data, - ); - } - let res = CommunicationValue::new(CommunicationType::save_app_data) .with_id(cv.get_id()) .with_receiver(sender_id); @@ -529,13 +148,7 @@ impl OmikronConnection { if cv.is_type(CommunicationType::load_app_data) { let sender_id = cv.get_sender(); - let mut app_data = String::new(); - - let sessions = self.app_sessions.read().await; - if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { - app_data = - iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); - } + let app_data = String::new(); let res = CommunicationValue::new(CommunicationType::load_app_data) .with_id(cv.get_id()) @@ -660,16 +273,6 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::identification_response) { - if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { - let mut state = self.state.write().await; - if let ConnectionState::Connected { identified: _ } = *state { - *state = ConnectionState::Connected { identified: true }; - } - } - return; - } - // ************************************************ // // Direct messages // // ************************************************ // @@ -1334,11 +937,6 @@ impl OmikronConnection { if let Some(sender) = self.sender.write().await.take() { sender.close(); } - self.fail_all_waiting_tasks(format!( - "Send failed: connection closed (connection_id={})", - self.connection_id - )) - .await; return Err("connection closed".to_string()); } @@ -1350,11 +948,6 @@ impl OmikronConnection { } if let Err(e) = sender_clone.send(cv).await { - self.fail_all_waiting_tasks(format!( - "Send failed: {} (connection_id={})", - e, self.connection_id - )) - .await; return Err(e.to_string()); } @@ -1364,133 +957,40 @@ impl OmikronConnection { } } - async fn fail_all_waiting_tasks(&self, reason: String) { - let keys: Vec = WAITING_TASKS.iter().map(|entry| *entry.key()).collect(); - - for key in keys { - if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { - let response = CommunicationValue::new(CommunicationType::error) - .with_id(key) - .add_data(DataTypes::message, DataValue::Str(reason.clone())); - let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); - } - } - } - - pub async fn is_connected(&self) -> bool { - self.state.read().await.is_connected() - } - - pub async fn is_identified(&self) -> bool { - self.state.read().await.is_identified() - } - pub async fn await_response( - &self, + self: Arc, cv: &CommunicationValue, timeout_duration: Option, ) -> Result { let (tx, mut rx) = mpsc::channel(1); let msg_id = cv.get_id(); - WAITING_TASKS.insert( + let task_tx = tx.clone(); + self.waiting_tasks.insert( msg_id, - WaitingTask { - task: Box::new(move |_, response_cv| { - let inner_tx = tx.clone(); - tokio::spawn(async move { - let _ = inner_tx.send(response_cv).await; - }); - true - }), - inserted_at: Instant::now(), - }, + Box::new(move |_, response_cv| { + let inner_tx = task_tx.clone(); + tokio::spawn(async move { + let _ = inner_tx.send(response_cv).await; + }); + true + }), ); - if let Err(send_err) = self.send_message_result(cv).await { - WAITING_TASKS.remove(&msg_id); - return Err(format!( - "Request send failed (msg_id={}, reason={})", - msg_id, send_err - )); - } + self.send_message(cv).await; let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); match tokio::time::timeout(timeout, rx.recv()).await { - Ok(Some(response_cv)) => { - if response_cv.is_type(CommunicationType::error) { - let reason = response_cv - .get_data(DataTypes::message) - .as_str() - .unwrap_or("connection error") - .to_string(); - Err(format!( - "Request failed due to disconnect (msg_id={}, reason={})", - msg_id, reason - )) - } else { - Ok(response_cv) - } - } - Ok(_) => { - WAITING_TASKS.remove(&msg_id); - Err("Channel closed while awaiting response".to_string()) - } + Ok(Some(response_cv)) => Ok(response_cv), + Ok(_) => Err("Failed to receive response, channel was closed.".to_string()), Err(_) => { - let waiting_tasks_len = WAITING_TASKS.len(); - WAITING_TASKS.remove(&msg_id); + self.waiting_tasks.remove(&msg_id); Err(format!( - "Request timed out (msg_id={}, timeout={}s, connected={}, waiting_tasks={})", - msg_id, - timeout.as_secs(), - self.is_connected().await, - waiting_tasks_len + "Request timed out after {} seconds.", + timeout.as_secs() )) } } } - - pub async fn await_connection(&self, timeout_duration: Option) -> Result<(), String> { - if self.state.read().await.is_connected() { - return Ok(()); - } - - let timeout = timeout_duration.unwrap_or(CONNECTION_TIMEOUT); - let start = Instant::now(); - - loop { - if self.state.read().await.is_connected() { - return Ok(()); - } - - if start.elapsed() >= timeout { - return Err(format!( - "Connection not established within {} seconds", - timeout.as_secs() - )); - } - - sleep(Duration::from_millis(100)).await; - } - } -} - -// ============================================================================ -// Global Instance -// ============================================================================ - -pub static OMIKRON_CONNECTION: LazyLock> = LazyLock::new(|| { - let conn = Arc::new(OmikronConnection::new()); - - start_task_cleanup_loop(); - - conn -}); - -pub async fn get_omikron_connection() -> Arc { - let conn = OMIKRON_CONNECTION.clone(); - - conn.connect().await; - conn } diff --git a/client/src/lib.rs b/client/src/lib.rs index 228eaa9..f3e6b6c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1 +1,2 @@ mod client_connection; +pub use client_connection::ClientConnection; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 11376fc..3698da9 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -18,7 +18,7 @@ use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; -use ttp_native::{Receiver, Sender}; +use ttp_native::{Policy, Receiver, SendMode, Sender}; use uuid::Uuid; // ============================================================================ @@ -218,9 +218,26 @@ impl OmikronConnection { let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); - let (sender, mut receiver) = ttp_native::client::connect(&addr_str, None) - .await - .map_err(|e| format!("Connection failed: {}", e))?; + let (sender, mut receiver) = ttp_native::client::connect( + &addr_str, + None, + Policy { + send_mode: SendMode::SingleStreamPerMessage, + max_message_size: 1_000_000_000, + close_frame_len: u32::MAX, + application_close_code: 0, + open_stream_timeout: Duration::from_millis(2_000), + write_timeout: Duration::from_millis(2_000), + accept_stream_timeout: Duration::from_millis(10_000), + read_timeout: Duration::from_millis(30_000), + force_close_delay: Duration::from_millis(300), + max_transient_recv_errors: 20, + transient_recv_backoff: Duration::from_millis(100), + receiver_queue_capacity: 1000, + }, + ) + .await + .map_err(|e| format!("Connection failed: {}", e))?; log_t!("omikron_connection_success"); diff --git a/other_iota/Cargo.toml b/other-iota/Cargo.toml similarity index 98% rename from other_iota/Cargo.toml rename to other-iota/Cargo.toml index b930ed5..a1d34f7 100644 --- a/other_iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "communities" +name = "other-iota" version = "0.1.0" edition = "2024" diff --git a/other_iota/src/lib.rs b/other-iota/src/lib.rs similarity index 100% rename from other_iota/src/lib.rs rename to other-iota/src/lib.rs From 52c9c31c1a8247b30c42ca28a008506a3169b670 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 9 May 2026 14:51:56 +0200 Subject: [PATCH 026/119] [Add] Parrallel message sending --- Cargo.lock | 18 +++++++++--------- omikron-connector/src/omikron_connection.rs | 2 ++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index deeca7d..7b19efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,9 +561,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1448,9 +1448,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1800,7 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -4158,9 +4158,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4317,7 +4317,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#db53f44d9323fddceb0eba6ec854c27a3f5e0da7" +source = "git+https://git.methanium.net/Tensamin/TTP.git#929cb9d3a6aebebe6365973f13062ac2a8e03af6" dependencies = [ "base64", "byteorder", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#db53f44d9323fddceb0eba6ec854c27a3f5e0da7" +source = "git+https://git.methanium.net/Tensamin/TTP.git#929cb9d3a6aebebe6365973f13062ac2a8e03af6" dependencies = [ "quinn", "rustls", diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 3698da9..0637b73 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -230,6 +230,8 @@ impl OmikronConnection { write_timeout: Duration::from_millis(2_000), accept_stream_timeout: Duration::from_millis(10_000), read_timeout: Duration::from_millis(30_000), + keep_alive_interval: Some(Duration::from_secs(6)), + max_idle_timeout: Some(Duration::from_secs(30)), force_close_delay: Duration::from_millis(300), max_transient_recv_errors: 20, transient_recv_backoff: Duration::from_millis(100), From 0b3efa2f612f183fddcb1a8399d95ce4b3d74dca Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 16 May 2026 16:28:29 +0200 Subject: [PATCH 027/119] [Add] Nix Flake --- flake.lock | 78 ++++++++++++++++++++ flake.nix | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2875c20 --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "ttp": "ttp" + } + }, + "ttp": { + "flake": false, + "locked": { + "lastModified": 1778329030, + "narHash": "sha256-qEEPlOuGVco1g6lI/kfEvIZkJmBbjehuchGWPTywR10=", + "rev": "929cb9d3a6aebebe6365973f13062ac2a8e03af6", + "revCount": 115, + "type": "git", + "url": "https://git.methanium.net/Tensamin/TTP.git" + }, + "original": { + "rev": "929cb9d3a6aebebe6365973f13062ac2a8e03af6", + "type": "git", + "url": "https://git.methanium.net/Tensamin/TTP.git" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9b4c07b --- /dev/null +++ b/flake.nix @@ -0,0 +1,206 @@ +{ + description = "Iota"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + ttp = { + url = "git+https://git.methanium.net/Tensamin/TTP.git?rev=929cb9d3a6aebebe6365973f13062ac2a8e03af6"; + flake = false; + }; + }; + + outputs = inputs@{ self, nixpkgs, flake-parts, ttp, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + perSystem = { self', pkgs, ... }: { + packages = { + default = self'.packages.iota; + iota = pkgs.rustPlatform.buildRustPackage { + pname = "iota"; + version = "0.1.0"; + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; + nativeBuildInputs = with pkgs; [ cmake perl pkg-config ]; + buildInputs = with pkgs; [ openssl sqlite ]; + dontUseCmakeConfigure = true; + preConfigure = '' + if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then + cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json + fi + ''; + postInstall = '' + mv $out/bin/iota-core $out/bin/iota + for f in $out/bin/*; do + if [ "$(basename "$f")" != "iota" ]; then + rm "$f" + fi + done + ''; + passthru.dataDir = "/var/lib/iota"; + }; + }; + }; + + flake = { + nixosModules.default = { config, pkgs, lib, ... }: + let + cfg = config.services.iota; + defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + + configFile = if cfg.settingsFile != null then cfg.settingsFile else + pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); + + descriptionText = "Iota Service" + + lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)"; + in + { + options.services.iota = { + enable = lib.mkEnableOption "the Iota service"; + + dataDir = lib.mkOption { + type = lib.types.str; + default = cfg.package.passthru.dataDir or "/var/lib/iota"; + defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; + description = "Directory where Iota stores its data, config, and certificates."; + }; + + certFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the SSL certificate file (cert.pem)."; + }; + + keyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the SSL private key file (cert.key)."; + }; + + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + description = "Environment files to load for the Iota service."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to open the firewall for ports used by Iota."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = defaultPackage; + description = "The Iota package to use."; + }; + + useTmux = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to run Iota inside a tmux session for shared TUI access."; + }; + + settings = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Configuration attributes for Iota, written to config.json."; + }; + + settingsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to an existing config.json file to use instead of generating from settings."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.iota = { + isSystemUser = true; + group = "iota"; + home = cfg.dataDir; + createHome = true; + description = "Iota service user"; + }; + + users.groups.iota = { }; + + systemd.services.iota = { + description = descriptionText; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = "iota"; + Group = "iota"; + WorkingDirectory = cfg.dataDir; + + ExecStart = if cfg.useTmux then + pkgs.writeShellScript "iota-start" '' + ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota ${cfg.package}/bin/iota + while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do + sleep 2 + done + '' + else + "${cfg.package}/bin/iota"; + + ExecStartPre = [ + ("+" + pkgs.writeShellScript "iota-setup" '' + mkdir -p ${cfg.dataDir}/certs + + ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} + ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} + + install -m 644 ${configFile} ${cfg.dataDir}/config.json + + chown -R iota:iota ${cfg.dataDir} + + ${lib.optionalString cfg.useTmux '' + echo "Iota started under tmux. Attach with: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota" >&2 + ''} + '') + ]; + + Restart = "unless-stopped"; + RestartSec = "5"; + + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + ReadWritePaths = [ cfg.dataDir ]; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + } // lib.optionalAttrs (cfg.environmentFiles != [ ]) { + EnvironmentFile = cfg.environmentFiles; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ 1984 ]; + allowedUDPPorts = [ 1984 ]; + }; + }; + }; + }; + }; +} From 7313c4af998b8cff33fc16aafcd92d8995b78df3 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 16 May 2026 17:14:36 +0200 Subject: [PATCH 028/119] [Fix] Nix Flake --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9b4c07b..b83cbd6 100644 --- a/flake.nix +++ b/flake.nix @@ -172,8 +172,8 @@ '') ]; - Restart = "unless-stopped"; - RestartSec = "5"; + Restart = "always"; + RestartSec = "5s"; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; From f25815aa1b99dabbeaf544e1c7ea4695af330876 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 16 May 2026 19:01:54 +0200 Subject: [PATCH 029/119] Added tppBind --- Cargo.lock | 4 ++-- flake.lock | 10 +++++----- flake.nix | 18 +++++++++++++++++- web-ui/src/server.rs | 11 +++++++---- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b19efe..420eb6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4317,7 +4317,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#929cb9d3a6aebebe6365973f13062ac2a8e03af6" +source = "git+https://git.methanium.net/Tensamin/TTP.git#7e5d1953df8592a1feba0f390d205d0ef61a3119" dependencies = [ "base64", "byteorder", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#929cb9d3a6aebebe6365973f13062ac2a8e03af6" +source = "git+https://git.methanium.net/Tensamin/TTP.git#7e5d1953df8592a1feba0f390d205d0ef61a3119" dependencies = [ "quinn", "rustls", diff --git a/flake.lock b/flake.lock index 2875c20..dffc9d8 100644 --- a/flake.lock +++ b/flake.lock @@ -59,15 +59,15 @@ "ttp": { "flake": false, "locked": { - "lastModified": 1778329030, - "narHash": "sha256-qEEPlOuGVco1g6lI/kfEvIZkJmBbjehuchGWPTywR10=", - "rev": "929cb9d3a6aebebe6365973f13062ac2a8e03af6", - "revCount": 115, + "lastModified": 1778948017, + "narHash": "sha256-hqBYSZnPq7f/F2Z6nJK+6a8ITk6WRCcVBcNT0CR6SnM=", + "rev": "7e5d1953df8592a1feba0f390d205d0ef61a3119", + "revCount": 117, "type": "git", "url": "https://git.methanium.net/Tensamin/TTP.git" }, "original": { - "rev": "929cb9d3a6aebebe6365973f13062ac2a8e03af6", + "rev": "7e5d1953df8592a1feba0f390d205d0ef61a3119", "type": "git", "url": "https://git.methanium.net/Tensamin/TTP.git" } diff --git a/flake.nix b/flake.nix index b83cbd6..533af31 100644 --- a/flake.nix +++ b/flake.nix @@ -5,7 +5,7 @@ nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; ttp = { - url = "git+https://git.methanium.net/Tensamin/TTP.git?rev=929cb9d3a6aebebe6365973f13062ac2a8e03af6"; + url = "git+https://git.methanium.net/Tensamin/TTP.git?rev=7e5d1953df8592a1feba0f390d205d0ef61a3119"; flake = false; }; }; @@ -98,6 +98,18 @@ description = "Whether to open the firewall for ports used by Iota."; }; + ttpBind = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "IP address to bind the TTP/QUIC server to."; + }; + + bindAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "IP address to bind the HTTP server to."; + }; + package = lib.mkOption { type = lib.types.package; default = defaultPackage; @@ -190,6 +202,10 @@ RestrictSUIDSGID = true; LockPersonality = true; MemoryDenyWriteExecute = true; + Environment = [ + "TTP_BIND=${cfg.ttpBind}" + "BIND_ADDRESS=${cfg.bindAddress}" + ]; } // lib.optionalAttrs (cfg.environmentFiles != [ ]) { EnvironmentFile = cfg.environmentFiles; }; diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index edecfdd..263256e 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -19,10 +19,13 @@ use tokio::sync::oneshot; pub async fn start(port: u16) -> bool { let (tx, rx) = oneshot::channel::(); + let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string()); + let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS"); + let _ = tokio::spawn(async move { let server = match load_tls_config() { Ok(Some(tls_config)) => { - log!("HTTPS (HTTP/2) Server running on 0.0.0.0:{}", port); + log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port); let _config = (*tls_config).clone(); HttpServer::new(move || { App::new() @@ -30,19 +33,19 @@ pub async fn start(port: u16) -> bool { .configure(api_config) .default_service(web::to(web_path_parser::handle)) }) - .bind(("0.0.0.0", port)) + .bind((bind_ip, port)) .unwrap() .run() } Ok(_) => { - log!("HTTP Server running on 0.0.0.0:{}", port); + log!("HTTP Server running on {}:{}", bind_addr, port); HttpServer::new(move || { App::new() .app_data(web::Data::new(false)) .configure(api_config) .default_service(web::to(web_path_parser::handle)) }) - .bind(("0.0.0.0", port)) + .bind((bind_ip, port)) .unwrap() .run() } From 83bd05b79146f3f8f8bc293d0a9bfd11c20c139a Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 13:44:17 +0200 Subject: [PATCH 030/119] [Add] nix dev shell --- flake.lock | 21 +++++++++++++++ flake.nix | 77 ++++++++++++++++++++++++++++++++++-------------------- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/flake.lock b/flake.lock index dffc9d8..fb5bf79 100644 --- a/flake.lock +++ b/flake.lock @@ -53,9 +53,30 @@ "inputs": { "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", "ttp": "ttp" } }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778987862, + "narHash": "sha256-V3qGt9P1eJP/r/1ONablphfGiH0RP4agQhrRANpDYx8=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "6f44d8874ac29806c8d5cae42bf8e19ebb5ce0d3", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "ttp": { "flake": false, "locked": { diff --git a/flake.nix b/flake.nix index 533af31..8e9327e 100644 --- a/flake.nix +++ b/flake.nix @@ -4,13 +4,17 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; ttp = { url = "git+https://git.methanium.net/Tensamin/TTP.git?rev=7e5d1953df8592a1feba0f390d205d0ef61a3119"; flake = false; }; }; - outputs = inputs@{ self, nixpkgs, flake-parts, ttp, ... }: + outputs = inputs@{ self, nixpkgs, flake-parts, rust-overlay, ttp, ... }: flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" @@ -19,37 +23,52 @@ "aarch64-darwin" ]; - perSystem = { self', pkgs, ... }: { - packages = { - default = self'.packages.iota; - iota = pkgs.rustPlatform.buildRustPackage { - pname = "iota"; - version = "0.1.0"; - src = ./.; - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; - nativeBuildInputs = with pkgs; [ cmake perl pkg-config ]; - buildInputs = with pkgs; [ openssl sqlite ]; - dontUseCmakeConfigure = true; - preConfigure = '' - if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then - cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json - fi - ''; - postInstall = '' - mv $out/bin/iota-core $out/bin/iota - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota" ]; then - rm "$f" + perSystem = { self', pkgs, system, ... }: + let + rustPkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + }; + in + { + packages = { + default = self'.packages.iota; + iota = pkgs.rustPlatform.buildRustPackage { + pname = "iota"; + version = "0.1.0"; + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; + nativeBuildInputs = with pkgs; [ cmake perl pkg-config ]; + buildInputs = with pkgs; [ openssl sqlite ]; + dontUseCmakeConfigure = true; + preConfigure = '' + if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then + cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json fi - done - ''; - passthru.dataDir = "/var/lib/iota"; + ''; + postInstall = '' + mv $out/bin/iota-core $out/bin/iota + for f in $out/bin/*; do + if [ "$(basename "$f")" != "iota" ]; then + rm "$f" + fi + done + ''; + passthru.dataDir = "/var/lib/iota"; + }; + }; + + devShells.default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ rustToolchain git cmake perl pkg-config ]; + buildInputs = with pkgs; [ openssl sqlite ]; }; }; - }; flake = { nixosModules.default = { config, pkgs, lib, ... }: From 045e2ff15cc52514579c5efd4da3ee8809bbb42a Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 19:30:11 +0200 Subject: [PATCH 031/119] [Add] systemd log wrapper to nix flake --- flake.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 8e9327e..8cea3ba 100644 --- a/flake.nix +++ b/flake.nix @@ -178,13 +178,14 @@ ExecStart = if cfg.useTmux then pkgs.writeShellScript "iota-start" '' - ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota ${cfg.package}/bin/iota + ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota \ + '${cfg.package}/bin/iota 2>&1 | ${pkgs.systemd}/bin/systemd-cat -t iota-daemon' while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do sleep 2 done '' else - "${cfg.package}/bin/iota"; + "${pkgs.systemd}/bin/systemd-cat -t iota-daemon ${cfg.package}/bin/iota"; ExecStartPre = [ ("+" + pkgs.writeShellScript "iota-setup" '' From 5d5585cb7f1b3dadd919ab215a9d6fd0c12aae4d Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 19:43:55 +0200 Subject: [PATCH 032/119] Updated systemd stuff --- flake.nix | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 8cea3ba..32e59e0 100644 --- a/flake.nix +++ b/flake.nix @@ -178,14 +178,22 @@ ExecStart = if cfg.useTmux then pkgs.writeShellScript "iota-start" '' - ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota \ - '${cfg.package}/bin/iota 2>&1 | ${pkgs.systemd}/bin/systemd-cat -t iota-daemon' + ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota ' + ${cfg.package}/bin/iota 2>&1 | ${pkgs.coreutils}/bin/tee ${cfg.dataDir}/iota-output.log + EXIT_CODE=$? + echo "" + echo "============================================" + echo "Iota exited with code: $EXIT_CODE" + echo "Output saved to: ${cfg.dataDir}/iota-output.log" + echo "Press ENTER to close this session..." + read -r + ' while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do sleep 2 done '' else - "${pkgs.systemd}/bin/systemd-cat -t iota-daemon ${cfg.package}/bin/iota"; + "${cfg.package}/bin/iota"; ExecStartPre = [ ("+" + pkgs.writeShellScript "iota-setup" '' From b7f6ccf8a7cf002940ca100747185ee2cdd3603a Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 20:06:08 +0200 Subject: [PATCH 033/119] [Add] debugging to systemd nix service --- flake.nix | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 32e59e0..7b5e19f 100644 --- a/flake.nix +++ b/flake.nix @@ -179,11 +179,9 @@ ExecStart = if cfg.useTmux then pkgs.writeShellScript "iota-start" '' ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota ' + echo "debug: starting iota..." ${cfg.package}/bin/iota 2>&1 | ${pkgs.coreutils}/bin/tee ${cfg.dataDir}/iota-output.log EXIT_CODE=$? - echo "" - echo "============================================" - echo "Iota exited with code: $EXIT_CODE" echo "Output saved to: ${cfg.dataDir}/iota-output.log" echo "Press ENTER to close this session..." read -r From e18e8d1151567afe892ce6b4d430e440a54a801d Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 20:21:58 +0200 Subject: [PATCH 034/119] [Fix] tmux based systemd service --- flake.nix | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/flake.nix b/flake.nix index 7b5e19f..b422ab5 100644 --- a/flake.nix +++ b/flake.nix @@ -169,30 +169,29 @@ description = descriptionText; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; + path = [ pkgs.tmux pkgs.bash pkgs.coreutils pkgs.systemd ]; serviceConfig = { - Type = "simple"; + Type = if cfg.useTmux then "forking" else "simple"; User = "iota"; Group = "iota"; WorkingDirectory = cfg.dataDir; ExecStart = if cfg.useTmux then pkgs.writeShellScript "iota-start" '' - ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota ' - echo "debug: starting iota..." - ${cfg.package}/bin/iota 2>&1 | ${pkgs.coreutils}/bin/tee ${cfg.dataDir}/iota-output.log - EXIT_CODE=$? - echo "Output saved to: ${cfg.dataDir}/iota-output.log" - echo "Press ENTER to close this session..." - read -r - ' - while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do - sleep 2 - done + ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota \ + "${pkgs.bash}/bin/bash -lc 'exec > >(${pkgs.coreutils}/bin/tee -a ${cfg.dataDir}/iota-output.log >(${pkgs.systemd}/bin/systemd-cat -t iota-daemon)) 2>&1; ${cfg.package}/bin/iota; status=$?; printf \"\nProcess exited with status %s. Press any key to close this tmux session...\" \"\$status\"; read -r -n 1; exit \"\$status\"'" '' else "${cfg.package}/bin/iota"; + ExecStop = if cfg.useTmux then + (pkgs.writeShellScript "iota-stop" '' + ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock kill-session -t iota 2>/dev/null || true + '') + else + null; + ExecStartPre = [ ("+" + pkgs.writeShellScript "iota-setup" '' mkdir -p ${cfg.dataDir}/certs From 9d7438d1cb803fe04396f655ee114f0f22ca139e Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 17 May 2026 21:35:22 +0200 Subject: [PATCH 035/119] [Add] Push notifications --- omikron-connector/src/omikron_connection.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 0637b73..c967a57 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -955,6 +955,12 @@ impl OmikronConnection { MessageState::Sent, ); + // Send push notification to Omega since user is offline + let push_msg = CommunicationValue::new(CommunicationType::push_notification) + .with_receiver(receiver_id as u64) + .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)); + self.send_message(&push_msg).await; + // notify sender self.send_message( &CommunicationValue::new(CommunicationType::message_state) @@ -1071,6 +1077,12 @@ impl OmikronConnection { MessageState::Sent, ); + // Send push notification to Omega since user is offline + let push_msg = CommunicationValue::new(CommunicationType::push_notification) + .with_receiver(*receiver_id) + .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)); + self.send_message(&push_msg).await; + self.send_message( &CommunicationValue::new(CommunicationType::message_state) .with_id(cv.get_id()) From 41deb0d6deca6c7e3485ca4039073e7c1e9a824d Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 17 May 2026 22:22:03 +0200 Subject: [PATCH 036/119] [Fix] systemd stuff fr this time --- flake.nix | 379 ++++++++++++++++++++----------------- iota-util/src/file_util.rs | 5 +- 2 files changed, 209 insertions(+), 175 deletions(-) diff --git a/flake.nix b/flake.nix index b422ab5..9101c4b 100644 --- a/flake.nix +++ b/flake.nix @@ -14,8 +14,15 @@ }; }; - outputs = inputs@{ self, nixpkgs, flake-parts, rust-overlay, ttp, ... }: - flake-parts.lib.mkFlake { inherit inputs; } { + outputs = inputs @ { + self, + nixpkgs, + flake-parts, + rust-overlay, + ttp, + ... + }: + flake-parts.lib.mkFlake {inherit inputs;} { systems = [ "x86_64-linux" "aarch64-linux" @@ -23,203 +30,230 @@ "aarch64-darwin" ]; - perSystem = { self', pkgs, system, ... }: - let - rustPkgs = import nixpkgs { - inherit system; - overlays = [ (import rust-overlay) ]; - }; - rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { - extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; - }; - in - { - packages = { - default = self'.packages.iota; - iota = pkgs.rustPlatform.buildRustPackage { - pname = "iota"; - version = "0.1.0"; - src = ./.; - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; - nativeBuildInputs = with pkgs; [ cmake perl pkg-config ]; - buildInputs = with pkgs; [ openssl sqlite ]; - dontUseCmakeConfigure = true; - preConfigure = '' - if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then - cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json - fi - ''; - postInstall = '' - mv $out/bin/iota-core $out/bin/iota - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota" ]; then - rm "$f" - fi - done - ''; - passthru.dataDir = "/var/lib/iota"; + perSystem = { + self', + pkgs, + system, + ... + }: let + rustPkgs = import nixpkgs { + inherit system; + overlays = [(import rust-overlay)]; + }; + rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { + extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"]; + }; + in { + packages = { + default = self'.packages.iota; + iota = pkgs.rustPlatform.buildRustPackage { + pname = "iota"; + version = "0.1.0"; + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; }; - }; - - devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ rustToolchain git cmake perl pkg-config ]; - buildInputs = with pkgs; [ openssl sqlite ]; + nativeBuildInputs = with pkgs; [cmake perl pkg-config]; + buildInputs = with pkgs; [openssl sqlite]; + dontUseCmakeConfigure = true; + preConfigure = '' + if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then + cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json + fi + ''; + postInstall = '' + mv $out/bin/iota-core $out/bin/iota + for f in $out/bin/*; do + if [ "$(basename "$f")" != "iota" ]; then + rm "$f" + fi + done + ''; + passthru.dataDir = "/var/lib/iota"; }; }; + devShells.default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config]; + buildInputs = with pkgs; [openssl sqlite]; + }; + }; + flake = { - nixosModules.default = { config, pkgs, lib, ... }: - let - cfg = config.services.iota; - defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + nixosModules.default = { + config, + pkgs, + lib, + ... + }: let + cfg = config.services.iota; + defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); - configFile = if cfg.settingsFile != null then cfg.settingsFile else - pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); + configFile = + if cfg.settingsFile != null + then cfg.settingsFile + else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); - descriptionText = "Iota Service" - + lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)"; - in - { - options.services.iota = { - enable = lib.mkEnableOption "the Iota service"; + descriptionText = "Tensamin Iota"; + #+ lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)"; + in { + options.services.iota = { + enable = lib.mkEnableOption "Enable the Iota service."; - dataDir = lib.mkOption { - type = lib.types.str; - default = cfg.package.passthru.dataDir or "/var/lib/iota"; - defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; - description = "Directory where Iota stores its data, config, and certificates."; - }; - - certFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to the SSL certificate file (cert.pem)."; - }; - - keyFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to the SSL private key file (cert.key)."; - }; - - environmentFiles = lib.mkOption { - type = lib.types.listOf lib.types.path; - default = [ ]; - description = "Environment files to load for the Iota service."; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to open the firewall for ports used by Iota."; - }; - - ttpBind = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the TTP/QUIC server to."; - }; - - bindAddress = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the HTTP server to."; - }; - - package = lib.mkOption { - type = lib.types.package; - default = defaultPackage; - description = "The Iota package to use."; - }; - - useTmux = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to run Iota inside a tmux session for shared TUI access."; - }; - - settings = lib.mkOption { - type = lib.types.attrs; - default = { }; - description = "Configuration attributes for Iota, written to config.json."; - }; - - settingsFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to an existing config.json file to use instead of generating from settings."; - }; + dataDir = lib.mkOption { + type = lib.types.str; + default = cfg.package.passthru.dataDir or "/var/lib/iota"; + defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; + description = "Directory where Iota stores its data, config, and certificates."; }; - config = lib.mkIf cfg.enable { - users.users.iota = { - isSystemUser = true; - group = "iota"; - home = cfg.dataDir; - createHome = true; - description = "Iota service user"; - }; + certFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the SSL certificate file (cert.pem)."; + }; - users.groups.iota = { }; + keyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the SSL private key file (cert.key)."; + }; - systemd.services.iota = { - description = descriptionText; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; - path = [ pkgs.tmux pkgs.bash pkgs.coreutils pkgs.systemd ]; + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = []; + description = "Environment files to load for the Iota service."; + }; - serviceConfig = { - Type = if cfg.useTmux then "forking" else "simple"; + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to open the firewall for ports used by Iota."; + }; + + ttpBind = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "IP address to bind the TTP/QUIC server to."; + }; + + bindAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "IP address to bind the HTTP server to."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = defaultPackage; + description = "The Iota package to use."; + }; + + useTmux = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to run Iota inside a tmux session for shared TUI access."; + }; + + settings = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = "Configuration attributes for Iota, written to config.json."; + }; + + settingsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to an existing config.json file to use instead of generating from settings."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.iota = { + isSystemUser = true; + group = "iota"; + home = cfg.dataDir; + createHome = true; + description = "Iota service user"; + shell = pkgs.bash; + }; + + users.groups.iota = {}; + + systemd.services.iota = let + iotaTmuxCmd = pkgs.writeShellScript "iota-tmux-cmd" '' + mkdir -p ${cfg.dataDir} + echo "[$(date)] Running Iota..." + ${cfg.package}/bin/iota + status=$? + echo "" + echo "[$(date)] Iota exited with status: $status" + echo "Press any key to exit..." + read -r -n 1 + exit $status + ''; + in { + description = descriptionText; + wantedBy = ["multi-user.target"]; + after = ["network.target"]; + + serviceConfig = + { + Type = "simple"; User = "iota"; Group = "iota"; WorkingDirectory = cfg.dataDir; - ExecStart = if cfg.useTmux then - pkgs.writeShellScript "iota-start" '' - ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota \ - "${pkgs.bash}/bin/bash -lc 'exec > >(${pkgs.coreutils}/bin/tee -a ${cfg.dataDir}/iota-output.log >(${pkgs.systemd}/bin/systemd-cat -t iota-daemon)) 2>&1; ${cfg.package}/bin/iota; status=$?; printf \"\nProcess exited with status %s. Press any key to close this tmux session...\" \"\$status\"; read -r -n 1; exit \"\$status\"'" - '' - else - "${cfg.package}/bin/iota"; + ExecStart = + if cfg.useTmux + then + pkgs.writeShellScript "iota-start" '' + set -e + export TMUX_TMPDIR=${cfg.dataDir} + ${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDir} + ${pkgs.coreutils}/bin/chown iota:iota ${cfg.dataDir} - ExecStop = if cfg.useTmux then - (pkgs.writeShellScript "iota-stop" '' - ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock kill-session -t iota 2>/dev/null || true - '') - else - null; + echo "[iota-start] Creating tmux session..." + if ! ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota "${iotaTmuxCmd}"; then + echo "[iota-start] ERROR: tmux new-session failed" + exit 1 + fi + echo "[iota-start] tmux session created, waiting..." + echo "[iota-start] Run 'tmux -S ${cfg.dataDir}/tmux.sock attach -t iota' to attach to the tmux session." + + while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do + sleep 2 + done + echo "[iota-start] tmux session ended" + '' + else "${cfg.package}/bin/iota"; ExecStartPre = [ - ("+" + pkgs.writeShellScript "iota-setup" '' - mkdir -p ${cfg.dataDir}/certs + ("+" + + pkgs.writeShellScript "iota-setup" '' + mkdir -p ${cfg.dataDir}/certs - ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} - ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} + ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} + ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} - install -m 644 ${configFile} ${cfg.dataDir}/config.json + install -m 644 ${configFile} ${cfg.dataDir}/config.json - chown -R iota:iota ${cfg.dataDir} - - ${lib.optionalString cfg.useTmux '' - echo "Iota started under tmux. Attach with: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota" >&2 - ''} - '') + chown -R iota:iota ${cfg.dataDir} + '') ]; Restart = "always"; RestartSec = "5s"; - AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; - CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; + CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; - ReadWritePaths = [ cfg.dataDir ]; + ReadWritePaths = [cfg.dataDir]; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; @@ -231,17 +265,18 @@ "TTP_BIND=${cfg.ttpBind}" "BIND_ADDRESS=${cfg.bindAddress}" ]; - } // lib.optionalAttrs (cfg.environmentFiles != [ ]) { + } + // lib.optionalAttrs (cfg.environmentFiles != []) { EnvironmentFile = cfg.environmentFiles; }; - }; + }; - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ 1984 ]; - allowedUDPPorts = [ 1984 ]; - }; + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [1984]; + allowedUDPPorts = [1984]; }; }; + }; }; }; } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index b0cd3a3..8769c7f 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -149,9 +149,8 @@ pub fn get_children(path: &str) -> Vec { } pub fn get_directory() -> String { - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); - exe.parent() - .unwrap_or(Path::new(".")) + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) .to_string_lossy() .to_string() } From 9b99d95307b97d559f93e45edeb9bdbd433ff6a3 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 18 May 2026 20:47:27 +0200 Subject: [PATCH 037/119] Updated flake --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index fb5bf79..360f62c 100644 --- a/flake.lock +++ b/flake.lock @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1778987862, - "narHash": "sha256-V3qGt9P1eJP/r/1ONablphfGiH0RP4agQhrRANpDYx8=", + "lastModified": 1779074409, + "narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6f44d8874ac29806c8d5cae42bf8e19ebb5ce0d3", + "rev": "2a77b5b1dc952f214e8102acdef1622b68515560", "type": "github" }, "original": { From c04e70ef855a3fb07ea4a5f8856fb3ddfd112cf3 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 18 May 2026 21:25:21 +0200 Subject: [PATCH 038/119] [Fix] use working directory instead of executable directory --- communities/Cargo.lock | 5236 +++++++++++++++++ communities/src/interactables/text_chat.rs | 5 +- iota-logger/src/lib.rs | 6 +- iota-storage/src/users/user_community_util.rs | 33 +- 4 files changed, 5256 insertions(+), 24 deletions(-) create mode 100644 communities/Cargo.lock diff --git a/communities/Cargo.lock b/communities/Cargo.lock new file mode 100644 index 0000000..dec0ac6 --- /dev/null +++ b/communities/Cargo.lock @@ -0,0 +1,5236 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" +dependencies = [ + "actix-rt", + "bitflags 2.11.1", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "base64", + "bitflags 2.11.1", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash 0.1.5", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.10.1", + "sha1 0.11.0", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-actors" +version = "4.3.1+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" +dependencies = [ + "actix", + "actix-codec", + "actix-http", + "actix-web", + "bytes", + "bytestring", + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "communities" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-auth", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed448-goldilocks" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" +dependencies = [ + "fiat-crypto", + "hex", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1 0.10.6", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iota-auth" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "async-trait", + "base64", + "chrono", + "crossterm", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "json", + "lazy_static", + "once_cell", + "open", + "pnet", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde_json", + "sha2 0.10.9", + "strum 0.27.2", + "strum_macros 0.27.2", + "sysinfo", + "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", + "uuid", + "walkdir", + "warp", + "x448", + "zip", +] + +[[package]] +name = "iota-logger" +version = "0.1.0" +dependencies = [ + "iota-state", + "iota-util", + "json", + "once_cell", + "ratatui", + "ttp-core", +] + +[[package]] +name = "iota-state" +version = "0.1.0" +dependencies = [ + "dashmap", + "json", + "once_cell", + "sysinfo", + "tokio", + "ttp-core", +] + +[[package]] +name = "iota-storage" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "hex", + "hkdf", + "iota-logger", + "iota-state", + "iota-util", + "json", + "once_cell", + "rand 0.8.6", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "sha2 0.10.9", + "sysinfo", + "tokio", + "ttp-core", + "ttp-native", + "uuid", + "walkdir", + "x448", + "zip", +] + +[[package]] +name = "iota-util" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "hex", + "hkdf", + "rand_core 0.6.4", + "reqwest", + "sha2 0.10.9", + "sysinfo", + "tokio", + "ttp-core", + "ttp-native", + "uuid", + "walkdir", + "x448", + "zip", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" +dependencies = [ + "crc", + "sha2 0.10.9", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "octets" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "pnet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "682396b533413cc2e009fbb48aadf93619a149d3e57defba19ff50ce0201bd0d" +dependencies = [ + "ipnetwork", + "pnet_base", + "pnet_datalink", + "pnet_packet", + "pnet_sys", + "pnet_transport", +] + +[[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79e70ec0be163102a332e1d2d5586d362ad76b01cec86f830241f2b6452a7b7" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_macros" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "pnet_macros_support" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed67a952585d509dd0003049b1fc56b982ac665c8299b124b90ea2bdb3134ab" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "pnet_packet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c96ebadfab635fcc23036ba30a7d33a80c39e8461b8bd7dc7bb186acb96560f" +dependencies = [ + "glob", + "pnet_base", + "pnet_macros", + "pnet_macros_support", +] + +[[package]] +name = "pnet_sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4643d3d4db6b08741050c2f3afa9a892c4244c085a72fcda93c9c2c9a00f4b" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pnet_transport" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f604d98bc2a6591cf719b58d3203fd882bdd6bf1db696c4ac97978e9f4776bf" +dependencies = [ + "libc", + "pnet_base", + "pnet_packet", + "pnet_sys", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum 0.27.2", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttp-core" +version = "0.1.0" +source = "git+https://git.methanium.net/Tensamin/TTP.git#7829ee8b296b06731bba3d1749f56767d58cf153" +dependencies = [ + "base64", + "byteorder", + "rand 0.8.6", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", +] + +[[package]] +name = "ttp-native" +version = "0.1.0" +source = "git+https://git.methanium.net/Tensamin/TTP.git#7829ee8b296b06731bba3d1749f56767d58cf153" +dependencies = [ + "quinn", + "rustls", + "rustls-native-certs", + "thiserror 2.0.18", + "tokio", + "ttp-core", + "wtransport", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "native-tls", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "warp" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http 1.4.0", + "http-body", + "http-body-util", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", + "tower-service", + "tracing", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wtransport" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +dependencies = [ + "bytes", + "pem", + "quinn", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha2 0.11.0", + "socket2 0.6.3", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", + "wtransport-proto", + "x509-parser", +] + +[[package]] +name = "wtransport-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +dependencies = [ + "httlib-huffman", + "octets", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "x448" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" +dependencies = [ + "ed448-goldilocks", + "hex", + "rand_core 0.5.1", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1 0.10.6", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index 88e1545..b4d0392 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -9,6 +9,7 @@ use crate::{ use async_trait::async_trait; use json::{JsonValue, array, object}; use std::fs; +use std::path::Path; use std::sync::Arc; use std::{any::Any, collections::HashMap}; use ttp_core::{CommunicationType, CommunicationValue, DataTypes}; @@ -36,7 +37,9 @@ impl TextChat { self.get_name() ); - if let Err(e) = fs::create_dir_all(user_dir) { + let working_dir = iota_util::file_util::get_directory(); + let full_dir = Path::new(&working_dir).join(user_dir); + if let Err(e) = fs::create_dir_all(&full_dir) { log!("Failed to create chat directory: {}", e); return; } diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index afc9c0b..b603f1d 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -57,8 +57,10 @@ pub fn startup() { LOGGER.set(tx).expect("Logger already initialized"); thread::spawn(move || { - let log_dir = Path::new("logs"); - fs::create_dir_all(log_dir).expect("Failed to create log directory"); + let working_dir = iota_util::file_util::get_directory(); + let base_dir = Path::new(&working_dir); + let log_dir = base_dir.join("logs"); + fs::create_dir_all(&log_dir).expect("Failed to create log directory"); let start_ts = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/iota-storage/src/users/user_community_util.rs b/iota-storage/src/users/user_community_util.rs index 6f01e74..89e0fb6 100644 --- a/iota-storage/src/users/user_community_util.rs +++ b/iota-storage/src/users/user_community_util.rs @@ -1,14 +1,12 @@ -use iota_util::file_util::save_file; +use iota_util::file_util::{load_file, save_file}; use json::{self, Array, JsonValue}; -use std::fs; -use std::path::Path; pub struct UserCommunityUtil; impl UserCommunityUtil { pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { let file_path = format!("users/{}/", storage_owner); - let mut communities = Self::load_array(&file_path); + let mut communities = Self::load_array(&file_path, "communities.json"); let mut community = JsonValue::new_object(); community["title"] = JsonValue::String(title); @@ -26,7 +24,7 @@ impl UserCommunityUtil { pub fn remove_community(storage_owner: i64, community_address: String) { let file_path = format!("users/{}/", storage_owner); - let communities = Self::load_array(&file_path); + let communities = Self::load_array(&file_path, "communities.json"); let filtered: Array = communities .iter() @@ -41,27 +39,20 @@ impl UserCommunityUtil { } pub fn get_communities(storage_owner: i64) -> Array { - let file_path = format!("users/{}/communities.json", storage_owner); - Self::load_array(&file_path) + let file_path = format!("users/{}/", storage_owner); + Self::load_array(&file_path, "communities.json") } - fn load_array(file_path: &str) -> Array { - if !Path::new(file_path).exists() { + fn load_array(dir: &str, name: &str) -> Array { + let content = load_file(dir, name); + if content.is_empty() { return Array::new(); } - match fs::read_to_string(file_path) { - Ok(content) => { - let parsed = json::parse(&content); - match parsed { - Ok(JsonValue::Array(arr)) => arr, - _ => Array::new(), - } - } - Err(err) => { - eprintln!("Failed to read file {}: {}", file_path, err); - Array::new() - } + let parsed = json::parse(&content); + match parsed { + Ok(JsonValue::Array(arr)) => arr, + _ => Array::new(), } } } From 681db3f511bb070ab7d4df17860364045608d5ce Mon Sep 17 00:00:00 2001 From: Alois Date: Wed, 20 May 2026 21:11:00 +0200 Subject: [PATCH 039/119] [Chore] Update flake --- flake.lock | 20 ++++++++++---------- flake.nix | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/flake.lock b/flake.lock index 360f62c..7c708a8 100644 --- a/flake.lock +++ b/flake.lock @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1779074409, - "narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=", + "lastModified": 1779247103, + "narHash": "sha256-DwltBoBl9a7fCzlKi3xnNha1NHbfvawwkNdnTXEyfFQ=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "2a77b5b1dc952f214e8102acdef1622b68515560", + "rev": "86dbfb70dc1c2967245d87ed6d07d2c8bda305e3", "type": "github" }, "original": { @@ -80,17 +80,17 @@ "ttp": { "flake": false, "locked": { - "lastModified": 1778948017, - "narHash": "sha256-hqBYSZnPq7f/F2Z6nJK+6a8ITk6WRCcVBcNT0CR6SnM=", - "rev": "7e5d1953df8592a1feba0f390d205d0ef61a3119", - "revCount": 117, + "lastModified": 1779304128, + "narHash": "sha256-C/z7JV4RxcuhdYE7vS+x4WJA1rXM/w03kjNfPPRi334=", + "ref": "refs/heads/main", + "rev": "3e3f939f64088cb01c13348b58c504593953ec0e", + "revCount": 122, "type": "git", - "url": "https://git.methanium.net/Tensamin/TTP.git" + "url": "https://git.methanium.net/tensamin/ttp.git" }, "original": { - "rev": "7e5d1953df8592a1feba0f390d205d0ef61a3119", "type": "git", - "url": "https://git.methanium.net/Tensamin/TTP.git" + "url": "https://git.methanium.net/tensamin/ttp.git" } } }, diff --git a/flake.nix b/flake.nix index 9101c4b..8cf53d7 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,7 @@ inputs.nixpkgs.follows = "nixpkgs"; }; ttp = { - url = "git+https://git.methanium.net/Tensamin/TTP.git?rev=7e5d1953df8592a1feba0f390d205d0ef61a3119"; + url = "git+https://git.methanium.net/tensamin/ttp.git"; flake = false; }; }; From 63e68277d57ea7400e4df28ff5a785ac74b15275 Mon Sep 17 00:00:00 2001 From: Alois Date: Wed, 20 May 2026 21:21:50 +0200 Subject: [PATCH 040/119] [Chore] Update crates --- Cargo.lock | 98 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 420eb6c..4590333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -415,9 +415,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -426,9 +426,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -448,7 +448,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", ] [[package]] @@ -457,6 +457,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -857,9 +866,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -919,9 +928,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1025,7 +1034,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -1067,9 +1076,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -1579,9 +1588,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -2241,9 +2250,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" @@ -2487,9 +2496,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2598,9 +2607,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", @@ -2609,9 +2618,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -2640,9 +2649,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2863,18 +2872,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -3297,9 +3306,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "rustls-pki-types", @@ -3406,9 +3415,9 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", "thiserror 2.0.18", @@ -3832,9 +3841,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" dependencies = [ "cc", "js-sys", @@ -4248,9 +4257,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -4317,7 +4326,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#7e5d1953df8592a1feba0f390d205d0ef61a3119" +source = "git+https://git.methanium.net/Tensamin/TTP.git#3e3f939f64088cb01c13348b58c504593953ec0e" dependencies = [ "base64", "byteorder", @@ -4330,7 +4339,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#7e5d1953df8592a1feba0f390d205d0ef61a3119" +source = "git+https://git.methanium.net/Tensamin/TTP.git#3e3f939f64088cb01c13348b58c504593953ec0e" dependencies = [ "quinn", "rustls", @@ -5285,10 +5294,11 @@ dependencies = [ [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec 0.9.1", "time", ] @@ -5337,9 +5347,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] From 6b351ec9d20b79c86052b2d07bf3495665eeafe6 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 21 May 2026 21:37:30 +0200 Subject: [PATCH 041/119] Metrics, Tool config ... --- iota-core/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 7874057..ea57b40 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -13,6 +13,7 @@ use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use iota_util::file_util::{download_and_extract_zip, has_dir}; use omikron_connector as omikron; +use ttp_core::{CommunicationType, DataTypes}; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] #[allow(unused_must_use, dead_code, unused_assignments)] From e07ea9ea7ea93b594b49e0e8fcec0f64fe7470cf Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 21 May 2026 22:17:26 +0200 Subject: [PATCH 042/119] Reconnection --- iota-cli/src/elements/console_card.rs | 31 +++++++- iota-core/src/main.rs | 12 ++- omikron-connector/src/omikron_connection.rs | 86 ++++++++++++++++++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index a394e96..d911b05 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -2,7 +2,8 @@ use crossterm::event::{KeyCode, KeyEvent}; use iota_logger::{log, log_command, log_cv}; use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; use iota_storage::users::{user_manager, user_profile::UserProfile}; -use iota_util::file_util; +use iota_storage::util::config_util::CONFIG; +use iota_util::{crypto_helper, file_util}; use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ Frame, @@ -400,7 +401,7 @@ pub async fn run_command(command: &str) { } ["help"] => { - log!("Available commands: tasks, fps, ping, user"); + log!("Available commands: tasks, fps, ping, user, reconnect, regenerate"); } ["help", "tasks"] => { @@ -415,6 +416,12 @@ pub async fn run_command(command: &str) { ["help", "user"] => { log!("User command usage: user add | user remove | user list"); } + ["help", "reconnect"] => { + log!("Reconnect command usage: reconnect — retry connecting to the Omikron server"); + } + ["help", "regenerate"] => { + log!("Regenerate command usage: regenerate private-key — generate a new Iota key pair and reconnect"); + } ["ping"] => { ping(20).await; @@ -460,6 +467,26 @@ pub async fn run_command(command: &str) { log!("User info: Username doesn't exist"); } } + ["reconnect"] => { + log!("Reconnecting to Omikron server..."); + OMIKRON_CONNECTION.reconnect().await; + log!("Reconnected to Omikron server"); + } + ["regenerate", "private-key"] => { + log!("Regenerating Iota key pair..."); + let key_pair = crypto_helper::generate_keypair(); + let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); + let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); + { + let mut conf = CONFIG.write().await; + conf.change("public_key", json::JsonValue::from(public_key_base64)); + conf.change("private_key", json::JsonValue::from(private_key_base64)); + conf.update(); + } + log!("Key pair regenerated. Reconnecting to Omikron server..."); + OMIKRON_CONNECTION.reconnect().await; + log!("Reconnected with new key pair"); + } ["reload"] | ["restart"] => { log!("Restarting"); *RELOAD.write().await = true; diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index ea57b40..d95f268 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -13,7 +13,7 @@ use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use iota_util::file_util::{download_and_extract_zip, has_dir}; use omikron_connector as omikron; -use ttp_core::{CommunicationType, DataTypes}; +use omikron_connector::omikron_connection::OMIKRON_CONNECTION; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] #[allow(unused_must_use, dead_code, unused_assignments)] @@ -159,7 +159,15 @@ async fn main() { break; } - sleep(Duration::from_millis(100)).await; + if OMIKRON_CONNECTION.has_auth_failure().await { + if let Some(reason) = OMIKRON_CONNECTION.get_auth_failure().await { + log!("Authentication failed: {}", reason); + log!("Use /reconnect to try again or /regenerate private-key to create a new key pair"); + OMIKRON_CONNECTION.clear_auth_failure().await; + } + } + + sleep(Duration::from_millis(500)).await; } if *RELOAD.read().await { loop { diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index c967a57..558e628 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -91,6 +91,7 @@ pub struct OmikronConnection { pub connection_id: Uuid, shutdown_tx: Arc>>>, reconnect_on_close: Arc>, + auth_failure: Arc>>, pub app_challenges: Arc>>, pub app_sessions: Arc>>, } @@ -114,6 +115,7 @@ impl OmikronConnection { connection_id: Uuid::new_v4(), shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), + auth_failure: Arc::new(RwLock::new(None)), app_challenges: Arc::new(RwLock::new(HashMap::new())), app_sessions: Arc::new(RwLock::new(HashMap::new())), } @@ -134,6 +136,11 @@ impl OmikronConnection { handle.abort(); } + if self.shutdown_tx.lock().await.is_none() { + let (shutdown_tx, _) = watch::channel(false); + *self.shutdown_tx.lock().await = Some(shutdown_tx); + } + *self.reconnect_on_close.write().await = true; let self_clone = self.clone(); @@ -191,6 +198,10 @@ impl OmikronConnection { } } Err(e) => { + if self.auth_failure.read().await.is_some() { + log!("Authentication failed, stopping reconnection: {}", e); + break; + } log!( "Connection failed: {}, retrying in {:?}...", e, @@ -256,6 +267,22 @@ impl OmikronConnection { // Handle registration/identification self.handle_authentication().await; + // Wait for identification to complete + if !self.await_identification(Duration::from_secs(30)).await { + *self.reconnect_on_close.write().await = false; + let reason = "Authentication failed: server did not accept the challenge. Your Iota keys may be invalid or the private key has changed on the server." + .to_string(); + *self.auth_failure.write().await = Some(reason.clone()); + + if let Some(sender) = self.sender.write().await.take() { + sender.close(); + } + *self.state.write().await = ConnectionState::Disconnected; + return Err(reason); + } + + log_t!("omikron_authenticated"); + // Start heartbeat let heartbeat_self = self.clone(); let heartbeat_handle = tokio::spawn(async move { @@ -696,11 +723,21 @@ impl OmikronConnection { } if cv.is_type(CommunicationType::identification_response) { - if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { - let mut state = self.state.write().await; - if let ConnectionState::Connected { identified: _ } = *state { - *state = ConnectionState::Connected { identified: true }; + match cv.get_data(DataTypes::accepted).as_bool() { + Some(true) => { + let mut state = self.state.write().await; + if let ConnectionState::Connected { identified: _ } = *state { + *state = ConnectionState::Connected { identified: true }; + } } + Some(false) => { + *self.auth_failure.write().await = Some( + "Server rejected the challenge response — your Iota keys may be invalid." + .to_string(), + ); + log_t!("omikron_auth_rejected"); + } + None => {} } return; } @@ -1360,6 +1397,28 @@ impl OmikronConnection { .add_data(DataTypes::challenge, DataValue::Str(solved)); self.send_message(&response).await; + } else { + log_t!("omikron_challenge_decryption_failed"); + *self.auth_failure.write().await = Some( + "Challenge decryption failed — your Iota private key may not match the registered key on the server." + .to_string(), + ); + } + } + + async fn await_identification(&self, timeout: Duration) -> bool { + let start = Instant::now(); + loop { + if self.state.read().await.is_identified() { + return true; + } + if self.auth_failure.read().await.is_some() { + return false; + } + if start.elapsed() >= timeout { + return false; + } + sleep(Duration::from_millis(100)).await; } } @@ -1521,6 +1580,25 @@ impl OmikronConnection { sleep(Duration::from_millis(100)).await; } } + + pub async fn has_auth_failure(&self) -> bool { + self.auth_failure.read().await.is_some() + } + + pub async fn get_auth_failure(&self) -> Option { + self.auth_failure.read().await.clone() + } + + pub async fn clear_auth_failure(&self) { + *self.auth_failure.write().await = None; + } + + pub async fn reconnect(self: &Arc) { + self.clear_auth_failure().await; + *self.reconnect_on_close.write().await = true; + self.stop().await; + self.connect().await; + } } // ============================================================================ From b910178f35e0188d38f605fbcd49714efea08edf Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 21 May 2026 23:26:16 +0200 Subject: [PATCH 043/119] Reconnection --- communities/src/community.rs | 4 +-- communities/src/community_connection.rs | 32 ++++++++++----------- communities/src/interactables/category.rs | 2 +- communities/src/interactables/text_chat.rs | 2 +- communities/src/interactables/voice_chat.rs | 2 +- omikron-connector/src/omikron_connection.rs | 15 +++++++--- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/communities/src/community.rs b/communities/src/community.rs index 835c88a..fb79ba8 100644 --- a/communities/src/community.rs +++ b/communities/src/community.rs @@ -210,7 +210,7 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - return CommunicationValue::new(CommunicationType::error); + return CommunicationValue::new(CommunicationType::error_internal); } else { // cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161) return interactable.run_function(cv.clone()).await; @@ -231,7 +231,7 @@ impl Community { .run_function(cv.clone()) .await; } else { - return CommunicationValue::new(CommunicationType::error); + return CommunicationValue::new(CommunicationType::error_internal); } } } diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 62df251..5ed5ef8 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -167,7 +167,7 @@ impl CommunityConnection { }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; }; @@ -178,7 +178,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; } @@ -200,7 +200,7 @@ impl CommunityConnection { let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) { Ok(data) => data, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; } @@ -223,7 +223,7 @@ impl CommunityConnection { let client_challenge_response_b64 = match cv.get_data(DataTypes::challenge) { Some(data) => data.to_string(), _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) .await; return; } @@ -232,38 +232,38 @@ impl CommunityConnection { let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) .await; return; } }; if challenge_response_bytes.len() < 12 { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) .await; return; } let Some(user) = self.auth.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; }; let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) .await; return; }; let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_public_key) .await; return; }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; }; @@ -273,7 +273,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; } @@ -297,7 +297,7 @@ impl CommunityConnection { let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) { Ok(pt) => pt, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_challenge) .await; return; } @@ -306,7 +306,7 @@ impl CommunityConnection { let client_response = match String::from_utf8(decrypted_bytes) { Ok(str) => str, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) .await; return; } @@ -315,7 +315,7 @@ impl CommunityConnection { let expected_challenge = self.challenge.read().await.clone(); if client_response != expected_challenge { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_challenge) .await; self.close().await; return; @@ -327,7 +327,7 @@ impl CommunityConnection { } let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_internal) .await; return; }; @@ -335,7 +335,7 @@ impl CommunityConnection { let user_id = self.get_user_id().await; if user_id == 0 { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) .await; return; } diff --git a/communities/src/interactables/category.rs b/communities/src/interactables/category.rs index 6b6415f..c709014 100644 --- a/communities/src/interactables/category.rs +++ b/communities/src/interactables/category.rs @@ -95,7 +95,7 @@ impl Interactable for Category { v } async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue { - CommunicationValue::new(CommunicationType::error) + CommunicationValue::new(CommunicationType::error_internal) } fn to_json(&self) -> JsonValue { let mut v = JsonValue::new_object(); diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index b4d0392..25eb192 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -243,7 +243,7 @@ impl Interactable for TextChat { .add_data_str(DataTypes::result, "message_received".to_string()) .add_data(DataTypes::payload, JsonValue::new_object()); } - CommunicationValue::new(CommunicationType::error).with_id(cv.get_id()) + CommunicationValue::new(CommunicationType::error_internal).with_id(cv.get_id()) } fn to_json(&self) -> JsonValue { JsonValue::new_object() diff --git a/communities/src/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs index 2a2e3a2..8cf0589 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -164,7 +164,7 @@ impl Interactable for VoiceChat { .add_data_str(DataTypes::result, "user_changed".to_string()) .add_data(DataTypes::payload, response_payload); } - CommunicationValue::new(CommunicationType::error).with_id(cv.get_id()) + CommunicationValue::new(CommunicationType::error_internal).with_id(cv.get_id()) } fn to_json(&self) -> JsonValue { diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 558e628..e0c6b9c 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -536,7 +536,7 @@ impl OmikronConnection { } } - let res = CommunicationValue::new(CommunicationType::error) + let res = CommunicationValue::new(CommunicationType::error_invalid_challenge) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; @@ -558,7 +558,7 @@ impl OmikronConnection { } } } - let res = CommunicationValue::new(CommunicationType::error) + let res = CommunicationValue::new(CommunicationType::error_invalid_challenge) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; @@ -1475,7 +1475,7 @@ impl OmikronConnection { for key in keys { if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { - let response = CommunicationValue::new(CommunicationType::error) + let response = CommunicationValue::new(CommunicationType::error_internal) .with_id(key) .add_data(DataTypes::message, DataValue::Str(reason.clone())); let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); @@ -1525,7 +1525,14 @@ impl OmikronConnection { match tokio::time::timeout(timeout, rx.recv()).await { Ok(Some(response_cv)) => { - if response_cv.is_type(CommunicationType::error) { + let resp_type = response_cv.get_type(); + let is_error = resp_type == CommunicationType::error + || resp_type == CommunicationType::error_internal + || resp_type == CommunicationType::error_not_found + || resp_type == CommunicationType::error_invalid_data + || resp_type == CommunicationType::error_invalid_challenge + || resp_type == CommunicationType::error_not_authenticated; + if is_error { let reason = response_cv .get_data(DataTypes::message) .as_str() From 16749bafb3406e7f3f67cb94445d8cf0c6bdf8be Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 22 May 2026 17:40:38 +0200 Subject: [PATCH 044/119] Changes for Dev --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4590333..99105a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,9 +409,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -3675,9 +3675,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4326,7 +4326,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#3e3f939f64088cb01c13348b58c504593953ec0e" +source = "git+https://git.methanium.net/Tensamin/TTP.git#886edbf51d26636e46745c3637b80610df82f5dc" dependencies = [ "base64", "byteorder", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#3e3f939f64088cb01c13348b58c504593953ec0e" +source = "git+https://git.methanium.net/Tensamin/TTP.git#886edbf51d26636e46745c3637b80610df82f5dc" dependencies = [ "quinn", "rustls", From 2193583f005b7038fc5defe06dc89c11edc19a1d Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 23 May 2026 02:43:40 +0200 Subject: [PATCH 045/119] Key Regeneration --- Cargo.lock | 32 +++++++++++++-------------- iota-cli/src/elements/console_card.rs | 16 +++++++------- iota-storage/src/util/config_util.rs | 5 +++++ 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99105a2..d612092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,9 +519,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -2197,9 +2197,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -4571,9 +4571,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4584,9 +4584,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4594,9 +4594,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4604,9 +4604,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4617,9 +4617,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4668,9 +4668,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index d911b05..8c7182c 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -417,10 +417,12 @@ pub async fn run_command(command: &str) { log!("User command usage: user add | user remove | user list"); } ["help", "reconnect"] => { - log!("Reconnect command usage: reconnect — retry connecting to the Omikron server"); + log!("Reconnect command usage: reconnect. Retry connecting to the Omikron server"); } ["help", "regenerate"] => { - log!("Regenerate command usage: regenerate private-key — generate a new Iota key pair and reconnect"); + log!( + "Regenerate command usage: regenerate keys. Generate a new Iota key pair and reconnect" + ); } ["ping"] => { @@ -472,15 +474,13 @@ pub async fn run_command(command: &str) { OMIKRON_CONNECTION.reconnect().await; log!("Reconnected to Omikron server"); } - ["regenerate", "private-key"] => { + ["regenerate", "keys"] => { log!("Regenerating Iota key pair..."); - let key_pair = crypto_helper::generate_keypair(); - let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); - let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); { let mut conf = CONFIG.write().await; - conf.change("public_key", json::JsonValue::from(public_key_base64)); - conf.change("private_key", json::JsonValue::from(private_key_base64)); + conf.remove("public_key"); + conf.remove("private_key"); + conf.remove("iota_id"); conf.update(); } log!("Key pair regenerated. Reconnecting to Omikron server..."); diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 6d4082b..671d2c7 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -67,6 +67,11 @@ impl ConfigUtil { self.unique = true; } + pub fn remove(&mut self, key: &str) { + self.config.remove(key); + self.unique = true; + } + pub fn update(&mut self) { if self.unique { save_file("", "config.json", &self.config.to_string()); From 2b774a45f9ce25985237031c5db433fabcad33ec Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 23 May 2026 19:11:40 +0200 Subject: [PATCH 046/119] [Add] user Deletion --- iota-cli/src/elements/console_card.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 8c7182c..295ef7a 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -442,6 +442,9 @@ pub async fn run_command(command: &str) { } ["user", "remove", username] => { if let Some(user) = user_manager::get_user_by_username(username) { + let msg = CommunicationValue::new(CommunicationType::delete_user) + .with_sender(user.user_id as u64); + OMIKRON_CONNECTION.send_message(&msg).await; user_manager::remove_user(user.user_id); log!("Removed user {}", user.user_id); } else { From a4ec766351c22bb6e3e92c8b3985488c344f9985 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:55:15 +0200 Subject: [PATCH 047/119] Message States, Tauri, Global and Local Settings --- Cargo.lock | 162 ++++----- omikron-connector/src/omikron_connection.rs | 369 ++++++++++++++++---- 2 files changed, 377 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d612092..dfa84c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" dependencies = [ "actix-rt", - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "crossbeam-channel", "futures-core", @@ -31,7 +31,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "futures-core", "futures-sink", @@ -54,7 +54,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.11.1", + "bitflags 2.12.1", "brotli", "bytes", "bytestring", @@ -211,7 +211,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2 0.6.4", "time", "tracing", "url", @@ -474,9 +474,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "block-buffer" @@ -498,9 +498,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -509,9 +509,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -570,9 +570,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -699,9 +699,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -832,7 +832,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "crossterm_winapi", "derive_more", "document-features", @@ -1039,9 +1039,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1421,7 +1421,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.1", "indexmap", "slab", "tokio", @@ -1479,7 +1479,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http 1.4.0", + "http 1.4.1", "httpdate", "mime", "sha1 0.10.6", @@ -1491,7 +1491,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http 1.4.1", ] [[package]] @@ -1543,9 +1543,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1558,7 +1558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.1", ] [[package]] @@ -1569,7 +1569,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body", "pin-project-lite", ] @@ -1597,16 +1597,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body", "httparse", "httpdate", @@ -1623,7 +1623,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.4.1", "hyper", "hyper-util", "rustls", @@ -1642,14 +1642,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -2276,7 +2276,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -2325,9 +2325,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "lru" @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmem" @@ -2419,9 +2419,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -2452,7 +2452,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cfg-if", "cfg_aliases", "libc", @@ -2544,7 +2544,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -2622,7 +2622,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cfg-if", "foreign-types", "libc", @@ -3073,7 +3073,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -3111,7 +3111,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -3239,7 +3239,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3291,7 +3291,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "hashbrown 0.16.1", "indoc", "instability", @@ -3323,7 +3323,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -3363,16 +3363,16 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body", "http-body-util", "hyper", @@ -3429,7 +3429,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3468,7 +3468,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys", @@ -3493,9 +3493,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3609,7 +3609,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3744,9 +3744,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -3831,9 +3831,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3841,9 +3841,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -3976,7 +3976,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4033,7 +4033,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.12.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4177,7 +4177,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -4261,10 +4261,10 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body", "pin-project-lite", "tower", @@ -4326,7 +4326,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#886edbf51d26636e46745c3637b80610df82f5dc" +source = "git+https://git.methanium.net/Tensamin/TTP.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" dependencies = [ "base64", "byteorder", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#886edbf51d26636e46745c3637b80610df82f5dc" +source = "git+https://git.methanium.net/Tensamin/TTP.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" dependencies = [ "quinn", "rustls", @@ -4358,7 +4358,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.1", "httparse", "log", "native-tls", @@ -4369,9 +4369,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4393,9 +4393,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -4468,9 +4468,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -4527,7 +4527,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.0", + "http 1.4.1", "http-body", "http-body-util", "log", @@ -4652,7 +4652,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -5191,7 +5191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.12.1", "indexmap", "log", "serde", @@ -5241,7 +5241,7 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "sha2 0.11.0", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "time", "tokio", @@ -5327,18 +5327,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e0c6b9c..6e18561 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -2,14 +2,13 @@ use dashmap::DashMap; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::{ACTIVE_TASKS, SHUTDOWN}; use iota_storage::users::contact::Contact; -use iota_storage::util::chat_files::{MessageState, change_message_state}; -use iota_storage::util::chats_util::{get_user, mod_user}; +use iota_storage::util::chat_files::{self, MessageState, change_message_state}; +use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; -use iota_storage::util::{chat_files, chats_util}; use iota_util::crypto_helper; use iota_util::crypto_util::{DataFormat, SecurePayload}; -use iota_util::file_util::{get_children, load_file, save_file}; +use iota_util::file_util::{get_children, has_file, load_file, save_file}; use json::JsonValue; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; @@ -21,6 +20,18 @@ use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; use ttp_native::{Policy, Receiver, SendMode, Sender}; use uuid::Uuid; +// Helper function to check if read receipts are enabled globally +async fn is_read_receipts_enabled() -> bool { + // Check global config for read receipts setting + // Default to true if not set + let conf = CONFIG.read().await; + let value = conf.get("read_receipts_enabled"); + match value { + JsonValue::Boolean(b) => *b, + _ => true, + } +} + // ============================================================================ // Configuration // ============================================================================ @@ -933,7 +944,7 @@ impl OmikronConnection { // Attempt delivery and await a response from the local client let user_resp = self .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) + .await_response(&user_forward, Some(Duration::from_secs(3))) .await; if let Ok(user_resp) = user_resp { @@ -959,23 +970,25 @@ impl OmikronConnection { ms.clone(), ); - // notify original sender about the delivered/read state - self.send_message( - &CommunicationValue::new(CommunicationType::message_state) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), - ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; + // notify original sender about the delivered/read state (if read receipts are enabled) + if is_read_receipts_enabled().await { + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(receiver_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } } else { // Delivery failed or timed out; mark as Sent let _ = chat_files::change_message_state( @@ -1072,7 +1085,7 @@ impl OmikronConnection { let user_resp = self .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) + .await_response(&user_forward, Some(Duration::from_secs(3))) .await; if let Ok(user_resp) = user_resp { @@ -1089,22 +1102,25 @@ impl OmikronConnection { ms.clone(), ); - self.send_message( - &CommunicationValue::new(CommunicationType::message_state) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(*sender_id as i64), - ) - .add_data( - DataTypes::message_state, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; + // notify original sender about the delivered/read state (if read receipts are enabled) + if is_read_receipts_enabled().await { + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(*sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } } else { // Delivery timed out/failed — update stored state and notify sender with numeric timestamp let _ = chat_files::change_message_state( @@ -1306,20 +1322,145 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::settings_save) { + if cv.is_type(CommunicationType::global_settings_save) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataTypes::settings_name).as_str().unwrap(); - let settings_value = cv.get_data(DataTypes::payload).as_str().unwrap(); + let Some(settings_value) = cv.get_data(DataTypes::payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing settings payload".to_string()), + ); + self.send_message(&response).await; + return; + }; save_file( - &format!("users/{}/settings/", my_id), + &format!("users/{}", my_id), + "global.settings", + settings_value, + ); + + let mut response = CommunicationValue::new(CommunicationType::global_settings_save) + .with_receiver(my_id) + .with_id(cv.get_id()); + + if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { + response = response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + } + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::global_settings_load) { + let my_id = cv.get_sender(); + let path = format!("users/{}", my_id); + let name = "global.settings"; + + if !has_file(&path, name) { + let mut response = CommunicationValue::new(CommunicationType::error_not_found) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::path, DataValue::Str(name.to_string())); + + if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { + response = + response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + } + + self.send_message(&response).await; + return; + } + + let settings_value_str = load_file(&path, name); + let mut response = CommunicationValue::new(CommunicationType::global_settings_load) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::payload, DataValue::Str(settings_value_str)); + + if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { + response = response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + } + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::settings_save) { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing session_id".to_string()), + ); + self.send_message(&response).await; + return; + }; + let Some(settings_name) = cv.get_data(DataTypes::settings_name).as_str() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + }; + let Some(settings_value) = cv.get_data(DataTypes::payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing settings payload".to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + }; + + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Invalid settings_name".to_string()), + ) + .add_data( + DataTypes::settings_name, + DataValue::Str(settings_name.to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + } + + save_file( + &format!("users/{}/settings/{}/", my_id, session_id), &format!("{}.settings", settings_name), - &settings_value, + settings_value, ); let response = CommunicationValue::new(CommunicationType::settings_save) .with_receiver(my_id) - .with_id(cv.get_id()); + .with_id(cv.get_id()) + .add_data( + DataTypes::settings_name, + DataValue::Str(settings_name.to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); self.send_message(&response).await; return; @@ -1327,16 +1468,76 @@ impl OmikronConnection { if cv.is_type(CommunicationType::settings_load) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataTypes::settings_name).as_string().unwrap(); - let settings_value_str = load_file( - &format!("users/{}/settings/", my_id), - &format!("{}.settings", settings_name), - ); + let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing session_id".to_string()), + ); + self.send_message(&response).await; + return; + }; + let Some(settings_name) = cv.get_data(DataTypes::settings_name).as_str() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + }; + + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Invalid settings_name".to_string()), + ) + .add_data( + DataTypes::settings_name, + DataValue::Str(settings_name.to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + } + + let settings_file = format!("{}.settings", settings_name); + let settings_path = format!("users/{}/settings/{}/", my_id, session_id); + if !has_file(&settings_path, &settings_file) { + let response = CommunicationValue::new(CommunicationType::error_not_found) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::settings_name, + DataValue::Str(settings_name.to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); + self.send_message(&response).await; + return; + } + + let settings_value_str = load_file(&settings_path, &settings_file); let response = CommunicationValue::new(CommunicationType::settings_load) .with_id(cv.get_id()) .with_receiver(my_id) .add_data(DataTypes::payload, DataValue::Str(settings_value_str)) - .add_data(DataTypes::settings_name, DataValue::Str(settings_name)); + .add_data( + DataTypes::settings_name, + DataValue::Str(settings_name.to_string()), + ) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); self.send_message(&response).await; return; @@ -1344,7 +1545,19 @@ impl OmikronConnection { if cv.is_type(CommunicationType::settings_list) { let my_id = cv.get_sender(); - let settings = get_children(&format!("users/{}/settings/", my_id)); + let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { + let response = CommunicationValue::new(CommunicationType::error_invalid_data) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data( + DataTypes::message, + DataValue::Str("Missing session_id".to_string()), + ); + self.send_message(&response).await; + return; + }; + + let settings = get_children(&format!("users/{}/settings/{}/", my_id, session_id)); let mut settings_json = Vec::new(); for s in settings { let s = s.replace(".settings", ""); @@ -1356,7 +1569,8 @@ impl OmikronConnection { let response = CommunicationValue::new(CommunicationType::settings_list) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::settings, DataValue::Array(settings_json)); + .add_data(DataTypes::settings, DataValue::Array(settings_json)) + .add_data(DataTypes::session_id, DataValue::Number(session_id)); self.send_message(&response).await; return; @@ -1365,29 +1579,38 @@ impl OmikronConnection { async fn handle_challenge(&self, cv: &CommunicationValue) { let conf = CONFIG.read().await; - let private_key = conf.get_private_key().unwrap(); + let Some(private_key) = conf.get_private_key() else { + drop(conf); + log_t!("omikron_challenge_decryption_failed"); + *self.auth_failure.write().await = Some( + "Challenge decryption failed: no private key configured on this Iota.".to_string(), + ); + return; + }; drop(conf); - let omikron_public_key = cv.get_data(DataTypes::public_key).as_str().unwrap(); - let encrypted_challenge = cv.get_data(DataTypes::challenge).as_str().unwrap(); - - let solved_challenge = { - if let Ok(decrypted) = SecurePayload::new( - encrypted_challenge, - DataFormat::Base64, - crypto_helper::load_secret_key(&private_key).unwrap(), - ) { - if let Ok(decrypted) = decrypted - .decrypt_x448(crypto_helper::load_public_key(omikron_public_key).unwrap()) - { - Some(decrypted) - } else { - None - } - } else { - None - } + let Some(omikron_public_key) = cv.get_data(DataTypes::public_key).as_str() else { + log_t!("omikron_challenge_decryption_failed"); + return; }; + let Some(encrypted_challenge) = cv.get_data(DataTypes::challenge).as_str() else { + log_t!("omikron_challenge_decryption_failed"); + return; + }; + + let Some(secret_key) = crypto_helper::load_secret_key(&private_key) else { + log_t!("omikron_challenge_decryption_failed"); + return; + }; + let Some(pub_key) = crypto_helper::load_public_key(omikron_public_key) else { + log_t!("omikron_challenge_decryption_failed"); + return; + }; + + let solved_challenge = + SecurePayload::new(encrypted_challenge, DataFormat::Base64, secret_key) + .ok() + .and_then(|decrypted| decrypted.decrypt_x448(pub_key).ok()); if let Some(decrypted) = solved_challenge { let solved = decrypted.export(DataFormat::Raw); From 9e948ce6cf262f7519a986f00879957fd12f4e6c Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 4 Jun 2026 12:25:28 +0200 Subject: [PATCH 048/119] Update nix flake, update config options --- flake.lock | 20 +++++++-------- flake.nix | 72 ++++++++++++++---------------------------------------- 2 files changed, 29 insertions(+), 63 deletions(-) diff --git a/flake.lock b/flake.lock index 7c708a8..0d30539 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1778869304, - "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", + "lastModified": 1780243769, + "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", "owner": "nixos", "repo": "nixpkgs", - "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", + "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", "type": "github" }, "original": { @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1779247103, - "narHash": "sha256-DwltBoBl9a7fCzlKi3xnNha1NHbfvawwkNdnTXEyfFQ=", + "lastModified": 1780543271, + "narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "86dbfb70dc1c2967245d87ed6d07d2c8bda305e3", + "rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514", "type": "github" }, "original": { @@ -80,11 +80,11 @@ "ttp": { "flake": false, "locked": { - "lastModified": 1779304128, - "narHash": "sha256-C/z7JV4RxcuhdYE7vS+x4WJA1rXM/w03kjNfPPRi334=", + "lastModified": 1780494955, + "narHash": "sha256-i2VRRF6yNips3c4JHgfvmvMxb0HTkTCn69lmsKZLHRw=", "ref": "refs/heads/main", - "rev": "3e3f939f64088cb01c13348b58c504593953ec0e", - "revCount": 122, + "rev": "23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2", + "revCount": 125, "type": "git", "url": "https://git.methanium.net/tensamin/ttp.git" }, diff --git a/flake.nix b/flake.nix index 8cf53d7..24f3f07 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,6 @@ systems = [ "x86_64-linux" "aarch64-linux" - "x86_64-darwin" "aarch64-darwin" ]; @@ -96,7 +95,6 @@ else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); descriptionText = "Tensamin Iota"; - #+ lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)"; in { options.services.iota = { enable = lib.mkEnableOption "Enable the Iota service."; @@ -132,16 +130,24 @@ description = "Whether to open the firewall for ports used by Iota."; }; - ttpBind = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the TTP/QUIC server to."; - }; - bindAddress = lib.mkOption { type = lib.types.str; default = "0.0.0.0"; - description = "IP address to bind the HTTP server to."; + description = "IP address to bind the Iota HTTP and TTP/QUIC servers to."; + }; + + web = { + bindAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "IP address to bind the ttyd terminal server to."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 7681; + description = "Port to bind the ttyd terminal server to."; + }; }; package = lib.mkOption { @@ -150,12 +156,6 @@ description = "The Iota package to use."; }; - useTmux = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to run Iota inside a tmux session for shared TUI access."; - }; - settings = lib.mkOption { type = lib.types.attrs; default = {}; @@ -181,19 +181,7 @@ users.groups.iota = {}; - systemd.services.iota = let - iotaTmuxCmd = pkgs.writeShellScript "iota-tmux-cmd" '' - mkdir -p ${cfg.dataDir} - echo "[$(date)] Running Iota..." - ${cfg.package}/bin/iota - status=$? - echo "" - echo "[$(date)] Iota exited with status: $status" - echo "Press any key to exit..." - read -r -n 1 - exit $status - ''; - in { + systemd.services.iota = { description = descriptionText; wantedBy = ["multi-user.target"]; after = ["network.target"]; @@ -205,29 +193,7 @@ Group = "iota"; WorkingDirectory = cfg.dataDir; - ExecStart = - if cfg.useTmux - then - pkgs.writeShellScript "iota-start" '' - set -e - export TMUX_TMPDIR=${cfg.dataDir} - ${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDir} - ${pkgs.coreutils}/bin/chown iota:iota ${cfg.dataDir} - - echo "[iota-start] Creating tmux session..." - if ! ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota "${iotaTmuxCmd}"; then - echo "[iota-start] ERROR: tmux new-session failed" - exit 1 - fi - echo "[iota-start] tmux session created, waiting..." - echo "[iota-start] Run 'tmux -S ${cfg.dataDir}/tmux.sock attach -t iota' to attach to the tmux session." - - while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do - sleep 2 - done - echo "[iota-start] tmux session ended" - '' - else "${cfg.package}/bin/iota"; + ExecStart = "${pkgs.ttyd}/bin/ttyd -W -i ${cfg.web.bindAddress} -p ${toString cfg.web.port} ${cfg.package}/bin/iota"; ExecStartPre = [ ("+" @@ -262,7 +228,7 @@ LockPersonality = true; MemoryDenyWriteExecute = true; Environment = [ - "TTP_BIND=${cfg.ttpBind}" + "TTP_BIND=${cfg.bindAddress}" "BIND_ADDRESS=${cfg.bindAddress}" ]; } @@ -272,7 +238,7 @@ }; networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [1984]; + allowedTCPPorts = [1984 cfg.web.port]; allowedUDPPorts = [1984]; }; }; From d517144562f29589e47ef1f262ed1c62de2e823b Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 4 Jun 2026 22:39:14 +0200 Subject: [PATCH 049/119] Persist ttyd iota using tmux --- flake.nix | 153 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 20 deletions(-) diff --git a/flake.nix b/flake.nix index 24f3f07..be637c5 100644 --- a/flake.nix +++ b/flake.nix @@ -39,28 +39,43 @@ inherit system; overlays = [(import rust-overlay)]; }; + rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"]; }; in { packages = { default = self'.packages.iota; + iota = pkgs.rustPlatform.buildRustPackage { pname = "iota"; version = "0.1.0"; src = ./.; + cargoLock = { lockFile = ./Cargo.lock; allowBuiltinFetchGit = true; }; - nativeBuildInputs = with pkgs; [cmake perl pkg-config]; - buildInputs = with pkgs; [openssl sqlite]; + + nativeBuildInputs = with pkgs; [ + cmake + perl + pkg-config + ]; + + buildInputs = with pkgs; [ + openssl + sqlite + ]; + dontUseCmakeConfigure = true; + preConfigure = '' if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json fi ''; + postInstall = '' mv $out/bin/iota-core $out/bin/iota for f in $out/bin/*; do @@ -69,13 +84,24 @@ fi done ''; + passthru.dataDir = "/var/lib/iota"; }; }; devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config]; - buildInputs = with pkgs; [openssl sqlite]; + nativeBuildInputs = with pkgs; [ + rustToolchain + git + cmake + perl + pkg-config + ]; + + buildInputs = with pkgs; [ + openssl + sqlite + ]; }; }; @@ -87,7 +113,10 @@ ... }: let cfg = config.services.iota; - defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + + defaultPackage = + self.packages.${pkgs.stdenv.hostPlatform.system}.default + or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); configFile = if cfg.settingsFile != null @@ -95,6 +124,61 @@ else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); descriptionText = "Tensamin Iota"; + + tmuxSessionName = "iota"; + tmuxSocket = "${cfg.dataDir}/tmux.sock"; + + setupScript = pkgs.writeShellScript "iota-setup" '' + set -euo pipefail + + mkdir -p ${lib.escapeShellArg cfg.dataDir}/certs + + ${lib.optionalString (cfg.certFile != null) '' + ln -sf ${lib.escapeShellArg cfg.certFile} ${lib.escapeShellArg cfg.dataDir}/certs/cert.pem + ''} + + ${lib.optionalString (cfg.keyFile != null) '' + ln -sf ${lib.escapeShellArg cfg.keyFile} ${lib.escapeShellArg cfg.dataDir}/certs/cert.key + ''} + + install -m 644 ${lib.escapeShellArg configFile} ${lib.escapeShellArg cfg.dataDir}/config.json + + chown -R iota:iota ${lib.escapeShellArg cfg.dataDir} + ''; + + iotaSessionScript = pkgs.writeShellScript "iota-tmux-session" '' + set -euo pipefail + + socket=${lib.escapeShellArg tmuxSocket} + session=${lib.escapeShellArg tmuxSessionName} + + if ! ${pkgs.tmux}/bin/tmux -S "$socket" has-session -t "$session" 2>/dev/null; then + ${pkgs.tmux}/bin/tmux -S "$socket" new-session \ + -d \ + -s "$session" \ + -c ${lib.escapeShellArg cfg.dataDir} \ + ${lib.escapeShellArg "${cfg.package}/bin/iota"} + fi + + while ${pkgs.tmux}/bin/tmux -S "$socket" has-session -t "$session" 2>/dev/null; do + sleep 5 + done + + exit 1 + ''; + + ttydScript = pkgs.writeShellScript "iota-ttyd" '' + set -euo pipefail + + exec ${pkgs.ttyd}/bin/ttyd \ + -W \ + -i ${lib.escapeShellArg cfg.web.bindAddress} \ + -p ${lib.escapeShellArg (toString cfg.web.port)} \ + ${pkgs.tmux}/bin/tmux \ + -S ${lib.escapeShellArg tmuxSocket} \ + attach-session \ + -t ${lib.escapeShellArg tmuxSessionName} + ''; in { options.services.iota = { enable = lib.mkEnableOption "Enable the Iota service."; @@ -181,8 +265,8 @@ users.groups.iota = {}; - systemd.services.iota = { - description = descriptionText; + systemd.services.iota-session = { + description = "${descriptionText} tmux session"; wantedBy = ["multi-user.target"]; after = ["network.target"]; @@ -193,22 +277,16 @@ Group = "iota"; WorkingDirectory = cfg.dataDir; - ExecStart = "${pkgs.ttyd}/bin/ttyd -W -i ${cfg.web.bindAddress} -p ${toString cfg.web.port} ${cfg.package}/bin/iota"; - ExecStartPre = [ - ("+" - + pkgs.writeShellScript "iota-setup" '' - mkdir -p ${cfg.dataDir}/certs - - ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} - ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} - - install -m 644 ${configFile} ${cfg.dataDir}/config.json - - chown -R iota:iota ${cfg.dataDir} - '') + ("+" + setupScript) ]; + ExecStart = iotaSessionScript; + + ExecStop = '' + ${pkgs.tmux}/bin/tmux -S ${lib.escapeShellArg tmuxSocket} kill-session -t ${lib.escapeShellArg tmuxSessionName} + ''; + Restart = "always"; RestartSec = "5s"; @@ -237,6 +315,41 @@ }; }; + systemd.services.iota = { + description = descriptionText; + wantedBy = ["multi-user.target"]; + requires = ["iota-session.service"]; + after = ["iota-session.service" "network.target"]; + + serviceConfig = { + Type = "simple"; + User = "iota"; + Group = "iota"; + WorkingDirectory = cfg.dataDir; + + ExecStart = ttydScript; + + Restart = "always"; + RestartSec = "5s"; + + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; + CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + ReadWritePaths = [cfg.dataDir]; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + }; + }; + networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [1984 cfg.web.port]; allowedUDPPorts = [1984]; From b2a22456ff88a5ab26d63213c7b6aee31e009881 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:44:58 +0200 Subject: [PATCH 050/119] Initial MTP Migration [Broken] --- .cargo/config.toml | 2 + Cargo.lock | 807 +++++++++--------- client/Cargo.toml | 3 +- client/src/client_connection.rs | 383 +++++---- communities/Cargo.toml | 3 +- communities/src/community.rs | 6 +- communities/src/community_connection.rs | 72 +- communities/src/interactables/category.rs | 242 +++--- communities/src/interactables/interactable.rs | 2 +- communities/src/interactables/text_chat.rs | 528 ++++++------ communities/src/interactables/voice_chat.rs | 374 ++++---- flake.nix | 2 +- iota-auth/Cargo.toml | 3 +- iota-cli/Cargo.toml | 3 +- iota-cli/src/elements/console_card.rs | 6 +- iota-core/Cargo.toml | 3 +- iota-logger/Cargo.toml | 2 +- iota-logger/src/lib.rs | 26 +- iota-state/Cargo.toml | 2 +- iota-storage/Cargo.toml | 3 +- iota-updater/Cargo.toml | 3 +- iota-util/Cargo.toml | 3 +- omikron-connector/Cargo.toml | 4 +- omikron-connector/src/omikron_connection.rs | 682 ++++++++------- omikron-connector/src/ping_pong_task.rs | 12 +- omikron-connector/src/user_ops.rs | 24 +- other-iota/Cargo.toml | 3 +- type-maps.yaml | 241 ++++++ web-server/Cargo.toml | 3 +- web-ui/Cargo.toml | 13 +- 30 files changed, 1886 insertions(+), 1574 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 type-maps.yaml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d363b83 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true } diff --git a/Cargo.lock b/Cargo.lock index dfa84c5..1fff38f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" dependencies = [ "actix-rt", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "crossbeam-channel", "futures-core", @@ -31,7 +31,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-sink", @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.1" +version = "3.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" dependencies = [ "actix-codec", "actix-rt", @@ -54,14 +54,14 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", "flate2", - "foldhash 0.1.5", + "foldhash", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -89,7 +89,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -154,7 +154,7 @@ dependencies = [ "actix-service", "actix-utils", "futures-core", - "impl-more", + "impl-more 0.1.9", "pin-project-lite", "rustls-pki-types", "tokio", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" dependencies = [ "actix-codec", "actix-http", @@ -195,10 +195,10 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "futures-util", - "impl-more", + "impl-more 0.3.1", "itoa", "language-tags", "log", @@ -244,7 +244,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -305,9 +305,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -329,9 +329,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arbitrary" @@ -366,7 +375,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -378,7 +387,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -389,7 +398,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -474,9 +483,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -489,18 +498,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -509,9 +518,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -523,6 +532,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -537,9 +552,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bytestring" @@ -570,9 +585,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -594,9 +609,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -605,9 +620,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -651,6 +666,7 @@ dependencies = [ "iota-util", "json", "lazy_static", + "mtp", "once_cell", "open", "pnet", @@ -668,8 +684,6 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", - "ttp-core", - "ttp-native", "tungstenite", "uuid", "walkdir", @@ -811,6 +825,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -832,7 +852,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -912,7 +932,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -923,7 +943,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -977,9 +997,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_arbitrary" @@ -989,7 +1006,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1011,7 +1028,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -1032,7 +1049,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", ] @@ -1045,7 +1062,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1136,6 +1153,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -1194,12 +1217,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1292,7 +1309,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1363,16 +1380,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1412,16 +1427,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap", "slab", "tokio", @@ -1435,15 +1450,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1452,7 +1458,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1460,12 +1466,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -1479,7 +1490,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http 1.4.1", + "http 1.4.2", "httpdate", "mime", "sha1 0.10.6", @@ -1491,7 +1502,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -1543,9 +1554,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1558,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -1569,7 +1580,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -1588,9 +1599,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -1605,8 +1616,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -1623,7 +1634,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -1642,7 +1653,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -1763,12 +1774,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1802,6 +1807,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "impl-more" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" + [[package]] name = "indexmap" version = "2.14.0" @@ -1810,8 +1821,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -1842,7 +1851,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1867,6 +1876,7 @@ dependencies = [ "iota-util", "json", "lazy_static", + "mtp", "once_cell", "open", "pnet", @@ -1884,8 +1894,6 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", - "ttp-core", - "ttp-native", "tungstenite", "uuid", "walkdir", @@ -1919,6 +1927,7 @@ dependencies = [ "iota-util", "json", "lazy_static", + "mtp", "omikron-connector", "once_cell", "open", @@ -1937,8 +1946,6 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", - "ttp-core", - "ttp-native", "tungstenite", "uuid", "walkdir", @@ -1960,14 +1967,13 @@ dependencies = [ "iota-updater", "iota-util", "json", + "mtp", "omikron-connector", "once_cell", "pnet", "ratatui", "reqwest", "tokio", - "ttp-core", - "ttp-native", "web-server", "web-ui", ] @@ -1979,9 +1985,9 @@ dependencies = [ "iota-state", "iota-util", "json", + "mtp", "once_cell", "ratatui", - "ttp-core", ] [[package]] @@ -1990,10 +1996,10 @@ version = "0.1.0" dependencies = [ "dashmap", "json", + "mtp", "once_cell", "sysinfo", "tokio", - "ttp-core", ] [[package]] @@ -2008,6 +2014,7 @@ dependencies = [ "iota-state", "iota-util", "json", + "mtp", "once_cell", "rand 0.8.6", "rand_core 0.6.4", @@ -2017,8 +2024,6 @@ dependencies = [ "sha2 0.10.9", "sysinfo", "tokio", - "ttp-core", - "ttp-native", "uuid", "walkdir", "x448", @@ -2046,6 +2051,7 @@ dependencies = [ "hkdf", "iota-logger", "json", + "mtp", "once_cell", "pnet", "rand_core 0.6.4", @@ -2058,8 +2064,6 @@ dependencies = [ "sysinfo", "tempfile", "tokio", - "ttp-core", - "ttp-native", "uuid", "walkdir", "x448", @@ -2074,13 +2078,12 @@ dependencies = [ "base64", "hex", "hkdf", + "mtp", "rand_core 0.6.4", "reqwest", "sha2 0.10.9", "sysinfo", "tokio", - "ttp-core", - "ttp-native", "uuid", "walkdir", "x448", @@ -2163,7 +2166,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2182,7 +2185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2197,13 +2200,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2242,12 +2244,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -2260,6 +2256,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -2276,7 +2278,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -2325,17 +2327,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2366,9 +2368,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmem" @@ -2429,6 +2431,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mtp" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +dependencies = [ + "mtp-codec", + "mtp-common", + "mtp-transport", + "mtp-type-map", +] + +[[package]] +name = "mtp-codec" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +dependencies = [ + "base64", + "byteorder", + "mtp-common", + "mtp-type-map", + "rand 0.8.6", +] + +[[package]] +name = "mtp-common" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +dependencies = [ + "quinn", + "rustls", + "thiserror 2.0.18", + "wtransport", +] + +[[package]] +name = "mtp-transport" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +dependencies = [ + "log", + "mtp-codec", + "mtp-common", + "rustls", + "rustls-native-certs", + "tokio", + "wtransport", +] + +[[package]] +name = "mtp-type-map" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +dependencies = [ + "serde", + "serde_yaml", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2452,7 +2511,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2508,7 +2567,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2544,7 +2603,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -2584,11 +2643,11 @@ dependencies = [ "iota-storage", "iota-util", "json", + "mtp", + "rand 0.8.6", "rand_core 0.6.4", "sha2 0.10.9", "tokio", - "ttp-core", - "ttp-native", "uuid", "x448", ] @@ -2618,11 +2677,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -2638,7 +2697,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2649,9 +2708,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2693,6 +2752,7 @@ dependencies = [ "iota-util", "json", "lazy_static", + "mtp", "once_cell", "open", "pnet", @@ -2710,8 +2770,6 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", - "ttp-core", - "ttp-native", "tungstenite", "uuid", "walkdir", @@ -2720,6 +2778,30 @@ dependencies = [ "zip", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2805,7 +2887,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2858,7 +2940,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2887,7 +2969,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2947,7 +3029,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3041,16 +3123,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3062,9 +3134,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3082,9 +3154,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", @@ -3118,9 +3190,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3165,7 +3237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3221,32 +3293,36 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", - "strum 0.27.2", + "palette", + "serde", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -3255,9 +3331,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -3267,19 +3343,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3287,18 +3374,19 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.16.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", - "strum 0.27.2", + "serde", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width", @@ -3323,14 +3411,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3357,9 +3445,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3371,8 +3459,8 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -3429,7 +3517,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3468,7 +3556,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3477,9 +3565,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -3609,7 +3697,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3670,7 +3758,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3698,6 +3786,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3815,9 +3916,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -3874,15 +3975,15 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] [[package]] name = "strum_macros" @@ -3893,7 +3994,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3905,7 +4006,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3927,9 +4028,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -3953,7 +4054,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3976,7 +4077,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3998,12 +4099,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -4033,7 +4147,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4093,7 +4207,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4104,17 +4218,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -4126,15 +4239,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -4190,7 +4303,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4261,10 +4374,10 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", "tower", @@ -4305,7 +4418,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4323,33 +4436,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttp-core" -version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" -dependencies = [ - "base64", - "byteorder", - "rand 0.8.6", - "serde_json", - "strum 0.28.0", - "strum_macros 0.28.0", -] - -[[package]] -name = "ttp-native" -version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" -dependencies = [ - "quinn", - "rustls", - "rustls-native-certs", - "thiserror 2.0.18", - "tokio", - "ttp-core", - "wtransport", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -4358,7 +4444,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "native-tls", @@ -4430,6 +4516,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" @@ -4468,12 +4560,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -4527,7 +4619,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "log", @@ -4553,27 +4645,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4584,9 +4667,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4594,9 +4677,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4604,73 +4687,38 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-server" version = "0.1.0" dependencies = [ - "ttp-core", - "ttp-native", + "mtp", ] [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4710,6 +4758,7 @@ dependencies = [ "iota-util", "json", "lazy_static", + "mtp", "omikron-connector", "once_cell", "open", @@ -4728,8 +4777,6 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", - "ttp-core", - "ttp-native", "tungstenite", "uuid", "walkdir", @@ -4740,9 +4787,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -4903,7 +4950,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4914,7 +4961,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5127,100 +5174,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.12.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5304,9 +5263,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5321,28 +5280,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5362,28 +5321,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5416,7 +5375,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5448,9 +5407,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" [[package]] name = "zmij" diff --git a/client/Cargo.toml b/client/Cargo.toml index f38c0b4..72cb4d6 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index ea238a5..56bdf6a 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -10,14 +10,25 @@ use iota_storage::util::{chat_files, chats_util}; use iota_util::crypto_helper; use iota_util::crypto_util::{DataFormat, SecurePayload}; use iota_util::file_util::{get_children, load_file, save_file}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::transport::{Receiver, Sender}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tokio::task::JoinHandle; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; -use ttp_native::{Receiver, Sender}; use uuid::Uuid; +fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { + use mtp::type_map::{DataTypeId, TypeMap}; + let tm = TypeMap::latest(); + DataValue::Container( + items + .into_iter() + .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) + .collect(), + ) +} + // ============================================================================ // Waiting Task System // ============================================================================ @@ -97,76 +108,76 @@ impl ClientConnection { // ------------------------------------------------------------------------- async fn handle_ping(self: Arc, cv: CommunicationValue) { // Update our ping if provided - if let DataValue::Number(last_ping) = cv.get_data(DataTypes::last_ping) { + if let DataValue::SignedNumber(last_ping) = cv.get_data(DataType::LastPing) { let current = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis(); let mut ping_guard = self.ping.write().await; - *ping_guard = current as i64 - last_ping; + *ping_guard = current as i64 - *last_ping as i64; } // Send pong response - let response = CommunicationValue::new(CommunicationType::pong) + let response = CommunicationValue::new(CommunicationType::Pong) .with_id(cv.get_id()) - .add_data(DataTypes::ping_iota, DataValue::Number(0)); + .add_typed_default(DataType::PingIota, DataValue::SignedNumber(0)); self.send_message(&response).await; } pub async fn handle_message(self: Arc, cv: CommunicationValue) { - if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { log_cv_in!(&cv); } let _msg_id = cv.get_id(); - if cv.is_type(CommunicationType::ping) { + if cv.is_type(CommunicationType::Ping) { self.handle_ping(cv).await; return; } - if cv.is_type(CommunicationType::challenge) { + if cv.is_type(CommunicationType::Challenge) { self.handle_challenge(&cv).await; return; } - if cv.is_type(CommunicationType::save_app_data) { + if cv.is_type(CommunicationType::SaveAppData) { let sender_id = cv.get_sender(); let _app_data = cv - .get_data(DataTypes::app_data) + .get_data(DataType::AppData) .as_str() .unwrap_or("") .to_string(); - let res = CommunicationValue::new(CommunicationType::save_app_data) + let res = CommunicationValue::new(CommunicationType::SaveAppData) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::load_app_data) { + if cv.is_type(CommunicationType::LoadAppData) { let sender_id = cv.get_sender(); let app_data = String::new(); - let res = CommunicationValue::new(CommunicationType::load_app_data) + let res = CommunicationValue::new(CommunicationType::LoadAppData) .with_id(cv.get_id()) .with_receiver(sender_id) - .add_data(DataTypes::app_data, DataValue::Str(app_data)); + .add_typed_default(DataType::AppData, DataValue::Str(app_data)); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::create_app) { + if cv.is_type(CommunicationType::CreateApp) { let sender_id = cv.get_sender() as i64; let app_identifier = cv - .get_data(DataTypes::app_identifier) + .get_data(DataType::AppIdentifier) .as_str() .unwrap_or("") .to_string(); let app_public_key = cv - .get_data(DataTypes::app_public_key) + .get_data(DataType::AppPublicKey) .as_str() .unwrap_or("") .to_string(); @@ -180,17 +191,17 @@ impl ClientConnection { } } - let res = CommunicationValue::new(CommunicationType::create_app) + let res = CommunicationValue::new(CommunicationType::CreateApp) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::delete_app) { + if cv.is_type(CommunicationType::DeleteApp) { let sender_id = cv.get_sender() as i64; let app_identifier = cv - .get_data(DataTypes::app_identifier) + .get_data(DataType::AppIdentifier) .as_str() .unwrap_or("") .to_string(); @@ -204,30 +215,30 @@ impl ClientConnection { } } - let res = CommunicationValue::new(CommunicationType::delete_app) + let res = CommunicationValue::new(CommunicationType::DeleteApp) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::client_connected) { - let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; - let _session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; + if cv.is_type(CommunicationType::ClientConnected) { + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; let contacts = chats_util::get_users(user_id); let mut contacts_array = Vec::new(); for (i, contact) in contacts.iter().enumerate() { let mut contact_container = Vec::new(); - contact_container.push((DataTypes::user_id, DataValue::Number(contact.user_id))); + contact_container.push((DataType::UserId, DataValue::SignedNumber(contact.user_id as i128))); contact_container.push(( - DataTypes::last_message_at, - DataValue::Number(contact.last_message_at.unwrap_or(0)), + DataType::LastMessageAt, + DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), )); if let Some(ref name) = contact.user_name { - contact_container.push((DataTypes::username, DataValue::Str(name.clone()))); + contact_container.push((DataType::Username, DataValue::Str(name.clone()))); } let amount = if i < 10 { 20 } else { 1 }; @@ -242,12 +253,12 @@ impl ClientConnection { let message_state = m["message_state"].as_str().unwrap_or("").to_string(); let mut msg_container = Vec::new(); - msg_container.push((DataTypes::send_time, DataValue::Number(message_time))); - msg_container.push((DataTypes::content, DataValue::Str(content.clone()))); - msg_container.push((DataTypes::message_state, DataValue::Str(message_state))); - msg_container.push((DataTypes::height, DataValue::Number(height))); - msg_container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); - msg_array.push(DataValue::Container(msg_container)); + msg_container.push((DataType::SendTime, DataValue::SignedNumber(message_time as i128))); + msg_container.push((DataType::Content, DataValue::Str(content.clone()))); + msg_container.push((DataType::MessageState, DataValue::Str(message_state))); + msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + msg_container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_array.push(typed_container(msg_container)); if msg_array.len() == 1 { let sender_id = if sent_by_self { @@ -256,19 +267,19 @@ impl ClientConnection { contact.user_id }; let mut last_msg = Vec::new(); - last_msg.push((DataTypes::content, DataValue::Str(content))); - last_msg.push((DataTypes::sender_id, DataValue::Number(sender_id))); + last_msg.push((DataType::Content, DataValue::Str(content))); + last_msg.push((DataType::SenderId, DataValue::SignedNumber(sender_id as i128))); contact_container - .push((DataTypes::last_message, DataValue::Container(last_msg))); + .push((DataType::LastMessage, typed_container(last_msg))); } } - contact_container.push((DataTypes::messages, DataValue::Array(msg_array))); - contacts_array.push(DataValue::Container(contact_container)); + contact_container.push((DataType::Messages, DataValue::Array(msg_array))); + contacts_array.push(typed_container(contact_container)); } - let resp = CommunicationValue::new(CommunicationType::client_connected) + let resp = CommunicationValue::new(CommunicationType::ClientConnected) .with_id(cv.get_id()) - .add_data(DataTypes::contacts, DataValue::Array(contacts_array)); + .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); self.send_message(&resp).await; return; } @@ -277,15 +288,15 @@ impl ClientConnection { // Direct messages // // ************************************************ // - if cv.is_type(CommunicationType::message_state) { + if cv.is_type(CommunicationType::MessageState) { let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { Some(id) => id, _ => return, }; // Parse send_time robustly: accept numeric or string, fallback to current time - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -302,28 +313,25 @@ impl ClientConnection { timestamp_i64, receiver_id as i64, *sender_id as i64, - MessageState::from_str( - cv.get_data(DataTypes::message_state).as_str().unwrap_or(""), - ), + MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), ); } // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. - if cv.is_type(CommunicationType::message_send) { + if cv.is_type(CommunicationType::MessageSend) { let sender_id: u64 = cv.get_sender(); // parse receiver_id (the storage owner for this incoming message) - let receiver_id: i64 = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() - { + let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { n as i64 - } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { + } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { s.parse::().unwrap_or(0) } else { 0 }; // parse send_time robustly (number or string), fallback to now - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -339,12 +347,12 @@ impl ClientConnection { // content may be missing; default to empty string let content = cv - .get_data(DataTypes::content) + .get_data(DataType::Content) .as_str() .unwrap_or("") .to_string(); - let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); @@ -371,19 +379,19 @@ impl ClientConnection { ); // send confirmation back to sender - let conf_msg = CommunicationValue::new(CommunicationType::message_send) + let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&conf_msg).await; if !is_local { - let fw_msg = CommunicationValue::new(CommunicationType::message_other_iota) + let fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) .with_sender(sender_id as u64) - .add_data(DataTypes::height, DataValue::Number(height)) - .add_data(DataTypes::content, DataValue::Str(content)) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)); + .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) + .add_typed_default(DataType::Content, DataValue::Str(content)) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)); let other_iota_resp = self .clone() @@ -392,7 +400,7 @@ impl ClientConnection { if let Ok(resp) = other_iota_resp { let ms_raw = resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -405,17 +413,17 @@ impl ClientConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -429,17 +437,17 @@ impl ClientConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -448,16 +456,16 @@ impl ClientConnection { return; } else { // Build a live-delivery message for the local client (recipient) - let user_forward = CommunicationValue::new(CommunicationType::message_live) + let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) - .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) - .add_data( - DataTypes::message, - DataValue::Container(vec![ - (DataTypes::content, DataValue::Str(content.clone())), - (DataTypes::send_time, DataValue::Number(timestamp_i64)), - (DataTypes::height, DataValue::Number(height)), + .add_typed_default(DataType::SenderId, DataValue::SignedNumber(sender_id as i128)) + .add_typed_default( + DataType::Message, + typed_container(vec![ + (DataType::Content, DataValue::Str(content.clone())), + (DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)), + (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -469,7 +477,7 @@ impl ClientConnection { if let Ok(user_resp) = user_resp { let ms_raw = user_resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -492,17 +500,17 @@ impl ClientConnection { // notify original sender about the delivered/read state self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -525,17 +533,17 @@ impl ClientConnection { // notify sender self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -545,12 +553,12 @@ impl ClientConnection { } } - if cv.is_type(CommunicationType::message_other_iota) { + if cv.is_type(CommunicationType::MessageOtherIota) { let sender_id = &cv.get_sender(); let receiver_id = &cv.get_receiver(); // parse send_time safely (number or string), fallback to now - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -565,12 +573,12 @@ impl ClientConnection { // content may be missing or non-string; default to empty string let content = cv - .get_data(DataTypes::content) + .get_data(DataType::Content) .as_str() .unwrap_or("") .to_string(); - let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; chat_files::add_message( timestamp as u128, @@ -582,16 +590,16 @@ impl ClientConnection { ); // Build user_forward using the parsed numeric timestamp and safe content string - let user_forward = CommunicationValue::new(CommunicationType::message_live) + let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(*receiver_id) - .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) - .add_data( - DataTypes::message, - DataValue::Container(vec![ - (DataTypes::content, DataValue::Str(content.clone())), - (DataTypes::send_time, DataValue::Number(timestamp)), - (DataTypes::height, DataValue::Number(height)), + .add_typed_default(DataType::SenderId, DataValue::SignedNumber(*sender_id as i128)) + .add_typed_default( + DataType::Message, + typed_container(vec![ + (DataType::Content, DataValue::Str(content.clone())), + (DataType::SendTime, DataValue::SignedNumber(timestamp as i128)), + (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -602,7 +610,7 @@ impl ClientConnection { if let Ok(user_resp) = user_resp { let ms_raw = user_resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -615,17 +623,17 @@ impl ClientConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(*sender_id as i64), + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*sender_id as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -640,17 +648,17 @@ impl ClientConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(*receiver_id as i64), + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*receiver_id as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -659,12 +667,12 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::messages_get) { + if cv.is_type(CommunicationType::MessagesGet) { let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0); - let offset = cv.get_data(DataTypes::offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataTypes::amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages(my_id as i64, partner_id, offset, amount); + let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); + let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages(my_id as i64, partner_id as i64, offset as i64, amount as i64); let mut msg_array: Vec = Vec::new(); for m in messages.members() { let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); @@ -674,9 +682,9 @@ impl ClientConnection { let sender_id: i64 = if sent_by_self { my_id as i64 } else { - if let Some(n) = cv.get_data(DataTypes::chat_partner_id).as_number() { + if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { n as i64 - } else if let Some(s) = cv.get_data(DataTypes::chat_partner_id).as_str() { + } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { s.parse::().unwrap_or(partner_id as i64) } else { partner_id as i64 @@ -685,53 +693,53 @@ impl ClientConnection { let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); let mut container = Vec::new(); - container.push((DataTypes::send_time, DataValue::Number(message_time))); - container.push((DataTypes::content, DataValue::Str(content))); - container.push((DataTypes::sender_id, DataValue::Number(sender_id))); - container.push((DataTypes::message_state, DataValue::Str(message_state))); - container.push((DataTypes::height, DataValue::Number(height))); - container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); - msg_array.push(DataValue::Container(container)); + container.push((DataType::SendTime, DataValue::SignedNumber(message_time as i128))); + container.push((DataType::Content, DataValue::Str(content))); + container.push((DataType::SenderId, DataValue::SignedNumber(sender_id as i128))); + container.push((DataType::MessageState, DataValue::Str(message_state))); + container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::messages_get) + let resp = CommunicationValue::new(CommunicationType::MessagesGet) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::messages, DataValue::Array(msg_array)); + .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::get_chats) { + if cv.is_type(CommunicationType::GetChats) { let user_id = cv.get_sender(); let users = chats_util::get_users(user_id as i64); let mut user_array = Vec::new(); for user in users { let mut container = Vec::new(); - container.push((DataTypes::user_id, DataValue::Number(user.user_id))); + container.push((DataType::UserId, DataValue::SignedNumber(user.user_id as i128))); if let Some(name) = user.user_name { - container.push((DataTypes::username, DataValue::Str(name))); + container.push((DataType::Username, DataValue::Str(name))); } if let Some(ts) = user.last_message_at { - container.push((DataTypes::last_message_at, DataValue::Number(ts))); + container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); } - user_array.push(DataValue::Container(container)); + user_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::get_chats) + let resp = CommunicationValue::new(CommunicationType::GetChats) .with_id(cv.get_id()) .with_receiver(user_id) - .add_data(DataTypes::user_ids, DataValue::Array(user_array)); + .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::add_conversation) { + if cv.is_type(CommunicationType::AddConversation) { let user_id = cv.get_sender(); - let other_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { Some(n) => n as i64, None => cv - .get_data(DataTypes::chat_partner_id) + .get_data(DataType::ChatPartnerId) .as_str() .unwrap_or("0") .parse() @@ -739,7 +747,7 @@ impl ClientConnection { }; let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); - if let Some(name) = cv.get_data(DataTypes::chat_partner_name).as_str() { + if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { contact.user_name = Some(name.to_string()); } @@ -750,85 +758,82 @@ impl ClientConnection { .as_millis() as i64, ); mod_user(user_id as i64, &contact); - let resp = CommunicationValue::new(CommunicationType::add_conversation) + let resp = CommunicationValue::new(CommunicationType::AddConversation) .with_id(cv.get_id()) .with_receiver(user_id); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::add_community) { + if cv.is_type(CommunicationType::AddCommunity) { CommunitiesUtil::add_community( cv.get_sender() as i64, - cv.get_data(DataTypes::community_address) + cv.get_data(DataType::CommunityAddress) .as_str() .unwrap() .to_string(), - cv.get_data(DataTypes::community_title) + cv.get_data(DataType::CommunityTitle) .as_str() .unwrap() .to_string(), - cv.get_data(DataTypes::position) + cv.get_data(DataType::Position) .as_str() .unwrap() .to_string(), ); - let resp = CommunicationValue::new(CommunicationType::add_community) + let resp = CommunicationValue::new(CommunicationType::AddCommunity) .with_id(cv.get_id()) .with_receiver(cv.get_sender()); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::get_communities) { + if cv.is_type(CommunicationType::GetCommunities) { let mut comm_array = Vec::new(); for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataTypes, DataValue)> = Vec::new(); + let mut container: Vec<(DataType, DataValue)> = Vec::new(); if let Some(address) = c["address"].as_str() { container.push(( - DataTypes::community_address, + DataType::CommunityAddress, DataValue::Str(address.to_string()), )); } if let Some(title) = c["title"].as_str() { - container.push(( - DataTypes::community_title, - DataValue::Str(title.to_string()), - )); + container.push((DataType::CommunityTitle, DataValue::Str(title.to_string()))); } if let Some(position) = c["position"].as_str() { - container.push((DataTypes::position, DataValue::Str(position.to_string()))); + container.push((DataType::Position, DataValue::Str(position.to_string()))); } - comm_array.push(DataValue::Container(container)); + comm_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::get_communities) + let resp = CommunicationValue::new(CommunicationType::GetCommunities) .with_id(cv.get_id()) .with_receiver(cv.get_sender()) - .add_data(DataTypes::communities, DataValue::Array(comm_array)); + .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::remove_community) { + if cv.is_type(CommunicationType::RemoveCommunity) { CommunitiesUtil::remove_community( cv.get_sender() as i64, - cv.get_data(DataTypes::community_address) + cv.get_data(DataType::CommunityAddress) .as_str() .unwrap() .to_string(), ); - let resp = CommunicationValue::new(CommunicationType::remove_community) + let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) .with_id(cv.get_id()) .with_receiver(cv.get_sender()); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::settings_save) { + if cv.is_type(CommunicationType::SettingsSave) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataTypes::settings_name).as_str().unwrap(); - let settings_value = cv.get_data(DataTypes::payload).as_str().unwrap(); + let settings_name = cv.get_data(DataType::SettingsName).as_str().unwrap(); + let settings_value = cv.get_data(DataType::Payload).as_str().unwrap(); save_file( &format!("users/{}/settings/", my_id), @@ -836,7 +841,7 @@ impl ClientConnection { &settings_value, ); - let response = CommunicationValue::new(CommunicationType::settings_save) + let response = CommunicationValue::new(CommunicationType::SettingsSave) .with_receiver(my_id) .with_id(cv.get_id()); @@ -844,24 +849,24 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::settings_load) { + if cv.is_type(CommunicationType::SettingsLoad) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataTypes::settings_name).as_string().unwrap(); + let settings_name = cv.get_data(DataType::SettingsName).as_string().unwrap(); let settings_value_str = load_file( &format!("users/{}/settings/", my_id), &format!("{}.settings", settings_name), ); - let response = CommunicationValue::new(CommunicationType::settings_load) + let response = CommunicationValue::new(CommunicationType::SettingsLoad) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::payload, DataValue::Str(settings_value_str)) - .add_data(DataTypes::settings_name, DataValue::Str(settings_name)); + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) + .add_typed_default(DataType::SettingsName, DataValue::Str(settings_name)); self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::settings_list) { + if cv.is_type(CommunicationType::SettingsList) { let my_id = cv.get_sender(); let settings = get_children(&format!("users/{}/settings/", my_id)); let mut settings_json = Vec::new(); @@ -872,10 +877,10 @@ impl ClientConnection { } let _ = settings_json.push(DataValue::Str(s)); } - let response = CommunicationValue::new(CommunicationType::settings_list) + let response = CommunicationValue::new(CommunicationType::SettingsList) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::settings, DataValue::Array(settings_json)); + .add_typed_default(DataType::Settings, DataValue::Array(settings_json)); self.send_message(&response).await; return; @@ -887,8 +892,8 @@ impl ClientConnection { let private_key = conf.get_private_key().unwrap(); drop(conf); - let omikron_public_key = cv.get_data(DataTypes::public_key).as_str().unwrap(); - let encrypted_challenge = cv.get_data(DataTypes::challenge).as_str().unwrap(); + let omikron_public_key = cv.get_data(DataType::PublicKey).as_str().unwrap(); + let encrypted_challenge = cv.get_data(DataType::Challenge).as_str().unwrap(); let solved_challenge = { if let Ok(decrypted) = SecurePayload::new( @@ -911,9 +916,9 @@ impl ClientConnection { if let Some(decrypted) = solved_challenge { let solved = decrypted.export(DataFormat::Raw); - let response = CommunicationValue::new(CommunicationType::challenge_response) + let response = CommunicationValue::new(CommunicationType::ChallengeResponse) .with_id(cv.get_id()) - .add_data(DataTypes::challenge, DataValue::Str(solved)); + .add_typed_default(DataType::Challenge, DataValue::Str(solved)); self.send_message(&response).await; } @@ -943,7 +948,7 @@ impl ClientConnection { let sender_clone = Arc::clone(sender); drop(sender_guard); - if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { log_cv_out!(&cv); } diff --git a/communities/Cargo.toml b/communities/Cargo.toml index b930ed5..812ff55 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/communities/src/community.rs b/communities/src/community.rs index fb79ba8..69ba273 100644 --- a/communities/src/community.rs +++ b/communities/src/community.rs @@ -210,7 +210,7 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - return CommunicationValue::new(CommunicationType::error_internal); + return CommunicationValue::new(CommunicationType::ErrorInternal); } else { // cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161) return interactable.run_function(cv.clone()).await; @@ -231,12 +231,12 @@ impl Community { .run_function(cv.clone()) .await; } else { - return CommunicationValue::new(CommunicationType::error_internal); + return CommunicationValue::new(CommunicationType::ErrorInternal); } } } } - CommunicationValue::new(CommunicationType::add_conversation) + CommunicationValue::new(CommunicationType::AddConversation) } pub async fn save(&self) { diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 5ed5ef8..3ceca2b 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -70,12 +70,12 @@ impl CommunityConnection { let user_id = self.get_user_id().await; cv = cv.with_sender(user_id); - if cv.is_type(CommunicationType::identification) && !self.is_identified().await { + if cv.is_type(CommunicationType::Identification) && !self.is_identified().await { self.handle_identification(cv).await; return; } - if cv.is_type(CommunicationType::challenge_response) && !self.is_identified().await { + if cv.is_type(CommunicationType::ChallengeResponse) && !self.is_identified().await { self.handle_challenge_response(cv).await; return; } @@ -84,25 +84,25 @@ impl CommunityConnection { return; } - if cv.is_type(CommunicationType::ping) { + if cv.is_type(CommunicationType::Ping) { self.handle_ping(cv).await; return; } - if cv.is_type(CommunicationType::client_changed) { + if cv.is_type(CommunicationType::ClientChanged) { //self.handle_client_changed(cv).await; return; } - if cv.is_type(CommunicationType::function) { + if cv.is_type(CommunicationType::Function) { self.handle_function(cv).await; return; } } async fn handle_function(&self, cv: CommunicationValue) { - let name = cv.get_data(DataTypes::name).unwrap().as_str().unwrap(); - let path = cv.get_data(DataTypes::path).unwrap().as_str().unwrap(); - let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap(); + let name = cv.get_data(DataType::Name).unwrap().as_str().unwrap(); + let path = cv.get_data(DataType::Path).unwrap().as_str().unwrap(); + let function = cv.get_data(DataType::Function).unwrap().as_str().unwrap(); let result = self .get_community() @@ -115,13 +115,13 @@ impl CommunityConnection { } async fn handle_identification(&self, cv: CommunicationValue) { let user_id = cv - .get_data(DataTypes::user_id) + .get_data(DataType::UserId) .unwrap_or(&JsonValue::Number(Number::from(0))) .as_i64() .unwrap_or(0); let Some(user) = get_user(user_id) else { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) .await; return; }; @@ -151,7 +151,7 @@ impl CommunityConnection { let user_public_key_bytes = match STANDARD.decode(&user.public_key) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) .await; return; } @@ -160,14 +160,14 @@ impl CommunityConnection { let user_pub_key: PublicKey = match PublicKey::from_bytes(&user_public_key_bytes) { Some(key) => key, __ => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) .await; return; } }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; }; @@ -178,7 +178,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; } @@ -200,7 +200,7 @@ impl CommunityConnection { let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) { Ok(data) => data, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; } @@ -209,21 +209,21 @@ impl CommunityConnection { let mut encrypted_out = nonce_bytes.to_vec(); encrypted_out.extend(encrypted_challenge); - let response = CommunicationValue::new(CommunicationType::challenge) + let response = CommunicationValue::new(CommunicationType::Challenge) .add_data_str( - DataTypes::public_key, + DataType::PublicKey, STANDARD.encode(community_public_key.as_bytes()), ) - .add_data_str(DataTypes::challenge, STANDARD.encode(&encrypted_out)) + .add_data_str(DataType::Challenge, STANDARD.encode(&encrypted_out)) .with_id(cv.get_id()); self.send_message(&response).await; } async fn handle_challenge_response(self: Arc, cv: CommunicationValue) { - let client_challenge_response_b64 = match cv.get_data(DataTypes::challenge) { + let client_challenge_response_b64 = match cv.get_data(DataType::Challenge) { Some(data) => data.to_string(), _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) .await; return; } @@ -232,38 +232,38 @@ impl CommunityConnection { let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) .await; return; } }; if challenge_response_bytes.len() < 12 { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) .await; return; } let Some(user) = self.auth.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; }; let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) .await; return; }; let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_public_key) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidPublicKey) .await; return; }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; }; @@ -273,7 +273,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; } @@ -297,7 +297,7 @@ impl CommunityConnection { let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) { Ok(pt) => pt, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_challenge) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidChallenge) .await; return; } @@ -306,7 +306,7 @@ impl CommunityConnection { let client_response = match String::from_utf8(decrypted_bytes) { Ok(str) => str, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_data) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) .await; return; } @@ -315,7 +315,7 @@ impl CommunityConnection { let expected_challenge = self.challenge.read().await.clone(); if client_response != expected_challenge { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_challenge) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidChallenge) .await; self.close().await; return; @@ -327,7 +327,7 @@ impl CommunityConnection { } let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error_internal) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) .await; return; }; @@ -335,15 +335,15 @@ impl CommunityConnection { let user_id = self.get_user_id().await; if user_id == 0 { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) .await; return; } arc.add_connection(self.clone()).await; - let response = CommunicationValue::new(CommunicationType::identification_response) - .add_data(DataTypes::interactables, { + let response = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_data(DataType::Interactables, { let a: Vec>> = arc.get_interactables(user_id).await; let mut c: JsonValue = JsonValue::new_object(); for b in a { @@ -382,14 +382,14 @@ impl CommunityConnection { } async fn handle_ping(&self, cv: CommunicationValue) { - if let Some(last_ping) = cv.get_data(DataTypes::last_ping) { + if let Some(last_ping) = cv.get_data(DataType::LastPing) { if let Ok(ping_val) = last_ping.to_string().parse::() { let mut ping_guard = self.ping.write().await; *ping_guard = ping_val; } } - let response = CommunicationValue::new(CommunicationType::pong).with_id(cv.get_id()); + let response = CommunicationValue::new(CommunicationType::Pong).with_id(cv.get_id()); self.send_message(&response).await; } diff --git a/communities/src/interactables/category.rs b/communities/src/interactables/category.rs index c709014..f2836ed 100644 --- a/communities/src/interactables/category.rs +++ b/communities/src/interactables/category.rs @@ -1,121 +1,121 @@ -use crate::communities::{community::Community, interactables::interactable::Interactable}; -use async_trait::async_trait; -use json::JsonValue; -use std::any::Any; -use std::sync::Arc; -use ttp_core::CommunicationValue; -use uuid::Uuid; - -pub struct Category { - id: Uuid, - name: String, - path: String, - community: Arc, - children: Vec>>, -} -impl Category { - pub fn new() -> Category { - Category { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - children: Vec::new(), - } - } - pub fn get_child(&self, path: String, name: String) -> Option>> { - if path.is_empty() { - self.children - .iter() - .find(|child| child.get_name() == &name) - .cloned() - } else { - let sub_module = path.split("/").next().unwrap(); - let next = self - .children - .iter() - .find(|child| child.get_name() == sub_module) - .unwrap(); - if next.get_codec() == "category" { - let next_cat = next.as_any().downcast_ref::().unwrap(); - next_cat.get_child(path, name) - } else { - Some(next.clone()) - } - } - } - pub fn get_children(&self) -> Vec>> { - self.children.iter().map(|child| child.clone()).collect() - } -} - -#[async_trait] -impl Interactable for Category { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "category".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - let mut v = JsonValue::new_object(); - for child in &self.children { - let mut subject = JsonValue::new_object(); - subject["codec"] = JsonValue::String(child.get_codec()); - subject["data"] = child.get_data(); - v[child.get_name()] = subject; - } - v - } - async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue { - CommunicationValue::new(CommunicationType::error_internal) - } - fn to_json(&self) -> JsonValue { - let mut v = JsonValue::new_object(); - v["children"] = JsonValue::new_array(); - for child in &self.children { - let _ = v["children"].push(child.to_json()); - } - v - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::communities::{community::Community, interactables::interactable::Interactable}; +use async_trait::async_trait; +use json::JsonValue; +use std::any::Any; +use std::sync::Arc; +use mtp::codec::CommunicationValue; +use uuid::Uuid; + +pub struct Category { + id: Uuid, + name: String, + path: String, + community: Arc, + children: Vec>>, +} +impl Category { + pub fn new() -> Category { + Category { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + children: Vec::new(), + } + } + pub fn get_child(&self, path: String, name: String) -> Option>> { + if path.is_empty() { + self.children + .iter() + .find(|child| child.get_name() == &name) + .cloned() + } else { + let sub_module = path.split("/").next().unwrap(); + let next = self + .children + .iter() + .find(|child| child.get_name() == sub_module) + .unwrap(); + if next.get_codec() == "category" { + let next_cat = next.as_any().downcast_ref::().unwrap(); + next_cat.get_child(path, name) + } else { + Some(next.clone()) + } + } + } + pub fn get_children(&self) -> Vec>> { + self.children.iter().map(|child| child.clone()).collect() + } +} + +#[async_trait] +impl Interactable for Category { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "category".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + let mut v = JsonValue::new_object(); + for child in &self.children { + let mut subject = JsonValue::new_object(); + subject["codec"] = JsonValue::String(child.get_codec()); + subject["data"] = child.get_data(); + v[child.get_name()] = subject; + } + v + } + async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue { + CommunicationValue::new(CommunicationType::ErrorInternal) + } + fn to_json(&self) -> JsonValue { + let mut v = JsonValue::new_object(); + v["children"] = JsonValue::new_array(); + for child in &self.children { + let _ = v["children"].push(child.to_json()); + } + v + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/communities/src/interactables/interactable.rs b/communities/src/interactables/interactable.rs index 0521de6..dd7f326 100644 --- a/communities/src/interactables/interactable.rs +++ b/communities/src/interactables/interactable.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use json::JsonValue; use std::any::Any; use std::sync::Arc; -use ttp_core::CommunicationValue; +use mtp::codec::CommunicationValue; use uuid::Uuid; pub type InteractableFactory = fn() -> Box; diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index 25eb192..967f27b 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -1,264 +1,264 @@ -use crate::{ - communities::{ - community::Community, community_connection::CommunityConnection, - interactables::interactable::Interactable, - }, - log, - util::file_util::{get_children, load_file, save_file}, -}; -use async_trait::async_trait; -use json::{JsonValue, array, object}; -use std::fs; -use std::path::Path; -use std::sync::Arc; -use std::{any::Any, collections::HashMap}; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes}; -use uuid::Uuid; -pub struct TextChat { - id: Uuid, - name: String, - path: String, - community: Arc, -} -impl TextChat { - pub fn new() -> TextChat { - TextChat { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - } - } - pub fn add_message(&self, send_time: u128, sender: i64, message: &str) { - let user_dir = &format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - ); - - let working_dir = iota_util::file_util::get_directory(); - let full_dir = Path::new(&working_dir).join(user_dir); - if let Err(e) = fs::create_dir_all(&full_dir) { - log!("Failed to create chat directory: {}", e); - return; - } - - let mut chunk_index = 0; - let mut message_chunk = array![]; - - // find latest chunk not full (max 800 msgs) - loop { - let file_name = format!("msgs_{}.json", chunk_index); - let file_content = load_file(&user_dir, &file_name); - - if !file_content.is_empty() { - if let Ok(current_chunk) = json::parse(&file_content) { - if current_chunk.is_array() && current_chunk.len() < 800 { - message_chunk = current_chunk; - break; - } - } else { - log!("Failed to parse existing JSON file: {}", file_name); - } - } else { - break; - } - - chunk_index += 1; - if chunk_index > 1000 { - log!("Too many message chunks. Aborting add."); - return; - } - } - - let json_obj = object! { - "timestamp" => send_time as i64, - "content" => message, - "sender" => sender.to_string(), - }; - - if let Err(e) = message_chunk.push(json_obj) { - log!("Failed to push new message into JSON array: {}", e); - return; - } - - let file_name = format!("msgs_{}.json", chunk_index); - log!("Saving message to {}/{}", user_dir, file_name); - save_file(&user_dir, &file_name, &message_chunk.dump()); - } - pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue { - let mut messages = array![]; - - let mut latest_chunk_index: i32 = -1; - let files = get_children(&format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - )); - - for entry in files { - if let Some(num) = { - entry - .strip_prefix("msgs_") - .and_then(|s| s.strip_suffix(".json")) - } { - if let Ok(index) = num.parse::() { - if index > latest_chunk_index { - latest_chunk_index = index; - } - } - } - } - - if latest_chunk_index == -1 { - return messages; - } - - let mut to_skip = loaded_messages; - let mut needed = amount; - - for chunk_index in (0..=latest_chunk_index).rev() { - if needed == 0 { - break; - } - let file_name = format!("msgs_{}.json", chunk_index); - let file_content = load_file( - &format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - ), - &file_name, - ); - if file_content.is_empty() { - continue; - } - if let Ok(chunk) = json::parse(&file_content) { - for i in (0..chunk.len()).rev() { - if needed == 0 { - break; - } - if to_skip > 0 { - to_skip -= 1; - continue; - } - messages.push(chunk[i].clone()).unwrap(); - needed -= 1; - } - } - } - - messages - } -} -#[async_trait] -impl Interactable for TextChat { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "text".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - JsonValue::new_object() - } - async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { - let payload = cv.get_data(DataTypes::payload).as_container().unwrap(); - if cv.get_data(DataTypes::function).as_str().unwrap() == "get_messages" { - let amount = payload.get(DataTypes::amount).as_i64().unwrap(); - let loaded_messages = payload["loaded_messages"].as_i64().unwrap(); - let messages = self.get_messages(loaded_messages, amount).clone(); - let mut payload = JsonValue::new_object(); - payload["messages"] = messages; - return CommunicationValue::new(CommunicationType::function) - .with_id(cv.get_id()) - .add_data_str(DataTypes::name, self.name.clone()) - .add_data_str(DataTypes::path, self.path.clone()) - .add_data_str(DataTypes::result, "message_chunk".to_string()) - .add_data(DataTypes::payload, payload); - } - if cv.get_data(DataTypes::function).unwrap().as_str().unwrap() == "send_message" { - let message = payload["message"].as_str().unwrap(); - let milliseconds_timestamp: u128 = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - self.add_message(milliseconds_timestamp, cv.get_sender(), message); - - let mut distribution_payload = JsonValue::new_object(); - distribution_payload["message"] = JsonValue::String(message.to_string()); - distribution_payload["sender_id"] = JsonValue::String(cv.get_sender().to_string()); - distribution_payload["send_time"] = - JsonValue::String(milliseconds_timestamp.to_string()); - let distribution = CommunicationValue::new(CommunicationType::update) - .with_id(cv.get_id()) - .add_data_str(DataTypes::name, self.name.clone()) - .add_data_str(DataTypes::path, self.path.clone()) - .add_data_str(DataTypes::result, "message_live".to_string()) - .add_data(DataTypes::payload, distribution_payload); - - let connections: HashMap>> = - self.get_community().get_connections().await.clone(); - - for con in connections.values() { - for c in con { - let cd: &Arc = c; - cd.send_message(&distribution).await; - } - } - return CommunicationValue::new(CommunicationType::function) - .with_id(cv.get_id()) - .add_data_str(DataTypes::name, self.name.clone()) - .add_data_str(DataTypes::path, self.path.clone()) - .add_data_str(DataTypes::result, "message_received".to_string()) - .add_data(DataTypes::payload, JsonValue::new_object()); - } - CommunicationValue::new(CommunicationType::error_internal).with_id(cv.get_id()) - } - fn to_json(&self) -> JsonValue { - JsonValue::new_object() - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::{ + communities::{ + community::Community, community_connection::CommunityConnection, + interactables::interactable::Interactable, + }, + log, + util::file_util::{get_children, load_file, save_file}, +}; +use async_trait::async_trait; +use json::{JsonValue, array, object}; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use std::{any::Any, collections::HashMap}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType}; +use uuid::Uuid; +pub struct TextChat { + id: Uuid, + name: String, + path: String, + community: Arc, +} +impl TextChat { + pub fn new() -> TextChat { + TextChat { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + } + } + pub fn add_message(&self, send_time: u128, sender: i64, message: &str) { + let user_dir = &format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + ); + + let working_dir = iota_util::file_util::get_directory(); + let full_dir = Path::new(&working_dir).join(user_dir); + if let Err(e) = fs::create_dir_all(&full_dir) { + log!("Failed to create chat directory: {}", e); + return; + } + + let mut chunk_index = 0; + let mut message_chunk = array![]; + + // find latest chunk not full (max 800 msgs) + loop { + let file_name = format!("msgs_{}.json", chunk_index); + let file_content = load_file(&user_dir, &file_name); + + if !file_content.is_empty() { + if let Ok(current_chunk) = json::parse(&file_content) { + if current_chunk.is_array() && current_chunk.len() < 800 { + message_chunk = current_chunk; + break; + } + } else { + log!("Failed to parse existing JSON file: {}", file_name); + } + } else { + break; + } + + chunk_index += 1; + if chunk_index > 1000 { + log!("Too many message chunks. Aborting add."); + return; + } + } + + let json_obj = object! { + "timestamp" => send_time as i64, + "content" => message, + "sender" => sender.to_string(), + }; + + if let Err(e) = message_chunk.push(json_obj) { + log!("Failed to push new message into JSON array: {}", e); + return; + } + + let file_name = format!("msgs_{}.json", chunk_index); + log!("Saving message to {}/{}", user_dir, file_name); + save_file(&user_dir, &file_name, &message_chunk.dump()); + } + pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue { + let mut messages = array![]; + + let mut latest_chunk_index: i32 = -1; + let files = get_children(&format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + )); + + for entry in files { + if let Some(num) = { + entry + .strip_prefix("msgs_") + .and_then(|s| s.strip_suffix(".json")) + } { + if let Ok(index) = num.parse::() { + if index > latest_chunk_index { + latest_chunk_index = index; + } + } + } + } + + if latest_chunk_index == -1 { + return messages; + } + + let mut to_skip = loaded_messages; + let mut needed = amount; + + for chunk_index in (0..=latest_chunk_index).rev() { + if needed == 0 { + break; + } + let file_name = format!("msgs_{}.json", chunk_index); + let file_content = load_file( + &format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + ), + &file_name, + ); + if file_content.is_empty() { + continue; + } + if let Ok(chunk) = json::parse(&file_content) { + for i in (0..chunk.len()).rev() { + if needed == 0 { + break; + } + if to_skip > 0 { + to_skip -= 1; + continue; + } + messages.push(chunk[i].clone()).unwrap(); + needed -= 1; + } + } + } + + messages + } +} +#[async_trait] +impl Interactable for TextChat { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "text".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + JsonValue::new_object() + } + async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { + let payload = cv.get_data(DataType::Payload).as_container().unwrap(); + if cv.get_data(DataType::Function).as_str().unwrap() == "get_messages" { + let amount = payload.get(DataType::Amount).as_i64().unwrap(); + let loaded_messages = payload["loaded_messages"].as_i64().unwrap(); + let messages = self.get_messages(loaded_messages, amount).clone(); + let mut payload = JsonValue::new_object(); + payload["messages"] = messages; + return CommunicationValue::new(CommunicationType::Function) + .with_id(cv.get_id()) + .add_data_str(DataType::Name, self.name.clone()) + .add_data_str(DataType::Path, self.path.clone()) + .add_data_str(DataType::Result, "message_chunk".to_string()) + .add_data(DataType::Payload, payload); + } + if cv.get_data(DataType::Function).unwrap().as_str().unwrap() == "send_message" { + let message = payload["message"].as_str().unwrap(); + let milliseconds_timestamp: u128 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + self.add_message(milliseconds_timestamp, cv.get_sender(), message); + + let mut distribution_payload = JsonValue::new_object(); + distribution_payload["message"] = JsonValue::String(message.to_string()); + distribution_payload["sender_id"] = JsonValue::String(cv.get_sender().to_string()); + distribution_payload["send_time"] = + JsonValue::String(milliseconds_timestamp.to_string()); + let distribution = CommunicationValue::new(CommunicationType::Update) + .with_id(cv.get_id()) + .add_data_str(DataType::Name, self.name.clone()) + .add_data_str(DataType::Path, self.path.clone()) + .add_data_str(DataType::Result, "message_live".to_string()) + .add_data(DataType::Payload, distribution_payload); + + let connections: HashMap>> = + self.get_community().get_connections().await.clone(); + + for con in connections.values() { + for c in con { + let cd: &Arc = c; + cd.send_message(&distribution).await; + } + } + return CommunicationValue::new(CommunicationType::Function) + .with_id(cv.get_id()) + .add_data_str(DataType::Name, self.name.clone()) + .add_data_str(DataType::Path, self.path.clone()) + .add_data_str(DataType::Result, "message_received".to_string()) + .add_data(DataType::Payload, JsonValue::new_object()); + } + CommunicationValue::new(CommunicationType::ErrorInternal).with_id(cv.get_id()) + } + fn to_json(&self) -> JsonValue { + JsonValue::new_object() + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/communities/src/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs index 8cf0589..5e83181 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -1,187 +1,187 @@ -use crate::communities::{community::Community, interactables::interactable::Interactable}; -use async_trait::async_trait; -use json::JsonValue; -use std::sync::Arc; -use std::{any::Any, sync::RwLock}; -use uuid::Uuid; -pub enum CallUserState { - Active, - Muted, - Deafed, -} -impl CallUserState { - pub fn parse(state: &str) -> CallUserState { - match state { - "active" => CallUserState::Active, - "muted" => CallUserState::Muted, - "deafed" => CallUserState::Deafed, - _ => CallUserState::Active, - } - } - pub fn to_string(&self) -> String { - match self { - CallUserState::Active => "active".to_string(), - CallUserState::Muted => "muted".to_string(), - CallUserState::Deafed => "deafed".to_string(), - } - } -} - -pub struct CallUser { - pub user_id: Uuid, - pub user_state: CallUserState, - pub streaming: bool, -} - -pub struct VoiceChat { - id: Uuid, - name: String, - path: String, - community: Arc, - users: RwLock>, -} -impl VoiceChat { - pub fn new() -> VoiceChat { - VoiceChat { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - users: RwLock::new(Vec::new()), - } - } - pub fn update_user_state( - self: Arc, - user_id: Uuid, - state: CallUserState, - streaming: bool, - ) { - if let Some(user) = self - .users - .write() - .unwrap() - .iter_mut() - .find(|u| u.user_id == user_id) - { - user.user_state = state; - user.streaming = streaming; - } - } -} -#[async_trait] -impl Interactable for VoiceChat { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "voice".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - let mut data = JsonValue::new_object(); - let mut active_users = JsonValue::new_object(); - for user in self.users.read().unwrap().iter() { - let mut user_data = JsonValue::new_object(); - let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string())); - let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming)); - let _ = active_users.insert(&user.user_id.to_string(), user_data); - } - let _ = data.insert("active_users", active_users); - data - } - async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { - let payload = cv.get_data(DataTypes::payload).unwrap(); - let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap(); - - if function == "get_call" { - let sender_id = payload["sender_id"].as_str().unwrap(); - let message_id = payload["message"].as_str().unwrap(); - let send_time = payload["send_time"].as_str().unwrap(); - - let mut response_payload = JsonValue::new_object(); - response_payload["sender_id"] = JsonValue::String(sender_id.to_string()); - response_payload["message"] = JsonValue::String(message_id.to_string()); - response_payload["send_time"] = JsonValue::String(send_time.to_string()); - - return CommunicationValue::new(CommunicationType::function) - .with_id(cv.get_id()) - .add_data_str(DataTypes::name, self.name.clone()) - .add_data_str(DataTypes::path, self.path.clone()) - .add_data_str(DataTypes::result, "getting_call".to_string()) - .add_data(DataTypes::payload, response_payload); - } - - if function == "update_user_state" { - let user_id = payload["user_id"].as_str().unwrap(); - let state = payload["state"].as_str().unwrap(); - let streaming = payload["streaming"].as_bool().unwrap(); - - if let Some(user) = self - .users - .write() - .unwrap() - .iter_mut() - .find(|u| u.user_id == Uuid::parse_str(user_id).unwrap()) - { - user.user_state = CallUserState::parse(state); - user.streaming = streaming; - } - let mut response_payload = JsonValue::new_object(); - response_payload["user_id"] = JsonValue::String(user_id.to_string()); - response_payload["state"] = JsonValue::String(state.to_string()); - response_payload["streaming"] = JsonValue::Boolean(streaming); - - return CommunicationValue::new(CommunicationType::update) - .with_id(cv.get_id()) - .add_data_str(DataTypes::name, self.name.clone()) - .add_data_str(DataTypes::path, self.path.clone()) - .add_data_str(DataTypes::result, "user_changed".to_string()) - .add_data(DataTypes::payload, response_payload); - } - CommunicationValue::new(CommunicationType::error_internal).with_id(cv.get_id()) - } - - fn to_json(&self) -> JsonValue { - let v = JsonValue::new_object(); - v - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::communities::{community::Community, interactables::interactable::Interactable}; +use async_trait::async_trait; +use json::JsonValue; +use std::sync::Arc; +use std::{any::Any, sync::RwLock}; +use uuid::Uuid; +pub enum CallUserState { + Active, + Muted, + Deafed, +} +impl CallUserState { + pub fn parse(state: &str) -> CallUserState { + match state { + "active" => CallUserState::Active, + "muted" => CallUserState::Muted, + "deafed" => CallUserState::Deafed, + _ => CallUserState::Active, + } + } + pub fn to_string(&self) -> String { + match self { + CallUserState::Active => "active".to_string(), + CallUserState::Muted => "muted".to_string(), + CallUserState::Deafed => "deafed".to_string(), + } + } +} + +pub struct CallUser { + pub user_id: Uuid, + pub user_state: CallUserState, + pub streaming: bool, +} + +pub struct VoiceChat { + id: Uuid, + name: String, + path: String, + community: Arc, + users: RwLock>, +} +impl VoiceChat { + pub fn new() -> VoiceChat { + VoiceChat { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + users: RwLock::new(Vec::new()), + } + } + pub fn update_user_state( + self: Arc, + user_id: Uuid, + state: CallUserState, + streaming: bool, + ) { + if let Some(user) = self + .users + .write() + .unwrap() + .iter_mut() + .find(|u| u.user_id == user_id) + { + user.user_state = state; + user.streaming = streaming; + } + } +} +#[async_trait] +impl Interactable for VoiceChat { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "voice".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + let mut data = JsonValue::new_object(); + let mut active_users = JsonValue::new_object(); + for user in self.users.read().unwrap().iter() { + let mut user_data = JsonValue::new_object(); + let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string())); + let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming)); + let _ = active_users.insert(&user.user_id.to_string(), user_data); + } + let _ = data.insert("active_users", active_users); + data + } + async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { + let payload = cv.get_data(DataType::Payload).unwrap(); + let function = cv.get_data(DataType::Function).unwrap().as_str().unwrap(); + + if function == "get_call" { + let sender_id = payload["sender_id"].as_str().unwrap(); + let message_id = payload["message"].as_str().unwrap(); + let send_time = payload["send_time"].as_str().unwrap(); + + let mut response_payload = JsonValue::new_object(); + response_payload["sender_id"] = JsonValue::String(sender_id.to_string()); + response_payload["message"] = JsonValue::String(message_id.to_string()); + response_payload["send_time"] = JsonValue::String(send_time.to_string()); + + return CommunicationValue::new(CommunicationType::Function) + .with_id(cv.get_id()) + .add_data_str(DataType::Name, self.name.clone()) + .add_data_str(DataType::Path, self.path.clone()) + .add_data_str(DataType::Result, "getting_call".to_string()) + .add_data(DataType::Payload, response_payload); + } + + if function == "update_user_state" { + let user_id = payload["user_id"].as_str().unwrap(); + let state = payload["state"].as_str().unwrap(); + let streaming = payload["streaming"].as_bool().unwrap(); + + if let Some(user) = self + .users + .write() + .unwrap() + .iter_mut() + .find(|u| u.user_id == Uuid::parse_str(user_id).unwrap()) + { + user.user_state = CallUserState::parse(state); + user.streaming = streaming; + } + let mut response_payload = JsonValue::new_object(); + response_payload["user_id"] = JsonValue::Number(user_id); + response_payload["state"] = JsonValue::String(state.to_string()); + response_payload["streaming"] = JsonValue::Boolean(streaming); + + return CommunicationValue::new(CommunicationType::Update) + .with_id(cv.get_id()) + .add_data_str(DataType::Name, self.name.clone()) + .add_data_str(DataType::Path, self.path.clone()) + .add_data_str(DataType::Result, "user_changed".to_string()) + .add_data(DataType::Payload, response_payload); + } + CommunicationValue::new(CommunicationType::ErrorInternal).with_id(cv.get_id()) + } + + fn to_json(&self) -> JsonValue { + let v = JsonValue::new_object(); + v + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/flake.nix b/flake.nix index 8cf53d7..5fc6085 100644 --- a/flake.nix +++ b/flake.nix @@ -262,7 +262,7 @@ LockPersonality = true; MemoryDenyWriteExecute = true; Environment = [ - "TTP_BIND=${cfg.ttpBind}" + "mtp::BIND=${cfg.ttpBind}" "BIND_ADDRESS=${cfg.bindAddress}" ]; } diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index 56f6eac..5a9ad09 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 60a0ce1..9fa889f 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -12,8 +12,7 @@ iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 295ef7a..0de1c82 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -4,6 +4,7 @@ use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; use iota_storage::users::{user_manager, user_profile::UserProfile}; use iota_storage::util::config_util::CONFIG; use iota_util::{crypto_helper, file_util}; +use mtp::codec::{CommunicationType, CommunicationValue}; use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ Frame, @@ -12,7 +13,6 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use ttp_core::{CommunicationType, CommunicationValue}; use uuid::Uuid; use std::{ @@ -442,7 +442,7 @@ pub async fn run_command(command: &str) { } ["user", "remove", username] => { if let Some(user) = user_manager::get_user_by_username(username) { - let msg = CommunicationValue::new(CommunicationType::delete_user) + let msg = CommunicationValue::new(CommunicationType::DeleteUser) .with_sender(user.user_id as u64); OMIKRON_CONNECTION.send_message(&msg).await; user_manager::remove_user(user.user_id); @@ -510,7 +510,7 @@ pub async fn ping(time: u64) { let response_cv = conn .await_response( - &CommunicationValue::new(CommunicationType::ping), + &CommunicationValue::new(CommunicationType::Ping), Some(Duration::from_secs(time)), ) .await; diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index a1f6de1..3f7a5f6 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -15,8 +15,7 @@ omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } web-ui = { path = "../web-ui" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" json = "*" diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index bdfc84b..23f4a66 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } ratatui = "0.30.0" json = "0.12.4" diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index b603f1d..bfde4ce 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -8,8 +8,8 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use mtp::codec::{CommunicationValue, DataType, DataTypeId, DataValue, Version}; use ratatui::style::Color; -use ttp_core::{CommunicationValue, DataTypes, DataValue}; use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; pub mod language_creator; @@ -317,17 +317,19 @@ pub fn format_cv(cv: &CommunicationValue) -> String { let comm_type = cv.get_type().to_string(); parts.push(format!("{}", comm_type)); - let data: &BTreeMap = cv.get_data_container(); + let data = cv.data(); - let formated_data = - format_data_container(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); + let formated_data = format_data_container( + data.iter().map(|(k, v)| (*k, v.clone())).collect(), + Version(1, 0), + ); parts.push(format!("{}", formated_data)); parts.join(": ") } -fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { +fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) -> String { let parts: Vec = data .into_iter() .map(|(key, value)| { @@ -337,12 +339,12 @@ fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); + let inner_formatted = format_data_container(inner, version.clone()); format!("{}={{ {} }}", key_str, inner_formatted) } DataValue::Array(arr) => { - let arr_formatted = format_array(arr); + let arr_formatted = format_array(arr, version.clone()); format!("{}=[{}]", key_str, arr_formatted) } @@ -351,7 +353,7 @@ fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { DataValue::BoolTrue => format!("{}=true", key_str), DataValue::BoolFalse => format!("{}=false", key_str), - DataValue::Number(num) => format!("{}={}", key_str, num), + DataValue::SignedNumber(num) => format!("{}={}", key_str, num), _ => "".to_string(), } @@ -361,19 +363,19 @@ fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { parts.join(", ") } -fn format_array(arr: Vec) -> String { +fn format_array(arr: Vec, version: Version) -> String { let parts: Vec = arr .into_iter() .map(|value| match value { DataValue::Str(s) => format!("\"{}\"", s), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); + let inner_formatted = format_data_container(inner, version.clone()); format!("{{ {} }}", inner_formatted) } DataValue::Array(inner_arr) => { - let formatted = format_array(inner_arr); + let formatted = format_array(inner_arr, version.clone()); format!("[{}]", formatted) } @@ -382,7 +384,7 @@ fn format_array(arr: Vec) -> String { DataValue::BoolTrue => "true".to_string(), DataValue::BoolFalse => "false".to_string(), - DataValue::Number(num) => num.to_string(), + DataValue::SignedNumber(num) => num.to_string(), _ => String::new(), }) diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index ce37258..e246e12 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -9,4 +9,4 @@ once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" sysinfo = "0.38.3" -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 2baa309..6f694a4 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -8,8 +8,7 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } aes-gcm = "0.10.3" base64 = "0.22.1" diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 7c4ec88..4d1ef48 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -6,8 +6,7 @@ edition = "2024" [dependencies] iota-logger = { path = "../iota-logger" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } json = "*" pnet = "0.35.0" diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 51d9d49..8fd86e8 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 5b29a2f..725c990 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -8,8 +8,7 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" json = "*" @@ -17,6 +16,7 @@ tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" hex = "*" +rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } sha2 = "0.10.9" x448 = { version = "*" } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 6e18561..1dbc346 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -10,16 +10,27 @@ use iota_util::crypto_helper; use iota_util::crypto_util::{DataFormat, SecurePayload}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; use json::JsonValue; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::transport::{Policy, Receiver, SendMode, Sender}; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; -use ttp_native::{Policy, Receiver, SendMode, Sender}; use uuid::Uuid; +fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { + use mtp::type_map::{DataTypeId, TypeMap}; + let tm = TypeMap::latest(); + DataValue::Container( + items + .into_iter() + .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) + .collect(), + ) +} + // Helper function to check if read receipts are enabled globally async fn is_read_receipts_enabled() -> bool { // Check global config for read receipts setting @@ -240,7 +251,7 @@ impl OmikronConnection { let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); - let (sender, mut receiver) = ttp_native::client::connect( + let (sender, mut receiver) = mtp::transport::client::connect( &addr_str, None, Policy { @@ -358,8 +369,8 @@ impl OmikronConnection { (public_key_base64, private_key_base64) }; - let register_msg = CommunicationValue::new(CommunicationType::register_iota) - .add_data(DataTypes::public_key, DataValue::Str(pub_k)); + let register_msg = CommunicationValue::new(CommunicationType::RegisterIota) + .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k)); let msg_id = register_msg.get_id(); @@ -367,25 +378,28 @@ impl OmikronConnection { msg_id, WaitingTask { task: Box::new(|selfc, cv| { - if !cv.is_type(CommunicationType::success) { + if !cv.is_type(CommunicationType::Success) { return false; } - let iota_value = cv.get_data(DataTypes::iota_id); + let iota_value = cv.get_data(DataType::IotaId); let iota_id = iota_value.as_number().unwrap_or(0); if iota_id != 0 { tokio::spawn(async move { let mut conf_write = CONFIG.write().await; - conf_write.change("iota_id", JsonValue::from(iota_id)); + conf_write.change("iota_id", JsonValue::from(iota_id as i64)); conf_write.update(); drop(conf_write); log!("Registered with Iota-ID: {}", iota_id); // Send identification after registration let identify_msg = - CommunicationValue::new(CommunicationType::identification) - .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + CommunicationValue::new(CommunicationType::Identification) + .add_typed_default( + DataType::IotaId, + DataValue::SignedNumber(iota_id as i128), + ); selfc.send_message(&identify_msg).await; }); } else { @@ -399,8 +413,8 @@ impl OmikronConnection { self.send_message(®ister_msg).await; } else { - let identify_msg = CommunicationValue::new(CommunicationType::identification) - .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + let identify_msg = CommunicationValue::new(CommunicationType::Identification) + .add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id as i128)); self.send_message(&identify_msg).await; } } @@ -461,7 +475,7 @@ impl OmikronConnection { // ------------------------------------------------------------------------- pub async fn handle_message(self: Arc, cv: CommunicationValue) { - if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { log_cv_in!(&cv); } @@ -474,29 +488,29 @@ impl OmikronConnection { } } - if cv.is_type(CommunicationType::pong) { + if cv.is_type(CommunicationType::Pong) { self.handle_pong(&cv).await; return; } - if cv.is_type(CommunicationType::challenge) { + if cv.is_type(CommunicationType::Challenge) { self.handle_challenge(&cv).await; return; } - if cv.is_type(CommunicationType::app_identification) { + if cv.is_type(CommunicationType::AppIdentification) { let sender_id = cv.get_sender(); let app_identifier = cv - .get_data(DataTypes::app_identifier) + .get_data(DataType::AppIdentifier) .as_str() .unwrap_or("") .to_string(); let app_public_key = cv - .get_data(DataTypes::app_public_key) + .get_data(DataType::AppPublicKey) .as_str() .unwrap_or("") .to_string(); - let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; let mut trusted = false; if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { @@ -535,11 +549,14 @@ impl OmikronConnection { .unwrap() .export(DataFormat::Base64); - let res = CommunicationValue::new(CommunicationType::app_challenge) + let res = CommunicationValue::new(CommunicationType::AppChallenge) .with_id(cv.get_id()) .with_receiver(sender_id) - .add_data(DataTypes::public_key, DataValue::Str(pub_k_str)) - .add_data(DataTypes::challenge, DataValue::Str(encrypted_challenge)); + .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_str)) + .add_typed_default( + DataType::Challenge, + DataValue::Str(encrypted_challenge), + ); self.send_message(&res).await; return; @@ -547,21 +564,21 @@ impl OmikronConnection { } } - let res = CommunicationValue::new(CommunicationType::error_invalid_challenge) + let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::app_challenge_response) { + if cv.is_type(CommunicationType::AppChallengeResponse) { let sender_id = cv.get_sender(); let mut challenges = self.app_challenges.write().await; if let Some(expected) = challenges.remove(&sender_id) { - if let DataValue::Str(response) = cv.get_data(DataTypes::challenge) { + if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { if expected == *response { let res = - CommunicationValue::new(CommunicationType::app_identification_response) + CommunicationValue::new(CommunicationType::AppIdentificationResponse) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; @@ -569,17 +586,17 @@ impl OmikronConnection { } } } - let res = CommunicationValue::new(CommunicationType::error_invalid_challenge) + let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::save_app_data) { + if cv.is_type(CommunicationType::SaveAppData) { let sender_id = cv.get_sender(); let app_data = cv - .get_data(DataTypes::app_data) + .get_data(DataType::AppData) .as_str() .unwrap_or("") .to_string(); @@ -593,14 +610,14 @@ impl OmikronConnection { ); } - let res = CommunicationValue::new(CommunicationType::save_app_data) + let res = CommunicationValue::new(CommunicationType::SaveAppData) .with_id(cv.get_id()) .with_receiver(sender_id); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::load_app_data) { + if cv.is_type(CommunicationType::LoadAppData) { let sender_id = cv.get_sender(); let mut app_data = String::new(); @@ -610,23 +627,23 @@ impl OmikronConnection { iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); } - let res = CommunicationValue::new(CommunicationType::load_app_data) + let res = CommunicationValue::new(CommunicationType::LoadAppData) .with_id(cv.get_id()) .with_receiver(sender_id) - .add_data(DataTypes::app_data, DataValue::Str(app_data)); + .add_typed_default(DataType::AppData, DataValue::Str(app_data)); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::create_app) { + if cv.is_type(CommunicationType::CreateApp) { let sender_id = cv.get_sender() as i64; let app_identifier = cv - .get_data(DataTypes::app_identifier) + .get_data(DataType::AppIdentifier) .as_str() .unwrap_or("") .to_string(); let app_public_key = cv - .get_data(DataTypes::app_public_key) + .get_data(DataType::AppPublicKey) .as_str() .unwrap_or("") .to_string(); @@ -640,17 +657,17 @@ impl OmikronConnection { } } - let res = CommunicationValue::new(CommunicationType::create_app) + let res = CommunicationValue::new(CommunicationType::CreateApp) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::delete_app) { + if cv.is_type(CommunicationType::DeleteApp) { let sender_id = cv.get_sender() as i64; let app_identifier = cv - .get_data(DataTypes::app_identifier) + .get_data(DataType::AppIdentifier) .as_str() .unwrap_or("") .to_string(); @@ -664,30 +681,33 @@ impl OmikronConnection { } } - let res = CommunicationValue::new(CommunicationType::delete_app) + let res = CommunicationValue::new(CommunicationType::DeleteApp) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&res).await; return; } - if cv.is_type(CommunicationType::client_connected) { - let user_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0) as i64; - let _session_id = cv.get_data(DataTypes::session_id).as_number().unwrap_or(0) as i64; + if cv.is_type(CommunicationType::ClientConnected) { + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; let contacts = chats_util::get_users(user_id); let mut contacts_array = Vec::new(); for (i, contact) in contacts.iter().enumerate() { let mut contact_container = Vec::new(); - contact_container.push((DataTypes::user_id, DataValue::Number(contact.user_id))); contact_container.push(( - DataTypes::last_message_at, - DataValue::Number(contact.last_message_at.unwrap_or(0)), + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )); + contact_container.push(( + DataType::LastMessageAt, + DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), )); if let Some(ref name) = contact.user_name { - contact_container.push((DataTypes::username, DataValue::Str(name.clone()))); + contact_container.push((DataType::Username, DataValue::Str(name.clone()))); } let amount = if i < 10 { 20 } else { 1 }; @@ -702,12 +722,15 @@ impl OmikronConnection { let message_state = m["message_state"].as_str().unwrap_or("").to_string(); let mut msg_container = Vec::new(); - msg_container.push((DataTypes::send_time, DataValue::Number(message_time))); - msg_container.push((DataTypes::content, DataValue::Str(content.clone()))); - msg_container.push((DataTypes::message_state, DataValue::Str(message_state))); - msg_container.push((DataTypes::height, DataValue::Number(height))); - msg_container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); - msg_array.push(DataValue::Container(msg_container)); + msg_container.push(( + DataType::SendTime, + DataValue::SignedNumber(message_time as i128), + )); + msg_container.push((DataType::Content, DataValue::Str(content.clone()))); + msg_container.push((DataType::MessageState, DataValue::Str(message_state))); + msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + msg_container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_array.push(typed_container(msg_container)); if msg_array.len() == 1 { let sender_id = if sent_by_self { @@ -716,25 +739,27 @@ impl OmikronConnection { contact.user_id }; let mut last_msg = Vec::new(); - last_msg.push((DataTypes::content, DataValue::Str(content))); - last_msg.push((DataTypes::sender_id, DataValue::Number(sender_id))); - contact_container - .push((DataTypes::last_message, DataValue::Container(last_msg))); + last_msg.push((DataType::Content, DataValue::Str(content))); + last_msg.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + contact_container.push((DataType::LastMessage, typed_container(last_msg))); } } - contact_container.push((DataTypes::messages, DataValue::Array(msg_array))); - contacts_array.push(DataValue::Container(contact_container)); + contact_container.push((DataType::Messages, DataValue::Array(msg_array))); + contacts_array.push(typed_container(contact_container)); } - let resp = CommunicationValue::new(CommunicationType::client_connected) + let resp = CommunicationValue::new(CommunicationType::ClientConnected) .with_id(cv.get_id()) - .add_data(DataTypes::contacts, DataValue::Array(contacts_array)); + .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::identification_response) { - match cv.get_data(DataTypes::accepted).as_bool() { + if cv.is_type(CommunicationType::IdentificationResponse) { + match cv.get_data(DataType::Accepted).as_bool() { Some(true) => { let mut state = self.state.write().await; if let ConnectionState::Connected { identified: _ } = *state { @@ -757,15 +782,15 @@ impl OmikronConnection { // Direct messages // // ************************************************ // - if cv.is_type(CommunicationType::message_state) { + if cv.is_type(CommunicationType::MessageState) { let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { Some(id) => id, _ => return, }; // Parse send_time robustly: accept numeric or string, fallback to current time - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -782,28 +807,25 @@ impl OmikronConnection { timestamp_i64, receiver_id as i64, *sender_id as i64, - MessageState::from_str( - cv.get_data(DataTypes::message_state).as_str().unwrap_or(""), - ), + MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), ); } // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. - if cv.is_type(CommunicationType::message_send) { + if cv.is_type(CommunicationType::MessageSend) { let sender_id: u64 = cv.get_sender(); // parse receiver_id (the storage owner for this incoming message) - let receiver_id: i64 = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() - { + let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { n as i64 - } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { + } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { s.parse::().unwrap_or(0) } else { 0 }; // parse send_time robustly (number or string), fallback to now - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -819,12 +841,12 @@ impl OmikronConnection { // content may be missing; default to empty string let content = cv - .get_data(DataTypes::content) + .get_data(DataType::Content) .as_str() .unwrap_or("") .to_string(); - let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); @@ -851,19 +873,22 @@ impl OmikronConnection { ); // send confirmation back to sender - let conf_msg = CommunicationValue::new(CommunicationType::message_send) + let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) .with_id(cv.get_id()) .with_receiver(sender_id as u64); self.send_message(&conf_msg).await; if !is_local { - let fw_msg = CommunicationValue::new(CommunicationType::message_other_iota) + let fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) .with_sender(sender_id as u64) - .add_data(DataTypes::height, DataValue::Number(height)) - .add_data(DataTypes::content, DataValue::Str(content)) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)); + .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) + .add_typed_default(DataType::Content, DataValue::Str(content)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ); let other_iota_resp = self .clone() @@ -872,7 +897,7 @@ impl OmikronConnection { if let Ok(resp) = other_iota_resp { let ms_raw = resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -885,17 +910,20 @@ impl OmikronConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -909,17 +937,20 @@ impl OmikronConnection { ); self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -928,16 +959,22 @@ impl OmikronConnection { return; } else { // Build a live-delivery message for the local client (recipient) - let user_forward = CommunicationValue::new(CommunicationType::message_live) + let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) - .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) - .add_data( - DataTypes::message, - DataValue::Container(vec![ - (DataTypes::content, DataValue::Str(content.clone())), - (DataTypes::send_time, DataValue::Number(timestamp_i64)), - (DataTypes::height, DataValue::Number(height)), + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ) + .add_typed_default( + DataType::Message, + typed_container(vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -949,7 +986,7 @@ impl OmikronConnection { if let Ok(user_resp) = user_resp { let ms_raw = user_resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -973,17 +1010,20 @@ impl OmikronConnection { // notify original sender about the delivered/read state (if read receipts are enabled) if is_read_receipts_enabled().await { self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), ) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -1006,24 +1046,30 @@ impl OmikronConnection { ); // Send push notification to Omega since user is offline - let push_msg = CommunicationValue::new(CommunicationType::push_notification) + let push_msg = CommunicationValue::new(CommunicationType::PushNotification) .with_receiver(receiver_id as u64) - .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)); + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ); self.send_message(&push_msg).await; // notify sender self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(receiver_id as i64), + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -1033,12 +1079,12 @@ impl OmikronConnection { } } - if cv.is_type(CommunicationType::message_other_iota) { + if cv.is_type(CommunicationType::MessageOtherIota) { let sender_id = &cv.get_sender(); let receiver_id = &cv.get_receiver(); // parse send_time safely (number or string), fallback to now - let send_time_val = cv.get_data(DataTypes::send_time); + let send_time_val = cv.get_data(DataType::SendTime); let now_i64 = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -1053,12 +1099,12 @@ impl OmikronConnection { // content may be missing or non-string; default to empty string let content = cv - .get_data(DataTypes::content) + .get_data(DataType::Content) .as_str() .unwrap_or("") .to_string(); - let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; chat_files::add_message( timestamp as u128, @@ -1070,16 +1116,22 @@ impl OmikronConnection { ); // Build user_forward using the parsed numeric timestamp and safe content string - let user_forward = CommunicationValue::new(CommunicationType::message_live) + let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(*receiver_id) - .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) - .add_data( - DataTypes::message, - DataValue::Container(vec![ - (DataTypes::content, DataValue::Str(content.clone())), - (DataTypes::send_time, DataValue::Number(timestamp)), - (DataTypes::height, DataValue::Number(height)), + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(*sender_id as i128), + ) + .add_typed_default( + DataType::Message, + typed_container(vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -1090,7 +1142,7 @@ impl OmikronConnection { if let Ok(user_resp) = user_resp { let ms_raw = user_resp - .get_data(DataTypes::message_state) + .get_data(DataType::MessageState) .as_string() .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); @@ -1105,17 +1157,20 @@ impl OmikronConnection { // notify original sender about the delivered/read state (if read receipts are enabled) if is_read_receipts_enabled().await { self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(*sender_id as i64), + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*sender_id as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), ) @@ -1131,23 +1186,29 @@ impl OmikronConnection { ); // Send push notification to Omega since user is offline - let push_msg = CommunicationValue::new(CommunicationType::push_notification) + let push_msg = CommunicationValue::new(CommunicationType::PushNotification) .with_receiver(*receiver_id) - .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)); + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(*sender_id as i128), + ); self.send_message(&push_msg).await; self.send_message( - &CommunicationValue::new(CommunicationType::message_state) + &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data(DataTypes::send_time, DataValue::Number(timestamp)) - .add_data( - DataTypes::chat_partner_id, - DataValue::Number(*receiver_id as i64), + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), ) - .add_data( - DataTypes::message_state, + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*receiver_id as i128), + ) + .add_typed_default( + DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), ) @@ -1156,12 +1217,17 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::messages_get) { + if cv.is_type(CommunicationType::MessagesGet) { let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0); - let offset = cv.get_data(DataTypes::offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataTypes::amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages(my_id as i64, partner_id, offset, amount); + let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); + let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages( + my_id as i64, + partner_id as i64, + offset as i64, + amount as i64, + ); let mut msg_array: Vec = Vec::new(); for m in messages.members() { let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); @@ -1171,9 +1237,9 @@ impl OmikronConnection { let sender_id: i64 = if sent_by_self { my_id as i64 } else { - if let Some(n) = cv.get_data(DataTypes::chat_partner_id).as_number() { + if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { n as i64 - } else if let Some(s) = cv.get_data(DataTypes::chat_partner_id).as_str() { + } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { s.parse::().unwrap_or(partner_id as i64) } else { partner_id as i64 @@ -1182,53 +1248,62 @@ impl OmikronConnection { let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); let mut container = Vec::new(); - container.push((DataTypes::send_time, DataValue::Number(message_time))); - container.push((DataTypes::content, DataValue::Str(content))); - container.push((DataTypes::sender_id, DataValue::Number(sender_id))); - container.push((DataTypes::message_state, DataValue::Str(message_state))); - container.push((DataTypes::height, DataValue::Number(height))); - container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); - msg_array.push(DataValue::Container(container)); + container.push(( + DataType::SendTime, + DataValue::SignedNumber(message_time as i128), + )); + container.push((DataType::Content, DataValue::Str(content))); + container.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + container.push((DataType::MessageState, DataValue::Str(message_state))); + container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::messages_get) + let resp = CommunicationValue::new(CommunicationType::MessagesGet) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::messages, DataValue::Array(msg_array)); + .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::get_chats) { + if cv.is_type(CommunicationType::GetChats) { let user_id = cv.get_sender(); let users = chats_util::get_users(user_id as i64); let mut user_array = Vec::new(); for user in users { let mut container = Vec::new(); - container.push((DataTypes::user_id, DataValue::Number(user.user_id))); + container.push(( + DataType::UserId, + DataValue::SignedNumber(user.user_id as i128), + )); if let Some(name) = user.user_name { - container.push((DataTypes::username, DataValue::Str(name))); + container.push((DataType::Username, DataValue::Str(name))); } if let Some(ts) = user.last_message_at { - container.push((DataTypes::last_message_at, DataValue::Number(ts))); + container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); } - user_array.push(DataValue::Container(container)); + user_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::get_chats) + let resp = CommunicationValue::new(CommunicationType::GetChats) .with_id(cv.get_id()) .with_receiver(user_id) - .add_data(DataTypes::user_ids, DataValue::Array(user_array)); + .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::add_conversation) { + if cv.is_type(CommunicationType::AddConversation) { let user_id = cv.get_sender(); - let other_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { Some(n) => n as i64, None => cv - .get_data(DataTypes::chat_partner_id) + .get_data(DataType::ChatPartnerId) .as_str() .unwrap_or("0") .parse() @@ -1236,7 +1311,7 @@ impl OmikronConnection { }; let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); - if let Some(name) = cv.get_data(DataTypes::chat_partner_name).as_str() { + if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { contact.user_name = Some(name.to_string()); } @@ -1247,89 +1322,86 @@ impl OmikronConnection { .as_millis() as i64, ); mod_user(user_id as i64, &contact); - let resp = CommunicationValue::new(CommunicationType::add_conversation) + let resp = CommunicationValue::new(CommunicationType::AddConversation) .with_id(cv.get_id()) .with_receiver(user_id); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::add_community) { + if cv.is_type(CommunicationType::AddCommunity) { CommunitiesUtil::add_community( cv.get_sender() as i64, - cv.get_data(DataTypes::community_address) + cv.get_data(DataType::CommunityAddress) .as_str() .unwrap() .to_string(), - cv.get_data(DataTypes::community_title) + cv.get_data(DataType::CommunityTitle) .as_str() .unwrap() .to_string(), - cv.get_data(DataTypes::position) + cv.get_data(DataType::Position) .as_str() .unwrap() .to_string(), ); - let resp = CommunicationValue::new(CommunicationType::add_community) + let resp = CommunicationValue::new(CommunicationType::AddCommunity) .with_id(cv.get_id()) .with_receiver(cv.get_sender()); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::get_communities) { + if cv.is_type(CommunicationType::GetCommunities) { let mut comm_array = Vec::new(); for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataTypes, DataValue)> = Vec::new(); + let mut container: Vec<(DataType, DataValue)> = Vec::new(); if let Some(address) = c["address"].as_str() { container.push(( - DataTypes::community_address, + DataType::CommunityAddress, DataValue::Str(address.to_string()), )); } if let Some(title) = c["title"].as_str() { - container.push(( - DataTypes::community_title, - DataValue::Str(title.to_string()), - )); + container.push((DataType::CommunityTitle, DataValue::Str(title.to_string()))); } if let Some(position) = c["position"].as_str() { - container.push((DataTypes::position, DataValue::Str(position.to_string()))); + container.push((DataType::Position, DataValue::Str(position.to_string()))); } - comm_array.push(DataValue::Container(container)); + comm_array.push(typed_container(container)); } - let resp = CommunicationValue::new(CommunicationType::get_communities) + let resp = CommunicationValue::new(CommunicationType::GetCommunities) .with_id(cv.get_id()) .with_receiver(cv.get_sender()) - .add_data(DataTypes::communities, DataValue::Array(comm_array)); + .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::remove_community) { + if cv.is_type(CommunicationType::RemoveCommunity) { CommunitiesUtil::remove_community( cv.get_sender() as i64, - cv.get_data(DataTypes::community_address) + cv.get_data(DataType::CommunityAddress) .as_str() .unwrap() .to_string(), ); - let resp = CommunicationValue::new(CommunicationType::remove_community) + let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) .with_id(cv.get_id()) .with_receiver(cv.get_sender()); self.send_message(&resp).await; return; } - if cv.is_type(CommunicationType::global_settings_save) { + if cv.is_type(CommunicationType::GlobalSettingsSave) { let my_id = cv.get_sender(); - let Some(settings_value) = cv.get_data(DataTypes::payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing settings payload".to_string()), ); self.send_message(&response).await; @@ -1342,32 +1414,37 @@ impl OmikronConnection { settings_value, ); - let mut response = CommunicationValue::new(CommunicationType::global_settings_save) + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) .with_receiver(my_id) .with_id(cv.get_id()); - if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { - response = response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); } self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::global_settings_load) { + if cv.is_type(CommunicationType::GlobalSettingsLoad) { let my_id = cv.get_sender(); let path = format!("users/{}", my_id); let name = "global.settings"; if !has_file(&path, name) { - let mut response = CommunicationValue::new(CommunicationType::error_not_found) + let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::path, DataValue::Str(name.to_string())); + .add_typed_default(DataType::Path, DataValue::Str(name.to_string())); - if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { - response = - response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); } self.send_message(&response).await; @@ -1375,53 +1452,62 @@ impl OmikronConnection { } let settings_value_str = load_file(&path, name); - let mut response = CommunicationValue::new(CommunicationType::global_settings_load) + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::payload, DataValue::Str(settings_value_str)); + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); - if let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() { - response = response.add_data(DataTypes::session_id, DataValue::Number(session_id)); + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); } self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::settings_save) { + if cv.is_type(CommunicationType::SettingsSave) { let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing session_id".to_string()), ); self.send_message(&response).await; return; }; - let Some(settings_name) = cv.get_data(DataTypes::settings_name).as_str() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing settings_name".to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; }; - let Some(settings_value) = cv.get_data(DataTypes::payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing settings payload".to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; }; @@ -1431,18 +1517,21 @@ impl OmikronConnection { .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') || settings_name.contains("..") { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Invalid settings_name".to_string()), ) - .add_data( - DataTypes::settings_name, + .add_typed_default( + DataType::SettingsName, DataValue::Str(settings_name.to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; } @@ -1453,41 +1542,47 @@ impl OmikronConnection { settings_value, ); - let response = CommunicationValue::new(CommunicationType::settings_save) + let response = CommunicationValue::new(CommunicationType::SettingsSave) .with_receiver(my_id) .with_id(cv.get_id()) - .add_data( - DataTypes::settings_name, + .add_typed_default( + DataType::SettingsName, DataValue::Str(settings_name.to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::settings_load) { + if cv.is_type(CommunicationType::SettingsLoad) { let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing session_id".to_string()), ); self.send_message(&response).await; return; }; - let Some(settings_name) = cv.get_data(DataTypes::settings_name).as_str() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing settings_name".to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; }; @@ -1497,18 +1592,21 @@ impl OmikronConnection { .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') || settings_name.contains("..") { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Invalid settings_name".to_string()), ) - .add_data( - DataTypes::settings_name, + .add_typed_default( + DataType::SettingsName, DataValue::Str(settings_name.to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; } @@ -1516,41 +1614,47 @@ impl OmikronConnection { let settings_file = format!("{}.settings", settings_name); let settings_path = format!("users/{}/settings/{}/", my_id, session_id); if !has_file(&settings_path, &settings_file) { - let response = CommunicationValue::new(CommunicationType::error_not_found) + let response = CommunicationValue::new(CommunicationType::ErrorNotFound) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::settings_name, + .add_typed_default( + DataType::SettingsName, DataValue::Str(settings_name.to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; } let settings_value_str = load_file(&settings_path, &settings_file); - let response = CommunicationValue::new(CommunicationType::settings_load) + let response = CommunicationValue::new(CommunicationType::SettingsLoad) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::payload, DataValue::Str(settings_value_str)) - .add_data( - DataTypes::settings_name, + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) + .add_typed_default( + DataType::SettingsName, DataValue::Str(settings_name.to_string()), ) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::settings_list) { + if cv.is_type(CommunicationType::SettingsList) { let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataTypes::session_id).as_number() else { - let response = CommunicationValue::new(CommunicationType::error_invalid_data) + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data( - DataTypes::message, + .add_typed_default( + DataType::Message, DataValue::Str("Missing session_id".to_string()), ); self.send_message(&response).await; @@ -1566,11 +1670,14 @@ impl OmikronConnection { } let _ = settings_json.push(DataValue::Str(s)); } - let response = CommunicationValue::new(CommunicationType::settings_list) + let response = CommunicationValue::new(CommunicationType::SettingsList) .with_id(cv.get_id()) .with_receiver(my_id) - .add_data(DataTypes::settings, DataValue::Array(settings_json)) - .add_data(DataTypes::session_id, DataValue::Number(session_id)); + .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); self.send_message(&response).await; return; @@ -1589,11 +1696,11 @@ impl OmikronConnection { }; drop(conf); - let Some(omikron_public_key) = cv.get_data(DataTypes::public_key).as_str() else { + let Some(omikron_public_key) = cv.get_data(DataType::PublicKey).as_str() else { log_t!("omikron_challenge_decryption_failed"); return; }; - let Some(encrypted_challenge) = cv.get_data(DataTypes::challenge).as_str() else { + let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { log_t!("omikron_challenge_decryption_failed"); return; }; @@ -1615,9 +1722,9 @@ impl OmikronConnection { if let Some(decrypted) = solved_challenge { let solved = decrypted.export(DataFormat::Raw); - let response = CommunicationValue::new(CommunicationType::challenge_response) + let response = CommunicationValue::new(CommunicationType::ChallengeResponse) .with_id(cv.get_id()) - .add_data(DataTypes::challenge, DataValue::Str(solved)); + .add_typed_default(DataType::Challenge, DataValue::Str(solved)); self.send_message(&response).await; } else { @@ -1674,7 +1781,7 @@ impl OmikronConnection { let sender_clone = Arc::clone(sender); drop(sender_guard); - if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { log_cv_out!(&cv); } @@ -1698,9 +1805,9 @@ impl OmikronConnection { for key in keys { if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { - let response = CommunicationValue::new(CommunicationType::error_internal) + let response = CommunicationValue::new(CommunicationType::ErrorInternal) .with_id(key) - .add_data(DataTypes::message, DataValue::Str(reason.clone())); + .add_typed_default(DataType::Message, DataValue::Str(reason.clone())); let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); } } @@ -1748,16 +1855,15 @@ impl OmikronConnection { match tokio::time::timeout(timeout, rx.recv()).await { Ok(Some(response_cv)) => { - let resp_type = response_cv.get_type(); - let is_error = resp_type == CommunicationType::error - || resp_type == CommunicationType::error_internal - || resp_type == CommunicationType::error_not_found - || resp_type == CommunicationType::error_invalid_data - || resp_type == CommunicationType::error_invalid_challenge - || resp_type == CommunicationType::error_not_authenticated; + let is_error = response_cv.is_type(CommunicationType::Error) + || response_cv.is_type(CommunicationType::ErrorInternal) + || response_cv.is_type(CommunicationType::ErrorNotFound) + || response_cv.is_type(CommunicationType::ErrorInvalidData) + || response_cv.is_type(CommunicationType::ErrorInvalidChallenge) + || response_cv.is_type(CommunicationType::ErrorNotAuthenticated); if is_error { let reason = response_cv - .get_data(DataTypes::message) + .get_data(DataType::Message) .as_str() .unwrap_or("connection error") .to_string(); diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index 18e651f..9c81c60 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,26 +1,26 @@ use crate::omikron_connection::OmikronConnection; use dashmap::DashMap; use iota_state::APP_STATE; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::sync::LazyLock; use std::time::Instant; use tokio::time::Duration; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue, rand_u32}; static PING_TIMES: LazyLock> = LazyLock::new(|| DashMap::new()); impl OmikronConnection { pub async fn send_ping(&self) { - let id = rand_u32(); + let id = rand::random(); PING_TIMES.insert(id, Instant::now()); PING_TIMES.retain(|_, v| v.elapsed() < Duration::from_secs(30)); - let ping_message = CommunicationValue::new(CommunicationType::ping) + let ping_message = CommunicationValue::new(CommunicationType::Ping) .with_id(id) - .add_data( - DataTypes::last_ping, - DataValue::Array(vec![DataValue::Number(*self.last_ping.lock().await)]), + .add_typed_default( + DataType::LastPing, + DataValue::Array(vec![DataValue::SignedNumber(*self.last_ping.lock().await as i128)]), ); self.send_message(&ping_message).await; diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index eb14bcc..978e742 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -6,16 +6,16 @@ use iota_storage::users::user_manager::{add_user, save_users}; use iota_storage::users::user_profile::UserProfile; use iota_util::crypto_helper::public_key_to_base64; use iota_util::file_util::save_file; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; use std::time::Duration; -use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; use x448::{PublicKey, Secret}; use crate::omikron_connection::OMIKRON_CONNECTION; pub async fn create_user(username: &str) -> (Option, Option) { - let register_communication_value = CommunicationValue::new(CommunicationType::get_register); + let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); let connection = OMIKRON_CONNECTION.clone(); @@ -32,7 +32,7 @@ pub async fn create_user(username: &str) -> (Option, Option log_cv!(PrintType::Omega, response_communication_value); let user_id = match response_communication_value - .get_data(DataTypes::user_id) + .get_data(DataType::UserId) .as_number() { Some(id) => id, @@ -57,7 +57,7 @@ pub async fn create_user(username: &str) -> (Option, Option let reset_token = STANDARD.encode(&bytes); let user_profile = UserProfile::new( - user_id, + user_id as i64, username.to_string(), None, STANDARD.encode(&public_key.as_bytes()), @@ -65,15 +65,15 @@ pub async fn create_user(username: &str) -> (Option, Option reset_token.clone(), ); - let communication_value = CommunicationValue::new(CommunicationType::complete_register_user) - .add_data(DataTypes::user_id, DataValue::Number(user_id)) - .add_data(DataTypes::username, DataValue::Str(username.to_string())) - .add_data( - DataTypes::public_key, + let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id as i128)) + .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) + .add_typed_default( + DataType::PublicKey, DataValue::Str(public_key_to_base64(&public_key)), ) - .add_data(DataTypes::iota_id, DataValue::Number(user_id)) - .add_data(DataTypes::reset_token, DataValue::Str(reset_token)); + .add_typed_default(DataType::IotaId, DataValue::SignedNumber(user_id as i128)) + .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)); let response_communication_value = connection .await_response(&communication_value, Some(Duration::from_secs(20))) @@ -81,7 +81,7 @@ pub async fn create_user(username: &str) -> (Option, Option if let Ok(response) = response_communication_value { log_cv!(PrintType::Omega, response); - if !response.is_type(CommunicationType::success) { + if !response.is_type(CommunicationType::Success) { return (None, None); } } else { diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index a1d34f7..641de58 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/type-maps.yaml b/type-maps.yaml new file mode 100644 index 0000000..5cd196f --- /dev/null +++ b/type-maps.yaml @@ -0,0 +1,241 @@ +# The version a Client should use +protocol_version: "1.0" + +# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended +# Fixed CommunicationType markers are: +# Error: 0 +# ErrorParsing: 1 +# ErrorBadVersion: 2 +# Disconnect: 3 +# Redirect: 4 +# Shutdown: 5 +# BadRequest: 6 +# Unauthorized: 7 +# Forbidden: 8 +# NotFound: 9 +# TooManyRequests: 10 +# InternalServerError: 11 +# BadGateway: 12 +# ServiceUnavailable: 13 +# GatewayTimeout: 14 +# Identification: 15 +# IdentificationResponse: 16 +# Register: 17 +# RegisterResponse: 18 +# Ping: 19 +# Pong: 20 +# +# Fixed Data Type markers are: +# Error: 0 +# ErrorParsing: 1 +# ErrorMessage: 2 +# Version: 3 +# Description: 4 +# Timestamp: 5 +# Id: 6 +# ClientNonce: 7 +# ServerNonce: 8 +# PublicKeys: 9 +# Signature: 10 +# Connected: 11 +# +# If a Type can't be used it will be mapped to 0 + +type_maps: + "1.0": # Protocol version 1.0 + CommunicationTypes: + ErrorProtocol: 33 + ErrorAnonymous: 34 + ErrorInternal: 35 + ErrorInvalidData: 36 + ErrorInvalidUserId: 37 + ErrorInvalidOmikronId: 38 + ErrorNotFound: 39 + ErrorNotAuthenticated: 40 + ErrorNoIota: 41 + ErrorInvalidChallenge: 42 + ErrorInvalidSecret: 43 + ErrorInvalidPrivateKey: 44 + ErrorInvalidPublicKey: 45 + ErrorNoUserId: 46 + ErrorNoCallId: 47 + ErrorInvalidCallId: 48 + Success: 49 + ShortenLink: 50 + SettingsSave: 51 + SettingsLoad: 52 + SettingsList: 53 + GlobalSettingsSave: 54 + GlobalSettingsLoad: 55 + Message: 56 + MessageState: 57 + MessageSend: 58 + MessageLive: 59 + MessageOtherIota: 60 + MessageChunk: 61 + MessagesGet: 62 + PushNotification: 63 + ReadNotification: 64 + GetNotifications: 65 + TauriIdentification: 66 + ChangeConfirm: 67 + ConfirmReceive: 68 + ConfirmRead: 69 + GetChats: 70 + GetStates: 71 + AddCommunity: 72 + RemoveCommunity: 73 + GetCommunities: 74 + RegisterIota: 81 + RegisterIotaSuccess: 82 + AddConversation: 85 + SendChat: 86 + ClientChanged: 87 + ClientConnected: 88 + ClientDisconnected: 89 + ClientClosed: 90 + PublicKey: 91 + PrivateKey: 92 + WebrtcSdp: 93 + WebrtcIce: 94 + StartStream: 95 + EndStream: 96 + WatchStream: 97 + CallToken: 98 + CallInvite: 99 + CallDisconnectUser: 100 + CallTimeoutUser: 101 + CallSetAnonymousJoining: 102 + CallData: 103 + EndCall: 104 + Function: 105 + Update: 106 + CreateUser: 107 + RhoUpdate: 108 + UserConnected: 109 + UserDisconnected: 110 + IotaConnected: 111 + IotaDisconnected: 112 + SyncClientIotaStatus: 113 + GetUserData: 114 + GetIotaData: 115 + IotaUserData: 116 + ChangeUserData: 117 + ChangeIotaData: 118 + GetRegister: 119 + CompleteRegisterUser: 120 + CompleteRegisterIota: 121 + DeleteUser: 122 + DeleteIota: 123 + StartRegister: 124 + CompleteRegister: 125 + GetApp: 126 + CreateApp: 127 + DeleteApp: 128 + SaveAppData: 129 + LoadAppData: 130 + AppIdentification: 131 + AppChallenge: 132 + AppChallengeResponse: 133 + AppIdentificationResponse: 134 + LoadTxtRecord: 135 + DataTypes: + ErrorType: 32 + ErrorProtocol: 33 + AcceptedIds: 34 + Uuid: 35 + RegisterId: 36 + Link: 37 + Settings: 38 + SettingsName: 39 + ChatPartnerId: 40 + ChatPartnerName: 41 + IotaId: 42 + UserId: 43 + UserIds: 44 + IotaIds: 45 + UserState: 46 + UserStates: 47 + UserPings: 48 + CallState: 49 + ScreenShare: 50 + PrivateKeyHash: 51 + Accepted: 52 + AcceptedProfiles: 53 + DeniedProfiles: 54 + Content: 55 + Messages: 56 + Notifications: 57 + SendTime: 58 + GetTime: 59 + GetVariant: 60 + SharedSecretOwn: 61 + SharedSecretOther: 62 + SharedSecretSign: 63 + SharedSecret: 64 + CallId: 65 + CallToken: 66 + CallSecret: 67 + Untill: 68 + Enabled: 69 + StartDate: 70 + EndDate: 71 + ReceiverId: 72 + SenderId: 73 + Signed: 75 + Message: 76 + MessageState: 77 + LastPing: 78 + PingIota: 79 + PingClients: 80 + Matches: 81 + Omikron: 82 + Offset: 83 + Amount: 84 + Position: 85 + Name: 86 + Path: 87 + Codec: 88 + Function: 89 + Payload: 90 + Result: 91 + Interactables: 92 + WantToWatch: 93 + Watcher: 94 + CreatedAt: 95 + Username: 96 + Display: 97 + Avatar: 98 + About: 99 + Status: 100 + PublicKey: 101 + SubLevel: 102 + SubEnd: 103 + CommunityAddress: 104 + CommunityTitle: 106 + Communities: 107 + RhoConnections: 108 + User: 109 + OnlineStatus: 110 + OmikronId: 111 + OmikronConnections: 112 + ResetToken: 113 + NewToken: 114 + CallInvited: 115 + CallMembers: 116 + Calls: 117 + Timeout: 118 + HasAdmin: 119 + LastMessageAt: 120 + Height: 121 + SentBySelf: 122 + SessionId: 123 + Contacts: 124 + LastMessage: 125 + AppIdentifier: 127 + AppPrivateKey: 128 + AppPublicKey: 129 + AppSession: 130 + AppData: 131 + TauriToken: 132 + Challenge: 133 diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index b214806..74de956 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,5 +5,4 @@ version = "0.1.0" edition = "2024" [dependencies] -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index e0debbd..a6fbba1 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -9,8 +9,7 @@ iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } -ttp-core = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/Tensamin/TTP.git", package = "ttp-native" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" @@ -24,7 +23,15 @@ futures = "*" futures-util = "*" hex = "*" hkdf = "0.12.4" -hyper = { version = "1.8.1", features = ["capi", "client", "full", "http1", "http2", "nightly", "server"] } +hyper = { version = "1.8.1", features = [ + "capi", + "client", + "full", + "http1", + "http2", + "nightly", + "server", +] } hyper-util = { version = "*" } json = "*" lazy_static = "1.5.0" From 25fc07d45d0c9f1bee0cf9ffc1631eed08d78892 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:47:25 +0200 Subject: [PATCH 051/119] removed agreements --- .gitignore | 1 + agreements | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 agreements diff --git a/.gitignore b/.gitignore index 0000aa1..c05b9da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target logs +agreements diff --git a/agreements b/agreements deleted file mode 100644 index bf0bd5a..0000000 --- a/agreements +++ /dev/null @@ -1,8 +0,0 @@ -This file reflects the current consent state used by the application. -It may be regenerated or overwritten by the application. -This file was last edited by Tensamin at: -UNIX-SECOND=1782646711 -"EULA=true" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/ -EULA=true -EULA-VERSION=2.0 -EULA-HASH=a1b2c3d4e5f6 \ No newline at end of file From d3722a917bbc31aff1fb29f3f4548f017dbe86fc Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:47:25 +0200 Subject: [PATCH 052/119] removed agreements --- .gitignore | 2 ++ agreements | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 agreements diff --git a/.gitignore b/.gitignore index 0000aa1..257c89e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target logs +agreements +languages/ diff --git a/agreements b/agreements deleted file mode 100644 index bf0bd5a..0000000 --- a/agreements +++ /dev/null @@ -1,8 +0,0 @@ -This file reflects the current consent state used by the application. -It may be regenerated or overwritten by the application. -This file was last edited by Tensamin at: -UNIX-SECOND=1782646711 -"EULA=true" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/ -EULA=true -EULA-VERSION=2.0 -EULA-HASH=a1b2c3d4e5f6 \ No newline at end of file From 190926702b7b58278135ebf90a1fba48eee49070 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:23:36 +0200 Subject: [PATCH 053/119] (feat): update workflow --- .forgejo/workflows/release.yml | 125 +++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 23 ------ 2 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 .forgejo/workflows/release.yml delete mode 100644 .github/workflows/build.yml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..fc7c155 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,125 @@ +name: Build & Publish Release + +on: + workflow_dispatch: + inputs: + release_type: + description: "Release type: 'dev' or 'stable'" + required: true + default: "dev" + type: choice + options: + - dev + - stable + description: + description: "Release description" + required: true + type: string + +jobs: + build: + name: Build & Publish Release + runs-on: nixos + steps: + - name: Set up repository + uses: actions/checkout@v4 + + - name: Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWD }} + + - name: Build & Push + run: | + docker build -f dockerfile -t tensamin/iota:latest . + docker push tensamin/iota:latest + + - name: Build release binary + run: | + set -eu + + nix build .#iota --print-build-logs + install -Dm755 result/bin/iota dist/iota + + - name: Read release metadata + id: version + env: + RELEASE_TYPE: ${{ inputs.release_type }} + run: | + set -eu + + VERSION="$(nix eval --raw .#iota.version)" + SHORT_SHA="$(git rev-parse --short=7 HEAD)" + + case "$RELEASE_TYPE" in + dev) + TAG="${VERSION}-dev-${SHORT_SHA}" + PRERELEASE="true" + ;; + stable) + TAG="$VERSION" + PRERELEASE="false" + ;; + *) + echo "release_type must be either 'dev' or 'stable'" + exit 1 + ;; + esac + + ASSET_PATH="dist/iota" + ASSET_NAME="iota" + test -x "$ASSET_PATH" + + echo "version=$VERSION" >> "$FORGEJO_OUTPUT" + echo "tag=$TAG" >> "$FORGEJO_OUTPUT" + echo "title=$TAG" >> "$FORGEJO_OUTPUT" + echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" + echo "asset_path=$ASSET_PATH" >> "$FORGEJO_OUTPUT" + echo "asset_name=$ASSET_NAME" >> "$FORGEJO_OUTPUT" + + - name: Create release and upload binary + env: + TOKEN: ${{ forgejo.token }} + API: ${{ forgejo.api_url }} + REPO: ${{ forgejo.repository }} + SHA: ${{ forgejo.sha }} + TAG: ${{ steps.version.outputs.tag }} + TITLE: ${{ steps.version.outputs.title }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + ASSET_PATH: ${{ steps.version.outputs.asset_path }} + ASSET_NAME: ${{ steps.version.outputs.asset_name }} + DESCRIPTION: ${{ inputs.description }} + run: | + set -eu + + HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG") + + if [ "$HTTP_STATUS" = "200" ]; then + echo "Release $TAG already exists." + RELEASE_ID="$(jq -r .id release_out.json)" + else + echo "Creating release for $TAG" + RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg tag "$TAG" \ + --arg name "$TITLE" \ + --arg body "$DESCRIPTION" \ + --arg target "$SHA" \ + --argjson prerelease "$PRERELEASE" \ + '{ + tag_name: $tag, + name: $name, + body: $body, + target_commitish: $target, + draft: false, + prerelease: $prerelease + }')")" + RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" + fi + + curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$ASSET_PATH" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 4265c56..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Build & Publish Docker Image - -on: - workflow_dispatch: - -jobs: - build: - name: Build & Publish Docker Image - runs-on: ubuntu-latest - steps: - - name: Set up repository - uses: actions/checkout@v4 - - - name: Login - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASSWD }} - - - name: Build & Push - run: | - docker build -t tensamin/iota:latest . - docker push tensamin/iota:latest From 07bd232c1c9729e7c471d1781aca88215193f410 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:27:34 +0200 Subject: [PATCH 054/119] (fix): workflow --- .forgejo/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index fc7c155..ce91e4d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -21,6 +21,9 @@ jobs: name: Build & Publish Release runs-on: nixos steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Set up repository uses: actions/checkout@v4 From 1373a4f2845bf5dd036bad155724c6472f650a69 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:28:54 +0200 Subject: [PATCH 055/119] (fix): workflow --- .forgejo/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ce91e4d..dc48294 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,5 +1,8 @@ name: Build & Publish Release +env: + NIX_CONFIG: experimental-features = nix-command flakes + on: workflow_dispatch: inputs: From 3aafa364efd87e56f966ea3d1c0252c677aa1a50 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:33:42 +0200 Subject: [PATCH 056/119] (fix): add docker --- .forgejo/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index dc48294..3c6e82f 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -30,6 +30,9 @@ jobs: - name: Set up repository uses: actions/checkout@v4 + - name: Install docker + run: nix profile add nixpkgs#docker + - name: Login uses: docker/login-action@v3 with: From fcaa7d1d44d8578d9d7427fc344bc3e4c9b576ce Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:42:30 +0200 Subject: [PATCH 057/119] (fix): replace docker action --- .forgejo/workflows/release.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3c6e82f..8f13ba0 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -33,11 +33,17 @@ jobs: - name: Install docker run: nix profile add nixpkgs#docker - - name: Login - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASSWD }} + - name: Login to Docker Hub + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }} + run: | + set -eu + + DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" + DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" + + printf '%s' "$DOCKER_PASSWD" | docker login docker.io --username "$DOCKER_USER" --password-stdin - name: Build & Push run: | From d0ca9f9c78d3b541dfa84ce4b949b17b478f2887 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:45:08 +0200 Subject: [PATCH 058/119] (fix): try weird workflow fix --- .forgejo/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 8f13ba0..92e48b6 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -40,6 +40,9 @@ jobs: run: | set -eu + export DOCKER_CONFIG="$(mktemp -d)" + trap 'rm -rf "$DOCKER_CONFIG"' EXIT + DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" From ab82762831bc85af50ef38a4ca99756f6e9a2dfd Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 20:56:04 +0200 Subject: [PATCH 059/119] (fix): workflow debugging --- .forgejo/workflows/release.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 92e48b6..81bcb9d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -40,16 +40,36 @@ jobs: run: | set -eu - export DOCKER_CONFIG="$(mktemp -d)" - trap 'rm -rf "$DOCKER_CONFIG"' EXIT + export DOCKER_CONFIG="$PWD/.docker" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" - printf '%s' "$DOCKER_PASSWD" | docker login docker.io --username "$DOCKER_USER" --password-stdin + if [ -z "$DOCKER_USER" ]; then + echo "DOCKER_USER secret is empty" + exit 1 + fi + + if [ -z "$DOCKER_PASSWD" ]; then + echo "DOCKER_PASSWD secret is empty" + exit 1 + fi + + case "$DOCKER_USER" in + *:*) + echo "DOCKER_USER must be only the Docker Hub username, not username:token" + exit 1 + ;; + esac + + printf '%s' "$DOCKER_PASSWD" | docker login https://index.docker.io/v1/ --username "$DOCKER_USER" --password-stdin - name: Build & Push run: | + export DOCKER_CONFIG="$PWD/.docker" + docker build -f dockerfile -t tensamin/iota:latest . docker push tensamin/iota:latest From ac9a7cea27cbfef358e99bde75e907d68cdd2785 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:18:21 +0200 Subject: [PATCH 060/119] (wip): workflow fix --- .forgejo/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 81bcb9d..60bd08b 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -62,9 +62,14 @@ jobs: echo "DOCKER_USER must be only the Docker Hub username, not username:token" exit 1 ;; + *[!a-z0-9_-]*) + echo "DOCKER_USER must be only the Docker Hub username/namespace, using lowercase letters, numbers, '_' or '-'" + echo "Do not use an email address, repository name, JSON value, or username:token pair." + exit 1 + ;; esac - printf '%s' "$DOCKER_PASSWD" | docker login https://index.docker.io/v1/ --username "$DOCKER_USER" --password-stdin + printf '%s' "$DOCKER_PASSWD" | docker login --username "$DOCKER_USER" --password-stdin - name: Build & Push run: | From 47df5e00980581afe0cdf9e80a0b812d4e534b15 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:23:51 +0200 Subject: [PATCH 061/119] (fix): docker login workflow --- .forgejo/workflows/release.yml | 44 ++++------------------------------ 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 60bd08b..3c6e82f 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -33,48 +33,14 @@ jobs: - name: Install docker run: nix profile add nixpkgs#docker - - name: Login to Docker Hub - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }} - run: | - set -eu - - export DOCKER_CONFIG="$PWD/.docker" - rm -rf "$DOCKER_CONFIG" - mkdir -p "$DOCKER_CONFIG" - - DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" - DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" - - if [ -z "$DOCKER_USER" ]; then - echo "DOCKER_USER secret is empty" - exit 1 - fi - - if [ -z "$DOCKER_PASSWD" ]; then - echo "DOCKER_PASSWD secret is empty" - exit 1 - fi - - case "$DOCKER_USER" in - *:*) - echo "DOCKER_USER must be only the Docker Hub username, not username:token" - exit 1 - ;; - *[!a-z0-9_-]*) - echo "DOCKER_USER must be only the Docker Hub username/namespace, using lowercase letters, numbers, '_' or '-'" - echo "Do not use an email address, repository name, JSON value, or username:token pair." - exit 1 - ;; - esac - - printf '%s' "$DOCKER_PASSWD" | docker login --username "$DOCKER_USER" --password-stdin + - name: Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWD }} - name: Build & Push run: | - export DOCKER_CONFIG="$PWD/.docker" - docker build -f dockerfile -t tensamin/iota:latest . docker push tensamin/iota:latest From e77ae0e1e412fed1702dcce7e0d46dcba1203d91 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:25:39 +0200 Subject: [PATCH 062/119] (fix): workflow --- .forgejo/workflows/release.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3c6e82f..56d55b7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -22,17 +22,11 @@ on: jobs: build: name: Build & Publish Release - runs-on: nixos + runs-on: host steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Set up repository uses: actions/checkout@v4 - - name: Install docker - run: nix profile add nixpkgs#docker - - name: Login uses: docker/login-action@v3 with: From c39fb8753ba7c9156b8572e4c64689c13d75823f Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:27:46 +0200 Subject: [PATCH 063/119] (fix): docker login --- .forgejo/workflows/release.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 56d55b7..14ae8f7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -27,11 +27,15 @@ jobs: - name: Set up repository uses: actions/checkout@v4 - - name: Login - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASSWD }} + - name: Login to Docker Hub + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }} + run: | + set -eu + DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" + DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" + printf '%s' "$DOCKER_PASSWD" | docker login docker.io --username "$DOCKER_USER" --password-stdin - name: Build & Push run: | From d2c91a3f0dc8f0c12458759c0f241f683d2eb693 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:28:27 +0200 Subject: [PATCH 064/119] (fix): add docker --- .forgejo/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 14ae8f7..50f29c7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -35,7 +35,7 @@ jobs: set -eu DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" - printf '%s' "$DOCKER_PASSWD" | docker login docker.io --username "$DOCKER_USER" --password-stdin + printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin" - name: Build & Push run: | From af96da10a98619cbe21ba756c43674a34b062afe Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:29:20 +0200 Subject: [PATCH 065/119] (fix): add docker --- .forgejo/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 50f29c7..6b0dbe5 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -39,8 +39,7 @@ jobs: - name: Build & Push run: | - docker build -f dockerfile -t tensamin/iota:latest . - docker push tensamin/iota:latest + nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" - name: Build release binary run: | From cf6201fe18037a699d00d26048f7e48b18e8d9d2 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 21:55:24 +0200 Subject: [PATCH 066/119] (fix): workflow --- .forgejo/workflows/release.yml | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6b0dbe5..040df5a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -99,33 +99,15 @@ jobs: run: | set -eu - HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG") + HTTP_STATUS=$(nix-shell -p curl --run "curl -s -w \"%{http_code}\" -o release_out.json -H \"Authorization: token $TOKEN\" \"$API/repos/$REPO/releases/tags/$TAG\"") if [ "$HTTP_STATUS" = "200" ]; then echo "Release $TAG already exists." RELEASE_ID="$(jq -r .id release_out.json)" else echo "Creating release for $TAG" - RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg tag "$TAG" \ - --arg name "$TITLE" \ - --arg body "$DESCRIPTION" \ - --arg target "$SHA" \ - --argjson prerelease "$PRERELEASE" \ - '{ - tag_name: $tag, - name: $name, - body: $body, - target_commitish: $target, - draft: false, - prerelease: $prerelease - }')")" + RELEASE_JSON="$(nix-shell -p curl --run "curl -f -sS -X POST \"$API/repos/$REPO/releases\" -H \"Authorization: token $TOKEN\" -H \"Content-Type: application/json\" -d \"$(jq -n --arg tag \"$TAG\" --arg name \"$TITLE\" --arg body \"$DESCRIPTION\" --arg target \"$SHA\" --argjson prerelease \"$PRERELEASE\" '{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }')\"")" RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" fi - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@$ASSET_PATH" + nix-shell -p curl --run "curl -fsS -X POST \"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME\" -H \"Authorization: token $TOKEN\" -F \"attachment=@$ASSET_PATH\"" From f252724a43c67b42d92f787dab3ce3fbc5293204 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 22:10:08 +0200 Subject: [PATCH 067/119] (fix): workflow --- .forgejo/workflows/release.yml | 37 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 040df5a..06523ce 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -97,17 +97,32 @@ jobs: ASSET_NAME: ${{ steps.version.outputs.asset_name }} DESCRIPTION: ${{ inputs.description }} run: | - set -eu + nix-shell -p curl jq --run ' + set -eu - HTTP_STATUS=$(nix-shell -p curl --run "curl -s -w \"%{http_code}\" -o release_out.json -H \"Authorization: token $TOKEN\" \"$API/repos/$REPO/releases/tags/$TAG\"") + HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/releases/tags/$TAG") - if [ "$HTTP_STATUS" = "200" ]; then - echo "Release $TAG already exists." - RELEASE_ID="$(jq -r .id release_out.json)" - else - echo "Creating release for $TAG" - RELEASE_JSON="$(nix-shell -p curl --run "curl -f -sS -X POST \"$API/repos/$REPO/releases\" -H \"Authorization: token $TOKEN\" -H \"Content-Type: application/json\" -d \"$(jq -n --arg tag \"$TAG\" --arg name \"$TITLE\" --arg body \"$DESCRIPTION\" --arg target \"$SHA\" --argjson prerelease \"$PRERELEASE\" '{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }')\"")" - RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" - fi + if [ "$HTTP_STATUS" = "200" ]; then + echo "Release $TAG already exists." + RELEASE_ID="$(jq -r .id release_out.json)" + else + echo "Creating release for $TAG" + RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg tag "$TAG" \ + --arg name "$TITLE" \ + --arg body "$DESCRIPTION" \ + --arg target "$SHA" \ + --argjson prerelease "$PRERELEASE" \ + '"'"'{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }'"'"')")" + RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" + fi - nix-shell -p curl --run "curl -fsS -X POST \"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME\" -H \"Authorization: token $TOKEN\" -F \"attachment=@$ASSET_PATH\"" + curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$ASSET_PATH" + ' \ No newline at end of file From 5625c5db5fee0dcb0de2da60a34df8f5ed075aec Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:28:06 +0200 Subject: [PATCH 068/119] Updated Crypto to use MTP-Crypto --- Cargo.lock | 449 +++++++++++++++++--- client/src/client_connection.rs | 33 +- iota-cli/src/elements/console_card.rs | 2 +- iota-storage/src/users/user_manager.rs | 17 +- iota-storage/src/util/config_util.rs | 4 + iota-util/Cargo.toml | 6 +- iota-util/src/crypto_helper.rs | 161 ++----- iota-util/src/crypto_util.rs | 204 ++------- omikron-connector/src/omikron_connection.rs | 109 ++--- omikron-connector/src/user_ops.rs | 26 +- 10 files changed, 558 insertions(+), 453 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fff38f..b53ec44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "sha1 0.11.0", "smallvec", "tokio", @@ -424,9 +424,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -435,14 +435,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -451,6 +452,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -607,6 +614,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -618,6 +636,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -639,6 +670,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout", + "zeroize", ] [[package]] @@ -656,7 +688,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hyper", "hyper-util", "iota-auth", @@ -701,6 +733,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -725,6 +763,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -890,7 +934,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -912,6 +958,42 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.23.0" @@ -978,6 +1060,27 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -1050,8 +1153,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1080,13 +1184,37 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed448-goldilocks" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" dependencies = [ - "fiat-crypto", + "fiat-crypto 0.1.20", "hex", "subtle", ] @@ -1171,6 +1299,12 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1523,7 +1657,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1535,6 +1678,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "httlib-huffman" version = "0.3.4" @@ -1603,6 +1755,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "ctutils", "typenum", ] @@ -1660,7 +1813,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -1867,7 +2020,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hyper", "hyper-util", "iota-logger", @@ -1917,7 +2070,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hyper", "hyper-util", "iota-logger", @@ -2009,7 +2162,7 @@ dependencies = [ "aes-gcm", "base64", "hex", - "hkdf", + "hkdf 0.12.4", "iota-logger", "iota-state", "iota-util", @@ -2048,7 +2201,7 @@ dependencies = [ "anyhow", "base64", "hex", - "hkdf", + "hkdf 0.12.4", "iota-logger", "json", "mtp", @@ -2074,19 +2227,15 @@ dependencies = [ name = "iota-util" version = "0.1.0" dependencies = [ - "aes-gcm", "base64", "hex", - "hkdf", "mtp", - "rand_core 0.6.4", + "mtp-crypto", "reqwest", - "sha2 0.10.9", "sysinfo", "tokio", "uuid", "walkdir", - "x448", "zip", ] @@ -2226,6 +2375,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "lab" version = "0.11.0" @@ -2431,10 +2599,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "mlkem-rs" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b0965b8b081668ff0398dc5e9dc3f2ebb9e833393f4ab5b9f725ddce11acef8" +dependencies = [ + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "mlkem-tls" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b205d031298adf904d88efd6a57862d8650a4ab754aade19a9b5e87040bf4e" +dependencies = [ + "mlkem-rs", + "rand_core 0.6.4", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "mtp" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" dependencies = [ "mtp-codec", "mtp-common", @@ -2445,7 +2666,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" dependencies = [ "base64", "byteorder", @@ -2457,7 +2678,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" dependencies = [ "quinn", "rustls", @@ -2465,10 +2686,27 @@ dependencies = [ "wtransport", ] +[[package]] +name = "mtp-crypto" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +dependencies = [ + "chacha20poly1305", + "ed25519-dalek", + "getrandom 0.4.3", + "hkdf 0.13.0", + "ml-dsa", + "mlkem-tls", + "rand_core 0.6.4", + "sha2 0.11.0", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "mtp-transport" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" dependencies = [ "log", "mtp-codec", @@ -2482,7 +2720,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#15cc1d4c5e6a917f197ebf6685c8cea4e3ab668f" +source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" dependencies = [ "serde", "serde_yaml", @@ -2666,13 +2904,12 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.5" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -2742,7 +2979,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hyper", "hyper-util", "iota-auth", @@ -2825,12 +3062,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -2838,7 +3069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2851,6 +3082,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2978,6 +3218,26 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3075,6 +3335,17 @@ dependencies = [ "pnet_sys", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3145,7 +3416,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -3183,7 +3454,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -3232,11 +3503,11 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3602,9 +3873,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3843,6 +4114,27 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3880,6 +4172,25 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3940,6 +4251,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -4223,9 +4560,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "libc", @@ -4245,9 +4582,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4749,7 +5086,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hyper", "hyper-util", "iota-logger", @@ -5222,6 +5559,18 @@ dependencies = [ "url", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x448" version = "0.6.0" @@ -5392,7 +5741,7 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap", "lzma-rust2", "memchr", @@ -5407,9 +5756,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 56bdf6a..13e03b1 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -7,8 +7,8 @@ use iota_storage::util::chats_util::{get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; use iota_storage::util::{chat_files, chats_util}; -use iota_util::crypto_helper; -use iota_util::crypto_util::{DataFormat, SecurePayload}; +use iota_util::crypto_helper::keyring_from_base64; +use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, load_file, save_file}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::transport::{Receiver, Sender}; @@ -889,33 +889,18 @@ impl ClientConnection { async fn handle_challenge(&self, cv: &CommunicationValue) { let conf = CONFIG.read().await; - let private_key = conf.get_private_key().unwrap(); + let kr_str = conf.get_keyring().unwrap(); drop(conf); - let omikron_public_key = cv.get_data(DataType::PublicKey).as_str().unwrap(); - let encrypted_challenge = cv.get_data(DataType::Challenge).as_str().unwrap(); - - let solved_challenge = { - if let Ok(decrypted) = SecurePayload::new( - encrypted_challenge, - DataFormat::Base64, - crypto_helper::load_secret_key(&private_key).unwrap(), - ) { - if let Ok(decrypted) = decrypted - .decrypt_x448(crypto_helper::load_public_key(omikron_public_key).unwrap()) - { - Some(decrypted) - } else { - None - } - } else { - None - } + let Some(keyring) = keyring_from_base64(&kr_str) else { + return; }; - if let Some(decrypted) = solved_challenge { - let solved = decrypted.export(DataFormat::Raw); + let encrypted_challenge = cv.get_data(DataType::Challenge).as_str().unwrap(); + let solved = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); + + if let Some(solved) = solved { let response = CommunicationValue::new(CommunicationType::ChallengeResponse) .with_id(cv.get_id()) .add_typed_default(DataType::Challenge, DataValue::Str(solved)); diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 0de1c82..ea09717 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -3,7 +3,7 @@ use iota_logger::{log, log_command, log_cv}; use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; use iota_storage::users::{user_manager, user_profile::UserProfile}; use iota_storage::util::config_util::CONFIG; -use iota_util::{crypto_helper, file_util}; +use iota_util::file_util; use mtp::codec::{CommunicationType, CommunicationValue}; use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 9d0fbf1..248881a 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,14 +1,12 @@ use crate::users::user_profile::UserProfile; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use iota_util::crypto_helper::{self}; +use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64}; use iota_util::file_util::{load_file, save_file}; use json::JsonValue; use once_cell::sync::Lazy; -use rand::Rng; -use rand_core::OsRng; +use rand_core::{OsRng, RngCore}; use std::io::{self}; use std::sync::Mutex; -use x448::{PublicKey, Secret}; static USERS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); static UNIQUE: Lazy> = Lazy::new(|| Mutex::new(false)); @@ -20,19 +18,20 @@ pub async fn load_from_tu(username: &str) -> Result<(), ()> { let uuid = segments[0].parse::().unwrap_or(0); let b64_private_key = segments[1]; - let secret: Secret = crypto_helper::load_secret_key(b64_private_key).unwrap(); - let public_key = PublicKey::from(&secret); + let keyring = keyring_from_base64(b64_private_key).unwrap(); + let pub_key_bundle = keyring.public_key_bundle(); + let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); let mut bytes = [0u8; 192]; - OsRng.fill(bytes.as_mut()); + OsRng.fill_bytes(&mut bytes); let reset_token = STANDARD.encode(&bytes); let user_profile = UserProfile::new( uuid, username.to_string(), Some(username.to_string()), - crypto_helper::public_key_to_base64(&public_key), - crypto_helper::hex_hash(b64_private_key), + public_key_bundle_to_base64(&pub_key_bundle), + hex_hash(&keyring_b64), reset_token, ); USERS.lock().unwrap().push(user_profile); diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 671d2c7..2dbaada 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -50,6 +50,10 @@ impl ConfigUtil { self.config["port"].as_u16().unwrap_or(1984) } + pub fn get_keyring(&self) -> Option { + self.config["keyring"].as_str().map(String::from) + } + pub fn get_public_key(&self) -> Option { self.config["public_key"].as_str().map(String::from) } diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 8fd86e8..e88cebf 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp-crypto = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["pqc"] } reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } @@ -12,10 +13,5 @@ sysinfo = "0.38.3" uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" zip = "6.0.0" -aes-gcm = "0.10.3" base64 = "0.22.1" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.10.9" -x448 = { version = "*" } -hkdf = "0.12.4" hex = "*" diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs index 4a7fd76..2909c04 100644 --- a/iota-util/src/crypto_helper.rs +++ b/iota-util/src/crypto_helper.rs @@ -1,133 +1,28 @@ -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit, OsRng}, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use rand_core::RngCore; -use sha2::{Digest, Sha256}; -use x448::{PublicKey, Secret, SharedSecret}; - -/// Errors for crypto opertions -#[derive(Debug)] -#[allow(dead_code)] -pub enum CryptoError { - Base64Decode(base64::DecodeError), - InvalidKey, - AgreementError, - EncryptionError(aes_gcm::Error), - DecryptionError(aes_gcm::Error), -} - -impl From for CryptoError { - fn from(err: base64::DecodeError) -> Self { - CryptoError::Base64Decode(err) - } -} - -pub struct KeyPair { - pub secret: Secret, - pub public: PublicKey, -} - -pub fn generate_keypair() -> KeyPair { - let mut buf = [0u8; 56]; - let mut rng = OsRng; - rng.fill_bytes(&mut buf); - let secret = Secret::from_bytes(&buf).unwrap(); - let public = PublicKey::from(&secret); - KeyPair { secret, public } -} - -pub fn public_key_to_base64(pubkey: &PublicKey) -> String { - STANDARD.encode(pubkey.as_bytes().as_ref()) -} - -pub fn secret_key_to_base64(secret: &Secret) -> String { - STANDARD.encode(secret.as_bytes().as_ref()) -} - -pub fn load_public_key(base64_pub: &str) -> Option { - let bytes = STANDARD.decode(base64_pub).unwrap(); - PublicKey::from_bytes(&bytes) -} - -pub fn load_secret_key(base64_secret: &str) -> Option { - let bytes = STANDARD.decode(base64_secret).unwrap(); - Secret::from_bytes(&bytes) -} - -#[allow(dead_code)] -fn derive_aes_key(shared: &SharedSecret) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(shared.as_bytes()); - let result = hasher.finalize(); - let mut key = [0u8; 32]; - key.copy_from_slice(&result[..32]); - key -} - -#[allow(dead_code)] -pub fn encrypt( - base64_secret: &str, - base64_peer_pub: &str, - plaintext: &str, -) -> Result { - let secret = load_secret_key(base64_secret).unwrap(); - let peer_pub = load_public_key(base64_peer_pub).unwrap(); - let shared = secret - .to_diffie_hellman(&peer_pub) - .ok_or(CryptoError::AgreementError)?; - let key_bytes = derive_aes_key(&shared); - let cipher = Aes256Gcm::new_from_slice(&key_bytes).expect("Key length should be correct"); - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - let ciphertext = cipher - .encrypt(nonce, plaintext.as_bytes()) - .map_err(CryptoError::EncryptionError)?; - // prefix nonce to ciphertext - let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); - out.extend_from_slice(&nonce_bytes); - out.extend_from_slice(&ciphertext); - Ok(STANDARD.encode(&out)) -} - -#[allow(dead_code)] -pub fn decrypt( - base64_secret: &str, - base64_peer_pub: &str, - encrypted_base64: &str, -) -> Result { - let secret = load_secret_key(base64_secret).unwrap(); - let peer_pub = load_public_key(base64_peer_pub).unwrap(); - let shared = secret - .to_diffie_hellman(&peer_pub) - .ok_or(CryptoError::AgreementError)?; - let key_bytes = derive_aes_key(&shared); - let cipher = Aes256Gcm::new_from_slice(&key_bytes).expect("Key length should be correct"); - - let encrypted = STANDARD.decode(encrypted_base64)?; - if encrypted.len() < 12 { - return Err(CryptoError::DecryptionError(aes_gcm::Error)); - } - let nonce_bytes = &encrypted[..12]; - let ciphertext = &encrypted[12..]; - let nonce = Nonce::from_slice(nonce_bytes); - let plaintext_bytes = cipher - .decrypt(nonce, ciphertext) - .map_err(CryptoError::DecryptionError)?; - let plaintext = String::from_utf8(plaintext_bytes) - .map_err(|_| CryptoError::DecryptionError(aes_gcm::Error))?; - Ok(plaintext) -} - -pub fn hash_it(input: &str) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(input.as_bytes()); - hasher.finalize().to_vec() -} - -pub fn hex_hash(input: &str) -> String { - let digest = hash_it(input); - digest.iter().map(|b| format!("{:02x}", b)).collect() -} +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use mtp_crypto::{Keyring, PublicKeyBundle}; + +pub fn generate_keyring() -> Keyring { + Keyring::generate() +} + +pub fn keyring_to_base64(keyring: &Keyring) -> String { + STANDARD.encode(keyring.to_bytes()) +} + +pub fn keyring_from_base64(s: &str) -> Option { + let bytes = STANDARD.decode(s).ok()?; + Keyring::from_bytes(&bytes).ok() +} + +pub fn public_key_bundle_to_base64(bundle: &PublicKeyBundle) -> String { + STANDARD.encode(bundle.as_bytes()) +} + +pub fn public_key_bundle_from_base64(s: &str) -> Option { + let bytes = STANDARD.decode(s).ok()?; + PublicKeyBundle::from_bytes(&bytes).ok() +} + +pub fn hex_hash(input: &str) -> String { + hex::encode(mtp_crypto::sha256(input.as_bytes())) +} diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs index bb784e8..defc41c 100644 --- a/iota-util/src/crypto_util.rs +++ b/iota-util/src/crypto_util.rs @@ -1,178 +1,58 @@ -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit, Payload}, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STD}; -use hkdf::Hkdf; -type HkdfSha256 = sha2::Sha256; -use sha2::{Digest, Sha256 as HashSha256}; -use x448::{PublicKey, Secret}; - -#[derive(Debug)] -#[allow(dead_code)] -pub enum SecurePayloadError { - InvalidBase64, - InvalidHex, - EncryptionError, - DecryptionError, - InvalidKeyLength, -} +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use mtp_crypto::{EncryptionType, Keyring, PublicKeyBundle, encrypt_for, decrypt_with}; #[derive(Clone, Copy, Debug)] -#[allow(dead_code)] pub enum DataFormat { Raw, Base64, Hex, } -pub struct SecurePayload { - inner_data: Vec, - private_key: Secret, +pub fn encrypt( + plaintext: &[u8], + aad: &[u8], + recipient_pub_key_bundle: &PublicKeyBundle, +) -> Result, String> { + encrypt_for( + EncryptionType::MlKemChaCha20Poly1305, + recipient_pub_key_bundle, + plaintext, + aad, + ) + .map_err(|e| format!("encryption error: {:?}", e)) } -impl Clone for SecurePayload { - fn clone(&self) -> Self { - Self { - inner_data: self.inner_data.clone(), - private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), - } - } +pub fn decrypt( + ciphertext: &[u8], + aad: &[u8], + keyring: &Keyring, +) -> Result, String> { + decrypt_with(ciphertext, keyring, aad) + .map_err(|e| format!("decryption error: {:?}", e)) } -#[allow(dead_code)] -impl SecurePayload { - pub fn new>( - data: T, - format: DataFormat, - private_key: S, - ) -> Result - where - S: Into, - { - let raw_data = match format { - DataFormat::Raw => data.as_ref().to_vec(), - DataFormat::Base64 => BASE64_STD - .decode(data.as_ref()) - .map_err(|_| SecurePayloadError::InvalidBase64)?, - DataFormat::Hex => { - hex::decode(data.as_ref()).map_err(|_| SecurePayloadError::InvalidHex)? - } - }; +pub fn encrypt_challenge( + challenge: &str, + recipient_pub_key_bundle: &PublicKeyBundle, +) -> Result { + let blob = encrypt(challenge.as_bytes(), b"challenge", recipient_pub_key_bundle)?; + Ok(STANDARD.encode(&blob)) +} - Ok(Self { - inner_data: raw_data, - private_key: private_key.into(), - }) - } +pub fn decrypt_challenge( + encrypted: &str, + keyring: &Keyring, +) -> Result { + let blob = + STANDARD.decode(encrypted).map_err(|e| format!("base64 decode error: {}", e))?; + let pt = decrypt(&blob, b"challenge", keyring)?; + String::from_utf8(pt).map_err(|e| format!("utf8 decode error: {}", e)) +} - pub fn get_public_key(&self) -> [u8; 56] { - *PublicKey::from(&self.private_key).as_bytes() - } - - pub fn export(&self, format: DataFormat) -> String { - match format.into() { - DataFormat::Raw => String::from_utf8_lossy(&self.inner_data).to_string(), - DataFormat::Base64 => BASE64_STD.encode(&self.inner_data), - DataFormat::Hex => hex::encode(&self.inner_data), - } - } - - pub fn get_bytes(&self) -> &[u8] { - &self.inner_data - } - - pub fn get_hash(&self, format: DataFormat) -> String { - let mut hasher = HashSha256::new(); - hasher.update(&self.inner_data); - let result = hasher.finalize(); - - match format { - DataFormat::Raw => String::from_utf8_lossy(&result).to_string(), - DataFormat::Base64 => BASE64_STD.encode(result), - DataFormat::Hex => hex::encode(result), - } - } - - pub fn encrypt_x448(&self, public_key: S) -> Result - where - S: Into, - { - let peer_pub = public_key.into(); - let shared_secret = self.private_key.as_diffie_hellman(&peer_pub).unwrap(); - - let hkdf = Hkdf::::new(None, shared_secret.as_bytes()); - let mut okm = [0u8; 44]; - - hkdf.expand(b"x448-aes-gcm-no-overhead", &mut okm) - .map_err(|_| SecurePayloadError::EncryptionError)?; - - let key = &okm[..32]; - let nonce_bytes = &okm[32..]; - - let cipher = Aes256Gcm::new(key.into()); - let nonce = Nonce::from_slice(nonce_bytes); - - let ciphertext = cipher - .encrypt( - nonce, - Payload { - msg: &self.inner_data, - aad: &[], - }, - ) - .map_err(|_| SecurePayloadError::EncryptionError)?; - - Ok(SecurePayload { - inner_data: ciphertext, - private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), - }) - } - - pub fn decrypt_to_format( - &self, - peer_public_key_bytes: &[u8; 56], - output_format: DataFormat, - ) -> Result { - let decrypted_instance = - self.decrypt_x448(PublicKey::from_bytes(peer_public_key_bytes).unwrap())?; - Ok(decrypted_instance.export(output_format)) - } - - pub fn decrypt_x448( - &self, - peer_public_key_bytes: S, - ) -> Result - where - S: Into, - { - let peer_pub = peer_public_key_bytes.into(); - let shared_secret = self.private_key.as_diffie_hellman(&peer_pub).unwrap(); - - let hkdf = Hkdf::::new(None, shared_secret.as_bytes()); - let mut okm = [0u8; 44]; - hkdf.expand(b"x448-aes-gcm-no-overhead", &mut okm) - .map_err(|_| SecurePayloadError::DecryptionError)?; - - let key = &okm[..32]; - let nonce_bytes = &okm[32..]; - - let cipher = Aes256Gcm::new(key.into()); - let nonce = Nonce::from_slice(nonce_bytes); - - let plaintext = cipher - .decrypt( - nonce, - Payload { - msg: &self.inner_data, - aad: &[], - }, - ) - .map_err(|_| SecurePayloadError::DecryptionError)?; - - Ok(SecurePayload { - inner_data: plaintext, - private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), - }) +pub fn export(data: &[u8], format: DataFormat) -> String { + match format { + DataFormat::Raw => String::from_utf8_lossy(data).to_string(), + DataFormat::Base64 => STANDARD.encode(data), + DataFormat::Hex => hex::encode(data), } } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 1dbc346..e77c8c3 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -6,8 +6,8 @@ use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; -use iota_util::crypto_helper; -use iota_util::crypto_util::{DataFormat, SecurePayload}; +use iota_util::crypto_helper::{self, keyring_from_base64}; +use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; use json::JsonValue; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; @@ -347,30 +347,41 @@ impl OmikronConnection { async fn handle_authentication(&self) { let conf = CONFIG.read().await; let iota_id = conf.get_iota_id(); - let public_key = conf.get_public_key(); - let private_key = conf.get_private_key(); + let keyring_b64 = conf.get_keyring(); drop(conf); if iota_id == 0 { log_t!("iota_register_new"); - let (pub_k, _priv_k) = if let (Some(pk), Some(sk)) = (public_key, private_key) { - (pk, sk) + let pub_key_b64 = if let Some(kr) = keyring_b64 { + if let Some(keyring) = keyring_from_base64(&kr) { + let bundle = keyring.public_key_bundle(); + crypto_helper::public_key_bundle_to_base64(&bundle) + } else { + let keyring = crypto_helper::generate_keyring(); + let kb64 = crypto_helper::keyring_to_base64(&keyring); + let bundle = keyring.public_key_bundle(); + let pk_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); + let mut conf_write = CONFIG.write().await; + conf_write.change("keyring", JsonValue::from(kb64)); + conf_write.update(); + drop(conf_write); + pk_b64 + } } else { - let key_pair = crypto_helper::generate_keypair(); - let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); - let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); - + let keyring = crypto_helper::generate_keyring(); + let kb64 = crypto_helper::keyring_to_base64(&keyring); + let bundle = keyring.public_key_bundle(); + let pk_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); let mut conf_write = CONFIG.write().await; - conf_write.change("public_key", JsonValue::from(public_key_base64.clone())); - conf_write.change("private_key", JsonValue::from(private_key_base64.clone())); + conf_write.change("keyring", JsonValue::from(kb64)); conf_write.update(); drop(conf_write); - (public_key_base64, private_key_base64) + pk_b64 }; let register_msg = CommunicationValue::new(CommunicationType::RegisterIota) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k)); + .add_typed_default(DataType::PublicKey, DataValue::Str(pub_key_b64)); let msg_id = register_msg.get_id(); @@ -522,8 +533,6 @@ impl OmikronConnection { } if trusted { - use iota_util::crypto_util::{DataFormat, SecurePayload}; - let challenge = Uuid::new_v4().to_string(); self.app_challenges @@ -535,31 +544,36 @@ impl OmikronConnection { .await .insert(sender_id, (user_id, app_identifier.clone())); - if let Some(pub_key) = iota_util::crypto_helper::load_public_key(&app_public_key) { + if let Some(app_pub_bundle) = + iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) + { let conf = CONFIG.read().await; - let priv_k_str = conf.get_private_key().unwrap_or_default(); - let pub_k_str = conf.get_public_key().unwrap_or_default(); + let kr_str = conf.get_keyring().unwrap_or_default(); drop(conf); - if let Some(priv_key) = iota_util::crypto_helper::load_secret_key(&priv_k_str) { - let encrypted_challenge = - SecurePayload::new(challenge.as_bytes(), DataFormat::Raw, priv_key) - .unwrap() - .encrypt_x448(pub_key) - .unwrap() - .export(DataFormat::Base64); + if let Some(keyring) = keyring_from_base64(&kr_str) { + if let Ok(encrypted_challenge) = crypto_util::encrypt_challenge( + &challenge, + &app_pub_bundle, + ) { + let bundle = keyring.public_key_bundle(); + let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - let res = CommunicationValue::new(CommunicationType::AppChallenge) - .with_id(cv.get_id()) - .with_receiver(sender_id) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_str)) - .add_typed_default( - DataType::Challenge, - DataValue::Str(encrypted_challenge), - ); + let res = CommunicationValue::new(CommunicationType::AppChallenge) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_typed_default( + DataType::PublicKey, + DataValue::Str(pub_k_b64), + ) + .add_typed_default( + DataType::Challenge, + DataValue::Str(encrypted_challenge), + ); - self.send_message(&res).await; - return; + self.send_message(&res).await; + return; + } } } } @@ -1686,17 +1700,17 @@ impl OmikronConnection { async fn handle_challenge(&self, cv: &CommunicationValue) { let conf = CONFIG.read().await; - let Some(private_key) = conf.get_private_key() else { + let Some(kr_str) = conf.get_keyring() else { drop(conf); log_t!("omikron_challenge_decryption_failed"); *self.auth_failure.write().await = Some( - "Challenge decryption failed: no private key configured on this Iota.".to_string(), + "Challenge decryption failed: no keyring configured on this Iota.".to_string(), ); return; }; drop(conf); - let Some(omikron_public_key) = cv.get_data(DataType::PublicKey).as_str() else { + let Some(_omikron_pub_key_bundle) = cv.get_data(DataType::PublicKey).as_str() else { log_t!("omikron_challenge_decryption_failed"); return; }; @@ -1705,23 +1719,14 @@ impl OmikronConnection { return; }; - let Some(secret_key) = crypto_helper::load_secret_key(&private_key) else { - log_t!("omikron_challenge_decryption_failed"); - return; - }; - let Some(pub_key) = crypto_helper::load_public_key(omikron_public_key) else { + let Some(keyring) = keyring_from_base64(&kr_str) else { log_t!("omikron_challenge_decryption_failed"); return; }; - let solved_challenge = - SecurePayload::new(encrypted_challenge, DataFormat::Base64, secret_key) - .ok() - .and_then(|decrypted| decrypted.decrypt_x448(pub_key).ok()); - - if let Some(decrypted) = solved_challenge { - let solved = decrypted.export(DataFormat::Raw); + let solved_challenge = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); + if let Some(solved) = solved_challenge { let response = CommunicationValue::new(CommunicationType::ChallengeResponse) .with_id(cv.get_id()) .add_typed_default(DataType::Challenge, DataValue::Str(solved)); @@ -1730,7 +1735,7 @@ impl OmikronConnection { } else { log_t!("omikron_challenge_decryption_failed"); *self.auth_failure.write().await = Some( - "Challenge decryption failed — your Iota private key may not match the registered key on the server." + "Challenge decryption failed — your Iota keyring may not match the registered keys on the server." .to_string(), ); } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 978e742..2d98042 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,16 +1,13 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; -use hex; use iota_logger::{PrintType, log, log_cv, log_t}; use iota_state::{RELOAD, SHUTDOWN}; use iota_storage::users::user_manager::{add_user, save_users}; use iota_storage::users::user_profile::UserProfile; -use iota_util::crypto_helper::public_key_to_base64; +use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; use iota_util::file_util::save_file; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; -use sha2::{Digest, Sha256}; use std::time::Duration; -use x448::{PublicKey, Secret}; use crate::omikron_connection::OMIKRON_CONNECTION; @@ -41,16 +38,11 @@ pub async fn create_user(username: &str) -> (Option, Option return (None, None); } }; - let mut buffer = [0u8; 56]; - let mut rng = OsRng; - rng.fill_bytes(&mut buffer); - let private_key = Secret::from_bytes(&buffer).unwrap(); - let public_key = PublicKey::from(&private_key); + let keyring = crypto_helper::generate_keyring(); + let pub_key_bundle = keyring.public_key_bundle(); + let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); - let mut hasher = Sha256::new(); - hasher.update(&STANDARD.encode(&private_key.as_bytes()).as_bytes()); - let result = hasher.finalize(); - let private_key_hash = hex::encode(result); + let private_key_hash = hex_hash(&keyring_b64); let mut bytes = [0u8; 192]; OsRng.fill_bytes(&mut bytes); @@ -60,7 +52,7 @@ pub async fn create_user(username: &str) -> (Option, Option user_id as i64, username.to_string(), None, - STANDARD.encode(&public_key.as_bytes()), + public_key_bundle_to_base64(&pub_key_bundle), private_key_hash, reset_token.clone(), ); @@ -70,7 +62,7 @@ pub async fn create_user(username: &str) -> (Option, Option .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) .add_typed_default( DataType::PublicKey, - DataValue::Str(public_key_to_base64(&public_key)), + DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), ) .add_typed_default(DataType::IotaId, DataValue::SignedNumber(user_id as i128)) .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)); @@ -94,13 +86,13 @@ pub async fn create_user(username: &str) -> (Option, Option save_file( "", &format!("{}.tu", username), - &format!("{}::{}", user_id, STANDARD.encode(&private_key.as_bytes())), + &format!("{}::{}", user_id, keyring_b64), ); add_user(user_profile.clone()); save_users(); ( Some(user_profile), - Some(STANDARD.encode(&private_key.as_bytes())), + Some(keyring_b64), ) } From bc43ee43e4297f81149d569655c6e1abf1fa30c3 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:17:19 +0200 Subject: [PATCH 069/119] [WIP] MTP migration --- Cargo.lock | 68 ++++++++++++--- client/src/client_connection.rs | 97 ++++++++++++++++----- omikron-connector/Cargo.toml | 8 +- omikron-connector/src/omikron_connection.rs | 16 ++-- 4 files changed, 143 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b53ec44..c5717a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1813,7 +1813,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -2655,22 +2655,40 @@ dependencies = [ [[package]] name = "mtp" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ + "mtp-client", "mtp-codec", "mtp-common", + "mtp-crypto", + "mtp-files", + "mtp-host", "mtp-transport", "mtp-type-map", ] +[[package]] +name = "mtp-client" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +dependencies = [ + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-transport", + "rand 0.8.6", + "tokio", +] + [[package]] name = "mtp-codec" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ "base64", "byteorder", "mtp-common", + "mtp-crypto", "mtp-type-map", "rand 0.8.6", ] @@ -2678,7 +2696,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ "quinn", "rustls", @@ -2689,8 +2707,9 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ + "base64", "chacha20poly1305", "ed25519-dalek", "getrandom 0.4.3", @@ -2698,15 +2717,38 @@ dependencies = [ "ml-dsa", "mlkem-tls", "rand_core 0.6.4", + "serde", "sha2 0.11.0", "thiserror 1.0.69", "zeroize", ] +[[package]] +name = "mtp-files" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +dependencies = [ + "mtp-crypto", + "thiserror 1.0.69", +] + +[[package]] +name = "mtp-host" +version = "0.1.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +dependencies = [ + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-transport", + "rand 0.8.6", + "tokio", +] + [[package]] name = "mtp-transport" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ "log", "mtp-codec", @@ -2720,7 +2762,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#5bfcccc056a2a491a72e4315aa95effb2243ad3c" +source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" dependencies = [ "serde", "serde_yaml", @@ -2783,9 +2825,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" dependencies = [ "num-integer", "num-traits", @@ -3416,7 +3458,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -3454,7 +3496,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -3799,9 +3841,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 13e03b1..c367f7e 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -10,8 +10,8 @@ use iota_storage::util::{chat_files, chats_util}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, load_file, save_file}; +use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::transport::{Receiver, Sender}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; @@ -231,7 +231,10 @@ impl ClientConnection { for (i, contact) in contacts.iter().enumerate() { let mut contact_container = Vec::new(); - contact_container.push((DataType::UserId, DataValue::SignedNumber(contact.user_id as i128))); + contact_container.push(( + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )); contact_container.push(( DataType::LastMessageAt, DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), @@ -253,7 +256,10 @@ impl ClientConnection { let message_state = m["message_state"].as_str().unwrap_or("").to_string(); let mut msg_container = Vec::new(); - msg_container.push((DataType::SendTime, DataValue::SignedNumber(message_time as i128))); + msg_container.push(( + DataType::SendTime, + DataValue::SignedNumber(message_time as i128), + )); msg_container.push((DataType::Content, DataValue::Str(content.clone()))); msg_container.push((DataType::MessageState, DataValue::Str(message_state))); msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); @@ -268,9 +274,11 @@ impl ClientConnection { }; let mut last_msg = Vec::new(); last_msg.push((DataType::Content, DataValue::Str(content))); - last_msg.push((DataType::SenderId, DataValue::SignedNumber(sender_id as i128))); - contact_container - .push((DataType::LastMessage, typed_container(last_msg))); + last_msg.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + contact_container.push((DataType::LastMessage, typed_container(last_msg))); } } contact_container.push((DataType::Messages, DataValue::Array(msg_array))); @@ -391,7 +399,10 @@ impl ClientConnection { .with_sender(sender_id as u64) .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) .add_typed_default(DataType::Content, DataValue::Str(content)) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)); + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ); let other_iota_resp = self .clone() @@ -421,7 +432,10 @@ impl ClientConnection { DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128), ) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) .add_typed_default( DataType::MessageState, DataValue::Str(ms.as_str().to_string()), @@ -445,7 +459,10 @@ impl ClientConnection { DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128), ) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) .add_typed_default( DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), @@ -459,12 +476,18 @@ impl ClientConnection { let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) - .add_typed_default(DataType::SenderId, DataValue::SignedNumber(sender_id as i128)) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ) .add_typed_default( DataType::Message, typed_container(vec![ (DataType::Content, DataValue::Str(content.clone())), - (DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ), (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -508,7 +531,10 @@ impl ClientConnection { DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128), ) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) .add_typed_default( DataType::MessageState, DataValue::Str(ms.as_str().to_string()), @@ -537,7 +563,10 @@ impl ClientConnection { .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) .add_typed_default( DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128), @@ -593,12 +622,18 @@ impl ClientConnection { let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(cv.get_id()) .with_receiver(*receiver_id) - .add_typed_default(DataType::SenderId, DataValue::SignedNumber(*sender_id as i128)) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(*sender_id as i128), + ) .add_typed_default( DataType::Message, typed_container(vec![ (DataType::Content, DataValue::Str(content.clone())), - (DataType::SendTime, DataValue::SignedNumber(timestamp as i128)), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), (DataType::Height, DataValue::SignedNumber(height as i128)), ]), ); @@ -627,7 +662,10 @@ impl ClientConnection { .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) .add_typed_default( DataType::ChatPartnerId, DataValue::SignedNumber(*sender_id as i128), @@ -652,7 +690,10 @@ impl ClientConnection { .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) .add_typed_default( DataType::ChatPartnerId, DataValue::SignedNumber(*receiver_id as i128), @@ -672,7 +713,12 @@ impl ClientConnection { let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages(my_id as i64, partner_id as i64, offset as i64, amount as i64); + let messages = chat_files::get_messages( + my_id as i64, + partner_id as i64, + offset as i64, + amount as i64, + ); let mut msg_array: Vec = Vec::new(); for m in messages.members() { let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); @@ -693,9 +739,15 @@ impl ClientConnection { let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); let mut container = Vec::new(); - container.push((DataType::SendTime, DataValue::SignedNumber(message_time as i128))); + container.push(( + DataType::SendTime, + DataValue::SignedNumber(message_time as i128), + )); container.push((DataType::Content, DataValue::Str(content))); - container.push((DataType::SenderId, DataValue::SignedNumber(sender_id as i128))); + container.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); container.push((DataType::MessageState, DataValue::Str(message_state))); container.push((DataType::Height, DataValue::SignedNumber(height as i128))); container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); @@ -717,7 +769,10 @@ impl ClientConnection { let mut user_array = Vec::new(); for user in users { let mut container = Vec::new(); - container.push((DataType::UserId, DataValue::SignedNumber(user.user_id as i128))); + container.push(( + DataType::UserId, + DataValue::SignedNumber(user.user_id as i128), + )); if let Some(name) = user.user_name { container.push((DataType::Username, DataValue::Str(name))); } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 725c990..6337e17 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -8,9 +8,13 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ + "client", + "crypto", + "files", +] } -dashmap = "6.1.0" +dashmap = "6.2.1" json = "*" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e77c8c3..ecaeffd 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -10,8 +10,8 @@ use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; use json::JsonValue; +use mtp::client::{Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::transport::{Policy, Receiver, SendMode, Sender}; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -251,7 +251,7 @@ impl OmikronConnection { let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); - let (sender, mut receiver) = mtp::transport::client::connect( + let (sender, mut receiver) = mtp::client::client::connect( &addr_str, None, Policy { @@ -552,20 +552,16 @@ impl OmikronConnection { drop(conf); if let Some(keyring) = keyring_from_base64(&kr_str) { - if let Ok(encrypted_challenge) = crypto_util::encrypt_challenge( - &challenge, - &app_pub_bundle, - ) { + if let Ok(encrypted_challenge) = + crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) + { let bundle = keyring.public_key_bundle(); let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); let res = CommunicationValue::new(CommunicationType::AppChallenge) .with_id(cv.get_id()) .with_receiver(sender_id) - .add_typed_default( - DataType::PublicKey, - DataValue::Str(pub_k_b64), - ) + .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) .add_typed_default( DataType::Challenge, DataValue::Str(encrypted_challenge), From ee0202d56a829873e7edcb053fb0afbb9c121c71 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:40:57 +0200 Subject: [PATCH 070/119] [COMPLETE] MTP-MIGRATION [Some errors to be found] --- Cargo.lock | 1 + config.json | 1 + iota-storage/src/users/user_manager.rs | 3 +- iota-storage/src/util/config_util.rs | 8 + iota.mk | Bin 0 -> 5713 bytes omikron-connector/Cargo.toml | 1 + omikron-connector/src/lib.rs | 1 + omikron-connector/src/omega_discovery.rs | 88 +++++ omikron-connector/src/omikron_connection.rs | 385 ++++++++++---------- omikron-connector/src/user_ops.rs | 3 +- 10 files changed, 288 insertions(+), 203 deletions(-) create mode 100644 config.json create mode 100644 iota.mk create mode 100644 omikron-connector/src/omega_discovery.rs diff --git a/Cargo.lock b/Cargo.lock index c5717a9..76267a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2926,6 +2926,7 @@ dependencies = [ "mtp", "rand 0.8.6", "rand_core 0.6.4", + "reqwest", "sha2 0.10.9", "tokio", "uuid", diff --git a/config.json b/config.json new file mode 100644 index 0000000..03a370e --- /dev/null +++ b/config.json @@ -0,0 +1 @@ +{"keyring":"BMAGjI76myen0MXtUh7jwKfZBzJNeBofMRcVcDTJVSZy5IXAiR+f9GrbpQdIdYMyEgSRlbr6oaiqIBkhaSJZkI6YaIUFyIn4aRvPhG//ET/1NwLLyLw/1ripyKgAzAEFC3lGaLn0U7AS93vPuTxxMm5koKR7hzG40rTjEkcu1w6j1lpMupmFKqwpyj4WC2koVybhln8G0MjO947yXE0Tk47GsnWr0SENM7b79AlXNZvL9AMsGQbdgYqjIbMRZWthKY4P6EiZoWKFuYJrixeaVWSr+mCACgRDaMT+os+w+jw56oNCpFbBuswFR3iF8I3p9JdbiTl0ya1HwrngZJlUub9KxRgfCz5ZRhjzqKZ7Kgwhy7S4Nx2EFH4UhEaf+Y3QrGHitn7YCZXB5U0YGT+cqYJe8br77KiLW5rvjG2RaoaoA4AF8jiJ2LjWo1UrZDWL+hmiISuyWy3jZE7njI6oqXTZMb5oMSnB5r4KCyy/nA4DEMiTcLIcRgyUAXx1sDGX2Qc4yXwXqoMxnDzlCBFrRwn3EFbLYRNGpUM4y8Az4AROtTwTJq714RU9mc9v2V7jW7uCxYCMWWG2N3RIcppCJRbuwy1nWEPxNGEs8lp0wgDaSU1exz4WBldSCLD/mcImlCNiC174FVBgfLjM7Hp8oKnhCSziaTmAQ8mm5300Spw+A66VvCvGxheosy9BuTCpBKe9lJ/XdLrfnHxud1CoYgh9ymtGXDfkZ2H1c70XyC70+US/mFhfK6osMMDqOsd5eIRF43E/0oQfUcL8CBxEFcbDRyumSbEZpz42hCb9ccCzJZf6x6Bd9mzUeZYBlibrHJ+TKA6I+quB9CD1dljIQjTxQyLC91vbayoPuQ9iF2DKoEnicCNhhzLzZHRqE2oIE3MirDsCmMGno0+Cs7aqF8mvC1jBuzIQwlotOW7CYLjORmWLOrbbGMfV+XtOogEDWVYrxSe3dUOZ2x3fcQzGiVXjGjv2eTYQgHafDJUs6hbtDCqKUYXjzILF7MkpGx0EGYGhEQBYkMfQ7GQ8fI1ppYbpVUYh6m6zIWnWXII29aZOGldKAwO6uSjBwDI9d7lN1JgVR35Iu4Wj5zJj6ro9QxJ/mnSTwINSdj4SRo7JKIgWgXkeoS5VGM4Tq4umnIiQPFPXObrXBhQH8Lezsqyq83qeQFydy699zMz/WACflUua8Hhlgk6lMV0VfB3yBoJQx1WRxhGQm44CeBgt18u/Wm+ZTFJ+IR/+4yUg+qRhuc74k83LsCorFSSfymE4UDY7XJbAuZgCFVTs6TjzVCS4+YMdC4Row2p83J+CsYUAYoCnV67GNKEtwxH7XAJZFyKkc3b/kEPffDYJ9Ep7yrEy5AEqaWaT7B8f6U/6Nw4feykfinaruFPGplduEY9P6sLQ4Fvn+wDtA5EMwbkXQLwJSkGv3GOHBWbIWY+HmWnUW6lGPJd9O8US88SFIG0e8BdW0I9IaiAuvCVgpw8ImLJDswndSGV4sJ0A0Un4qIQrzCcszAR+GKArhMXcGo1SSndo9MxROyg1uJFwfJM9BQMZJjAE2+7LaIyaAGIf+/zEGWBoFhPYQqXu13laMW/npdMRUR40YydPhBRuMkXMeiJaTDFYCYCLmHASQghali1XJXH2TMJOh59iC2RiuJeTAZD7IFy/gaQtOLCxoHHz6L0kODXCdJB0xkr8CETSkgf7fKKlxSC8+n31IqRCq1tsgqssahhRBRFy+aQ4NzhVypvdoKiFqo3XmbRPhl3QxGHweaw9PMcq55au2SuttA9kZUcRo0C9PAppO3kLkTSjNamLWBj36Lmuo2KNlQjm9AS2CCwixxOSIWBj+SNCiWl3TF/0uhb6NEiKk3hSHDYBmbT+mK7KbIMqWM/Yemw9SyOdlI3KiEGRuj2BWzoy+ARSGxyCZ4HxGryAaRtWo8AwSaKD+Dz97LcEYUTC5Ja4qE/2yLzNtVJimTiFpkJLaojVEieWyZOdks8smBwTqz9I1aiE4GqX0no1/C4aYxtauMSWGRh9KV/1dLnEcgRExTvf6GIzEH+/kEi4Rh7AJLGuvKZT9kwEm7Nb9Sw4oUmHlZhyh5Q/gGChQZ3cDDFLdi4TBkePuAk10Dz2u575m5GvwULlN0NaJ3hx46KvyWbaVw84pIYuNg1Dq1wXWx0dOM+djL7LyXrQvJaD+spC+b0+N0o0MYcmvHgsG0VYm4IORMR7qAZg3HiBzF8Sdp1+Fz6jJG/ffDJTSmPtYSQDTIwctXrtpmw2+wOVW3G/hsf8RS7kNVSFxUnzrKTjmjovCFOaO8KEigz/CpPre6iUiVHX+w9ZIMBX8zScuLoObArc9Bn3Ykt2IcCoJaPW94V6pcgCUlzGi5lWo4cAx7pcSlL0pTRkk5AbcYxq+74kNKm4FFwB+rfPnE1R2QxyWTbi1psFgHJGchjypQBGeU1fUg19RCfz5HAL5YCCuL+Z4a+QiIGbdBrgSzBgUI6Hg1oG+Kp7IqJehG5/ekuMZ3MtNo9f1MLB603zUmcV+CE9gliIGFtVpzCwNlyRIbICwpT/1kqEjHFiK2jh4AgaWJ8ZqWg/mb6/qBz2BsqEqEVm+AL8BQsBOjtxh8uD3Aoat7mSApxNgrKKSh0Y5Emv1woxhUjueSFu5zRBWayaYl9oSkbMGZTPMGAYyT3vVDn2PKWEV7t8ND27PBktUM6/8ifisKI/K8ZTqDG7N6qioz3iY6sCZ2+Fq6jUBjQwqx34qYfRmHXMsX6198Fv0rrG2WZmEWc6QTJG60S0Ez90oAf72LathCal5MMRICSWEsx8BJpugX0ccaEgxZ6yJ5N2wa4FEzvDZXxL1LAJY5A05TvEB7fNQUSS9TnSc0+tu88ZhMKYMQjpMjnncoH+iL/VIRrlC4rxeRshoxHQsDD/23GzWz+n9buPiFwkQ1T+eCc/Q0XRIgrqPFmlIizSfAPhaQcNdcmfRkIrdosFNXxg95ciKAD14mD6ynjRZwYLSC+YKgCXQm7g1mOu1aGUBJkcl1nlxrnVQFudlpq3+RPCgS8g6JCCxFHrkgoBeqWZS6KfoxiOdZm6uj8LIzah6bFn2BFKC4xTEDQRGwxCHBFF5DKqd69ExqTFcLWD53/d0HFLmBfv1V7ESnzinKK2mQGpqss0VwdVsy16tLv1EqgGjI76myen0MXtUh7jwKfZBzJNeBofMRcVcDTJVSZy5IXAiR+f9GrbpQdIdYMyEgSRlbr6oaiqIBkhaSJZkI6YaIUFyIn4aRvPhG//ET/1NwLLyLw/1ripyKgAzAEFC3lGaLn0U7AS93vPuTxxMm5koKR7hzG40rTjEkcu1w6j1lpMupmFKqwpyj4WC2koVybhln8G0MjO947yXE0Tk47GsnWr0SENM7b79AlXNZvL9AMsGQbdgYqjIbMRZWthKY4P6EiZoWKFuYJrixeaVWSr+mCACgRDaMT+os+w+jw56oNCpFbBuswFR3iF8I3p9JdbiTl0ya1HwrngZJlUub9KxRgfCz5ZRhjzqKZ7Kgwhy7S4Nx2EFH4UhEaf+Y3QrGHitn7YCZXB5U0YGT+cqYJe8br77KiLW5rvjG2RaoaoA4AF8jiJ2LjWo1UrZDWL+hmiISuyWy3jZE7njI6oqXTZMb5oMSnB5r4KCyy/nA4DEMiTcLIcRgyUAXx1sDGX2Qc4yXwXqoMxnDzlCBFrRwn3EFbLYRNGpUM4y8Az4AROtTwTJq714RU9mc9v2V7jW7uCxYCMWWG2N3RIcppCJRbuwy1nWEPxNGEs8lp0wgDaSU1exz4WBldSCLD/mcImlCNiC174FVBgfLjM7Hp8oKnhCSziaTmAQ8mm5300Spw+A66VvCvGxheosy9BuTCpBKe9lJ/XdLrfnHxud1CoYgh9ymtGXDfkZ2H1c70XyC70+US/mFhfK6osMMDqOsd5eIRF43E/0oQfUcL8CBxEFcbDRyumSbEZpz42hCb9ccCzJZf6x6Bd9mzUeZYBlibrHJ+TKA6I+quB9CD1dljIQjTxQyLC91vbayoPuQ9iF2DKoEnicCNhhzLzZHRqE2oIE3MirDsCmMGno0+Cs7aqF8mvC1jBuzIQwlotOW7CYLjORmWLOrbbGMfV+XtOogEDWVYrxSe3dUOZ2x3fcQzGiVXjGjv2eTYQgHafDJUs6hbtDCqKUYXjzILF7MkpGx0EGYGhEQBYkMfQ7GQ8fI1ppYbpVUYh6m6zIWnWXII29aZOGldKAwO6uSjBwDI9d7lN1JgVR35Iu4Wj5zJj6ro9QxJ/mnSTwINSdj4SRo7JKIgWgXkeoS5VGM4Tq4umnIiQPFPXObrXBhQH8Lezsqyq83qeQFydy699zMz/WACflUua8Hhlgk6lMV0VfB3yBoJQx1WRxhGQm44CeBgt18u/Wm+ZTFJ+IR/+4yUg+qRhuc74k83LsCorFSSfymE4UDY7XJbAuZgCFVTs6TjzVCS4+YMdC4Row2p83J+CsYUAYoCnV67GNKEtwxH7XAJZFyKkc3b/kEPffDYJ9Ep7yrEy5AEqaWaT7B8f6U/6Nw4feykfinaruFPGplduEY9P6sLQ4Fvn+wDtA5EMwbkXQLwJSkGv3GOHBWbIWY+HmWnUW6lGPJd9O8US88SFIG0e8BdW0I9IaiAuvCVgpw8ImLJDswndSGV4sJ0A0Un4qIQrzCcszAR+GKArhMXcGo1SSndo9MxROyg1uJFwfJM9BQMZJjAE2+7LaIyaAGIf+/zEGWBoFu+riy8UUa4SXlhFlkzX0LxFF3GjDM8Ig7Z2e5dQPjhTF1E2/gR7C+el3nZRt3rb4xZF9OMFK1MOEdxBoJorubl0sqA89NULY0ePfMRef3eqe4PsNwcCSlUyLzqWwM10uQegMln/lXI/TJfBe4wuQ0IrPW3GBv+60A1pKSEmgyeT9RLSupHZGxbZHz0C5wocLbcpelkae/caApr7vUp8Kt3npl89ZM3xVj6HSb/8UgolC3yXfwG/W9ssCzmtQ4vC6HRhzM29MGBjZxMeLbYY+jrbI23L/iIgnKVeCenTh3wIVcoEKfkRvs8sAj1EjicL/YMJ0InKoiOrFC4CjMcclcuMw30Y8FmUcXxZjqltqbw0lhyuGHJTcUdesostQBapvrDkXTRK6hViPJehixApUEeTTLSUJDu9Nw2/LmOTEkYn0+sVDmkyjyYvaPmH/pvzuDIizEnYkRR/PKopmXhtnuis2UEh9U57qkNfJc7aj/OL245w062GpHpd2ynssXfbq87mjo4kfXwWACVAuxf7RWlbDGTfEF2wYeY+EjaouU137mA66uWPFhE3gyd9pvr2BsljYmcn3h7YStAMVWPe2m1Rtqlcbaez+O0g8fdG2elJgMt3asOhOzgJPIi19wRORZjoPPqqlZiwS1mu2m4EX91hJK1ZUT/d9UckuhaFVSyKAFo4edEKS+AkGsB6OBPuuvwxzwgYCv11eXcX5evB+b0mpEB+KPj5wkjWQcK51w5/Ondv+YPzgv1QN5SJznlz3kTn22P+qT/Zohz8Esy8EHZclpQ+lg+aF78ybvq3A9LKqiABSIX6ojLlQ0zODOm8UeTg18y8j8ofn5ivy9z3D1ihX7wwkSenDkDK8Fi+9wKTconAfFBoH4LINDHwiQuUUQ6BgmD6xpB4df7UdKowuODZO7nOQRy4mQVyrJMlvf8Hm54zw4vURSI4T5kFhbBizqcWEVftE+dki4Qtxxtd0rbUeXsxfykdgDrfkiGs7IoJL+cFQrY4rea1650/NLvQqk5/BILe0JybYPkzJLlFqHrL/mX1nG1RZJMfq8ChH1WECs9cZehKRFg7umjCEBXsr7+P713HqtVti33HYlQIAJEawbsM5Fj56/nxQiKVabUAykQ5krDq5OhLtn/z2IQAexjHmBTmkSLVWGl8P7JY4BfFQeAL/mW5TxVxf1MR7UE/GY1Re214zkrX+aa5tdWq0QKRa1xm5iepHYKgbAeU3D2jOwVzfhMWs3vDe1o4HjQrgC2+065TpcY57kizDEr1sX1mDvpaZBpTMBvyVdwpQsdSSUK3lruHkvYW5ATV4sdhPUua2ATk/gAVHC2WkFcxxiesCqkhSlib6YgolYDzVJu21dtR1mxMJed8FADYB7ICL7JgFBpGcit0Da0Khs1DAfCq1VT1cgHvXymzsjQPUKCFsixNKbk7fzByrU2IYOokjPAi4vA9jwYMsQ6fedoM2uyQT2Ro4nJ1DhGH5DaGdsi3nwKs6ceOadRSc43uYoVEyzpFcQyHAqBMYnz5e6tSTCqynK5cd5EwzJl/ShHhfRNH3nWoFWnYYenD4SWDqvKmyvLuxq4gISAw3onDRJsYde9M6gGvG6lWZtH3g/94qqH2g6RugRM5n/+5riMiN5AYY5LCMgkHeM4eOur5HKymjw1EVGKnPsX2m00bSkWSrTwUiiv3ogjmzM+F52CU7+BjYhdnaGdW+3zDCxfL4LIC/10MJD4Btt5vsdZwnXQdDizb/OmuVkIp47fPDR2Ni8LlWB3HPLcSQiE2B9RBKSeDoC5rr02SvS6be9oB9gYigmEp3QkhxfNdszW0Fw7rIWmzRRumRid6zqJRK39gH1f6XHFIJkTluZPqMdbHG6ZFSMxevrpNwUsjO/R8FYUYLw6ntj4VhU54T3s6Zh/kYLKi+qiVwPkv7RchRxB1gsnUgGBhA5nuU1AHtoABZorru7Wfrc9PaYty6mPrbyNKE56OCkhL/U5xqcSVKaZuLij5Qc60KzN0ixumiCPmKWaCSwK5rrjdKjD0OhTz5gmFmmaEisuvh94o1N6tEe2vYFHVTQ0biDq8/AbmahaSLU1TAcCjIznvl77nLBG1MJdMIH4kzyIw7uhSz/m/aET88RxIvc399PuTlXyOuysCfPBig1AMiRnpBWPhIlh9KxFj53P8QtfZgfBrEU/bHe27bkxFWkwGUxJSKeqHcrt8H7NzqaQbw7vdGXB6TO5saqz8qX1oJUzF0Fiuq0ce2nLeC5tpGWNswQ/WHEJmyJPGUAGCYyWE88qi2AKmJ/XBgZnugSCmtHjDQ6iuHt3+a9ZWUEl0ZzEf03PgOHCu/fjr7yAlk62+s9CW5L6YWx2ZQlrbvKWik8N76JS5lerB+TYIPqy4uYw+1UVzVC2yjmuqdnBU1Mm922FQ+JdZ4tmHQpHdswXiQSwvZkLN3MAle2yRQbIgmBcAUaI+rFBw4wUYM3UtAjjQoyKA2ATtRs7539E7KjjgNmpeHY/8ezDH+VqHscdOp1vncoMWI7AGgdai9LRnsGttSMddGMr2tgizkL6ucvIX5P8Oi57actaPRjeMRIsSyop9HQblrQhffJ1y0GlKwxI1gZQCqf955zn5P1uGCHGSh07qvP0e1qySPOb6rGkNhRV5xpPQ+Ik9YJBtx23j6kGwRI0DBFucoHJzRXsdgZZdn7MJ//jztsNzgVpx65QT8AAEeEX4qHHR8AVDqq49MrCQihEAIFXhH997tCM+mQuv4UGlq0pcTU3lxXZU/iFXBcKGNM57ACCjaD7MAxirp1DcoqFvmBAoogV577OS9UCCo+4cgL0MewAgXmP0NeyVgq5Dg60GcRXJ5P+4ulE/8wQX5boc1bhFEYY="} \ No newline at end of file diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 248881a..bf1dc00 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -15,7 +15,8 @@ static UNIQUE: Lazy> = Lazy::new(|| Mutex::new(false)); pub async fn load_from_tu(username: &str) -> Result<(), ()> { let file_content = load_file("", &format!("{}.tu", username)); let segments = file_content.split("::").collect::>(); - let uuid = segments[0].parse::().unwrap_or(0); + let (uuid_str, _omega_host) = segments[0].split_once('@').unwrap_or((segments[0], "")); + let uuid = uuid_str.parse::().unwrap_or(0); let b64_private_key = segments[1]; let keyring = keyring_from_base64(b64_private_key).unwrap(); diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 2dbaada..74e75e8 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -50,6 +50,14 @@ impl ConfigUtil { self.config["port"].as_u16().unwrap_or(1984) } + pub fn get_omikron_host(&self) -> Option { + self.config["omikron_host"].as_str().map(String::from) + } + + pub fn get_omikron_port(&self) -> Option { + self.config["omikron_port"].as_u16() + } + pub fn get_keyring(&self) -> Option { self.config["keyring"].as_str().map(String::from) } diff --git a/iota.mk b/iota.mk new file mode 100644 index 0000000000000000000000000000000000000000..61083565cb37fe02c651ab466a776954201300aa GIT binary patch literal 5713 zcmeYb@%3h8Il$J__iMKL@(V}b2FX1>u>2;wk#B{RyrH;gfyv2GwW24j2Rh~Ff62PN zl-;AW*+__G;?!Ng7Oq&OAgP$C6gi=9Mn)^^iOwIH(&t<9{|nlGHD@|~Vvqf`9V<_) zU^v6b%3bM}vGYst2BGiO=Xcr^8s()dSW?|?xZ~26$3pIU*ZCG-i}KkuvsG)2<|#Wd z?o5qvwTIK{*)E(o_r32^jIZ$IzGIt8S6@`*HQx673un0L?9*SEbtKvDHg+vm+$@-y zov7Kz|H5PD!lc%nP1)VzvqDo=|4L}!VsXwm@^8`k4ZmzGUo|@|2|Kv!46A!Z>xbT# zU#3TQT9%w#>wakGgOr&eJNJ7Xm5}GQi*%Ftykc3k7LVfTEj!F*TSV$aTHNOU?7gri z@zJ)r8=O-QKJ}H5w4bxGDemL0-)~lQN6&iSlRGi1Z3S}!>nDrO8#}Ho4%JRE?fxaX zNKt!JwC>{+zvn%DD^`}=G~AbAsCn?&J}z#Z{d4%31x`#Z*d*h|Glj9Hbc5mao9q@R zYs6PI8_uzL$|0ET&iP#+>~x~A+frwX(+7+nu=s7Y5msCG^`WTk%=7s-;~q!vZaUh~ z6PdWpyu_numXoU3yTiKa5zZe?5_LXBl^kNY<>?!D+)j)wJcwh%|Cxu>rYI+I$Ndlu zNT}Iy=1o=2f|ULy7r55?X zLw`7ATttr@cGq6!xlwYtomq?8-@*f%Rj2kO> zuVoP@oJ>ACD;@eCeLGu=egGX zwwIx9im&oED`sAcX)^n|%ug!ZiHjBLwy`d*nK>R#uuyLIt%qvTh+Y@LPb zXO&Do&>U1|C*;<5Qlmqxu~KfKUZ})5;nm&C=5$Q33BGQ*>pGhV`-knDH?3LqxoVz6 z%-qxKYtNkdAHgtxs`so96{$^rOATX1Yh*vMH3b|Gop?-e!t6e#3JKlor}szY&-4kZ zQsLK3Pi53B7)-lr#?3}?Q8uI3)#pe)}9Y338xmz+0 zXVu)9-?XunA*o?`__|{z3v~|*{*Gaa6jxeOT=sv0^Zgn#&M#ior#2crVbsb@oBT#z z{-ysfb3XZMP5G{})jNWZEep>R?Dv0l=)!~O=f4@=GEd|=xKrF=52u&o`a8+(tZ64A z``c$`UWs1mW;4Ck`l!(7BdrR#av#LQF7$h3Dd_D{O<2y)F=LbSX3o1FsTCXMGF3-yB+tn8OqEiUk6{F7L-&jVAt#QgX}aO6 zh2MM*`L)ka;!a80F?}-Qgx?A=`x}?&T5Q<3pz!mHy($)_he{@t9P|3a;c{sb`|p}X zOOGn-`BnQ>X^GS7=$xk2I$08dtb#>9msprvgr1sxcfpF*RlV0|Zt-u6y>KM)L**J< zo8wx~r>(oGy><(KN~*iyVu!spT$$FD+!IX}o38ARkof*$=eos7y;C`!ePP+gp`&zM zc#>j5@=s-_&dhS3_%FM}ewlc5O|A%%F=L#$<=>2Tr*fLLBF^8a%CYrUo;#)YREOil zUAB$URz^Qqf}~}d(i=ZY?P*cUH$$A@yk|C(oU9UT21k@M?RHt1DU_WKF+RW%@@?Dp@*e$B}8061AG~UrTl# zDPnOsYJLAjlCePj{s|sC+~f|ZY+SczS@1U>mf4%5zv@^l^lYCxqo{p~eM7=R$GLZS z486E~WMb1xIacC|efI?}EYvzz!_j#RH;OSn_fcqU$`Y;K*n z-C~ve`!zUj8`W(Tt ztPizx@%-nS{JMI@l+M8Gzxg8-4upR;nX_XTUk=xuFOuJryvr01tWaHi?R#t0(i2QU zF~_=RhAnPqIKC^!E9lEola$F5qzikpe(zH;S-C?bhVj?-^K*OyZ}JpHnmxKUo3)|H ztw`e2QUr#uVewtBp)RLpyB;uyJRR#JS1m)jZ1Dd!CmBu?7C53&4av$Q3Aca4edZW~G6fOGpl zsXyAV$X@$c@Cw7-=BpMhwtbYmnkhZMb@hrXY$gV)Wq+(}zc{1x%*MK{-w) z3R@pet?|CHfiro6$y4hi?Ay;ex=i|Nd8ycc?e6oEEr(_pa=bLMd|uS}uVepJMX9IU zT^}o@6&DL$*kJJgcH!n|`{iGE_jkmoIEVbJP`7t>y{N?X$|iEDlFp?X=7*W=yrn1S zyE$o>b+ek*Bz&K)q`~m@QNpiN6&KUlxIOe|XfaH8%6o7vdEM28Q&?uoOpkneZ0A*n z=(*EoZT~5Js8L_x#e}9Kfv+cVF;*>|>Ah(FVu`-enY(t`b1R!Ie7Q0GhM*UBPq2WA zpfrz@jG*fiqgCbWU5+g|TClbGdHvlBh2As7-(QV8;#Ko#&Z2EI8CR}4Z4%BNx>>hs z%kHm2D@I#Tqb;b>7Sw19YOuDT-mmV~7YSS^6c^z-&FA`sJ+9(~i+Rp-G;b@bo*rOl z5iA~P_K&5S`}xv)Wr5qPZa)@t{qmSqJD5-Kj^lz^+BjH14rlMN2`sA-d zmv&9ODJ^zW-j?Y(myGUq&8kSL>hDravwrXOs?oapd|AA0%Gr-$cI}?~{{(TVa@S0+ zXWSosTZh|nt#kLG7bS^j&h9lxNKO}))7>WV%j&jr?&*I@3UijmalX9VUc(W3ibeCM z;J))ZOtvn4>fC>uIWKgcTBN*MM31TGxXjekJ%?*0K15C_tcmPfnY(h2$uyaD5=Fs< z?s1#CbsfZ3?%VJr*2L?TXp+tJh1~+00q&E1woFm6-fPagUoUyGkemAD*P?uxM*V8~ z89&?q&HlW@Na>8{jfo=lHmfveR^-lmvF4_u;#a@wRnGCM=Wg|X?!MhuaCvRplB(F- znr}9i-(G$0Szn(@ZH*X%s>5#a->#X_JSq1DVmBl{vlB8~vD3HwU4qrCr~P7r=FRH0 z%YJ=hJDHr6u6|GMhSvq2(Byl!as#)mjLBWT`Nvy@kKf&HzVvK3U7mG#p|u64O~=;n zEPk#tUfBFvHFd@Y@5ptx@>t^UCaSEB479)d)m>$mSZkL$$>586wXn zDqW4ptg+t|@j(2j;{)!0sXP5e3+sag-#Xe$_6AnxR-E&?{&U&RtyfoFWSW>AllDw~ zrEJrJ9QG-9Y!_Rz7S{=jZLU6C9c3YBqTQgo@AA6frN=Dad2HtK`ns_;jqg`fid3+H z^rz4}noh@qJe{^r+uc6to7fYUtB;N++Ir8r!SduEgQ$$|ve#Mug!8`z9v+O$j1WZbjN9ylc7I&N?%GSamhzYZ2r7c+Jh5 zO!xy9v~JSz)!b=aZ&0+>wpbi-TcY&6&nw3D(ksK#E`D$RU$JW8 zx8@~zjl!1m|LL-a5Bptnm|OhxgH25TV|i5U7`NTa-*~NHZiy_P&h0-h z*M&K0KHh$wSGKqN(9;On<2KucoD|L2uQ+O|H!sl3Uhg|;uiotHTa4e>l$sJX?{X?0 z{T#d5bc;COYsJjXuF}ig)T_=d3e>JokPrVAQ|O`Q@^t6qSBBS)OD}WvI1{&Tm+wJu zW$Q0BqOB78e9O1liMIMx_*Yw{$v;WhwCLB0sRw@QzZF+>7btByd8HvCk$L93-~je* z4UB1BuXk^qzxKR;W_Qu6jZ3deu2)ydcE8StW=MUSnEU`(t zzQK$K7Asr6pT6(8j^I{<={^c|D(969-n|Gq|8swa%b$-j9(&LJ{qlSA)SABC+DtVc zl9~f}IwfDSCO=e)sMQuseqQ{?>H5va57~nLw`Je%&hv4N@?i@W3etSlUbMSLesl54 zCDMm?-<2$=@_Cn&wdT*t+6+~nqZcC9t#+5YRdkPgcBW)<&O!cbGEQkHCLar6Y)V#b z`Fv{84W?!4Uk^6UeAlS3Y)i#q=N0SZ?*7ZZ78c-Hl5Qw}x%h!a!MeXcUcXmRoxFD6 z<_puF?3)oSJJTuZ_MWASCLgYTF=gk}R|kKZaoDZdv9rhSs%vqG?xw!%Rb>SsS5EG| zofz`PHwMAdHq;JU1^*A0Y zaq8PPj?EMHtt zS3bA=X&>FjQ8=mH@712aa@W>OvU&DvO(t)vXyviV7k+fwCQQgZp8NQf;|7;rW|rtV z3yO+et7RLf#m?W%`Txh~ZHJ2+qY7V75&poyQsMezMd8H{tj?>}*&1z_&?U&A5c*L5 ze)Se*yP4eUA382w?G@we`}Am8$Unt!) String { + env::var("OMEGA_API_URL").unwrap_or_else(|_| OMEGA_API_BASE_DEFAULT.to_string()) +} + +/* The Omega host as stored in `.tu` files: no `https://` scheme, but with port. */ +pub fn omega_host() -> String { + api_base() + .trim_start_matches("https://") + .trim_start_matches("http://") + .to_string() +} + +/* `GET /api/get/omikron` - random connected Omikron. Used on first-ever run; + * the only discovery endpoint with a liveness guarantee. */ +pub async fn discover_random() -> Result { + fetch(&format!("{}/api/get/omikron", api_base())).await +} + +/* `GET /api/get/omikron/{iota_id}` - this Iota's primary Omikron. + * No liveness guarantee (may 404 after restart or point at a stale Omikron); + * fall back to `discover_random`. */ +pub async fn discover_primary(iota_id: u64) -> Result { + fetch(&format!("{}/api/get/omikron/{}", api_base(), iota_id)).await +} + +async fn fetch(url: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + let body = client + .get(url) + .send() + .await + .map_err(|e| format!("Request to {} failed: {}", url, e))? + .text() + .await + .map_err(|e| format!("Failed to read response body from {}: {}", url, e))?; + + let json = json::parse(&body).map_err(|e| format!("Invalid JSON from {}: {}", url, e))?; + + if json["status"].as_str() != Some("success") { + return Err(format!( + "Omega returned status {:?} for {}", + json["status"].as_str(), + url + )); + } + + let id = json["id"] + .as_i64() + .ok_or_else(|| format!("Missing/invalid \"id\" in response from {}", url))?; + let host = json["ip_address"] + .as_str() + .ok_or_else(|| format!("Missing/invalid \"ip_address\" in response from {}", url))? + .to_string(); + let port = json["port"] + .as_u16() + .ok_or_else(|| format!("Missing/invalid \"port\" in response from {}", url))?; + let public_key_b64 = json["public_key"] + .as_str() + .ok_or_else(|| format!("Missing/invalid \"public_key\" in response from {}", url))?; + let public_key = PublicKeyBundle::from_base64(public_key_b64) + .map_err(|e| format!("Failed to decode public key from {}: {}", url, e))?; + + Ok(OmikronEndpoint { + id, + host, + port, + public_key, + }) +} diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index ecaeffd..bf8a570 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -10,9 +10,11 @@ use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; use json::JsonValue; -use mtp::client::{Policy, Receiver, SendMode, Sender}; +use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::{Keyring, PublicKeyBundle}; use std::collections::HashMap; +use std::env; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; @@ -20,6 +22,8 @@ use tokio::task::JoinHandle; use tokio::time::sleep; use uuid::Uuid; +use crate::omega_discovery; + fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { use mtp::type_map::{DataTypeId, TypeMap}; let tm = TypeMap::latest(); @@ -47,8 +51,8 @@ async fn is_read_receipts_enabled() -> bool { // Configuration // ============================================================================ -const OMIKRON_HOST_DEFAULT: &str = "tensamin.net"; -const OMIKRON_PORT_DEFAULT: u16 = 959; +const IOTA_KEYRING_PATH: &str = "iota.mk"; +const OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb"; const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); @@ -106,8 +110,6 @@ pub struct OmikronConnection { state: Arc>, sender: Arc>>>, connection_loop_handle: Arc>>>, - host: String, - port: u16, pub last_ping: Arc>, heartbeat_handle: Arc>>>, pub connection_id: Uuid, @@ -120,18 +122,12 @@ pub struct OmikronConnection { impl OmikronConnection { pub fn new() -> Self { - Self::with_host(OMIKRON_HOST_DEFAULT, OMIKRON_PORT_DEFAULT) - } - - pub fn with_host(host: &str, port: u16) -> Self { let (shutdown_tx, _) = watch::channel(false); OmikronConnection { state: Arc::new(RwLock::new(ConnectionState::Disconnected)), sender: Arc::new(RwLock::new(None)), connection_loop_handle: Arc::new(Mutex::new(None)), - host: host.to_string(), - port, last_ping: Arc::new(Mutex::new(-1)), heartbeat_handle: Arc::new(Mutex::new(None)), connection_id: Uuid::new_v4(), @@ -249,12 +245,21 @@ impl OmikronConnection { *self.state.write().await = ConnectionState::Connecting; log_t!("omikron_connecting"); - let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); + let keyring = self.load_or_migrate_keyring().await; - let (sender, mut receiver) = mtp::client::client::connect( - &addr_str, - None, - Policy { + let existing_iota_id = match CONFIG.read().await.get_iota_id() { + 0 => None, + id => Some(id as u64), + }; + + let (host, port, omikron_public_key) = + self.resolve_omikron_endpoint(existing_iota_id).await?; + + let addr_str = format!("https://{}:{}/ws/iota/", host, port); + + let client_config = ClientConfig::new(&addr_str) + .with_description("iota") + .with_policy(Policy { send_mode: SendMode::SingleStreamPerMessage, max_message_size: 1_000_000_000, close_frame_len: u32::MAX, @@ -269,40 +274,51 @@ impl OmikronConnection { max_transient_recv_errors: 20, transient_recv_backoff: Duration::from_millis(100), receiver_queue_capacity: 1000, - }, + }); + + let connection = match Client::auth_connect_or_register( + client_config, + existing_iota_id, + &keyring, + &omikron_public_key, ) .await - .map_err(|e| format!("Connection failed: {}", e))?; + { + Ok(connection) => connection, + Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => { + let reason = format!( + "Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.", + reason + ); + *self.reconnect_on_close.write().await = false; + *self.auth_failure.write().await = Some(reason.clone()); + *self.state.write().await = ConnectionState::Disconnected; + return Err(reason); + } + Err(e) => return Err(format!("Connection failed: {}", e)), + }; log_t!("omikron_connection_success"); - let sender_arc = Arc::new(sender); + if existing_iota_id.is_none() { + let mut conf_write = CONFIG.write().await; + conf_write.change("iota_id", JsonValue::from(connection.client_id as i64)); + conf_write.update(); + drop(conf_write); + log!("Registered with Iota-ID: {}", connection.client_id); + } + + let sender_arc = Arc::new(connection.sender); *self.sender.write().await = Some(sender_arc.clone()); - *self.state.write().await = ConnectionState::Connected { identified: false }; + *self.state.write().await = ConnectionState::Connected { identified: true }; // Start read loop + let mut receiver = connection.receiver; let read_self = self.clone(); let read_handle = tokio::spawn(async move { read_self.read_loop(&mut receiver).await; }); - // Handle registration/identification - self.handle_authentication().await; - - // Wait for identification to complete - if !self.await_identification(Duration::from_secs(30)).await { - *self.reconnect_on_close.write().await = false; - let reason = "Authentication failed: server did not accept the challenge. Your Iota keys may be invalid or the private key has changed on the server." - .to_string(); - *self.auth_failure.write().await = Some(reason.clone()); - - if let Some(sender) = self.sender.write().await.take() { - sender.close(); - } - *self.state.write().await = ConnectionState::Disconnected; - return Err(reason); - } - log_t!("omikron_authenticated"); // Start heartbeat @@ -341,93 +357,144 @@ impl OmikronConnection { } // ------------------------------------------------------------------------- - // Authentication (Registration/Identification) + // Identity (own Keyring, migrated from the legacy base64-in-config format) // ------------------------------------------------------------------------- - async fn handle_authentication(&self) { - let conf = CONFIG.read().await; - let iota_id = conf.get_iota_id(); - let keyring_b64 = conf.get_keyring(); + /* + * `iota.mk` is now the source of truth for this Iota's identity. A + * pre-existing base64 keyring in config.json (from before the MTP auth + * migration) is imported once so already-registered Iotas keep their + * identity, and mirrored back into config.json for older code paths + * that still read it directly. + */ + async fn load_or_migrate_keyring(&self) -> Keyring { + if let Ok(kr) = mtp::files::load_keyring(IOTA_KEYRING_PATH) { + return kr; + } + + let legacy = CONFIG.read().await.get_keyring(); + let keyring = legacy + .and_then(|b64| keyring_from_base64(&b64)) + .unwrap_or_else(crypto_helper::generate_keyring); + + if let Err(e) = mtp::files::save_keyring(&keyring, IOTA_KEYRING_PATH) { + log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); + } + + let b64 = crypto_helper::keyring_to_base64(&keyring); + let mut conf = CONFIG.write().await; + conf.change("keyring", JsonValue::from(b64)); + conf.update(); drop(conf); - if iota_id == 0 { - log_t!("iota_register_new"); + keyring + } - let pub_key_b64 = if let Some(kr) = keyring_b64 { - if let Some(keyring) = keyring_from_base64(&kr) { - let bundle = keyring.public_key_bundle(); - crypto_helper::public_key_bundle_to_base64(&bundle) - } else { - let keyring = crypto_helper::generate_keyring(); - let kb64 = crypto_helper::keyring_to_base64(&keyring); - let bundle = keyring.public_key_bundle(); - let pk_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - let mut conf_write = CONFIG.write().await; - conf_write.change("keyring", JsonValue::from(kb64)); - conf_write.update(); - drop(conf_write); - pk_b64 - } - } else { - let keyring = crypto_helper::generate_keyring(); - let kb64 = crypto_helper::keyring_to_base64(&keyring); - let bundle = keyring.public_key_bundle(); - let pk_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - let mut conf_write = CONFIG.write().await; - conf_write.change("keyring", JsonValue::from(kb64)); - conf_write.update(); - drop(conf_write); - pk_b64 - }; + // ------------------------------------------------------------------------- + // Omikron discovery (via Omega's HTTP API, replacing the static + // host/port/public-key-file model) + // ------------------------------------------------------------------------- - let register_msg = CommunicationValue::new(CommunicationType::RegisterIota) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_key_b64)); - - let msg_id = register_msg.get_id(); - - WAITING_TASKS.insert( - msg_id, - WaitingTask { - task: Box::new(|selfc, cv| { - if !cv.is_type(CommunicationType::Success) { - return false; - } - - let iota_value = cv.get_data(DataType::IotaId); - let iota_id = iota_value.as_number().unwrap_or(0); - - if iota_id != 0 { - tokio::spawn(async move { - let mut conf_write = CONFIG.write().await; - conf_write.change("iota_id", JsonValue::from(iota_id as i64)); - conf_write.update(); - drop(conf_write); - log!("Registered with Iota-ID: {}", iota_id); - - // Send identification after registration - let identify_msg = - CommunicationValue::new(CommunicationType::Identification) - .add_typed_default( - DataType::IotaId, - DataValue::SignedNumber(iota_id as i128), - ); - selfc.send_message(&identify_msg).await; - }); - } else { - log!("Iota registration failed."); - } - true - }), - inserted_at: Instant::now(), - }, - ); - - self.send_message(®ister_msg).await; - } else { - let identify_msg = CommunicationValue::new(CommunicationType::Identification) - .add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id as i128)); - self.send_message(&identify_msg).await; + /* + * Discovery runs fresh on every `connect_once()` attempt rather than once + * at construction, since a fixed `OmikronConnection` may need to move to + * a different Omikron across reconnects (e.g. after the sticky/primary + * Omikron dies). `OMIKRON_HOST`/`OMIKRON_PORT` remain as a manual + * override for local dev/testing against a hand-run Omikron without a + * live Omega. + * + * The fetched Omikron public key is pinned to `omikron.mpkb` (trust on + * first use): if a cached key exists and a fresh discovery response + * disagrees with it, the mismatch is logged loudly and the cached key is + * kept rather than silently trusting whatever Omega's HTTP API returned + * this time - the same trust boundary the previous manual-file-drop + * model had, just automated for the common case. + */ + async fn resolve_omikron_endpoint( + &self, + existing_iota_id: Option, + ) -> Result<(String, u16, PublicKeyBundle), String> { + if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) { + let port: u16 = port_str + .parse() + .map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?; + let public_key = mtp::files::load_public_key_bundle(OMIKRON_PUBLIC_KEY_PATH) + .map_err(|e| { + format!( + "Failed to load Omikron public key bundle from {}: {}. Obtain {} from the Omikron operator and place it in the working directory.", + OMIKRON_PUBLIC_KEY_PATH, e, OMIKRON_PUBLIC_KEY_PATH + ) + })?; + return Ok((host, port, public_key)); } + + let cached_key = mtp::files::load_public_key_bundle(OMIKRON_PUBLIC_KEY_PATH).ok(); + let cached_host_port = { + let conf = CONFIG.read().await; + match (conf.get_omikron_host(), conf.get_omikron_port()) { + (Some(host), Some(port)) => Some((host, port)), + _ => None, + } + }; + + let discovered = match existing_iota_id { + Some(id) => match omega_discovery::discover_primary(id).await { + Ok(endpoint) => Some(endpoint), + Err(e) => { + log!( + "Sticky Omikron discovery failed ({}), falling back to a random Omikron", + e + ); + omega_discovery::discover_random().await.ok() + } + }, + None => omega_discovery::discover_random().await.ok(), + }; + + let (host, port, public_key) = if let Some(endpoint) = discovered { + match &cached_key { + Some(cached) if cached.as_bytes() != endpoint.public_key.as_bytes() => { + log!( + "Fetched Omikron public key differs from the cached {} - keeping the \ + cached key. Delete {} manually if this is an expected key rotation.", + OMIKRON_PUBLIC_KEY_PATH, + OMIKRON_PUBLIC_KEY_PATH + ); + (endpoint.host, endpoint.port, cached.clone()) + } + Some(cached) => (endpoint.host, endpoint.port, cached.clone()), + None => { + if let Err(e) = mtp::files::save_public_key_bundle( + &endpoint.public_key, + OMIKRON_PUBLIC_KEY_PATH, + ) { + log!("Failed to cache Omikron public key: {}", e); + } + (endpoint.host, endpoint.port, endpoint.public_key) + } + } + } else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) { + log!( + "Omega discovery unreachable, falling back to last-known Omikron {}:{}", + host, + port + ); + (host.clone(), *port, cached.clone()) + } else { + return Err( + "Omega discovery failed and no cached Omikron address/key is available" + .to_string(), + ); + }; + + { + let mut conf = CONFIG.write().await; + conf.change("omikron_host", JsonValue::from(host.clone())); + conf.change("omikron_port", JsonValue::from(port)); + conf.update(); + } + + Ok((host, port, public_key)) } // ------------------------------------------------------------------------- @@ -504,11 +571,6 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::Challenge) { - self.handle_challenge(&cv).await; - return; - } - if cv.is_type(CommunicationType::AppIdentification) { let sender_id = cv.get_sender(); let app_identifier = cv @@ -768,26 +830,6 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::IdentificationResponse) { - match cv.get_data(DataType::Accepted).as_bool() { - Some(true) => { - let mut state = self.state.write().await; - if let ConnectionState::Connected { identified: _ } = *state { - *state = ConnectionState::Connected { identified: true }; - } - } - Some(false) => { - *self.auth_failure.write().await = Some( - "Server rejected the challenge response — your Iota keys may be invalid." - .to_string(), - ); - log_t!("omikron_auth_rejected"); - } - None => {} - } - return; - } - // ************************************************ // // Direct messages // // ************************************************ // @@ -1694,65 +1736,6 @@ impl OmikronConnection { } } - async fn handle_challenge(&self, cv: &CommunicationValue) { - let conf = CONFIG.read().await; - let Some(kr_str) = conf.get_keyring() else { - drop(conf); - log_t!("omikron_challenge_decryption_failed"); - *self.auth_failure.write().await = Some( - "Challenge decryption failed: no keyring configured on this Iota.".to_string(), - ); - return; - }; - drop(conf); - - let Some(_omikron_pub_key_bundle) = cv.get_data(DataType::PublicKey).as_str() else { - log_t!("omikron_challenge_decryption_failed"); - return; - }; - let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { - log_t!("omikron_challenge_decryption_failed"); - return; - }; - - let Some(keyring) = keyring_from_base64(&kr_str) else { - log_t!("omikron_challenge_decryption_failed"); - return; - }; - - let solved_challenge = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); - - if let Some(solved) = solved_challenge { - let response = CommunicationValue::new(CommunicationType::ChallengeResponse) - .with_id(cv.get_id()) - .add_typed_default(DataType::Challenge, DataValue::Str(solved)); - - self.send_message(&response).await; - } else { - log_t!("omikron_challenge_decryption_failed"); - *self.auth_failure.write().await = Some( - "Challenge decryption failed — your Iota keyring may not match the registered keys on the server." - .to_string(), - ); - } - } - - async fn await_identification(&self, timeout: Duration) -> bool { - let start = Instant::now(); - loop { - if self.state.read().await.is_identified() { - return true; - } - if self.auth_failure.read().await.is_some() { - return false; - } - if start.elapsed() >= timeout { - return false; - } - sleep(Duration::from_millis(100)).await; - } - } - // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 2d98042..d02ffbe 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -9,6 +9,7 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; use std::time::Duration; +use crate::omega_discovery; use crate::omikron_connection::OMIKRON_CONNECTION; pub async fn create_user(username: &str) -> (Option, Option) { @@ -86,7 +87,7 @@ pub async fn create_user(username: &str) -> (Option, Option save_file( "", &format!("{}.tu", username), - &format!("{}::{}", user_id, keyring_b64), + &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), ); add_user(user_profile.clone()); From f304e1df651fec15ae92cfa5ed8533ca6c2e4129 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:02:01 +0200 Subject: [PATCH 071/119] [Fix]Connection Stability --- Cargo.lock | 168 +++++++------------- client/src/client_connection.rs | 4 +- iota-cli/src/elements/console_card.rs | 14 +- iota-core/src/main.rs | 8 +- iota-storage/Cargo.toml | 3 + iota-storage/src/util/config_util.rs | 158 +++++++++--------- iota-util/src/file_util.rs | 17 +- omikron-connector/src/omega_discovery.rs | 2 +- omikron-connector/src/omikron_connection.rs | 56 +++---- type-maps.yaml | 40 ----- web-ui/src/api.rs | 30 ++-- 11 files changed, 201 insertions(+), 299 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76267a3..8fd8645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -1505,11 +1514,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1519,9 +1526,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2160,6 +2169,7 @@ name = "iota-storage" version = "0.1.0" dependencies = [ "aes-gcm", + "arc-swap", "base64", "hex", "hkdf 0.12.4", @@ -2174,6 +2184,8 @@ dependencies = [ "ratatui", "reqwest", "rusqlite", + "serde", + "serde_json", "sha2 0.10.9", "sysinfo", "tokio", @@ -2655,7 +2667,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "mtp-client", "mtp-codec", @@ -2663,14 +2675,13 @@ dependencies = [ "mtp-crypto", "mtp-files", "mtp-host", - "mtp-transport", "mtp-type-map", ] [[package]] name = "mtp-client" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "mtp-codec", "mtp-common", @@ -2683,7 +2694,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "base64", "byteorder", @@ -2696,7 +2707,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "quinn", "rustls", @@ -2707,7 +2718,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "base64", "chacha20poly1305", @@ -2726,7 +2737,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "mtp-crypto", "thiserror 1.0.69", @@ -2735,7 +2746,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "mtp-codec", "mtp-common", @@ -2748,7 +2759,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "log", "mtp-codec", @@ -2762,7 +2773,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b96c072a0f87de7828e45cf3dcd44aef4a9e459d" +source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" dependencies = [ "serde", "serde_yaml", @@ -3142,9 +3153,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -3152,9 +3163,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -3162,9 +3173,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", @@ -3175,12 +3186,11 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -3468,15 +3478,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -3490,16 +3501,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2 0.6.4", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3605,6 +3616,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -5395,16 +5415,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5422,31 +5433,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5464,96 +5458,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index c367f7e..a386a4e 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -943,9 +943,7 @@ impl ClientConnection { } async fn handle_challenge(&self, cv: &CommunicationValue) { - let conf = CONFIG.read().await; - let kr_str = conf.get_keyring().unwrap(); - drop(conf); + let kr_str = CONFIG.load().keyring.clone().unwrap(); let Some(keyring) = keyring_from_base64(&kr_str) else { return; diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index ea09717..6e9a7a0 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -2,7 +2,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use iota_logger::{log, log_command, log_cv}; use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; use iota_storage::users::{user_manager, user_profile::UserProfile}; -use iota_storage::util::config_util::CONFIG; +use iota_storage::util::config_util::modify_config; use iota_util::file_util; use mtp::codec::{CommunicationType, CommunicationValue}; use omikron_connector::omikron_connection::OMIKRON_CONNECTION; @@ -479,13 +479,11 @@ pub async fn run_command(command: &str) { } ["regenerate", "keys"] => { log!("Regenerating Iota key pair..."); - { - let mut conf = CONFIG.write().await; - conf.remove("public_key"); - conf.remove("private_key"); - conf.remove("iota_id"); - conf.update(); - } + modify_config(|cfg| { + cfg.public_key = None; + cfg.private_key = None; + cfg.iota_id = None; + }); log!("Key pair regenerated. Reconnecting to Omikron server..."); OMIKRON_CONNECTION.reconnect().await; log!("Reconnected with new key pair"); diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index d95f268..2baf39b 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -82,7 +82,7 @@ async fn main() { logger::startup(); // BASIC CONFIGURATION - &CONFIG.write().await.load(); + iota_storage::util::config_util::load_config(); // USER MANAGEMENT if let Err(_) = user_manager::load_users().await { @@ -102,7 +102,7 @@ async fn main() { } log!( "IOTA ID: {}", - CONFIG.read().await.get_iota_id().to_string() + CONFIG.load().iota_id.map(|id| id.to_string()).unwrap_or_else(|| "N/A".to_string()) ); log!("User IDS: {}", sb); @@ -121,7 +121,7 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ - let _port = CONFIG.read().await.get_port(); + let _port = CONFIG.load().port; let mut _ip = "0.0.0.0".to_string(); for iface in pnet::datalink::interfaces() { let iface: NetworkInterface = iface; @@ -176,7 +176,7 @@ async fn main() { } sleep(Duration::from_secs(1)).await; } - &CONFIG.write().await.clear(); + iota_storage::util::config_util::clear_config(); user_manager::clear(); // Commhnities have not been implemented yet. /*community_manager::clear();*/ diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 6f694a4..78359be 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -15,7 +15,10 @@ base64 = "0.22.1" hex = "*" hkdf = "0.12.4" json = "*" +arc-swap = "1" once_cell = "1.21.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 74e75e8..450ffb4 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -1,93 +1,85 @@ +use arc_swap::ArcSwap; use iota_util::file_util::{load_file, save_file}; -use json::JsonValue; use once_cell::sync::Lazy; -use tokio::sync::RwLock; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; -pub static CONFIG: Lazy> = Lazy::new(|| RwLock::new(ConfigUtil::new())); +pub static CONFIG: Lazy> = + Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default()))); -pub struct ConfigUtil { - pub config: JsonValue, - pub unique: bool, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IotaConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub iota_id: Option, + #[serde(default = "default_port")] + pub port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub omikron_host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub omikron_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keyring: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub private_key: Option, + #[serde(default = "default_read_receipts_enabled")] + pub read_receipts_enabled: bool, } -impl ConfigUtil { - pub fn new() -> Self { +const fn default_port() -> u16 { + 1984 +} + +const fn default_read_receipts_enabled() -> bool { + true +} + +impl Default for IotaConfig { + fn default() -> Self { Self { - config: JsonValue::new_object(), - unique: false, - } - } - pub fn clear(&mut self) { - self.config = JsonValue::new_object(); - self.unique = false; - } - pub fn load(&mut self) { - let s = load_file("", "config.json"); - if s.is_empty() { - // File might be missing or empty/being written. - // We don't want to wipe the current config if it already has data. - // But if it's the first load, it will stay empty. - return; - } - - match json::parse(&s) { - Ok(parsed) => { - self.config = parsed; - self.unique = false; - } - Err(e) => { - eprintln!("Failed to parse config.json: {}. Content: '{}'", e, s); - // Keep the current config rather than wiping it. - } - } - } - - pub fn get_iota_id(&self) -> i64 { - self.config["iota_id"].as_i64().unwrap_or(0) - } - - pub fn get_port(&self) -> u16 { - self.config["port"].as_u16().unwrap_or(1984) - } - - pub fn get_omikron_host(&self) -> Option { - self.config["omikron_host"].as_str().map(String::from) - } - - pub fn get_omikron_port(&self) -> Option { - self.config["omikron_port"].as_u16() - } - - pub fn get_keyring(&self) -> Option { - self.config["keyring"].as_str().map(String::from) - } - - pub fn get_public_key(&self) -> Option { - self.config["public_key"].as_str().map(String::from) - } - - pub fn get_private_key(&self) -> Option { - self.config["private_key"].as_str().map(String::from) - } - - pub fn get(&self, key: &str) -> &JsonValue { - &self.config[key] - } - - pub fn change(&mut self, key: &str, value: JsonValue) { - self.config[key] = value; - self.unique = true; - } - - pub fn remove(&mut self, key: &str) { - self.config.remove(key); - self.unique = true; - } - - pub fn update(&mut self) { - if self.unique { - save_file("", "config.json", &self.config.to_string()); - self.unique = false; + iota_id: None, + port: default_port(), + omikron_host: None, + omikron_port: None, + keyring: None, + public_key: None, + private_key: None, + read_receipts_enabled: default_read_receipts_enabled(), } } } + +pub fn load_config() { + let s = load_file("", "config.json"); + if s.is_empty() { + return; + } + + match serde_json::from_str::(&s) { + Ok(parsed) => { + CONFIG.store(Arc::new(parsed)); + } + Err(e) => { + eprintln!("Failed to parse config.json: {}. Content: '{}'", e, s); + } + } +} + +pub fn clear_config() { + CONFIG.store(Arc::new(IotaConfig::default())); + save_config(); +} + +pub fn save_config() { + if let Ok(json) = serde_json::to_string(&**CONFIG.load()) { + save_file("", "config.json", &json); + } +} + +pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { + let mut cfg = IotaConfig::clone(&**CONFIG.load()); + f(&mut cfg); + CONFIG.store(Arc::new(cfg)); + save_config(); +} diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 8769c7f..286c2f2 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -126,12 +126,25 @@ pub fn save_file(path: &str, name: &str, value: &str) { } } - if let Err(e) = fs::write(&file_path, value) { + // Write to a temp file first, then atomically rename to prevent partial writes. + let tmp_name = format!(".{}.tmp", name); + let tmp_path = dir.join(&tmp_name); + if let Err(e) = fs::write(&tmp_path, value) { println!( - "[IMPORTANT] Couldn't write file {}: {}", + "[IMPORTANT] Couldn't write temp file {}: {}", + tmp_path.display(), + e + ); + return; + } + if let Err(e) = fs::rename(&tmp_path, &file_path) { + println!( + "[IMPORTANT] Couldn't rename {} to {}: {}", + tmp_path.display(), file_path.display(), e ); + let _ = fs::remove_file(&tmp_path); } } diff --git a/omikron-connector/src/omega_discovery.rs b/omikron-connector/src/omega_discovery.rs index 7957cf0..e623daf 100644 --- a/omikron-connector/src/omega_discovery.rs +++ b/omikron-connector/src/omega_discovery.rs @@ -3,7 +3,7 @@ use std::time::Duration; use mtp::crypto::PublicKeyBundle; -const OMEGA_API_BASE_DEFAULT: &str = "https://tensamin.net:9188"; +const OMEGA_API_BASE_DEFAULT: &str = "https://omega.tensamin.net"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); pub struct OmikronEndpoint { diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index bf8a570..0d07db9 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -5,11 +5,10 @@ use iota_storage::users::contact::Contact; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; -use iota_storage::util::config_util::CONFIG; +use iota_storage::util::config_util::{modify_config, CONFIG}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; -use json::JsonValue; use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; @@ -37,14 +36,7 @@ fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { // Helper function to check if read receipts are enabled globally async fn is_read_receipts_enabled() -> bool { - // Check global config for read receipts setting - // Default to true if not set - let conf = CONFIG.read().await; - let value = conf.get("read_receipts_enabled"); - match value { - JsonValue::Boolean(b) => *b, - _ => true, - } + CONFIG.load().read_receipts_enabled } // ============================================================================ @@ -247,15 +239,14 @@ impl OmikronConnection { let keyring = self.load_or_migrate_keyring().await; - let existing_iota_id = match CONFIG.read().await.get_iota_id() { - 0 => None, - id => Some(id as u64), - }; + let existing_iota_id = CONFIG.load().iota_id; let (host, port, omikron_public_key) = self.resolve_omikron_endpoint(existing_iota_id).await?; - let addr_str = format!("https://{}:{}/ws/iota/", host, port); + let addr_str = format!("https://{}:{}", host, port); + + log!("Connecting to Omikron at {}", addr_str); let client_config = ClientConfig::new(&addr_str) .with_description("iota") @@ -301,10 +292,7 @@ impl OmikronConnection { log_t!("omikron_connection_success"); if existing_iota_id.is_none() { - let mut conf_write = CONFIG.write().await; - conf_write.change("iota_id", JsonValue::from(connection.client_id as i64)); - conf_write.update(); - drop(conf_write); + modify_config(|cfg| cfg.iota_id = Some(connection.client_id)); log!("Registered with Iota-ID: {}", connection.client_id); } @@ -372,7 +360,7 @@ impl OmikronConnection { return kr; } - let legacy = CONFIG.read().await.get_keyring(); + let legacy = CONFIG.load().keyring.clone(); let keyring = legacy .and_then(|b64| keyring_from_base64(&b64)) .unwrap_or_else(crypto_helper::generate_keyring); @@ -382,10 +370,7 @@ impl OmikronConnection { } let b64 = crypto_helper::keyring_to_base64(&keyring); - let mut conf = CONFIG.write().await; - conf.change("keyring", JsonValue::from(b64)); - conf.update(); - drop(conf); + modify_config(|cfg| cfg.keyring = Some(b64)); keyring } @@ -430,9 +415,9 @@ impl OmikronConnection { let cached_key = mtp::files::load_public_key_bundle(OMIKRON_PUBLIC_KEY_PATH).ok(); let cached_host_port = { - let conf = CONFIG.read().await; - match (conf.get_omikron_host(), conf.get_omikron_port()) { - (Some(host), Some(port)) => Some((host, port)), + let conf = CONFIG.load(); + match (&conf.omikron_host, conf.omikron_port) { + (Some(host), Some(port)) => Some((host.clone(), port)), _ => None, } }; @@ -482,17 +467,14 @@ impl OmikronConnection { (host.clone(), *port, cached.clone()) } else { return Err( - "Omega discovery failed and no cached Omikron address/key is available" - .to_string(), + "Omega discovery failed and no cached Omikron address/key is available".to_string(), ); }; - { - let mut conf = CONFIG.write().await; - conf.change("omikron_host", JsonValue::from(host.clone())); - conf.change("omikron_port", JsonValue::from(port)); - conf.update(); - } + modify_config(|cfg| { + cfg.omikron_host = Some(host.clone()); + cfg.omikron_port = Some(port); + }); Ok((host, port, public_key)) } @@ -609,9 +591,7 @@ impl OmikronConnection { if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) { - let conf = CONFIG.read().await; - let kr_str = conf.get_keyring().unwrap_or_default(); - drop(conf); + let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); if let Some(keyring) = keyring_from_base64(&kr_str) { if let Ok(encrypted_challenge) = diff --git a/type-maps.yaml b/type-maps.yaml index 5cd196f..7fecac8 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -1,46 +1,6 @@ # The version a Client should use protocol_version: "1.0" -# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended -# Fixed CommunicationType markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorBadVersion: 2 -# Disconnect: 3 -# Redirect: 4 -# Shutdown: 5 -# BadRequest: 6 -# Unauthorized: 7 -# Forbidden: 8 -# NotFound: 9 -# TooManyRequests: 10 -# InternalServerError: 11 -# BadGateway: 12 -# ServiceUnavailable: 13 -# GatewayTimeout: 14 -# Identification: 15 -# IdentificationResponse: 16 -# Register: 17 -# RegisterResponse: 18 -# Ping: 19 -# Pong: 20 -# -# Fixed Data Type markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorMessage: 2 -# Version: 3 -# Description: 4 -# Timestamp: 5 -# Id: 6 -# ClientNonce: 7 -# ServerNonce: 8 -# PublicKeys: 9 -# Signature: 10 -# Connected: 11 -# -# If a Type can't be used it will be mapped to 0 - type_maps: "1.0": # Protocol version 1.0 CommunicationTypes: diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 6cbdf78..2114029 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -1,6 +1,6 @@ use crate::server::is_local_network; use actix_web::{HttpRequest, HttpResponse, Responder, web}; -use iota_storage::util::config_util::CONFIG; +use iota_storage::util::config_util::{modify_config, CONFIG}; use serde_json::{Value, json}; use std::net::SocketAddr; @@ -29,12 +29,25 @@ async fn settings_set(req: HttpRequest, ssl: web::Data) -> impl Responder match (key, value) { (Some(k), Some(v)) => { - let _ = CONFIG - .write() - .await - .config - .insert(&k.to_string(), v.to_string()); - + modify_config(|cfg| match k { + "port" => { + if let Ok(port) = v.parse::() { + cfg.port = port; + } + } + "omikron_host" => { + cfg.omikron_host = Some(v.to_string()); + } + "omikron_port" => { + if let Ok(port) = v.parse::() { + cfg.omikron_port = Some(port); + } + } + "read_receipts_enabled" => { + cfg.read_receipts_enabled = v == "true"; + } + _ => {} + }); success() } _ => error(), @@ -45,8 +58,7 @@ async fn settings_get(req: HttpRequest, ssl: web::Data) -> impl Responder if !is_allowed_req(&req, *ssl.get_ref()) { return forbidden(); } - let config = CONFIG.read().await.config.clone(); - let serde_config: Value = serde_json::to_value(config.to_string()).unwrap(); + let serde_config: Value = serde_json::to_value(&**CONFIG.load()).unwrap(); HttpResponse::Ok().json(serde_config) } From caa0572c3aef1a6c6e3ffc63a0111b81d56c85ad Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 5 Jul 2026 21:45:44 +0200 Subject: [PATCH 072/119] (feat): crypto migrations --- client/src/client_connection.rs | 479 +++++++++----------- iota-logger/src/lib.rs | 3 +- iota-storage/src/util/e2ee_storage.rs | 411 +++++++++++++++++ iota-storage/src/util/mod.rs | 1 + omikron-connector/src/omikron_connection.rs | 222 +++++++++ type-maps.yaml | 29 ++ 6 files changed, 890 insertions(+), 255 deletions(-) create mode 100644 iota-storage/src/util/e2ee_storage.rs diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index a386a4e..390a969 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -7,11 +7,16 @@ use iota_storage::util::chats_util::{get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; use iota_storage::util::{chat_files, chats_util}; +use iota_storage::util::e2ee_storage::{ + self, EncryptedDeviceSecretQuery, EncryptedMessageQuery, + StoredEncryptedDeviceSecret, StoredEncryptedMessage, +}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, load_file, save_file}; use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, mpsc, watch}; @@ -29,6 +34,41 @@ fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { ) } + + +fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_str() + .map(|s| s.to_string()) + .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) + .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) +} + +fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_number() + .and_then(|n| i64::try_from(n).ok()) + .or_else(|| cv.get_data(dt).as_signed_number().and_then(|n| i64::try_from(n).ok())) + .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) +} + +fn data_bytes(cv: &CommunicationValue, dt: DataType) -> Option> { + cv.get_bytes(dt).map(|bytes| bytes.to_vec()) +} + +fn now_millis_i64() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { + CommunicationValue::new(ty) + .with_id(request.get_id()) + .with_receiver(request.get_sender()) +} + // ============================================================================ // Waiting Task System // ============================================================================ @@ -142,6 +182,190 @@ impl ClientConnection { return; } + if cv.is_type(CommunicationType::SetEncryptedDeviceSecret) { + let sender_id = cv.get_sender().to_string(); + if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + } + let now = now_millis_i64(); + let record = data_string(&cv, DataType::UserId) + .zip(data_string(&cv, DataType::DeviceId)) + .zip(data_string(&cv, DataType::SecretId)) + .zip(data_i64(&cv, DataType::VersionNumber)) + .zip(data_bytes(&cv, DataType::EncryptedSecret)) + .zip(data_string(&cv, DataType::WrappingScheme)) + .map(|(((((user_id, device_id), secret_id), version), encrypted_secret), wrapping_scheme)| { + StoredEncryptedDeviceSecret { + user_id, + device_id, + secret_id, + version, + encrypted_secret, + wrapping_public_key_id: data_string(&cv, DataType::WrappingPublicKeyId), + wrapping_scheme, + created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), + updated_at: now, + } + }); + + match record.map(e2ee_storage::put_encrypted_device_secret) { + Some(Ok(())) => { + self.send_message(&error_response(&cv, CommunicationType::Success)).await; + } + _ => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + } + return; + } + + if cv.is_type(CommunicationType::GetEncryptedDeviceSecret) { + let Some(user_id) = data_string(&cv, DataType::UserId) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + }; + let sender_id = cv.get_sender().to_string(); + if user_id != sender_id { + self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + return; + } + + match e2ee_storage::get_encrypted_device_secret(EncryptedDeviceSecretQuery { + user_id, + device_id: data_string(&cv, DataType::DeviceId), + secret_id: data_string(&cv, DataType::SecretId), + }) { + Ok(Some(record)) => { + let mut response = CommunicationValue::new(CommunicationType::EncryptedDeviceSecretResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) + .add_typed_default(DataType::DeviceId, DataValue::Str(record.device_id)) + .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) + .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) + .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) + .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) + .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); + if let Some(value) = record.wrapping_public_key_id { + response = response.add_typed_default(DataType::WrappingPublicKeyId, DataValue::Str(value)); + } + self.send_message(&response).await; + } + Ok(None) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + } + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + } + return; + } + + if cv.is_type(CommunicationType::EncryptedMessage) { + let sender_id = cv.get_sender().to_string(); + if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + } + let record = data_string(&cv, DataType::MessageId) + .zip(data_string(&cv, DataType::ConversationId)) + .zip(data_string(&cv, DataType::SenderClientId)) + .zip(data_string(&cv, DataType::RecipientClientId)) + .zip(data_bytes(&cv, DataType::EncryptedPayload)) + .map(|((((message_id, conversation_id), sender_client_id), recipient_client_id), encrypted_payload)| { + StoredEncryptedMessage { + message_id, + conversation_id, + sender_client_id, + recipient_client_id, + sender_user_id: data_string(&cv, DataType::SenderUserId), + recipient_user_id: data_string(&cv, DataType::RecipientUserId), + created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), + encryption_version: data_i64(&cv, DataType::EncryptionVersion).unwrap_or(1), + encrypted_payload, + } + }); + + if let Some(record) = record { + let message_id = record.message_id.clone(); + let conversation_id = record.conversation_id.clone(); + let recipient_client_id = record.recipient_client_id.clone(); + match e2ee_storage::put_encrypted_message(record) { + Ok(()) => { + self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessageAck) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::MessageId, DataValue::Str(message_id)) + .add_typed_default(DataType::ConversationId, DataValue::Str(conversation_id)) + .add_typed_default(DataType::RecipientClientId, DataValue::Str(recipient_client_id)) + .add_typed_default(DataType::GetTime, DataValue::SignedNumber(now_millis_i64() as i128)) + ).await; + } + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + } + } else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + return; + } + + if cv.is_type(CommunicationType::EncryptedMessagesGet) { + let requester_user_id = cv.get_sender().to_string(); + let limit = data_i64(&cv, DataType::Limit).map(|v| v as i64); + let since = data_i64(&cv, DataType::Since); + let conversation_id = data_string(&cv, DataType::ConversationId); + let peer_client_id = data_string(&cv, DataType::PeerClientId); + + match e2ee_storage::get_encrypted_messages(EncryptedMessageQuery { + sender_user_id: data_string(&cv, DataType::SenderUserId), + recipient_client_id: None, + recipient_user_id: Some(requester_user_id.clone()), + conversation_id, + limit, + offset: since.map(|v| v.max(0)), + }) { + Ok(records) => { + let messages = records + .into_iter() + .filter(|record| { + peer_client_id + .as_ref() + .map(|peer| &record.sender_client_id == peer || &record.recipient_client_id == peer) + .unwrap_or(true) + }) + .map(|record| { + typed_container(vec![ + (DataType::MessageId, DataValue::Str(record.message_id)), + (DataType::ConversationId, DataValue::Str(record.conversation_id)), + (DataType::SenderClientId, DataValue::Str(record.sender_client_id)), + (DataType::RecipientClientId, DataValue::Str(record.recipient_client_id)), + (DataType::SenderUserId, DataValue::Str(record.sender_user_id.unwrap_or_default())), + (DataType::RecipientUserId, DataValue::Str(record.recipient_user_id.unwrap_or_default())), + (DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)), + (DataType::EncryptionVersion, DataValue::SignedNumber(record.encryption_version as i128)), + (DataType::EncryptedPayload, DataValue::Bytes(record.encrypted_payload)), + ]) + }) + .collect::>(); + + self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessagesResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::Messages, DataValue::Array(messages)) + .add_typed_default(DataType::HasMore, DataValue::Bool(false)) + ).await; + } + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + } + return; + } + if cv.is_type(CommunicationType::SaveAppData) { let sender_id = cv.get_sender(); let _app_data = cv @@ -327,259 +551,8 @@ impl ClientConnection { // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. if cv.is_type(CommunicationType::MessageSend) { - let sender_id: u64 = cv.get_sender(); - - // parse receiver_id (the storage owner for this incoming message) - let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { - s.parse::().unwrap_or(0) - } else { - 0 - }; - - // parse send_time robustly (number or string), fallback to now - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let timestamp_i64 = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; - let timestamp_u128 = timestamp_i64 as u128; - - // content may be missing; default to empty string - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - - let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); - - if is_local { - // persist message for the receiver (storage_owner = receiver_id) - chat_files::add_message( - timestamp_u128, - false, - receiver_id as i64, - sender_id as i64, - &content, - height, - ); - } - - // persist message for the sender (storage_owner = sender_id) - chat_files::add_message( - timestamp_u128, - true, - sender_id as i64, - receiver_id as i64, - &content, - height, - ); - - // send confirmation back to sender - let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&conf_msg).await; - - if !is_local { - let fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .with_sender(sender_id as u64) - .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) - .add_typed_default(DataType::Content, DataValue::Str(content)) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ); - - let other_iota_resp = self - .clone() - .await_response(&fw_msg, Some(Duration::from_secs(10))) - .await; - - if let Ok(resp) = other_iota_resp { - let ms_raw = resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - ms.clone(), - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } else { - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - MessageState::Sent, - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - return; - } else { - // Build a live-delivery message for the local client (recipient) - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ) - .add_typed_default( - DataType::Message, - typed_container(vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]), - ); - - // Attempt delivery and await a response from the local client - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) - .await; - - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - // update stored message state for receiver - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - sender_id as i64, - ms.clone(), - ); - - // update stored message state for sender - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - ms.clone(), - ); - - // notify original sender about the delivered/read state - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } else { - // Delivery failed or timed out; mark as Sent - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - sender_id as i64, - MessageState::Sent, - ); - - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - MessageState::Sent, - ); - - // notify sender - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - return; - } + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; } if cv.is_type(CommunicationType::MessageOtherIota) { diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index bfde4ce..01ebbff 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -1,5 +1,4 @@ use std::{ - collections::BTreeMap, fs::{self, OpenOptions}, io::Write, path::Path, @@ -8,7 +7,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use mtp::codec::{CommunicationValue, DataType, DataTypeId, DataValue, Version}; +use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version}; use ratatui::style::Color; use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs new file mode 100644 index 0000000..4872e4d --- /dev/null +++ b/iota-storage/src/util/e2ee_storage.rs @@ -0,0 +1,411 @@ +use crate::util::db; +use rusqlite::{params, OptionalExtension}; +use std::sync::{Arc, LazyLock, Mutex}; + +pub type StorageError = String; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredEncryptedMessage { + pub message_id: String, + pub conversation_id: String, + pub sender_client_id: String, + pub recipient_client_id: String, + pub sender_user_id: Option, + pub recipient_user_id: Option, + pub created_at: i64, + pub encryption_version: i64, + pub encrypted_payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredEncryptedDeviceSecret { + pub user_id: String, + pub device_id: String, + pub secret_id: String, + pub version: i64, + pub encrypted_secret: Vec, + pub wrapping_public_key_id: Option, + pub wrapping_scheme: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Default)] +pub struct EncryptedMessageQuery { + pub sender_user_id: Option, + pub recipient_client_id: Option, + pub recipient_user_id: Option, + pub conversation_id: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct EncryptedDeviceSecretQuery { + pub user_id: String, + pub device_id: Option, + pub secret_id: Option, +} + +static E2EE_DB: LazyLock>> = LazyLock::new(|| { + db::create_shared_connection( + "e2ee", + r#" + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS encrypted_messages ( + message_id TEXT NOT NULL PRIMARY KEY, + conversation_id TEXT NOT NULL, + sender_client_id TEXT NOT NULL, + recipient_client_id TEXT NOT NULL, + sender_user_id TEXT, + recipient_user_id TEXT, + created_at INTEGER NOT NULL, + encryption_version INTEGER NOT NULL, + encrypted_payload BLOB NOT NULL, + acked_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_encrypted_messages_recipient + ON encrypted_messages (recipient_client_id, created_at ASC); + CREATE INDEX IF NOT EXISTS idx_encrypted_messages_recipient_user + ON encrypted_messages (recipient_user_id, created_at ASC); + CREATE INDEX IF NOT EXISTS idx_encrypted_messages_conversation + ON encrypted_messages (conversation_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS encrypted_device_secrets ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + version INTEGER NOT NULL, + encrypted_secret BLOB NOT NULL, + wrapping_public_key_id TEXT, + wrapping_scheme TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, device_id, secret_id) + ); + + CREATE INDEX IF NOT EXISTS idx_encrypted_device_secrets_owner + ON encrypted_device_secrets (user_id, device_id, secret_id); + "#, + ) + .expect("Failed to create or initialize E2EE DB") +}); + +pub fn put_encrypted_message(record: StoredEncryptedMessage) -> Result<(), StorageError> { + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + INSERT OR REPLACE INTO encrypted_messages ( + message_id, conversation_id, sender_client_id, recipient_client_id, + sender_user_id, recipient_user_id, created_at, encryption_version, + encrypted_payload + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + params![ + record.message_id, + record.conversation_id, + record.sender_client_id, + record.recipient_client_id, + record.sender_user_id, + record.recipient_user_id, + record.created_at, + record.encryption_version, + record.encrypted_payload, + ], + )?; + Ok(()) + }) +} + +pub fn get_encrypted_messages( + query: EncryptedMessageQuery, +) -> Result, StorageError> { + let limit = query.limit.unwrap_or(100).clamp(1, 500); + let offset = query.offset.unwrap_or(0).max(0); + + db::with_conn(&E2EE_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT message_id, conversation_id, sender_client_id, recipient_client_id, + sender_user_id, recipient_user_id, created_at, encryption_version, + encrypted_payload + FROM encrypted_messages + WHERE (?2 IS NULL OR recipient_client_id = ?2) + AND ( + (?1 IS NULL AND (?3 IS NULL OR sender_user_id = ?3 OR recipient_user_id = ?3)) + OR (?1 IS NOT NULL AND ?3 IS NOT NULL AND ( + (sender_user_id = ?1 AND recipient_user_id = ?3) + OR (sender_user_id = ?3 AND recipient_user_id = ?1) + )) + OR (?1 IS NOT NULL AND ?3 IS NULL AND (sender_user_id = ?1 OR recipient_user_id = ?1)) + ) + AND (?4 IS NULL OR conversation_id = ?4) + ORDER BY created_at ASC + LIMIT ?5 OFFSET ?6 + "#, + )?; + + let rows = stmt.query_map( + params![ + query.sender_user_id, + query.recipient_client_id, + query.recipient_user_id, + query.conversation_id, + limit, + offset, + ], + encrypted_message_from_row, + )?; + + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + }) +} + +pub fn ack_encrypted_message( + message_id: &str, + recipient_client_id: &str, +) -> Result<(), StorageError> { + let now = now_millis(); + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + UPDATE encrypted_messages + SET acked_at = ?1 + WHERE message_id = ?2 AND recipient_client_id = ?3 + "#, + params![now, message_id, recipient_client_id], + )?; + Ok(()) + }) +} + +pub fn put_encrypted_device_secret( + record: StoredEncryptedDeviceSecret, +) -> Result<(), StorageError> { + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + INSERT INTO encrypted_device_secrets ( + user_id, device_id, secret_id, version, encrypted_secret, + wrapping_public_key_id, wrapping_scheme, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(user_id, device_id, secret_id) DO UPDATE SET + version = excluded.version, + encrypted_secret = excluded.encrypted_secret, + wrapping_public_key_id = excluded.wrapping_public_key_id, + wrapping_scheme = excluded.wrapping_scheme, + created_at = excluded.created_at, + updated_at = excluded.updated_at + "#, + params![ + record.user_id, + record.device_id, + record.secret_id, + record.version, + record.encrypted_secret, + record.wrapping_public_key_id, + record.wrapping_scheme, + record.created_at, + record.updated_at, + ], + )?; + Ok(()) + }) +} + +pub fn get_encrypted_device_secret( + query: EncryptedDeviceSecretQuery, +) -> Result, StorageError> { + if query.user_id.is_empty() { + return Ok(None); + } + + db::with_conn(&E2EE_DB, |conn| { + conn.query_row( + r#" + SELECT user_id, device_id, secret_id, version, encrypted_secret, + wrapping_public_key_id, wrapping_scheme, created_at, updated_at + FROM encrypted_device_secrets + WHERE user_id = ?1 + AND (?2 IS NULL OR device_id = ?2) + AND (?3 IS NULL OR secret_id = ?3) + ORDER BY updated_at DESC + LIMIT 1 + "#, + params![query.user_id, query.device_id, query.secret_id], + encrypted_device_secret_from_row, + ) + .optional() + }) +} + +fn encrypted_message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredEncryptedMessage { + message_id: row.get(0)?, + conversation_id: row.get(1)?, + sender_client_id: row.get(2)?, + recipient_client_id: row.get(3)?, + sender_user_id: row.get(4)?, + recipient_user_id: row.get(5)?, + created_at: row.get(6)?, + encryption_version: row.get(7)?, + encrypted_payload: row.get(8)?, + }) +} + +fn encrypted_device_secret_from_row( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(StoredEncryptedDeviceSecret { + user_id: row.get(0)?, + device_id: row.get(1)?, + secret_id: row.get(2)?, + version: row.get(3)?, + encrypted_secret: row.get(4)?, + wrapping_public_key_id: row.get(5)?, + wrapping_scheme: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +fn now_millis() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn suffix(name: &str) -> String { + format!("{name}-{}", now_millis()) + } + + #[test] + fn stores_encrypted_message_as_opaque_ciphertext() { + let message_id = suffix("msg"); + put_encrypted_message(StoredEncryptedMessage { + message_id: message_id.clone(), + conversation_id: suffix("conversation"), + sender_client_id: "sender".to_string(), + recipient_client_id: "recipient".to_string(), + sender_user_id: Some("sender-user".to_string()), + recipient_user_id: Some(suffix("recipient-user")), + created_at: now_millis(), + encryption_version: 1, + encrypted_payload: vec![9, 8, 7], + }) + .unwrap(); + + let messages = get_encrypted_messages(EncryptedMessageQuery { + recipient_client_id: Some("recipient".to_string()), + limit: Some(10), + ..Default::default() + }) + .unwrap(); + assert!(messages.iter().any(|m| m.message_id == message_id && m.encrypted_payload == vec![9, 8, 7])); + } + + #[test] + fn encrypted_history_query_returns_both_directions_for_peer() { + let user = suffix("user"); + let peer = suffix("peer"); + let incoming_id = suffix("incoming"); + let outgoing_id = suffix("outgoing"); + let other_id = suffix("other"); + + put_encrypted_message(StoredEncryptedMessage { + message_id: incoming_id.clone(), + conversation_id: suffix("conversation"), + sender_client_id: "peer-client".to_string(), + recipient_client_id: "user-client".to_string(), + sender_user_id: Some(peer.clone()), + recipient_user_id: Some(user.clone()), + created_at: now_millis(), + encryption_version: 1, + encrypted_payload: vec![1], + }) + .unwrap(); + put_encrypted_message(StoredEncryptedMessage { + message_id: outgoing_id.clone(), + conversation_id: suffix("conversation"), + sender_client_id: "user-client".to_string(), + recipient_client_id: "peer-client".to_string(), + sender_user_id: Some(user.clone()), + recipient_user_id: Some(peer.clone()), + created_at: now_millis() + 1, + encryption_version: 1, + encrypted_payload: vec![2], + }) + .unwrap(); + put_encrypted_message(StoredEncryptedMessage { + message_id: other_id.clone(), + conversation_id: suffix("conversation"), + sender_client_id: "other-client".to_string(), + recipient_client_id: "user-client".to_string(), + sender_user_id: Some(suffix("other")), + recipient_user_id: Some(user.clone()), + created_at: now_millis() + 2, + encryption_version: 1, + encrypted_payload: vec![3], + }) + .unwrap(); + + let messages = get_encrypted_messages(EncryptedMessageQuery { + sender_user_id: Some(peer), + recipient_user_id: Some(user), + limit: Some(10), + ..Default::default() + }) + .unwrap(); + + assert!(messages.iter().any(|m| m.message_id == incoming_id)); + assert!(messages.iter().any(|m| m.message_id == outgoing_id)); + assert!(!messages.iter().any(|m| m.message_id == other_id)); + } + + #[test] + fn stores_and_retrieves_encrypted_device_secret_blob_for_owner_only() { + let user_id = suffix("user"); + put_encrypted_device_secret(StoredEncryptedDeviceSecret { + user_id: user_id.clone(), + device_id: "device".to_string(), + secret_id: "main".to_string(), + version: 1, + encrypted_secret: vec![42, 43], + wrapping_public_key_id: None, + wrapping_scheme: "mtp-chacha20poly1305-hkdf-sha256-v1".to_string(), + created_at: 1, + updated_at: 2, + }) + .unwrap(); + + let found = get_encrypted_device_secret(EncryptedDeviceSecretQuery { + user_id: user_id.clone(), + device_id: Some("device".to_string()), + secret_id: Some("main".to_string()), + }) + .unwrap() + .unwrap(); + assert_eq!(found.encrypted_secret, vec![42, 43]); + + let denied = get_encrypted_device_secret(EncryptedDeviceSecretQuery { + user_id: suffix("other-user"), + device_id: Some("device".to_string()), + secret_id: Some("main".to_string()), + }) + .unwrap(); + assert!(denied.is_none()); + } +} diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index dff0d65..d5b2e4d 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -3,3 +3,4 @@ pub mod chats_util; pub mod communities_util; pub mod config_util; pub mod db; +pub mod e2ee_storage; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 0d07db9..95dcd22 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -6,6 +6,10 @@ use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::{modify_config, CONFIG}; +use iota_storage::util::e2ee_storage::{ + self, EncryptedDeviceSecretQuery, EncryptedMessageQuery, + StoredEncryptedDeviceSecret, StoredEncryptedMessage, +}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, has_file, load_file, save_file}; @@ -34,6 +38,39 @@ fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { ) } +fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_str() + .map(|s| s.to_string()) + .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) + .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) +} + +fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_number() + .and_then(|n| i64::try_from(n).ok()) + .or_else(|| cv.get_data(dt).as_signed_number().and_then(|n| i64::try_from(n).ok())) + .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) +} + +fn data_bytes(cv: &CommunicationValue, dt: DataType) -> Option> { + cv.get_bytes(dt).map(|bytes| bytes.to_vec()) +} + +fn now_millis_i64() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { + CommunicationValue::new(ty) + .with_id(request.get_id()) + .with_receiver(request.get_sender()) +} + // Helper function to check if read receipts are enabled globally async fn is_read_receipts_enabled() -> bool { CONFIG.load().read_receipts_enabled @@ -553,6 +590,191 @@ impl OmikronConnection { return; } + if cv.is_type(CommunicationType::SetEncryptedDeviceSecret) { + let sender_id = cv.get_sender().to_string(); + if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + } + let now = now_millis_i64(); + let record = data_string(&cv, DataType::UserId) + .zip(data_string(&cv, DataType::DeviceId)) + .zip(data_string(&cv, DataType::SecretId)) + .zip(data_i64(&cv, DataType::VersionNumber)) + .zip(data_bytes(&cv, DataType::EncryptedSecret)) + .zip(data_string(&cv, DataType::WrappingScheme)) + .map(|(((((user_id, device_id), secret_id), version), encrypted_secret), wrapping_scheme)| { + StoredEncryptedDeviceSecret { + user_id, + device_id, + secret_id, + version, + encrypted_secret, + wrapping_public_key_id: data_string(&cv, DataType::WrappingPublicKeyId), + wrapping_scheme, + created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), + updated_at: now, + } + }); + + match record.map(e2ee_storage::put_encrypted_device_secret) { + Some(Ok(())) => self.send_message(&error_response(&cv, CommunicationType::Success)).await, + _ => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + } + return; + } + + if cv.is_type(CommunicationType::GetEncryptedDeviceSecret) { + let Some(user_id) = data_string(&cv, DataType::UserId) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + }; + if user_id != cv.get_sender().to_string() { + self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + return; + } + + match e2ee_storage::get_encrypted_device_secret(EncryptedDeviceSecretQuery { + user_id, + device_id: data_string(&cv, DataType::DeviceId), + secret_id: data_string(&cv, DataType::SecretId), + }) { + Ok(Some(record)) => { + let mut response = CommunicationValue::new(CommunicationType::EncryptedDeviceSecretResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) + .add_typed_default(DataType::DeviceId, DataValue::Str(record.device_id)) + .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) + .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) + .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) + .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) + .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); + if let Some(value) = record.wrapping_public_key_id { + response = response.add_typed_default(DataType::WrappingPublicKeyId, DataValue::Str(value)); + } + self.send_message(&response).await; + } + Ok(None) => self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await, + Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + } + return; + } + + if cv.is_type(CommunicationType::EncryptedMessage) { + let sender_id = cv.get_sender().to_string(); + if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + } + let record = data_string(&cv, DataType::MessageId) + .zip(data_string(&cv, DataType::ConversationId)) + .zip(data_string(&cv, DataType::SenderClientId)) + .zip(data_string(&cv, DataType::RecipientClientId)) + .zip(data_bytes(&cv, DataType::EncryptedPayload)) + .map(|((((message_id, conversation_id), sender_client_id), recipient_client_id), encrypted_payload)| { + StoredEncryptedMessage { + message_id, + conversation_id, + sender_client_id, + recipient_client_id, + sender_user_id: data_string(&cv, DataType::SenderUserId), + recipient_user_id: data_string(&cv, DataType::RecipientUserId), + created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), + encryption_version: data_i64(&cv, DataType::EncryptionVersion).unwrap_or(1), + encrypted_payload, + } + }); + + if let Some(record) = record { + let message_id = record.message_id.clone(); + let conversation_id = record.conversation_id.clone(); + let recipient_client_id = record.recipient_client_id.clone(); + let sender_client_id = record.sender_client_id.clone(); + let sender_user_id = record.sender_user_id.clone().unwrap_or_default(); + let recipient_user_id = record.recipient_user_id.clone().unwrap_or_default(); + match e2ee_storage::put_encrypted_message(record) { + Ok(()) => { + self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessageAck) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::MessageId, DataValue::Str(message_id)) + .add_typed_default(DataType::ConversationId, DataValue::Str(conversation_id)) + .add_typed_default(DataType::RecipientClientId, DataValue::Str(recipient_client_id.clone())) + .add_typed_default(DataType::GetTime, DataValue::SignedNumber(now_millis_i64() as i128)) + ).await; + + if !recipient_user_id.is_empty() + && recipient_user_id != sender_user_id + && recipient_client_id != sender_client_id + { + if cv.get_receiver().to_string() != recipient_user_id { + self.send_message(&cv.clone().with_receiver(recipient_user_id.parse::().unwrap_or(0))).await; + } else { + self.send_message(&cv).await; + } + } + } + Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + } + } else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + } + return; + } + + if cv.is_type(CommunicationType::EncryptedMessagesGet) { + let requester_user_id = cv.get_sender().to_string(); + let limit = data_i64(&cv, DataType::Limit).map(|v| v as i64); + let since = data_i64(&cv, DataType::Since); + let conversation_id = data_string(&cv, DataType::ConversationId); + let peer_client_id = data_string(&cv, DataType::PeerClientId); + + match e2ee_storage::get_encrypted_messages(EncryptedMessageQuery { + sender_user_id: data_string(&cv, DataType::SenderUserId), + recipient_client_id: None, + recipient_user_id: Some(requester_user_id.clone()), + conversation_id, + limit, + offset: since.map(|v| v.max(0)), + }) { + Ok(records) => { + let messages = records + .into_iter() + .filter(|record| { + peer_client_id + .as_ref() + .map(|peer| &record.sender_client_id == peer || &record.recipient_client_id == peer) + .unwrap_or(true) + }) + .map(|record| { + typed_container(vec![ + (DataType::MessageId, DataValue::Str(record.message_id)), + (DataType::ConversationId, DataValue::Str(record.conversation_id)), + (DataType::SenderClientId, DataValue::Str(record.sender_client_id)), + (DataType::RecipientClientId, DataValue::Str(record.recipient_client_id)), + (DataType::SenderUserId, DataValue::Str(record.sender_user_id.unwrap_or_default())), + (DataType::RecipientUserId, DataValue::Str(record.recipient_user_id.unwrap_or_default())), + (DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)), + (DataType::EncryptionVersion, DataValue::SignedNumber(record.encryption_version as i128)), + (DataType::EncryptedPayload, DataValue::Bytes(record.encrypted_payload)), + ]) + }) + .collect::>(); + + self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessagesResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::Messages, DataValue::Array(messages)) + .add_typed_default(DataType::HasMore, DataValue::Bool(false)) + ).await; + } + Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + } + return; + } + if cv.is_type(CommunicationType::AppIdentification) { let sender_id = cv.get_sender(); let app_identifier = cv diff --git a/type-maps.yaml b/type-maps.yaml index 7fecac8..354f186 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -99,6 +99,13 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 + SetEncryptedDeviceSecret: 139 + GetEncryptedDeviceSecret: 140 + EncryptedDeviceSecretResponse: 141 + EncryptedMessage: 142 + EncryptedMessageAck: 143 + EncryptedMessagesGet: 144 + EncryptedMessagesResponse: 145 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -199,3 +206,25 @@ type_maps: AppData: 131 TauriToken: 132 Challenge: 133 + EncryptedPayload: 134 + SecurePayload: 135 + DeviceId: 136 + ClientId: 137 + SecretId: 142 + VersionNumber: 143 + EncryptedSecret: 144 + WrappingPublicKeyId: 145 + WrappingScheme: 146 + UpdatedAt: 147 + MessageId: 148 + ConversationId: 149 + SenderClientId: 150 + RecipientClientId: 151 + SenderUserId: 152 + RecipientUserId: 153 + EncryptionVersion: 154 + HasMore: 155 + NextCursor: 156 + Since: 157 + Limit: 158 + PeerClientId: 159 From 77711d781158693c5f9b82d23643b4087362f05f Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:38:25 +0200 Subject: [PATCH 073/119] [Upd] MTP Cargo Update --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8fd8645..d51f28a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,9 +601,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -2667,7 +2667,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "mtp-client", "mtp-codec", @@ -2681,7 +2681,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "mtp-codec", "mtp-common", @@ -2694,7 +2694,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "base64", "byteorder", @@ -2707,7 +2707,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "quinn", "rustls", @@ -2718,7 +2718,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "base64", "chacha20poly1305", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "mtp-crypto", "thiserror 1.0.69", @@ -2746,7 +2746,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "mtp-codec", "mtp-common", @@ -2759,7 +2759,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "log", "mtp-codec", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2e7c0b489360d5d05fe53326811616e730c1a556" +source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" dependencies = [ "serde", "serde_yaml", @@ -2836,9 +2836,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", From 25caeb852eabecc84863d180ae25290e38e2ca31 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 6 Jul 2026 00:13:41 +0200 Subject: [PATCH 074/119] (feat): more crypto migration --- client/Cargo.toml | 2 +- client/src/client_connection.rs | 142 ++---- iota-storage/src/util/e2ee_storage.rs | 450 ++++++++------------ omikron-connector/src/omikron_connection.rs | 241 +++++------ type-maps.yaml | 24 +- 5 files changed, 319 insertions(+), 540 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 72cb4d6..c29f662 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client"] } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 390a969..d1c7cf2 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -7,10 +7,7 @@ use iota_storage::util::chats_util::{get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; use iota_storage::util::{chat_files, chats_util}; -use iota_storage::util::e2ee_storage::{ - self, EncryptedDeviceSecretQuery, EncryptedMessageQuery, - StoredEncryptedDeviceSecret, StoredEncryptedMessage, -}; +use iota_storage::util::e2ee_storage::{self, ChatSecretQuery, StoredChatSecret}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, load_file, save_file}; @@ -34,8 +31,6 @@ fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { ) } - - fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) .as_str() @@ -182,7 +177,7 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::SetEncryptedDeviceSecret) { + if cv.is_type(CommunicationType::SetChatSecret) { let sender_id = cv.get_sender().to_string(); if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; @@ -190,26 +185,27 @@ impl ClientConnection { } let now = now_millis_i64(); let record = data_string(&cv, DataType::UserId) - .zip(data_string(&cv, DataType::DeviceId)) + .zip(data_string(&cv, DataType::ChatId)) .zip(data_string(&cv, DataType::SecretId)) .zip(data_i64(&cv, DataType::VersionNumber)) .zip(data_bytes(&cv, DataType::EncryptedSecret)) + .zip(data_bytes(&cv, DataType::KemCiphertext)) .zip(data_string(&cv, DataType::WrappingScheme)) - .map(|(((((user_id, device_id), secret_id), version), encrypted_secret), wrapping_scheme)| { - StoredEncryptedDeviceSecret { + .map(|((((((user_id, chat_id), secret_id), version), encrypted_secret), kem_ciphertext), wrapping_scheme)| { + StoredChatSecret { user_id, - device_id, + chat_id, secret_id, version, encrypted_secret, - wrapping_public_key_id: data_string(&cv, DataType::WrappingPublicKeyId), + kem_ciphertext, wrapping_scheme, created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), updated_at: now, } }); - match record.map(e2ee_storage::put_encrypted_device_secret) { + match record.map(e2ee_storage::put_chat_secret) { Some(Ok(())) => { self.send_message(&error_response(&cv, CommunicationType::Success)).await; } @@ -220,7 +216,7 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::GetEncryptedDeviceSecret) { + if cv.is_type(CommunicationType::GetChatSecret) { let Some(user_id) = data_string(&cv, DataType::UserId) else { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; return; @@ -230,27 +226,29 @@ impl ClientConnection { self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; return; } + let Some(chat_id) = data_string(&cv, DataType::ChatId) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + }; - match e2ee_storage::get_encrypted_device_secret(EncryptedDeviceSecretQuery { + match e2ee_storage::get_chat_secret(ChatSecretQuery { user_id, - device_id: data_string(&cv, DataType::DeviceId), + chat_id, secret_id: data_string(&cv, DataType::SecretId), }) { Ok(Some(record)) => { - let mut response = CommunicationValue::new(CommunicationType::EncryptedDeviceSecretResponse) + let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) .with_id(cv.get_id()) .with_receiver(cv.get_sender()) .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::DeviceId, DataValue::Str(record.device_id)) + .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) + .add_typed_default(DataType::KemCiphertext, DataValue::Bytes(record.kem_ciphertext)) .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); - if let Some(value) = record.wrapping_public_key_id { - response = response.add_typed_default(DataType::WrappingPublicKeyId, DataValue::Str(value)); - } self.send_message(&response).await; } Ok(None) => { @@ -263,106 +261,16 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::EncryptedMessage) { + if cv.is_type(CommunicationType::ChatSecretForward) { let sender_id = cv.get_sender().to_string(); - if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) { + let recipient_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default(); + if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) + || recipient_id.is_empty() + { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; return; } - let record = data_string(&cv, DataType::MessageId) - .zip(data_string(&cv, DataType::ConversationId)) - .zip(data_string(&cv, DataType::SenderClientId)) - .zip(data_string(&cv, DataType::RecipientClientId)) - .zip(data_bytes(&cv, DataType::EncryptedPayload)) - .map(|((((message_id, conversation_id), sender_client_id), recipient_client_id), encrypted_payload)| { - StoredEncryptedMessage { - message_id, - conversation_id, - sender_client_id, - recipient_client_id, - sender_user_id: data_string(&cv, DataType::SenderUserId), - recipient_user_id: data_string(&cv, DataType::RecipientUserId), - created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), - encryption_version: data_i64(&cv, DataType::EncryptionVersion).unwrap_or(1), - encrypted_payload, - } - }); - - if let Some(record) = record { - let message_id = record.message_id.clone(); - let conversation_id = record.conversation_id.clone(); - let recipient_client_id = record.recipient_client_id.clone(); - match e2ee_storage::put_encrypted_message(record) { - Ok(()) => { - self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessageAck) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::MessageId, DataValue::Str(message_id)) - .add_typed_default(DataType::ConversationId, DataValue::Str(conversation_id)) - .add_typed_default(DataType::RecipientClientId, DataValue::Str(recipient_client_id)) - .add_typed_default(DataType::GetTime, DataValue::SignedNumber(now_millis_i64() as i128)) - ).await; - } - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - } - } - } else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - } - return; - } - - if cv.is_type(CommunicationType::EncryptedMessagesGet) { - let requester_user_id = cv.get_sender().to_string(); - let limit = data_i64(&cv, DataType::Limit).map(|v| v as i64); - let since = data_i64(&cv, DataType::Since); - let conversation_id = data_string(&cv, DataType::ConversationId); - let peer_client_id = data_string(&cv, DataType::PeerClientId); - - match e2ee_storage::get_encrypted_messages(EncryptedMessageQuery { - sender_user_id: data_string(&cv, DataType::SenderUserId), - recipient_client_id: None, - recipient_user_id: Some(requester_user_id.clone()), - conversation_id, - limit, - offset: since.map(|v| v.max(0)), - }) { - Ok(records) => { - let messages = records - .into_iter() - .filter(|record| { - peer_client_id - .as_ref() - .map(|peer| &record.sender_client_id == peer || &record.recipient_client_id == peer) - .unwrap_or(true) - }) - .map(|record| { - typed_container(vec![ - (DataType::MessageId, DataValue::Str(record.message_id)), - (DataType::ConversationId, DataValue::Str(record.conversation_id)), - (DataType::SenderClientId, DataValue::Str(record.sender_client_id)), - (DataType::RecipientClientId, DataValue::Str(record.recipient_client_id)), - (DataType::SenderUserId, DataValue::Str(record.sender_user_id.unwrap_or_default())), - (DataType::RecipientUserId, DataValue::Str(record.recipient_user_id.unwrap_or_default())), - (DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)), - (DataType::EncryptionVersion, DataValue::SignedNumber(record.encryption_version as i128)), - (DataType::EncryptedPayload, DataValue::Bytes(record.encrypted_payload)), - ]) - }) - .collect::>(); - - self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessagesResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Messages, DataValue::Array(messages)) - .add_typed_default(DataType::HasMore, DataValue::Bool(false)) - ).await; - } - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - } - } + self.send_message(&cv.with_receiver(recipient_id.parse::().unwrap_or(0))).await; return; } diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 4872e4d..d26a092 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -5,46 +5,36 @@ use std::sync::{Arc, LazyLock, Mutex}; pub type StorageError = String; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredEncryptedMessage { - pub message_id: String, - pub conversation_id: String, - pub sender_client_id: String, - pub recipient_client_id: String, - pub sender_user_id: Option, - pub recipient_user_id: Option, - pub created_at: i64, - pub encryption_version: i64, - pub encrypted_payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredEncryptedDeviceSecret { +pub struct StoredChatSecret { pub user_id: String, - pub device_id: String, + pub chat_id: String, pub secret_id: String, pub version: i64, pub encrypted_secret: Vec, - pub wrapping_public_key_id: Option, + pub kem_ciphertext: Vec, pub wrapping_scheme: String, pub created_at: i64, pub updated_at: i64, } #[derive(Debug, Clone, Default)] -pub struct EncryptedMessageQuery { - pub sender_user_id: Option, - pub recipient_client_id: Option, - pub recipient_user_id: Option, - pub conversation_id: Option, - pub limit: Option, - pub offset: Option, +pub struct ChatSecretQuery { + pub user_id: String, + pub chat_id: String, + pub secret_id: Option, } -#[derive(Debug, Clone, Default)] -pub struct EncryptedDeviceSecretQuery { - pub user_id: String, - pub device_id: Option, - pub secret_id: Option, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingChatSecretForward { + pub recipient_user_id: String, + pub chat_id: String, + pub sender_user_id: String, + pub secret_id: String, + pub version: i64, + pub encrypted_secret: Vec, + pub kem_ciphertext: Vec, + pub wrapping_scheme: String, + pub created_at: i64, } static E2EE_DB: LazyLock>> = LazyLock::new(|| { @@ -54,163 +44,68 @@ static E2EE_DB: LazyLock>> = LazyLock::new(|| { PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; - CREATE TABLE IF NOT EXISTS encrypted_messages ( - message_id TEXT NOT NULL PRIMARY KEY, - conversation_id TEXT NOT NULL, - sender_client_id TEXT NOT NULL, - recipient_client_id TEXT NOT NULL, - sender_user_id TEXT, - recipient_user_id TEXT, - created_at INTEGER NOT NULL, - encryption_version INTEGER NOT NULL, - encrypted_payload BLOB NOT NULL, - acked_at INTEGER - ); + DROP TABLE IF EXISTS encrypted_messages; + DROP TABLE IF EXISTS encrypted_device_secrets; - CREATE INDEX IF NOT EXISTS idx_encrypted_messages_recipient - ON encrypted_messages (recipient_client_id, created_at ASC); - CREATE INDEX IF NOT EXISTS idx_encrypted_messages_recipient_user - ON encrypted_messages (recipient_user_id, created_at ASC); - CREATE INDEX IF NOT EXISTS idx_encrypted_messages_conversation - ON encrypted_messages (conversation_id, created_at ASC); - - CREATE TABLE IF NOT EXISTS encrypted_device_secrets ( + CREATE TABLE IF NOT EXISTS chat_secrets ( user_id TEXT NOT NULL, - device_id TEXT NOT NULL, + chat_id TEXT NOT NULL, secret_id TEXT NOT NULL, version INTEGER NOT NULL, encrypted_secret BLOB NOT NULL, - wrapping_public_key_id TEXT, + kem_ciphertext BLOB NOT NULL, wrapping_scheme TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - PRIMARY KEY (user_id, device_id, secret_id) + PRIMARY KEY (user_id, chat_id, secret_id) ); - CREATE INDEX IF NOT EXISTS idx_encrypted_device_secrets_owner - ON encrypted_device_secrets (user_id, device_id, secret_id); + CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner + ON chat_secrets (user_id, chat_id, secret_id); + + CREATE TABLE IF NOT EXISTS pending_chat_secret_forwards ( + recipient_user_id TEXT NOT NULL, + chat_id TEXT NOT NULL, + sender_user_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + version INTEGER NOT NULL, + encrypted_secret BLOB NOT NULL, + kem_ciphertext BLOB NOT NULL, + wrapping_scheme TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (recipient_user_id, chat_id, secret_id) + ); + + CREATE INDEX IF NOT EXISTS idx_pending_chat_secret_forwards_recipient + ON pending_chat_secret_forwards (recipient_user_id, created_at); "#, ) .expect("Failed to create or initialize E2EE DB") }); -pub fn put_encrypted_message(record: StoredEncryptedMessage) -> Result<(), StorageError> { +pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> { db::with_conn(&E2EE_DB, |conn| { conn.execute( r#" - INSERT OR REPLACE INTO encrypted_messages ( - message_id, conversation_id, sender_client_id, recipient_client_id, - sender_user_id, recipient_user_id, created_at, encryption_version, - encrypted_payload + INSERT INTO chat_secrets ( + user_id, chat_id, secret_id, version, encrypted_secret, + kem_ciphertext, wrapping_scheme, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - "#, - params![ - record.message_id, - record.conversation_id, - record.sender_client_id, - record.recipient_client_id, - record.sender_user_id, - record.recipient_user_id, - record.created_at, - record.encryption_version, - record.encrypted_payload, - ], - )?; - Ok(()) - }) -} - -pub fn get_encrypted_messages( - query: EncryptedMessageQuery, -) -> Result, StorageError> { - let limit = query.limit.unwrap_or(100).clamp(1, 500); - let offset = query.offset.unwrap_or(0).max(0); - - db::with_conn(&E2EE_DB, |conn| { - let mut stmt = conn.prepare( - r#" - SELECT message_id, conversation_id, sender_client_id, recipient_client_id, - sender_user_id, recipient_user_id, created_at, encryption_version, - encrypted_payload - FROM encrypted_messages - WHERE (?2 IS NULL OR recipient_client_id = ?2) - AND ( - (?1 IS NULL AND (?3 IS NULL OR sender_user_id = ?3 OR recipient_user_id = ?3)) - OR (?1 IS NOT NULL AND ?3 IS NOT NULL AND ( - (sender_user_id = ?1 AND recipient_user_id = ?3) - OR (sender_user_id = ?3 AND recipient_user_id = ?1) - )) - OR (?1 IS NOT NULL AND ?3 IS NULL AND (sender_user_id = ?1 OR recipient_user_id = ?1)) - ) - AND (?4 IS NULL OR conversation_id = ?4) - ORDER BY created_at ASC - LIMIT ?5 OFFSET ?6 - "#, - )?; - - let rows = stmt.query_map( - params![ - query.sender_user_id, - query.recipient_client_id, - query.recipient_user_id, - query.conversation_id, - limit, - offset, - ], - encrypted_message_from_row, - )?; - - let mut out = Vec::new(); - for row in rows { - out.push(row?); - } - Ok(out) - }) -} - -pub fn ack_encrypted_message( - message_id: &str, - recipient_client_id: &str, -) -> Result<(), StorageError> { - let now = now_millis(); - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - UPDATE encrypted_messages - SET acked_at = ?1 - WHERE message_id = ?2 AND recipient_client_id = ?3 - "#, - params![now, message_id, recipient_client_id], - )?; - Ok(()) - }) -} - -pub fn put_encrypted_device_secret( - record: StoredEncryptedDeviceSecret, -) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - INSERT INTO encrypted_device_secrets ( - user_id, device_id, secret_id, version, encrypted_secret, - wrapping_public_key_id, wrapping_scheme, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(user_id, device_id, secret_id) DO UPDATE SET + ON CONFLICT(user_id, chat_id, secret_id) DO UPDATE SET version = excluded.version, encrypted_secret = excluded.encrypted_secret, - wrapping_public_key_id = excluded.wrapping_public_key_id, + kem_ciphertext = excluded.kem_ciphertext, wrapping_scheme = excluded.wrapping_scheme, created_at = excluded.created_at, updated_at = excluded.updated_at "#, params![ record.user_id, - record.device_id, + record.chat_id, record.secret_id, record.version, record.encrypted_secret, - record.wrapping_public_key_id, + record.kem_ciphertext, record.wrapping_scheme, record.created_at, record.updated_at, @@ -220,68 +115,131 @@ pub fn put_encrypted_device_secret( }) } -pub fn get_encrypted_device_secret( - query: EncryptedDeviceSecretQuery, -) -> Result, StorageError> { - if query.user_id.is_empty() { +pub fn put_pending_chat_secret_forward( + record: PendingChatSecretForward, +) -> Result<(), StorageError> { + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + INSERT INTO pending_chat_secret_forwards ( + recipient_user_id, chat_id, sender_user_id, secret_id, version, + encrypted_secret, kem_ciphertext, wrapping_scheme, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(recipient_user_id, chat_id, secret_id) DO UPDATE SET + sender_user_id = excluded.sender_user_id, + version = excluded.version, + encrypted_secret = excluded.encrypted_secret, + kem_ciphertext = excluded.kem_ciphertext, + wrapping_scheme = excluded.wrapping_scheme, + created_at = excluded.created_at + "#, + params![ + record.recipient_user_id, + record.chat_id, + record.sender_user_id, + record.secret_id, + record.version, + record.encrypted_secret, + record.kem_ciphertext, + record.wrapping_scheme, + record.created_at, + ], + )?; + Ok(()) + }) +} + +pub fn get_pending_chat_secret_forwards( + limit: i64, +) -> Result, StorageError> { + db::with_conn(&E2EE_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT recipient_user_id, chat_id, sender_user_id, secret_id, version, + encrypted_secret, kem_ciphertext, wrapping_scheme, created_at + FROM pending_chat_secret_forwards + ORDER BY created_at ASC + LIMIT ?1 + "#, + )?; + let rows = stmt.query_map(params![limit.clamp(1, 500)], pending_forward_from_row)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + }) +} + +pub fn delete_pending_chat_secret_forward( + recipient_user_id: &str, + chat_id: &str, + secret_id: &str, +) -> Result<(), StorageError> { + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + DELETE FROM pending_chat_secret_forwards + WHERE recipient_user_id = ?1 AND chat_id = ?2 AND secret_id = ?3 + "#, + params![recipient_user_id, chat_id, secret_id], + )?; + Ok(()) + }) +} + +pub fn get_chat_secret(query: ChatSecretQuery) -> Result, StorageError> { + if query.user_id.is_empty() || query.chat_id.is_empty() { return Ok(None); } db::with_conn(&E2EE_DB, |conn| { conn.query_row( r#" - SELECT user_id, device_id, secret_id, version, encrypted_secret, - wrapping_public_key_id, wrapping_scheme, created_at, updated_at - FROM encrypted_device_secrets + SELECT user_id, chat_id, secret_id, version, encrypted_secret, + kem_ciphertext, wrapping_scheme, created_at, updated_at + FROM chat_secrets WHERE user_id = ?1 - AND (?2 IS NULL OR device_id = ?2) + AND chat_id = ?2 AND (?3 IS NULL OR secret_id = ?3) ORDER BY updated_at DESC LIMIT 1 "#, - params![query.user_id, query.device_id, query.secret_id], - encrypted_device_secret_from_row, + params![query.user_id, query.chat_id, query.secret_id], + chat_secret_from_row, ) .optional() }) } -fn encrypted_message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(StoredEncryptedMessage { - message_id: row.get(0)?, - conversation_id: row.get(1)?, - sender_client_id: row.get(2)?, - recipient_client_id: row.get(3)?, - sender_user_id: row.get(4)?, - recipient_user_id: row.get(5)?, - created_at: row.get(6)?, - encryption_version: row.get(7)?, - encrypted_payload: row.get(8)?, - }) -} - -fn encrypted_device_secret_from_row( - row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(StoredEncryptedDeviceSecret { +fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredChatSecret { user_id: row.get(0)?, - device_id: row.get(1)?, + chat_id: row.get(1)?, secret_id: row.get(2)?, version: row.get(3)?, encrypted_secret: row.get(4)?, - wrapping_public_key_id: row.get(5)?, + kem_ciphertext: row.get(5)?, wrapping_scheme: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, }) } -fn now_millis() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 +fn pending_forward_from_row( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(PendingChatSecretForward { + recipient_user_id: row.get(0)?, + chat_id: row.get(1)?, + sender_user_id: row.get(2)?, + secret_id: row.get(3)?, + version: row.get(4)?, + encrypted_secret: row.get(5)?, + kem_ciphertext: row.get(6)?, + wrapping_scheme: row.get(7)?, + created_at: row.get(8)?, + }) } #[cfg(test)] @@ -289,120 +247,44 @@ mod tests { use super::*; fn suffix(name: &str) -> String { - format!("{name}-{}", now_millis()) + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{name}-{unix}") } #[test] - fn stores_encrypted_message_as_opaque_ciphertext() { - let message_id = suffix("msg"); - put_encrypted_message(StoredEncryptedMessage { - message_id: message_id.clone(), - conversation_id: suffix("conversation"), - sender_client_id: "sender".to_string(), - recipient_client_id: "recipient".to_string(), - sender_user_id: Some("sender-user".to_string()), - recipient_user_id: Some(suffix("recipient-user")), - created_at: now_millis(), - encryption_version: 1, - encrypted_payload: vec![9, 8, 7], - }) - .unwrap(); - - let messages = get_encrypted_messages(EncryptedMessageQuery { - recipient_client_id: Some("recipient".to_string()), - limit: Some(10), - ..Default::default() - }) - .unwrap(); - assert!(messages.iter().any(|m| m.message_id == message_id && m.encrypted_payload == vec![9, 8, 7])); - } - - #[test] - fn encrypted_history_query_returns_both_directions_for_peer() { - let user = suffix("user"); - let peer = suffix("peer"); - let incoming_id = suffix("incoming"); - let outgoing_id = suffix("outgoing"); - let other_id = suffix("other"); - - put_encrypted_message(StoredEncryptedMessage { - message_id: incoming_id.clone(), - conversation_id: suffix("conversation"), - sender_client_id: "peer-client".to_string(), - recipient_client_id: "user-client".to_string(), - sender_user_id: Some(peer.clone()), - recipient_user_id: Some(user.clone()), - created_at: now_millis(), - encryption_version: 1, - encrypted_payload: vec![1], - }) - .unwrap(); - put_encrypted_message(StoredEncryptedMessage { - message_id: outgoing_id.clone(), - conversation_id: suffix("conversation"), - sender_client_id: "user-client".to_string(), - recipient_client_id: "peer-client".to_string(), - sender_user_id: Some(user.clone()), - recipient_user_id: Some(peer.clone()), - created_at: now_millis() + 1, - encryption_version: 1, - encrypted_payload: vec![2], - }) - .unwrap(); - put_encrypted_message(StoredEncryptedMessage { - message_id: other_id.clone(), - conversation_id: suffix("conversation"), - sender_client_id: "other-client".to_string(), - recipient_client_id: "user-client".to_string(), - sender_user_id: Some(suffix("other")), - recipient_user_id: Some(user.clone()), - created_at: now_millis() + 2, - encryption_version: 1, - encrypted_payload: vec![3], - }) - .unwrap(); - - let messages = get_encrypted_messages(EncryptedMessageQuery { - sender_user_id: Some(peer), - recipient_user_id: Some(user), - limit: Some(10), - ..Default::default() - }) - .unwrap(); - - assert!(messages.iter().any(|m| m.message_id == incoming_id)); - assert!(messages.iter().any(|m| m.message_id == outgoing_id)); - assert!(!messages.iter().any(|m| m.message_id == other_id)); - } - - #[test] - fn stores_and_retrieves_encrypted_device_secret_blob_for_owner_only() { + fn stores_and_retrieves_chat_secret_blob_for_owner_chat() { let user_id = suffix("user"); - put_encrypted_device_secret(StoredEncryptedDeviceSecret { + let chat_id = suffix("chat"); + + put_chat_secret(StoredChatSecret { user_id: user_id.clone(), - device_id: "device".to_string(), + chat_id: chat_id.clone(), secret_id: "main".to_string(), version: 1, encrypted_secret: vec![42, 43], - wrapping_public_key_id: None, - wrapping_scheme: "mtp-chacha20poly1305-hkdf-sha256-v1".to_string(), + kem_ciphertext: vec![9, 8, 7], + wrapping_scheme: "mtp-kem-chacha20poly1305-hkdf-sha256-v1".to_string(), created_at: 1, updated_at: 2, }) .unwrap(); - let found = get_encrypted_device_secret(EncryptedDeviceSecretQuery { + let found = get_chat_secret(ChatSecretQuery { user_id: user_id.clone(), - device_id: Some("device".to_string()), + chat_id: chat_id.clone(), secret_id: Some("main".to_string()), }) .unwrap() .unwrap(); assert_eq!(found.encrypted_secret, vec![42, 43]); + assert_eq!(found.kem_ciphertext, vec![9, 8, 7]); - let denied = get_encrypted_device_secret(EncryptedDeviceSecretQuery { + let denied = get_chat_secret(ChatSecretQuery { user_id: suffix("other-user"), - device_id: Some("device".to_string()), + chat_id, secret_id: Some("main".to_string()), }) .unwrap(); diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 95dcd22..61ce63a 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -7,8 +7,7 @@ use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::{modify_config, CONFIG}; use iota_storage::util::e2ee_storage::{ - self, EncryptedDeviceSecretQuery, EncryptedMessageQuery, - StoredEncryptedDeviceSecret, StoredEncryptedMessage, + self, ChatSecretQuery, PendingChatSecretForward, StoredChatSecret, }; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; @@ -65,6 +64,57 @@ fn now_millis_i64() -> i64 { .as_millis() as i64 } +fn pending_chat_secret_forward_from_cv( + cv: &CommunicationValue, +) -> Option { + Some(PendingChatSecretForward { + recipient_user_id: data_string(cv, DataType::RecipientUserId)?, + chat_id: data_string(cv, DataType::ChatId)?, + sender_user_id: data_string(cv, DataType::SenderUserId)?, + secret_id: data_string(cv, DataType::SecretId)?, + version: data_i64(cv, DataType::VersionNumber)?, + encrypted_secret: data_bytes(cv, DataType::EncryptedSecret)?, + kem_ciphertext: data_bytes(cv, DataType::KemCiphertext)?, + wrapping_scheme: data_string(cv, DataType::WrappingScheme)?, + created_at: data_i64(cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), + }) +} + +fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationValue { + CommunicationValue::new(CommunicationType::ChatSecretForward) + .with_sender(record.sender_user_id.parse::().unwrap_or(0)) + .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id.clone())) + .add_typed_default( + DataType::SenderUserId, + DataValue::Str(record.sender_user_id.clone()), + ) + .add_typed_default( + DataType::RecipientUserId, + DataValue::Str(record.recipient_user_id.clone()), + ) + .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id.clone())) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(record.version as i128), + ) + .add_typed_default( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret.clone()), + ) + .add_typed_default( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext.clone()), + ) + .add_typed_default( + DataType::WrappingScheme, + DataValue::Str(record.wrapping_scheme.clone()), + ) + .add_typed_default( + DataType::CreatedAt, + DataValue::SignedNumber(record.created_at as i128), + ) +} + fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { CommunicationValue::new(ty) .with_id(request.get_id()) @@ -563,10 +613,50 @@ impl OmikronConnection { break; } + self.flush_pending_chat_secret_forwards().await; self.send_ping().await; } } + async fn forward_chat_secret(&self, cv: &CommunicationValue) -> bool { + self.await_response(cv, Some(Duration::from_secs(10))) + .await + .is_ok() + } + + async fn store_pending_chat_secret_forward(&self, cv: &CommunicationValue) { + let Some(record) = pending_chat_secret_forward_from_cv(cv) else { + return; + }; + let _ = e2ee_storage::put_pending_chat_secret_forward(record); + } + + async fn flush_pending_chat_secret_forwards(&self) { + let Ok(records) = e2ee_storage::get_pending_chat_secret_forwards(100) else { + return; + }; + + for record in records { + let Ok(recipient) = record.recipient_user_id.parse::() else { + let _ = e2ee_storage::delete_pending_chat_secret_forward( + &record.recipient_user_id, + &record.chat_id, + &record.secret_id, + ); + continue; + }; + + let message = chat_secret_forward_cv(&record).with_receiver(recipient); + if self.forward_chat_secret(&message).await { + let _ = e2ee_storage::delete_pending_chat_secret_forward( + &record.recipient_user_id, + &record.chat_id, + &record.secret_id, + ); + } + } + } + // ------------------------------------------------------------------------- // Message Handling (Preserved from original) // ------------------------------------------------------------------------- @@ -590,7 +680,7 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::SetEncryptedDeviceSecret) { + if cv.is_type(CommunicationType::SetChatSecret) { let sender_id = cv.get_sender().to_string(); if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; @@ -598,33 +688,34 @@ impl OmikronConnection { } let now = now_millis_i64(); let record = data_string(&cv, DataType::UserId) - .zip(data_string(&cv, DataType::DeviceId)) + .zip(data_string(&cv, DataType::ChatId)) .zip(data_string(&cv, DataType::SecretId)) .zip(data_i64(&cv, DataType::VersionNumber)) .zip(data_bytes(&cv, DataType::EncryptedSecret)) + .zip(data_bytes(&cv, DataType::KemCiphertext)) .zip(data_string(&cv, DataType::WrappingScheme)) - .map(|(((((user_id, device_id), secret_id), version), encrypted_secret), wrapping_scheme)| { - StoredEncryptedDeviceSecret { + .map(|((((((user_id, chat_id), secret_id), version), encrypted_secret), kem_ciphertext), wrapping_scheme)| { + StoredChatSecret { user_id, - device_id, + chat_id, secret_id, version, encrypted_secret, - wrapping_public_key_id: data_string(&cv, DataType::WrappingPublicKeyId), + kem_ciphertext, wrapping_scheme, created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), updated_at: now, } }); - match record.map(e2ee_storage::put_encrypted_device_secret) { + match record.map(e2ee_storage::put_chat_secret) { Some(Ok(())) => self.send_message(&error_response(&cv, CommunicationType::Success)).await, _ => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, } return; } - if cv.is_type(CommunicationType::GetEncryptedDeviceSecret) { + if cv.is_type(CommunicationType::GetChatSecret) { let Some(user_id) = data_string(&cv, DataType::UserId) else { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; return; @@ -633,27 +724,29 @@ impl OmikronConnection { self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; return; } + let Some(chat_id) = data_string(&cv, DataType::ChatId) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + return; + }; - match e2ee_storage::get_encrypted_device_secret(EncryptedDeviceSecretQuery { + match e2ee_storage::get_chat_secret(ChatSecretQuery { user_id, - device_id: data_string(&cv, DataType::DeviceId), + chat_id, secret_id: data_string(&cv, DataType::SecretId), }) { Ok(Some(record)) => { - let mut response = CommunicationValue::new(CommunicationType::EncryptedDeviceSecretResponse) + let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) .with_id(cv.get_id()) .with_receiver(cv.get_sender()) .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::DeviceId, DataValue::Str(record.device_id)) + .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) + .add_typed_default(DataType::KemCiphertext, DataValue::Bytes(record.kem_ciphertext)) .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); - if let Some(value) = record.wrapping_public_key_id { - response = response.add_typed_default(DataType::WrappingPublicKeyId, DataValue::Str(value)); - } self.send_message(&response).await; } Ok(None) => self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await, @@ -662,115 +755,23 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::EncryptedMessage) { + if cv.is_type(CommunicationType::ChatSecretForward) { let sender_id = cv.get_sender().to_string(); - if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) { + let recipient_user_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default(); + if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) + || recipient_user_id.is_empty() + || pending_chat_secret_forward_from_cv(&cv).is_none() + { self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; return; } - let record = data_string(&cv, DataType::MessageId) - .zip(data_string(&cv, DataType::ConversationId)) - .zip(data_string(&cv, DataType::SenderClientId)) - .zip(data_string(&cv, DataType::RecipientClientId)) - .zip(data_bytes(&cv, DataType::EncryptedPayload)) - .map(|((((message_id, conversation_id), sender_client_id), recipient_client_id), encrypted_payload)| { - StoredEncryptedMessage { - message_id, - conversation_id, - sender_client_id, - recipient_client_id, - sender_user_id: data_string(&cv, DataType::SenderUserId), - recipient_user_id: data_string(&cv, DataType::RecipientUserId), - created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), - encryption_version: data_i64(&cv, DataType::EncryptionVersion).unwrap_or(1), - encrypted_payload, - } - }); - if let Some(record) = record { - let message_id = record.message_id.clone(); - let conversation_id = record.conversation_id.clone(); - let recipient_client_id = record.recipient_client_id.clone(); - let sender_client_id = record.sender_client_id.clone(); - let sender_user_id = record.sender_user_id.clone().unwrap_or_default(); - let recipient_user_id = record.recipient_user_id.clone().unwrap_or_default(); - match e2ee_storage::put_encrypted_message(record) { - Ok(()) => { - self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessageAck) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::MessageId, DataValue::Str(message_id)) - .add_typed_default(DataType::ConversationId, DataValue::Str(conversation_id)) - .add_typed_default(DataType::RecipientClientId, DataValue::Str(recipient_client_id.clone())) - .add_typed_default(DataType::GetTime, DataValue::SignedNumber(now_millis_i64() as i128)) - ).await; - - if !recipient_user_id.is_empty() - && recipient_user_id != sender_user_id - && recipient_client_id != sender_client_id - { - if cv.get_receiver().to_string() != recipient_user_id { - self.send_message(&cv.clone().with_receiver(recipient_user_id.parse::().unwrap_or(0))).await; - } else { - self.send_message(&cv).await; - } - } - } - Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, - } + let forward = cv.clone().with_receiver(recipient_user_id.parse::().unwrap_or(0)); + if self.forward_chat_secret(&forward).await { + self.send_message(&error_response(&cv, CommunicationType::Success)).await; } else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - } - return; - } - - if cv.is_type(CommunicationType::EncryptedMessagesGet) { - let requester_user_id = cv.get_sender().to_string(); - let limit = data_i64(&cv, DataType::Limit).map(|v| v as i64); - let since = data_i64(&cv, DataType::Since); - let conversation_id = data_string(&cv, DataType::ConversationId); - let peer_client_id = data_string(&cv, DataType::PeerClientId); - - match e2ee_storage::get_encrypted_messages(EncryptedMessageQuery { - sender_user_id: data_string(&cv, DataType::SenderUserId), - recipient_client_id: None, - recipient_user_id: Some(requester_user_id.clone()), - conversation_id, - limit, - offset: since.map(|v| v.max(0)), - }) { - Ok(records) => { - let messages = records - .into_iter() - .filter(|record| { - peer_client_id - .as_ref() - .map(|peer| &record.sender_client_id == peer || &record.recipient_client_id == peer) - .unwrap_or(true) - }) - .map(|record| { - typed_container(vec![ - (DataType::MessageId, DataValue::Str(record.message_id)), - (DataType::ConversationId, DataValue::Str(record.conversation_id)), - (DataType::SenderClientId, DataValue::Str(record.sender_client_id)), - (DataType::RecipientClientId, DataValue::Str(record.recipient_client_id)), - (DataType::SenderUserId, DataValue::Str(record.sender_user_id.unwrap_or_default())), - (DataType::RecipientUserId, DataValue::Str(record.recipient_user_id.unwrap_or_default())), - (DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)), - (DataType::EncryptionVersion, DataValue::SignedNumber(record.encryption_version as i128)), - (DataType::EncryptedPayload, DataValue::Bytes(record.encrypted_payload)), - ]) - }) - .collect::>(); - - self.send_message(&CommunicationValue::new(CommunicationType::EncryptedMessagesResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Messages, DataValue::Array(messages)) - .add_typed_default(DataType::HasMore, DataValue::Bool(false)) - ).await; - } - Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + self.store_pending_chat_secret_forward(&cv).await; + self.send_message(&error_response(&cv, CommunicationType::Success)).await; } return; } diff --git a/type-maps.yaml b/type-maps.yaml index 354f186..34d0757 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -99,13 +99,10 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 - SetEncryptedDeviceSecret: 139 - GetEncryptedDeviceSecret: 140 - EncryptedDeviceSecretResponse: 141 - EncryptedMessage: 142 - EncryptedMessageAck: 143 - EncryptedMessagesGet: 144 - EncryptedMessagesResponse: 145 + SetChatSecret: 139 + GetChatSecret: 140 + ChatSecretResponse: 141 + ChatSecretForward: 142 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -213,18 +210,9 @@ type_maps: SecretId: 142 VersionNumber: 143 EncryptedSecret: 144 - WrappingPublicKeyId: 145 WrappingScheme: 146 UpdatedAt: 147 - MessageId: 148 - ConversationId: 149 - SenderClientId: 150 - RecipientClientId: 151 + ChatId: 148 + KemCiphertext: 149 SenderUserId: 152 RecipientUserId: 153 - EncryptionVersion: 154 - HasMore: 155 - NextCursor: 156 - Since: 157 - Limit: 158 - PeerClientId: 159 From f81e366a31bc9be56935e252bf859bd25548dabc Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 6 Jul 2026 18:50:01 +0200 Subject: [PATCH 075/119] (feat): chat crypto migrations --- client/src/client_connection.rs | 229 ++++++++++++---- omikron-connector/src/omikron_connection.rs | 286 +++++++++++++++----- type-maps.yaml | 2 + 3 files changed, 406 insertions(+), 111 deletions(-) diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index d1c7cf2..648038d 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -6,13 +6,14 @@ use iota_storage::util::chat_files::{MessageState, change_message_state}; use iota_storage::util::chats_util::{get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::CONFIG; -use iota_storage::util::{chat_files, chats_util}; use iota_storage::util::e2ee_storage::{self, ChatSecretQuery, StoredChatSecret}; +use iota_storage::util::{chat_files, chats_util}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use iota_util::file_util::{get_children, load_file, save_file}; use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::type_map::TypeMap; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -43,12 +44,102 @@ fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) .as_number() .and_then(|n| i64::try_from(n).ok()) - .or_else(|| cv.get_data(dt).as_signed_number().and_then(|n| i64::try_from(n).ok())) + .or_else(|| { + cv.get_data(dt) + .as_signed_number() + .and_then(|n| i64::try_from(n).ok()) + }) .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) } -fn data_bytes(cv: &CommunicationValue, dt: DataType) -> Option> { - cv.get_bytes(dt).map(|bytes| bytes.to_vec()) +#[derive(Debug, Clone)] +struct ChatSecretRecipient { + user_id: String, + encrypted_secret: Vec, + kem_ciphertext: Vec, +} + +fn recipient_from_value(value: &DataValue) -> Option { + let tm = TypeMap::latest(); + let user_id = value + .get_field(DataType::UserId.to_id(&tm))? + .as_str() + .map(|s| s.to_string()) + .or_else(|| { + value + .get_field(DataType::UserId.to_id(&tm))? + .as_number() + .map(|n| n.to_string()) + })?; + let encrypted_secret = value + .get_field(DataType::EncryptedSecret.to_id(&tm))? + .as_bytes()?; + let kem_ciphertext = value + .get_field(DataType::KemCiphertext.to_id(&tm))? + .as_bytes()?; + + Some(ChatSecretRecipient { + user_id, + encrypted_secret, + kem_ciphertext, + }) +} + +fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { + let recipients = cv.get_data(DataType::Recipients).as_array()?; + let parsed = recipients + .iter() + .map(recipient_from_value) + .collect::>>()?; + + if parsed.is_empty() { + None + } else { + Some(parsed) + } +} + +fn set_chat_secret_cv_for_recipient( + source: &CommunicationValue, + recipient: &ChatSecretRecipient, +) -> CommunicationValue { + let recipient_value = typed_container(vec![ + (DataType::UserId, DataValue::Str(recipient.user_id.clone())), + ( + DataType::EncryptedSecret, + DataValue::Bytes(recipient.encrypted_secret.clone()), + ), + ( + DataType::KemCiphertext, + DataValue::Bytes(recipient.kem_ciphertext.clone()), + ), + ]); + + CommunicationValue::new(CommunicationType::SetChatSecret) + .with_id(source.get_id()) + .with_sender(source.get_sender()) + .with_receiver(recipient.user_id.parse::().unwrap_or(0)) + .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) + .add_typed_default( + DataType::SecretId, + source.get_data(DataType::SecretId).clone(), + ) + .add_typed_default( + DataType::VersionNumber, + source.get_data(DataType::VersionNumber).clone(), + ) + .add_typed_default( + DataType::WrappingScheme, + source.get_data(DataType::WrappingScheme).clone(), + ) + .add_typed_default( + DataType::CreatedAt, + source.get_data(DataType::CreatedAt).clone(), + ) + .add_typed_default( + DataType::Recipients, + DataValue::Array(vec![recipient_value]), + ) } fn now_millis_i64() -> i64 { @@ -179,55 +270,74 @@ impl ClientConnection { if cv.is_type(CommunicationType::SetChatSecret) { let sender_id = cv.get_sender().to_string(); - if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - return; - } + let recipients = match chat_secret_recipients(&cv) { + Some(recipients) => recipients, + None => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let now = now_millis_i64(); - let record = data_string(&cv, DataType::UserId) - .zip(data_string(&cv, DataType::ChatId)) - .zip(data_string(&cv, DataType::SecretId)) - .zip(data_i64(&cv, DataType::VersionNumber)) - .zip(data_bytes(&cv, DataType::EncryptedSecret)) - .zip(data_bytes(&cv, DataType::KemCiphertext)) - .zip(data_string(&cv, DataType::WrappingScheme)) - .map(|((((((user_id, chat_id), secret_id), version), encrypted_secret), kem_ciphertext), wrapping_scheme)| { - StoredChatSecret { - user_id, - chat_id, - secret_id, - version, - encrypted_secret, - kem_ciphertext, - wrapping_scheme, - created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), - updated_at: now, - } - }); + let chat_id = data_string(&cv, DataType::ChatId); + let secret_id = data_string(&cv, DataType::SecretId); + let version = data_i64(&cv, DataType::VersionNumber); + let wrapping_scheme = data_string(&cv, DataType::WrappingScheme); + let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now); - match record.map(e2ee_storage::put_chat_secret) { - Some(Ok(())) => { - self.send_message(&error_response(&cv, CommunicationType::Success)).await; - } - _ => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + let Some((((chat_id, secret_id), version), wrapping_scheme)) = + chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) + else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + + for recipient in recipients.iter().filter(|item| item.user_id == sender_id) { + if e2ee_storage::put_chat_secret(StoredChatSecret { + user_id: recipient.user_id.clone(), + chat_id: chat_id.clone(), + secret_id: secret_id.clone(), + version, + encrypted_secret: recipient.encrypted_secret.clone(), + kem_ciphertext: recipient.kem_ciphertext.clone(), + wrapping_scheme: wrapping_scheme.clone(), + created_at, + updated_at: now, + }) + .is_err() + { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; } } + + for recipient in recipients.iter().filter(|item| item.user_id != sender_id) { + self.send_message(&set_chat_secret_cv_for_recipient(&cv, recipient)) + .await; + } + + self.send_message(&error_response(&cv, CommunicationType::Success)) + .await; return; } if cv.is_type(CommunicationType::GetChatSecret) { let Some(user_id) = data_string(&cv, DataType::UserId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; }; let sender_id = cv.get_sender().to_string(); if user_id != sender_id { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)) + .await; return; } let Some(chat_id) = data_string(&cv, DataType::ChatId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; }; @@ -243,19 +353,39 @@ impl ClientConnection { .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) - .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) - .add_typed_default(DataType::KemCiphertext, DataValue::Bytes(record.kem_ciphertext)) - .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) - .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) - .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(record.version as i128), + ) + .add_typed_default( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret), + ) + .add_typed_default( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext), + ) + .add_typed_default( + DataType::WrappingScheme, + DataValue::Str(record.wrapping_scheme), + ) + .add_typed_default( + DataType::CreatedAt, + DataValue::SignedNumber(record.created_at as i128), + ) + .add_typed_default( + DataType::UpdatedAt, + DataValue::SignedNumber(record.updated_at as i128), + ); self.send_message(&response).await; } Ok(None) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorNotSet)) + .await; } Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; } } return; @@ -267,10 +397,12 @@ impl ClientConnection { if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) || recipient_id.is_empty() { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; } - self.send_message(&cv.with_receiver(recipient_id.parse::().unwrap_or(0))).await; + self.send_message(&cv.with_receiver(recipient_id.parse::().unwrap_or(0))) + .await; return; } @@ -459,7 +591,8 @@ impl ClientConnection { // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. if cv.is_type(CommunicationType::MessageSend) { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 61ce63a..2c56ee5 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -5,7 +5,7 @@ use iota_storage::users::contact::Contact; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; -use iota_storage::util::config_util::{modify_config, CONFIG}; +use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::e2ee_storage::{ self, ChatSecretQuery, PendingChatSecretForward, StoredChatSecret, }; @@ -15,6 +15,7 @@ use iota_util::file_util::{get_children, has_file, load_file, save_file}; use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; +use mtp::type_map::TypeMap; use std::collections::HashMap; use std::env; use std::sync::{Arc, LazyLock}; @@ -49,12 +50,102 @@ fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) .as_number() .and_then(|n| i64::try_from(n).ok()) - .or_else(|| cv.get_data(dt).as_signed_number().and_then(|n| i64::try_from(n).ok())) + .or_else(|| { + cv.get_data(dt) + .as_signed_number() + .and_then(|n| i64::try_from(n).ok()) + }) .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) } -fn data_bytes(cv: &CommunicationValue, dt: DataType) -> Option> { - cv.get_bytes(dt).map(|bytes| bytes.to_vec()) +#[derive(Debug, Clone)] +struct ChatSecretRecipient { + user_id: String, + encrypted_secret: Vec, + kem_ciphertext: Vec, +} + +fn recipient_from_value(value: &DataValue) -> Option { + let tm = TypeMap::latest(); + let user_id = value + .get_field(DataType::UserId.to_id(&tm))? + .as_str() + .map(|s| s.to_string()) + .or_else(|| { + value + .get_field(DataType::UserId.to_id(&tm))? + .as_number() + .map(|n| n.to_string()) + })?; + let encrypted_secret = value + .get_field(DataType::EncryptedSecret.to_id(&tm))? + .as_bytes()?; + let kem_ciphertext = value + .get_field(DataType::KemCiphertext.to_id(&tm))? + .as_bytes()?; + + Some(ChatSecretRecipient { + user_id, + encrypted_secret, + kem_ciphertext, + }) +} + +fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { + let recipients = cv.get_data(DataType::Recipients).as_array()?; + let parsed = recipients + .iter() + .map(recipient_from_value) + .collect::>>()?; + + if parsed.is_empty() { + None + } else { + Some(parsed) + } +} + +fn set_chat_secret_cv_for_recipient( + source: &CommunicationValue, + recipient: &ChatSecretRecipient, +) -> CommunicationValue { + let recipient_value = typed_container(vec![ + (DataType::UserId, DataValue::Str(recipient.user_id.clone())), + ( + DataType::EncryptedSecret, + DataValue::Bytes(recipient.encrypted_secret.clone()), + ), + ( + DataType::KemCiphertext, + DataValue::Bytes(recipient.kem_ciphertext.clone()), + ), + ]); + + CommunicationValue::new(CommunicationType::SetChatSecret) + .with_id(source.get_id()) + .with_sender(source.get_sender()) + .with_receiver(recipient.user_id.parse::().unwrap_or(0)) + .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) + .add_typed_default( + DataType::SecretId, + source.get_data(DataType::SecretId).clone(), + ) + .add_typed_default( + DataType::VersionNumber, + source.get_data(DataType::VersionNumber).clone(), + ) + .add_typed_default( + DataType::WrappingScheme, + source.get_data(DataType::WrappingScheme).clone(), + ) + .add_typed_default( + DataType::CreatedAt, + source.get_data(DataType::CreatedAt).clone(), + ) + .add_typed_default( + DataType::Recipients, + DataValue::Array(vec![recipient_value]), + ) } fn now_millis_i64() -> i64 { @@ -67,44 +158,49 @@ fn now_millis_i64() -> i64 { fn pending_chat_secret_forward_from_cv( cv: &CommunicationValue, ) -> Option { + let recipient = chat_secret_recipients(cv)?.into_iter().next()?; Some(PendingChatSecretForward { - recipient_user_id: data_string(cv, DataType::RecipientUserId)?, + recipient_user_id: recipient.user_id, chat_id: data_string(cv, DataType::ChatId)?, - sender_user_id: data_string(cv, DataType::SenderUserId)?, + sender_user_id: cv.get_sender().to_string(), secret_id: data_string(cv, DataType::SecretId)?, version: data_i64(cv, DataType::VersionNumber)?, - encrypted_secret: data_bytes(cv, DataType::EncryptedSecret)?, - kem_ciphertext: data_bytes(cv, DataType::KemCiphertext)?, + encrypted_secret: recipient.encrypted_secret, + kem_ciphertext: recipient.kem_ciphertext, wrapping_scheme: data_string(cv, DataType::WrappingScheme)?, created_at: data_i64(cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), }) } fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationValue { - CommunicationValue::new(CommunicationType::ChatSecretForward) + let recipient = typed_container(vec![ + ( + DataType::UserId, + DataValue::Str(record.recipient_user_id.clone()), + ), + ( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret.clone()), + ), + ( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext.clone()), + ), + ]); + + CommunicationValue::new(CommunicationType::SetChatSecret) .with_sender(record.sender_user_id.parse::().unwrap_or(0)) + .with_receiver(record.recipient_user_id.parse::().unwrap_or(0)) .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id.clone())) .add_typed_default( DataType::SenderUserId, DataValue::Str(record.sender_user_id.clone()), ) - .add_typed_default( - DataType::RecipientUserId, - DataValue::Str(record.recipient_user_id.clone()), - ) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id.clone())) .add_typed_default( DataType::VersionNumber, DataValue::SignedNumber(record.version as i128), ) - .add_typed_default( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret.clone()), - ) - .add_typed_default( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext.clone()), - ) .add_typed_default( DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme.clone()), @@ -113,6 +209,7 @@ fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationVal DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128), ) + .add_typed_default(DataType::Recipients, DataValue::Array(vec![recipient])) } fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { @@ -682,50 +779,84 @@ impl OmikronConnection { if cv.is_type(CommunicationType::SetChatSecret) { let sender_id = cv.get_sender().to_string(); - if data_string(&cv, DataType::UserId).as_deref() != Some(sender_id.as_str()) { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; - return; - } + let recipients = match chat_secret_recipients(&cv) { + Some(recipients) => recipients, + None => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let now = now_millis_i64(); - let record = data_string(&cv, DataType::UserId) - .zip(data_string(&cv, DataType::ChatId)) - .zip(data_string(&cv, DataType::SecretId)) - .zip(data_i64(&cv, DataType::VersionNumber)) - .zip(data_bytes(&cv, DataType::EncryptedSecret)) - .zip(data_bytes(&cv, DataType::KemCiphertext)) - .zip(data_string(&cv, DataType::WrappingScheme)) - .map(|((((((user_id, chat_id), secret_id), version), encrypted_secret), kem_ciphertext), wrapping_scheme)| { - StoredChatSecret { - user_id, - chat_id, - secret_id, - version, - encrypted_secret, - kem_ciphertext, - wrapping_scheme, - created_at: data_i64(&cv, DataType::CreatedAt).unwrap_or(now), - updated_at: now, - } - }); + let chat_id = data_string(&cv, DataType::ChatId); + let secret_id = data_string(&cv, DataType::SecretId); + let version = data_i64(&cv, DataType::VersionNumber); + let wrapping_scheme = data_string(&cv, DataType::WrappingScheme); + let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now); - match record.map(e2ee_storage::put_chat_secret) { - Some(Ok(())) => self.send_message(&error_response(&cv, CommunicationType::Success)).await, - _ => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + let Some((((chat_id, secret_id), version), wrapping_scheme)) = + chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) + else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + + for recipient in &recipients { + let recipient_id = recipient.user_id.parse::().unwrap_or(0); + let is_local = iota_storage::users::user_manager::get_user(recipient_id).is_some(); + + if is_local { + if e2ee_storage::put_chat_secret(StoredChatSecret { + user_id: recipient.user_id.clone(), + chat_id: chat_id.clone(), + secret_id: secret_id.clone(), + version, + encrypted_secret: recipient.encrypted_secret.clone(), + kem_ciphertext: recipient.kem_ciphertext.clone(), + wrapping_scheme: wrapping_scheme.clone(), + created_at, + updated_at: now, + }) + .is_err() + { + self.send_message(&error_response( + &cv, + CommunicationType::ErrorInvalidData, + )) + .await; + return; + } + continue; + } + + if recipient.user_id != sender_id { + let forward = set_chat_secret_cv_for_recipient(&cv, recipient); + if !self.forward_chat_secret(&forward).await { + self.store_pending_chat_secret_forward(&forward).await; + } + } } + + self.send_message(&error_response(&cv, CommunicationType::Success)) + .await; return; } if cv.is_type(CommunicationType::GetChatSecret) { let Some(user_id) = data_string(&cv, DataType::UserId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; }; if user_id != cv.get_sender().to_string() { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)) + .await; return; } let Some(chat_id) = data_string(&cv, DataType::ChatId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; }; @@ -741,16 +872,40 @@ impl OmikronConnection { .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(record.version as i128)) - .add_typed_default(DataType::EncryptedSecret, DataValue::Bytes(record.encrypted_secret)) - .add_typed_default(DataType::KemCiphertext, DataValue::Bytes(record.kem_ciphertext)) - .add_typed_default(DataType::WrappingScheme, DataValue::Str(record.wrapping_scheme)) - .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128)) - .add_typed_default(DataType::UpdatedAt, DataValue::SignedNumber(record.updated_at as i128)); + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(record.version as i128), + ) + .add_typed_default( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret), + ) + .add_typed_default( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext), + ) + .add_typed_default( + DataType::WrappingScheme, + DataValue::Str(record.wrapping_scheme), + ) + .add_typed_default( + DataType::CreatedAt, + DataValue::SignedNumber(record.created_at as i128), + ) + .add_typed_default( + DataType::UpdatedAt, + DataValue::SignedNumber(record.updated_at as i128), + ); self.send_message(&response).await; } - Ok(None) => self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)).await, - Err(_) => self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await, + Ok(None) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorNotSet)) + .await + } + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await + } } return; } @@ -762,16 +917,21 @@ impl OmikronConnection { || recipient_user_id.is_empty() || pending_chat_secret_forward_from_cv(&cv).is_none() { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)).await; + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; return; } - let forward = cv.clone().with_receiver(recipient_user_id.parse::().unwrap_or(0)); + let forward = cv + .clone() + .with_receiver(recipient_user_id.parse::().unwrap_or(0)); if self.forward_chat_secret(&forward).await { - self.send_message(&error_response(&cv, CommunicationType::Success)).await; + self.send_message(&error_response(&cv, CommunicationType::Success)) + .await; } else { self.store_pending_chat_secret_forward(&cv).await; - self.send_message(&error_response(&cv, CommunicationType::Success)).await; + self.send_message(&error_response(&cv, CommunicationType::Success)) + .await; } return; } diff --git a/type-maps.yaml b/type-maps.yaml index 34d0757..db7fc47 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -99,6 +99,7 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 + ErrorNotSet: 136 SetChatSecret: 139 GetChatSecret: 140 ChatSecretResponse: 141 @@ -216,3 +217,4 @@ type_maps: KemCiphertext: 149 SenderUserId: 152 RecipientUserId: 153 + Recipients: 154 From b3311aa4568daffe67f1063edcf62bd38bd32994 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 6 Jul 2026 20:05:41 +0200 Subject: [PATCH 076/119] (feat): update types --- type-maps.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/type-maps.yaml b/type-maps.yaml index db7fc47..6fb2616 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -218,3 +218,4 @@ type_maps: SenderUserId: 152 RecipientUserId: 153 Recipients: 154 + OwnCallSecret: 155 From 846746223e722c91779aa2a0a9186a2e023fe39c Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 6 Jul 2026 20:50:59 +0200 Subject: [PATCH 077/119] (feat): more call stuff --- type-maps.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/type-maps.yaml b/type-maps.yaml index 6fb2616..db7fc47 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -218,4 +218,3 @@ type_maps: SenderUserId: 152 RecipientUserId: 153 Recipients: 154 - OwnCallSecret: 155 From b9e0df139404e3c4871bbe4484cca04aac0d0fbd Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:50:10 +0200 Subject: [PATCH 078/119] types --- client/src/client_connection.rs | 27 +++++--- iota-storage/src/util/chat_files.rs | 20 ++++-- iota-storage/src/util/db.rs | 9 +++ omikron-connector/src/omikron_connection.rs | 69 ++++++++++++++++----- type-maps.yaml | 1 + 5 files changed, 95 insertions(+), 31 deletions(-) diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 648038d..f779842 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -622,6 +622,7 @@ impl ClientConnection { .to_string(); let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; + let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); chat_files::add_message( timestamp as u128, @@ -630,6 +631,7 @@ impl ClientConnection { *sender_id as i64, &content, height, + reply_to, ); // Build user_forward using the parsed numeric timestamp and safe content string @@ -642,14 +644,23 @@ impl ClientConnection { ) .add_typed_default( DataType::Message, - typed_container(vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]), + { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }, ); let user_resp = self diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index 143aa14..a5b50fe 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -58,6 +58,7 @@ pub fn add_message( external_user: i64, message: &str, height: i64, + reply_to: Option, ) { let message_time = match i64::try_from(send_time) { Ok(v) => v, @@ -78,8 +79,9 @@ pub fn add_message( content, sent_by_self, message_state, - height - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + height, + reply_to + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) "#, params![ storage_owner, @@ -93,6 +95,7 @@ pub fn add_message( }, MessageState::Sending.as_str(), height, + reply_to, ], )?; Ok(()) @@ -191,7 +194,8 @@ pub fn get_messages( content, sent_by_self, message_state, - height + height, + reply_to FROM messages WHERE storage_owner = ?1 AND external_user = ?2 @@ -208,21 +212,25 @@ pub fn get_messages( let sent_by_self: i64 = row.get(2)?; let message_state: String = row.get(3)?; let height: i64 = row.get(4).unwrap_or(0); - Ok((message_time, content, sent_by_self, message_state, height)) + let reply_to: Option = row.get(5).ok().flatten(); + Ok((message_time, content, sent_by_self, message_state, height, reply_to)) }, )?; let mut out = array![]; for row in rows { match row { - Ok((message_time, content, sent_by_self, message_state, height)) => { - let msg = object! { + Ok((message_time, content, sent_by_self, message_state, height, reply_to)) => { + let mut msg = object! { "message_time" => message_time, "content" => content, "sent_by_self" => (sent_by_self != 0), "message_state" => message_state, "height" => height }; + if let Some(rt) = reply_to { + let _ = msg.insert("reply_to", rt); + } if let Err(e) = out.push(msg) { // out.push returns a JsonError; log it instead of using `?` to avoid // incompatible error conversions inside the DB closure. diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index eb48400..c39a4bf 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -162,6 +162,15 @@ pub fn create_general_messages_db() -> Result>, String> { ); Ok(()) }); + // Attempt to add the reply_to column for backwards compatibility. + // This will fail if the column already exists, which is expected. + let _ = with_conn(&shared_conn, |conn| { + let _ = conn.execute( + "ALTER TABLE messages ADD COLUMN reply_to INTEGER", + [], + ); + Ok(()) + }); Ok(shared_conn) } Err(e) => Err(e), diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 2c56ee5..8d1a002 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1262,6 +1262,7 @@ impl OmikronConnection { .to_string(); let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; + let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); @@ -1274,6 +1275,7 @@ impl OmikronConnection { sender_id as i64, &content, height, + reply_to, ); } @@ -1285,6 +1287,7 @@ impl OmikronConnection { receiver_id as i64, &content, height, + reply_to, ); // send confirmation back to sender @@ -1294,7 +1297,7 @@ impl OmikronConnection { self.send_message(&conf_msg).await; if !is_local { - let fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) + let mut fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) .with_id(cv.get_id()) .with_receiver(receiver_id as u64) .with_sender(sender_id as u64) @@ -1304,6 +1307,12 @@ impl OmikronConnection { DataType::SendTime, DataValue::SignedNumber(timestamp_i64 as i128), ); + if let Some(rt) = reply_to { + fw_msg = fw_msg.add_typed_default( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + ); + } let other_iota_resp = self .clone() @@ -1383,14 +1392,23 @@ impl OmikronConnection { ) .add_typed_default( DataType::Message, - typed_container(vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]), + { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }, ); // Attempt delivery and await a response from the local client @@ -1520,6 +1538,7 @@ impl OmikronConnection { .to_string(); let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; + let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); chat_files::add_message( timestamp as u128, @@ -1528,6 +1547,7 @@ impl OmikronConnection { *sender_id as i64, &content, height, + reply_to, ); // Build user_forward using the parsed numeric timestamp and safe content string @@ -1540,14 +1560,23 @@ impl OmikronConnection { ) .add_typed_default( DataType::Message, - typed_container(vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]), + { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }, ); let user_resp = self @@ -1675,6 +1704,12 @@ impl OmikronConnection { container.push((DataType::MessageState, DataValue::Str(message_state))); container.push((DataType::Height, DataValue::SignedNumber(height as i128))); container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + if let Some(rt) = m["reply_to"].as_i64() { + container.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } msg_array.push(typed_container(container)); } diff --git a/type-maps.yaml b/type-maps.yaml index db7fc47..0eebb7f 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -218,3 +218,4 @@ type_maps: SenderUserId: 152 RecipientUserId: 153 Recipients: 154 + ReplyId: 155 From 9b20d159b50d6224b370319fd10b0f21c393027d Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:37:13 +0200 Subject: [PATCH 079/119] Typemap --- Cargo.lock | 48 ++++++----- client/src/client_connection.rs | 55 +++++++----- omikron-connector/src/omikron_connection.rs | 92 +++++++++++---------- type-maps.yaml | 53 +++++++++++- 4 files changed, 159 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d51f28a..cf4ad0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,18 +886,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -2351,11 +2351,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2667,7 +2667,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "mtp-client", "mtp-codec", @@ -2681,7 +2681,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "mtp-codec", "mtp-common", @@ -2694,7 +2694,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "base64", "byteorder", @@ -2707,7 +2707,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "quinn", "rustls", @@ -2718,7 +2718,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "base64", "chacha20poly1305", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "mtp-crypto", "thiserror 1.0.69", @@ -2746,7 +2746,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "mtp-codec", "mtp-common", @@ -2759,11 +2759,12 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "log", "mtp-codec", "mtp-common", + "rcgen", "rustls", "rustls-native-certs", "tokio", @@ -2773,7 +2774,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#515244ce6664b355f0a89b00c8c18c26c8b95dbd" +source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" dependencies = [ "serde", "serde_yaml", @@ -3733,6 +3734,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", + "pem", + "ring", "rustls-pki-types", "time", "x509-parser", @@ -3985,9 +3988,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -5584,6 +5587,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", "thiserror 2.0.18", "time", @@ -5624,18 +5628,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index f779842..774119f 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -527,7 +527,14 @@ impl ClientConnection { msg_container.push((DataType::Content, DataValue::Str(content.clone()))); msg_container.push((DataType::MessageState, DataValue::Str(message_state))); msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - msg_container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if sent_by_self { + user_id as u128 + } else { + contact.user_id as u128 + }), + )); msg_array.push(typed_container(msg_container)); if msg_array.len() == 1 { @@ -642,26 +649,23 @@ impl ClientConnection { DataType::SenderId, DataValue::SignedNumber(*sender_id as i128), ) - .add_typed_default( - DataType::Message, - { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }, - ); + .add_typed_default(DataType::Message, { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }); let user_resp = self .clone() @@ -775,7 +779,14 @@ impl ClientConnection { )); container.push((DataType::MessageState, DataValue::Str(message_state))); container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if sent_by_self { + my_id as u128 + } else { + partner_id as u128 + }), + )); msg_array.push(typed_container(container)); } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 8d1a002..19ed8da 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1164,7 +1164,14 @@ impl OmikronConnection { msg_container.push((DataType::Content, DataValue::Str(content.clone()))); msg_container.push((DataType::MessageState, DataValue::Str(message_state))); msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - msg_container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + msg_container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if sent_by_self { + user_id as u128 + } else { + contact.user_id as u128 + }), + )); msg_array.push(typed_container(msg_container)); if msg_array.len() == 1 { @@ -1390,26 +1397,23 @@ impl OmikronConnection { DataType::SenderId, DataValue::SignedNumber(sender_id as i128), ) - .add_typed_default( - DataType::Message, - { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }, - ); + .add_typed_default(DataType::Message, { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }); // Attempt delivery and await a response from the local client let user_resp = self @@ -1558,26 +1562,23 @@ impl OmikronConnection { DataType::SenderId, DataValue::SignedNumber(*sender_id as i128), ) - .add_typed_default( - DataType::Message, - { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }, - ); + .add_typed_default(DataType::Message, { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.clone())), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + typed_container(msg_fields) + }); let user_resp = self .clone() @@ -1703,7 +1704,14 @@ impl OmikronConnection { )); container.push((DataType::MessageState, DataValue::Str(message_state))); container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - container.push((DataType::SentBySelf, DataValue::Bool(sent_by_self))); + container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if sent_by_self { + my_id as u128 + } else { + partner_id as u128 + }), + )); if let Some(rt) = m["reply_to"].as_i64() { container.push(( DataType::ReplyId, diff --git a/type-maps.yaml b/type-maps.yaml index 0eebb7f..dddd7a2 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -1,8 +1,46 @@ -# The version a Client should use protocol_version: "1.0" +# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended + +# Fixed CommunicationType markers are: +# Error: 0 +# ErrorParsing: 1 +# ErrorBadVersion: 2 +# Disconnect: 3 +# Redirect: 4 +# Shutdown: 5 +# BadRequest: 6 +# Unauthorized: 7 +# Forbidden: 8 +# NotFound: 9 +# TooManyRequests: 10 +# InternalServerError: 11 +# BadGateway: 12 +# ServiceUnavailable: 13 +# GatewayTimeout: 14 +# Identification: 15 +# IdentificationResponse: 16 +# Register: 17 +# RegisterResponse: 18 +# Ping: 19 +# Pong: 20 + +# Fixed Data Type markers are: +# Error: 0 +# ErrorParsing: 1 +# ErrorMessage: 2 +# Version: 3 +# Description: 4 +# Timestamp: 5 +# Id: 6 +# ClientNonce: 7 +# ServerNonce: 8 +# PublicKeys: 9 +# Signature: 10 +# Connected: 11 + type_maps: - "1.0": # Protocol version 1.0 + "1.0": CommunicationTypes: ErrorProtocol: 33 ErrorAnonymous: 34 @@ -33,6 +71,7 @@ type_maps: MessageLive: 59 MessageOtherIota: 60 MessageChunk: 61 + MessageGet: 143 MessagesGet: 62 PushNotification: 63 ReadNotification: 64 @@ -104,6 +143,11 @@ type_maps: GetChatSecret: 140 ChatSecretResponse: 141 ChatSecretForward: 142 + MessageEditLive: 144 + MessageEdit: 145 + MessageReactionAdd: 146 + MessageReactionRemove: 147 + MessageReactionLive: 148 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -218,4 +262,7 @@ type_maps: SenderUserId: 152 RecipientUserId: 153 Recipients: 154 - ReplyId: 155 + Edited: 155 + Reactions: 156 + Reaction: 157 + ReplyId: 158 From 3be1d9f308269c280466f74121e0825786861b2c Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:40:46 +0200 Subject: [PATCH 080/119] [Mig] storage to SQLite pool [Clean] split message handler dispatch --- Cargo.lock | 39 +- client/src/client_connection.rs | 59 +- iota-cli/src/elements/console_card.rs | 2 +- iota-storage/Cargo.toml | 3 + iota-storage/src/lib.rs | 1 + iota-storage/src/storage_error.rs | 13 + iota-storage/src/users/contact.rs | 26 - iota-storage/src/users/mod.rs | 1 - iota-storage/src/users/user_community_util.rs | 58 - iota-storage/src/users/user_manager.rs | 294 +- iota-storage/src/users/user_profile.rs | 52 +- iota-storage/src/util/chat_files.rs | 356 ++- iota-storage/src/util/chats_util.rs | 57 +- iota-storage/src/util/communities_util.rs | 56 +- iota-storage/src/util/config_util.rs | 10 +- iota-storage/src/util/db.rs | 345 +-- omikron-connector/src/omikron_connection.rs | 2378 +++++++++-------- omikron-connector/src/ping_pong_task.rs | 7 +- 18 files changed, 2003 insertions(+), 1754 deletions(-) create mode 100644 iota-storage/src/storage_error.rs delete mode 100644 iota-storage/src/users/user_community_util.rs diff --git a/Cargo.lock b/Cargo.lock index cf4ad0b..cc8ec44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,9 +568,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" @@ -2179,6 +2179,7 @@ dependencies = [ "json", "mtp", "once_cell", + "r2d2", "rand 0.8.6", "rand_core 0.6.4", "ratatui", @@ -2186,8 +2187,10 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "sha2 0.10.9", "sysinfo", + "thiserror 2.0.18", "tokio", "uuid", "walkdir", @@ -2548,9 +2551,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -3535,6 +3538,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.6" @@ -4016,6 +4030,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5628,18 +5651,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 774119f..f1474ec 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -512,24 +512,18 @@ impl ClientConnection { let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); let mut msg_array = Vec::new(); - for m in messages.members() { - let message_time = m["message_time"].as_i64().unwrap_or(0); - let content = m["content"].as_str().unwrap_or("").to_string(); - let sent_by_self = m["sent_by_self"].as_bool().unwrap_or(false); - let height = m["height"].as_i64().unwrap_or(0); - let message_state = m["message_state"].as_str().unwrap_or("").to_string(); - + for m in &messages { let mut msg_container = Vec::new(); msg_container.push(( DataType::SendTime, - DataValue::SignedNumber(message_time as i128), + DataValue::SignedNumber(m.message_time as i128), )); - msg_container.push((DataType::Content, DataValue::Str(content.clone()))); - msg_container.push((DataType::MessageState, DataValue::Str(message_state))); - msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + msg_container.push((DataType::Content, DataValue::Str(m.content.clone()))); + msg_container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); + msg_container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); msg_container.push(( DataType::SenderId, - DataValue::UnsignedNumber(if sent_by_self { + DataValue::UnsignedNumber(if m.sent_by_self { user_id as u128 } else { contact.user_id as u128 @@ -538,13 +532,13 @@ impl ClientConnection { msg_array.push(typed_container(msg_container)); if msg_array.len() == 1 { - let sender_id = if sent_by_self { + let sender_id = if m.sent_by_self { user_id } else { contact.user_id }; let mut last_msg = Vec::new(); - last_msg.push((DataType::Content, DataValue::Str(content))); + last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); last_msg.push(( DataType::SenderId, DataValue::SignedNumber(sender_id as i128), @@ -749,12 +743,8 @@ impl ClientConnection { amount as i64, ); let mut msg_array: Vec = Vec::new(); - for m in messages.members() { - let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); - let content: String = m["content"].as_str().unwrap_or("").to_string(); - let sent_by_self: bool = m["sent_by_self"].as_bool().unwrap_or(false); - let height: i64 = m["height"].as_i64().unwrap_or(0); - let sender_id: i64 = if sent_by_self { + for m in &messages { + let sender_id: i64 = if m.sent_by_self { my_id as i64 } else { if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { @@ -765,23 +755,22 @@ impl ClientConnection { partner_id as i64 } }; - let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); let mut container = Vec::new(); container.push(( DataType::SendTime, - DataValue::SignedNumber(message_time as i128), + DataValue::SignedNumber(m.message_time as i128), )); - container.push((DataType::Content, DataValue::Str(content))); + container.push((DataType::Content, DataValue::Str(m.content.clone()))); container.push(( DataType::SenderId, DataValue::SignedNumber(sender_id as i128), )); - container.push((DataType::MessageState, DataValue::Str(message_state))); - container.push((DataType::Height, DataValue::SignedNumber(height as i128))); + container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); + container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); container.push(( DataType::SenderId, - DataValue::UnsignedNumber(if sent_by_self { + DataValue::UnsignedNumber(if m.sent_by_self { my_id as u128 } else { partner_id as u128 @@ -883,18 +872,12 @@ impl ClientConnection { let mut comm_array = Vec::new(); for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { let mut container: Vec<(DataType, DataValue)> = Vec::new(); - if let Some(address) = c["address"].as_str() { - container.push(( - DataType::CommunityAddress, - DataValue::Str(address.to_string()), - )); - } - if let Some(title) = c["title"].as_str() { - container.push((DataType::CommunityTitle, DataValue::Str(title.to_string()))); - } - if let Some(position) = c["position"].as_str() { - container.push((DataType::Position, DataValue::Str(position.to_string()))); - } + container.push(( + DataType::CommunityAddress, + DataValue::Str(c.address.clone()), + )); + container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); + container.push((DataType::Position, DataValue::Str(c.position.clone()))); comm_array.push(typed_container(container)); } diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 6e9a7a0..820ae1e 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -444,7 +444,7 @@ pub async fn run_command(command: &str) { if let Some(user) = user_manager::get_user_by_username(username) { let msg = CommunicationValue::new(CommunicationType::DeleteUser) .with_sender(user.user_id as u64); - OMIKRON_CONNECTION.send_message(&msg).await; + let _ = OMIKRON_CONNECTION.send_message(&msg).await; user_manager::remove_user(user.user_id); log!("Removed user {}", user.user_id); } else { diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 78359be..24d7603 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -17,8 +17,11 @@ hkdf = "0.12.4" json = "*" arc-swap = "1" once_cell = "1.21.3" +r2d2 = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" +thiserror = "2" rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs index 7f0e8ef..a0e5061 100644 --- a/iota-storage/src/lib.rs +++ b/iota-storage/src/lib.rs @@ -1,2 +1,3 @@ +pub mod storage_error; pub mod users; pub mod util; diff --git a/iota-storage/src/storage_error.rs b/iota-storage/src/storage_error.rs new file mode 100644 index 0000000..100bf08 --- /dev/null +++ b/iota-storage/src/storage_error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum StorageError { + #[error("Database error: {0}")] + Db(#[from] rusqlite::Error), + #[error("Connection pool error: {0}")] + Pool(String), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Other(String), +} diff --git a/iota-storage/src/users/contact.rs b/iota-storage/src/users/contact.rs index 632cea3..50d224d 100644 --- a/iota-storage/src/users/contact.rs +++ b/iota-storage/src/users/contact.rs @@ -1,4 +1,3 @@ -use json::{self, JsonValue, number::Number}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] @@ -33,29 +32,4 @@ impl Contact { pub fn set_last_message_at(&mut self, p0: i64) { self.last_message_at = Option::from(p0); } - - pub fn to_json(&self) -> JsonValue { - let mut obj = JsonValue::new_object(); - obj["user_id"] = JsonValue::Number(Number::from(self.user_id)); - if let Some(name) = &self.user_name { - obj["user_name"] = JsonValue::from(name.as_str()); - } - if let Some(ts) = &self.last_message_at { - obj["last_message_at"] = JsonValue::Number(Number::from(*ts)); - } - obj - } - pub fn from_json(o: &JsonValue) -> Contact { - let user_id = o["user_id"].as_i64().unwrap_or(0); - - let user_name = o["user_name"].as_str().map(|s| s.to_string()); - - let last_message_at = o["last_message_at"].as_i64(); - - Contact { - user_id, - user_name, - last_message_at, - } - } } diff --git a/iota-storage/src/users/mod.rs b/iota-storage/src/users/mod.rs index aef5a02..cd4fa45 100644 --- a/iota-storage/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,4 +1,3 @@ pub mod contact; -pub mod user_community_util; pub mod user_manager; pub mod user_profile; diff --git a/iota-storage/src/users/user_community_util.rs b/iota-storage/src/users/user_community_util.rs deleted file mode 100644 index 89e0fb6..0000000 --- a/iota-storage/src/users/user_community_util.rs +++ /dev/null @@ -1,58 +0,0 @@ -use iota_util::file_util::{load_file, save_file}; -use json::{self, Array, JsonValue}; - -pub struct UserCommunityUtil; - -impl UserCommunityUtil { - pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { - let file_path = format!("users/{}/", storage_owner); - let mut communities = Self::load_array(&file_path, "communities.json"); - - let mut community = JsonValue::new_object(); - community["title"] = JsonValue::String(title); - community["address"] = JsonValue::String(address); - community["position"] = JsonValue::String(position); - - communities.push(community); - - save_file( - &file_path, - "communities.json", - &JsonValue::Array(communities).to_string(), - ); - } - - pub fn remove_community(storage_owner: i64, community_address: String) { - let file_path = format!("users/{}/", storage_owner); - let communities = Self::load_array(&file_path, "communities.json"); - - let filtered: Array = communities - .iter() - .filter(|entry| entry["address"].as_str() != Some(&community_address)) - .cloned() - .collect(); - save_file( - &file_path, - "communities.json", - &JsonValue::Array(filtered).to_string(), - ); - } - - pub fn get_communities(storage_owner: i64) -> Array { - let file_path = format!("users/{}/", storage_owner); - Self::load_array(&file_path, "communities.json") - } - - fn load_array(dir: &str, name: &str) -> Array { - let content = load_file(dir, name); - if content.is_empty() { - return Array::new(); - } - - let parsed = json::parse(&content); - match parsed { - Ok(JsonValue::Array(arr)) => arr, - _ => Array::new(), - } - } -} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index bf1dc00..68afe5e 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,15 +1,212 @@ use crate::users::user_profile::UserProfile; +use crate::util::db; use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64}; use iota_util::file_util::{load_file, save_file}; -use json::JsonValue; -use once_cell::sync::Lazy; +use rusqlite::params; use rand_core::{OsRng, RngCore}; -use std::io::{self}; -use std::sync::Mutex; -static USERS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); -static UNIQUE: Lazy> = Lazy::new(|| Mutex::new(false)); +pub fn add_user(user: UserProfile) { + if let Err(e) = db::with_db(|conn| { + conn.execute( + r#" + INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(user_id) DO UPDATE SET + username = excluded.username, + public_key = excluded.public_key, + private_key_hash = excluded.private_key_hash, + reset_token = excluded.reset_token, + display_name = excluded.display_name + "#, + params![ + user.user_id, + user.username, + user.public_key, + user.private_key_hash, + user.reset_token, + user.created_at, + user.display_name, + ], + )?; + + for (app_id, app_secret) in &user.trusted_apps { + conn.execute( + r#" + INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) + VALUES (?1, ?2, ?3) + "#, + params![user.user_id, app_id, app_secret], + )?; + } + Ok(()) + }) { + eprintln!("Failed to add_user: {}", e); + } +} + +pub fn update_user(user: UserProfile) { + add_user(user); +} + +pub fn get_user_by_username(username: &str) -> Option { + match db::with_db(|conn| { + match conn.query_row( + "SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name FROM users WHERE username = ?1 LIMIT 1", + params![username], + |r| { + let user_id: i64 = r.get(0)?; + Ok(UserProfile { + user_id, + username: r.get(1)?, + display_name: r.get(6)?, + public_key: r.get(2)?, + private_key_hash: r.get(3)?, + created_at: r.get(5)?, + reset_token: r.get(4)?, + trusted_apps: load_trusted_apps(user_id), + }) + }, + ) { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + }) { + Ok(opt) => opt, + Err(e) => { + eprintln!("Error querying user by username: {}", e); + None + } + } +} + +pub fn get_user(user_id: i64) -> Option { + match db::with_db(|conn| { + match conn.query_row( + "SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name FROM users WHERE user_id = ?1 LIMIT 1", + params![user_id], + |r| { + let user_id: i64 = r.get(0)?; + Ok(UserProfile { + user_id, + username: r.get(1)?, + display_name: r.get(6)?, + public_key: r.get(2)?, + private_key_hash: r.get(3)?, + created_at: r.get(5)?, + reset_token: r.get(4)?, + trusted_apps: load_trusted_apps(user_id), + }) + }, + ) { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + }) { + Ok(opt) => opt, + Err(e) => { + eprintln!("Error querying user: {}", e); + None + } + } +} + +pub fn get_users() -> Vec { + match db::with_db(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name + FROM users + ORDER BY username + "#, + )?; + + let rows = stmt.query_map([], |r| { + let user_id: i64 = r.get(0)?; + let username: String = r.get(1)?; + let public_key: String = r.get(2)?; + let private_key_hash: String = r.get(3)?; + let reset_token: String = r.get(4)?; + let created_at: i64 = r.get(5)?; + let display_name: Option = r.get(6)?; + + Ok(UserProfile { + user_id, + username, + display_name, + public_key, + private_key_hash, + created_at, + reset_token, + trusted_apps: std::collections::HashMap::new(), + }) + })?; + + let mut out = Vec::new(); + for row in rows { + match row { + Ok(mut user) => { + user.trusted_apps = load_trusted_apps(user.user_id); + out.push(user); + } + Err(e) => eprintln!("Failed to read user row: {}", e), + } + } + Ok(out) + }) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to query users: {}", e); + Vec::new() + } + } +} + +fn load_trusted_apps(user_id: i64) -> std::collections::HashMap { + match db::with_db(|conn| { + let mut stmt = conn.prepare( + "SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1", + )?; + let rows = stmt.query_map(params![user_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + + let mut map = std::collections::HashMap::new(); + for row in rows { + if let Ok((k, v)) = row { + map.insert(k, v); + } + } + Ok(map) + }) { + Ok(m) => m, + Err(e) => { + eprintln!("Failed to load trusted apps: {}", e); + std::collections::HashMap::new() + } + } +} + +pub fn remove_user(user_id: i64) { + if let Err(e) = db::with_db(|conn| { + conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + Ok(()) + }) { + eprintln!("Failed to remove_user: {}", e); + } +} + +pub fn clear() { + if let Err(e) = db::with_db(|conn| { + conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?; + Ok(()) + }) { + eprintln!("Failed to clear users: {}", e); + } +} #[allow(dead_code)] pub async fn load_from_tu(username: &str) -> Result<(), ()> { @@ -35,91 +232,38 @@ pub async fn load_from_tu(username: &str) -> Result<(), ()> { hex_hash(&keyring_b64), reset_token, ); - USERS.lock().unwrap().push(user_profile); + add_user(user_profile); Ok(()) } -pub fn add_user(user: UserProfile) { - USERS.lock().unwrap().push(user); -} - -pub fn update_user(user: UserProfile) { - let mut users = USERS.lock().unwrap(); - if let Some(pos) = users.iter().position(|u| u.user_id == user.user_id) { - users[pos] = user; - } - *UNIQUE.lock().unwrap() = true; -} -pub fn get_user_by_username(username: &str) -> Option { - USERS - .lock() - .unwrap() - .iter() - .cloned() - .find(|u| u.username == username) -} - -pub fn get_user(user_id: i64) -> Option { - USERS - .lock() - .unwrap() - .iter() - .cloned() - .find(|u| u.user_id == user_id) -} - -pub fn get_users() -> Vec { - USERS.lock().unwrap().clone() -} - -pub fn remove_user(user_id: i64) { - let mut users = USERS.lock().unwrap(); - users.retain(|u| u.user_id != user_id); - *UNIQUE.lock().unwrap() = true; -} - pub fn save_users() { - *UNIQUE.lock().unwrap() = false; - let users = USERS.lock().unwrap(); - let arr: Vec = users.iter().map(|u| u.to_json()).collect(); - let json_str = JsonValue::Array(arr).dump(); - - save_file("", "users.json", &json_str); + // No-op: users are auto-saved via SQLite. } -pub fn clear() { - let mut users = USERS.lock().unwrap(); - users.clear(); - *UNIQUE.lock().unwrap() = true; -} - -pub async fn load_users() -> io::Result<()> { +pub async fn load_users() -> std::io::Result<()> { + // Users are loaded from SQLite on demand. This function is kept for API compat. + // If we need to migrate from a legacy users.json file, we can do so here. let content = load_file("", "users.json"); if content.trim().is_empty() { return Ok(()); } - - let parsed = - json::parse(&content).map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; - if let JsonValue::Array(arr) = parsed { - let mut users = USERS.lock().unwrap(); - for j in arr.iter() { - if let Some(up) = UserProfile::from_json(j).await { - users.push(up); + if let Ok(parsed) = json::parse(&content) { + if let json::JsonValue::Array(arr) = parsed { + for j in arr.iter() { + if let Some(up) = UserProfile::from_json(j) { + add_user(up); + } } } } - if *UNIQUE.lock().unwrap() { - save_users(); - } + // Rename the old file so we don't re-import + let _ = std::fs::rename( + std::path::PathBuf::from(iota_util::file_util::get_directory()).join("users.json"), + std::path::PathBuf::from(iota_util::file_util::get_directory()).join("users.json.imported"), + ); Ok(()) } -#[allow(dead_code)] -pub fn set_unique(val: bool) { - *UNIQUE.lock().unwrap() = val; -} - pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { let path = format!("users/{}/apps", user_id); let name = format!("{}.json", app_identifier); diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index c0ae20e..2ecb9cc 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -5,9 +5,9 @@ use iota_util::file_util::{has_file, load_file, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; -// --- UserProfile --- -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserProfile { pub user_id: i64, pub username: String, @@ -43,26 +43,6 @@ impl UserProfile { } } - pub fn to_json(&self) -> JsonValue { - let mut trusted_apps_obj = json::JsonValue::new_object(); - for (k, v) in &self.trusted_apps { - trusted_apps_obj[k] = v.clone().into(); - } - - let mut obj = object! { - "uuid" => self.user_id, - "username" => self.username.clone(), - "public_key" => self.public_key.clone(), - "private_key_hash" => self.private_key_hash.clone(), - "created_at" => self.created_at, - "reset_token" => self.reset_token.clone(), - "trusted_apps" => trusted_apps_obj, - }; - if let Some(d) = &self.display_name { - obj["display_name"] = d.clone().into(); - } - obj - } pub fn frontend(&self) -> JsonValue { let mut obj = object! { "uuid" => self.user_id, @@ -78,10 +58,11 @@ impl UserProfile { if has_file("", &format!("{}.tu", self.username.clone())) { obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); } - obj } - pub async fn from_json(j: &JsonValue) -> Option { + + /// Legacy JSON import - used when migrating from users.json to SQLite. + pub fn from_json(j: &JsonValue) -> Option { let user_id = j["uuid"].as_i64()?; let username = j["username"].as_str()?.to_string(); let public_key = j["public_key"].as_str()?.to_string(); @@ -99,7 +80,7 @@ impl UserProfile { } } - let up = UserProfile { + Some(UserProfile { user_id, username, display_name, @@ -108,22 +89,15 @@ impl UserProfile { created_at, reset_token, trusted_apps, - }; + }) + } - // TODO: Migrate to Omikron / Wss - /* if j.has_key("migrate") - || j.has_key("migrating") - || j.has_key("changing") - || j.has_key("move") - || j.has_key("moving") - { - if auth_connector::migrate_user(&mut up).await { - log_message(format!("[INFO] Migration triggered for {}", up.username)); - user_manager::set_unique(true); - } - } */ + pub fn from_yaml(s: &str) -> Result { + serde_yaml::from_str(s) + } - Some(up) + pub fn to_yaml(&self) -> Result { + serde_yaml::to_string(self) } #[allow(dead_code)] diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index a5b50fe..4aba87d 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,9 +1,7 @@ use crate::util::db; use iota_logger::log; -use json::{JsonValue, array, object}; use rusqlite::params; -use std::io; -use std::sync::{Arc, LazyLock, Mutex}; +use crate::storage_error::StorageError; #[derive(PartialEq, Debug, Clone)] pub enum MessageState { @@ -45,11 +43,202 @@ impl MessageState { } } -// Shared DB created via helper. -// The db helper constructs the messages sqlite file and ensures PRAGMAs and schema exist. -static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { - db::create_general_messages_db().expect("Failed to create or initialize general messages DB") -}); +#[derive(Debug, Clone)] +pub struct StoredMessage { + pub id: i64, + pub message_time: i64, + pub content: String, + pub edited: bool, + pub sent_by_self: bool, + pub message_state: String, + pub height: i64, + pub reply_to: Option, + pub reactions: Vec, +} + +/* + * Each edit is recorded in message_edits with the before/after content and a + * timestamp. Only the original sender (sent_by_self = 1) may edit. + */ +pub fn edit_message( + storage_owner: i64, + external_user: i64, + message_time: i64, + editor_id: i64, + new_content: &str, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let msg = conn.query_row( + r#" + SELECT id, content, sent_by_self + FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }, + )?; + + let (msg_id, old_content, sent_by_self) = msg; + if sent_by_self != 1 { + return Err(StorageError::Other( + "Only the original sender can edit this message".into(), + )); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + conn.execute( + r#" + INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, + params![msg_id, old_content, new_content, now, editor_id], + )?; + + conn.execute( + r#" + UPDATE messages + SET content = ?1, edited_count = edited_count + 1 + WHERE id = ?2 + "#, + params![new_content, msg_id], + )?; + + Ok(()) + }) +} + +pub fn hard_delete_message(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { + db::with_db(|conn| { + let msg_id: i64 = conn.query_row( + r#" + SELECT id FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| row.get(0), + )?; + + conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?; + conn.execute("DELETE FROM reactions WHERE message_id = ?1", params![msg_id])?; + conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; + Ok(()) + }) +} + +/* + * Marks a message as deleted by the external user rather than removing the row, + * so the storage owner still sees a tombstone in the UI. + */ +pub fn flag_deleted_by_external(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { + db::with_db(|conn| { + let affected = conn.execute( + r#" + UPDATE messages + SET deleted_by_external = 1 + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + "#, + params![storage_owner, external_user, message_time], + )?; + if affected == 0 { + return Err(StorageError::Other("Message not found".into())); + } + Ok(()) + }) +} + +/* + * Removes the edit trail but keeps the message with edited_count > 0 so + * the UI still shows the "edited" indicator. Only the own user should + * call this. + */ +pub fn delete_edit_history(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { + db::with_db(|conn| { + let msg_id: i64 = conn.query_row( + r#" + SELECT id FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| row.get(0), + )?; + + conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?; + Ok(()) + }) +} + +pub fn add_reaction( + storage_owner: i64, + external_user: i64, + message_time: i64, + user_id: i64, + reaction: &str, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let msg_id: i64 = conn.query_row( + r#" + SELECT id FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| row.get(0), + )?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + conn.execute( + r#" + INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) + VALUES (?1, ?2, ?3, ?4) + "#, + params![msg_id, user_id, reaction, now], + )?; + Ok(()) + }) +} + +pub fn remove_reaction( + storage_owner: i64, + external_user: i64, + message_time: i64, + user_id: i64, + reaction: &str, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let msg_id: i64 = conn.query_row( + r#" + SELECT id FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| row.get(0), + )?; + + conn.execute( + "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3", + params![msg_id, user_id, reaction], + )?; + Ok(()) + }) +} pub fn add_message( send_time: u128, @@ -68,19 +257,12 @@ pub fn add_message( } }; - // Insert the message into the DB - let insert_result = db::with_conn(&MESSAGES_DB, |conn| { + if let Err(e) = db::with_db(|conn| { conn.execute( r#" INSERT INTO messages ( - storage_owner, - external_user, - message_time, - content, - sent_by_self, - message_state, - height, - reply_to + storage_owner, external_user, message_time, content, + sent_by_self, message_state, height, reply_to ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) "#, params![ @@ -99,18 +281,13 @@ pub fn add_message( ], )?; Ok(()) - }); - - if let Err(e) = insert_result { + }) { log!("Failed to insert message into sqlite: {}", e); return; } - // Update contacts table to reflect that this conversation exists and has a recent message. - // Use the Contact helper to set last_message_at to the message timestamp. let mut contact = crate::users::contact::Contact::new(external_user); contact.set_last_message_at(message_time); - // This will insert or update the contact for the storage owner. crate::util::chats_util::mod_user(storage_owner, &contact); } @@ -119,25 +296,21 @@ pub fn change_message_state( storage_owner: i64, external_user: i64, new_state: MessageState, -) -> io::Result<()> { - // Run the SELECT and UPDATE inside with_conn to centralize connection access. - let res: Result<(), String> = db::with_conn(&MESSAGES_DB, |conn| { +) -> std::io::Result<()> { + db::with_db(|conn| { let current: Option = match conn.query_row( r#" SELECT message_state FROM messages - WHERE storage_owner = ?1 - AND external_user = ?2 - AND message_time = ?3 - ORDER BY id DESC - LIMIT 1 + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 "#, params![storage_owner, external_user, timestamp], |row| row.get(0), ) { Ok(state) => Some(state), Err(rusqlite::Error::QueryReturnedNoRows) => None, - Err(e) => return Err(e), + Err(e) => return Err(e.into()), }; let Some(current_state_raw) = current else { @@ -154,24 +327,45 @@ pub fn change_message_state( UPDATE messages SET message_state = ?1 WHERE id = ( - SELECT id - FROM messages - WHERE storage_owner = ?2 - AND external_user = ?3 - AND message_time = ?4 - ORDER BY id DESC - LIMIT 1 + SELECT id FROM messages + WHERE storage_owner = ?2 AND external_user = ?3 AND message_time = ?4 + ORDER BY id DESC LIMIT 1 ) "#, params![upgraded, storage_owner, external_user, timestamp], )?; Ok(()) - }); + }) + .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) +} - match res { - Ok(_) => Ok(()), - Err(e) => Err(io::Error::new(io::ErrorKind::Other, e)), +fn load_reactions(conn: &rusqlite::Connection, msg_ids: &[i64]) -> std::collections::HashMap> { + if msg_ids.is_empty() { + return std::collections::HashMap::new(); } + + let placeholders: Vec = msg_ids.iter().enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect(); + let query = format!( + "SELECT message_id, reaction || ':' || COUNT(*) FROM reactions WHERE message_id IN ({}) GROUP BY message_id, reaction", + placeholders.join(", ") + ); + + let mut map: std::collections::HashMap> = std::collections::HashMap::new(); + if let Ok(mut stmt) = conn.prepare(&query) { + let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids.iter() + .map(|id| id as &dyn rusqlite::types::ToSql) + .collect(); + if let Ok(rows) = stmt.query_map(params.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) { + for row in rows.flatten() { + map.entry(row.0).or_default().push(row.1); + } + } + } + map } pub fn get_messages( @@ -179,26 +373,17 @@ pub fn get_messages( external_user: i64, loaded_messages: i64, amount: i64, -) -> JsonValue { - let messages = array![]; - +) -> Vec { if amount <= 0 || loaded_messages < 0 { - return messages; + return Vec::new(); } - let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + match db::with_db(|conn| { let mut stmt = conn.prepare( r#" - SELECT - message_time, - content, - sent_by_self, - message_state, - height, - reply_to + SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count FROM messages - WHERE storage_owner = ?1 - AND external_user = ?2 + WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 ORDER BY message_time DESC, id DESC LIMIT ?3 OFFSET ?4 "#, @@ -207,49 +392,40 @@ pub fn get_messages( let rows = stmt.query_map( params![storage_owner, external_user, amount, loaded_messages], |row| { - let message_time: i64 = row.get(0)?; - let content: String = row.get(1)?; - let sent_by_self: i64 = row.get(2)?; - let message_state: String = row.get(3)?; - let height: i64 = row.get(4).unwrap_or(0); - let reply_to: Option = row.get(5).ok().flatten(); - Ok((message_time, content, sent_by_self, message_state, height, reply_to)) + Ok(StoredMessage { + id: row.get(0)?, + message_time: row.get(1)?, + content: row.get(2)?, + sent_by_self: row.get::<_, i64>(3)? != 0, + message_state: row.get(4)?, + height: row.get(5).unwrap_or(0), + reply_to: row.get(6).ok().flatten(), + edited: row.get::<_, i64>(7).unwrap_or(0) > 0, + reactions: Vec::new(), + }) }, )?; - let mut out = array![]; + let mut out = Vec::new(); for row in rows { match row { - Ok((message_time, content, sent_by_self, message_state, height, reply_to)) => { - let mut msg = object! { - "message_time" => message_time, - "content" => content, - "sent_by_self" => (sent_by_self != 0), - "message_state" => message_state, - "height" => height - }; - if let Some(rt) = reply_to { - let _ = msg.insert("reply_to", rt); - } - if let Err(e) = out.push(msg) { - // out.push returns a JsonError; log it instead of using `?` to avoid - // incompatible error conversions inside the DB closure. - log!("Failed to append message to output array: {:?}", e); - } - } - Err(e) => { - log!("Failed to read row from sqlite: {}", e); - } + Ok(msg) => out.push(msg), + Err(e) => log!("Failed to read row from sqlite: {}", e), } } - Ok(out) - }); - match res { + let msg_ids: Vec = out.iter().map(|m| m.id).collect(); + let reaction_map = load_reactions(conn, &msg_ids); + for msg in &mut out { + msg.reactions = reaction_map.get(&msg.id).cloned().unwrap_or_default(); + } + + Ok(out) + }) { Ok(v) => v, Err(e) => { log!("Failed to query messages: {}", e); - messages + Vec::new() } } } diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index b8e0ad2..eb8f069 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -1,24 +1,13 @@ use crate::users::contact::Contact; use crate::util::db; use rusqlite::params; -use std::sync::{Arc, LazyLock, Mutex}; -/// Shared DB connection for contacts/messages (created by db helper). -static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { - db::create_general_messages_db().expect("Failed to create or initialize general messages DB") -}); - -/// Insert or update a contact for the given storage owner. pub fn mod_user(storage_owner: i64, contact: &Contact) { - if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + if let Err(e) = db::with_db(|conn| { conn.execute( r#" - INSERT INTO contacts ( - storage_owner, - user_id, - user_name, - last_message_at - ) VALUES (?1, ?2, ?3, ?4) + INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) + VALUES (?1, ?2, ?3, ?4) ON CONFLICT(storage_owner, user_id) DO UPDATE SET user_name = excluded.user_name, last_message_at = excluded.last_message_at @@ -27,7 +16,7 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) { storage_owner, contact.user_id, contact.user_name.clone(), - contact.last_message_at + contact.last_message_at, ], )?; Ok(()) @@ -36,9 +25,8 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) { } } -/// Retrieve a single contact for storage_owner/user_id. pub fn get_user(storage_owner: i64, user_id: i64) -> Option { - let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + match db::with_db(|conn| { match conn.query_row( r#" SELECT user_id, user_name, last_message_at @@ -48,23 +36,18 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Option { "#, params![storage_owner, user_id], |r| { - let user_id: i64 = r.get(0)?; - let user_name: Option = r.get(1)?; - let last_message_at: Option = r.get(2)?; Ok(Contact { - user_id, - user_name, - last_message_at, + user_id: r.get(0)?, + user_name: r.get(1)?, + last_message_at: r.get(2)?, }) }, ) { Ok(c) => Ok(Some(c)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), + Err(e) => Err(e.into()), } - }); - - match res { + }) { Ok(opt) => opt, Err(e) => { eprintln!("Error querying user in get_user: {}", e); @@ -73,11 +56,8 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Option { } } -/// Retrieve all contacts for a storage owner, ordered by last_message_at desc / user_id asc. pub fn get_users(storage_owner: i64) -> Vec { - let contacts_out = Vec::new(); - - let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + match db::with_db(|conn| { let mut stmt = conn.prepare( r#" SELECT user_id, user_name, last_message_at @@ -91,13 +71,10 @@ pub fn get_users(storage_owner: i64) -> Vec { )?; let rows = stmt.query_map(params![storage_owner], |r| { - let user_id: i64 = r.get(0)?; - let user_name: Option = r.get(1)?; - let last_message_at: Option = r.get(2)?; Ok(Contact { - user_id, - user_name, - last_message_at, + user_id: r.get(0)?, + user_name: r.get(1)?, + last_message_at: r.get(2)?, }) })?; @@ -109,13 +86,11 @@ pub fn get_users(storage_owner: i64) -> Vec { } } Ok(out) - }); - - match res { + }) { Ok(v) => v, Err(e) => { eprintln!("Failed to query contacts in get_users: {}", e); - contacts_out + Vec::new() } } } diff --git a/iota-storage/src/util/communities_util.rs b/iota-storage/src/util/communities_util.rs index ed8b7ae..c560056 100644 --- a/iota-storage/src/util/communities_util.rs +++ b/iota-storage/src/util/communities_util.rs @@ -1,25 +1,22 @@ use crate::util::db; -use json::Array; use rusqlite::params; -use std::sync::{Arc, LazyLock, Mutex}; -static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { - db::create_general_messages_db().expect("Failed to create or initialize general messages DB") -}); +#[derive(Debug, Clone)] +pub struct StoredCommunity { + pub address: String, + pub title: String, + pub position: String, +} pub struct CommunitiesUtil; impl CommunitiesUtil { pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { - if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + if let Err(e) = db::with_db(|conn| { conn.execute( r#" - INSERT INTO communities ( - storage_owner, - address, - title, - position - ) VALUES (?1, ?2, ?3, ?4) + INSERT INTO communities (storage_owner, address, title, position) + VALUES (?1, ?2, ?3, ?4) ON CONFLICT(storage_owner, address) DO UPDATE SET title = excluded.title, position = excluded.position @@ -33,7 +30,7 @@ impl CommunitiesUtil { } pub fn remove_community(storage_owner: i64, community_address: String) { - if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + if let Err(e) = db::with_db(|conn| { conn.execute( "DELETE FROM communities WHERE storage_owner = ?1 AND address = ?2", params![storage_owner, community_address], @@ -44,10 +41,8 @@ impl CommunitiesUtil { } } - pub fn get_communities(storage_owner: i64) -> Array { - let communities_out = Array::new(); - - let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + pub fn get_communities(storage_owner: i64) -> Vec { + match db::with_db(|conn| { let mut stmt = conn.prepare( r#" SELECT address, title, position @@ -57,33 +52,26 @@ impl CommunitiesUtil { )?; let rows = stmt.query_map(params![storage_owner], |r| { - let address: String = r.get(0)?; - let title: String = r.get(1)?; - let position: String = r.get(2)?; - Ok((address, title, position)) + Ok(StoredCommunity { + address: r.get(0)?, + title: r.get(1)?, + position: r.get(2)?, + }) })?; - let mut out = Array::new(); + let mut out = Vec::new(); for row in rows { match row { - Ok((address, title, position)) => { - let mut community = json::JsonValue::new_object(); - community["title"] = json::JsonValue::String(title); - community["address"] = json::JsonValue::String(address); - community["position"] = json::JsonValue::String(position); - out.push(community); - } + Ok(community) => out.push(community), Err(e) => eprintln!("Failed to read community row: {}", e), } } Ok(out) - }); - - match res { - Ok(arr) => arr, + }) { + Ok(v) => v, Err(e) => { eprintln!("Failed to query communities in get_communities: {}", e); - communities_out + Vec::new() } } } diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 450ffb4..82fbbaa 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -51,17 +51,17 @@ impl Default for IotaConfig { } pub fn load_config() { - let s = load_file("", "config.json"); + let s = load_file("", "config.yaml"); if s.is_empty() { return; } - match serde_json::from_str::(&s) { + match serde_yaml::from_str::(&s) { Ok(parsed) => { CONFIG.store(Arc::new(parsed)); } Err(e) => { - eprintln!("Failed to parse config.json: {}. Content: '{}'", e, s); + eprintln!("Failed to parse config.yaml: {}. Content: '{}'", e, s); } } } @@ -72,8 +72,8 @@ pub fn clear_config() { } pub fn save_config() { - if let Ok(json) = serde_json::to_string(&**CONFIG.load()) { - save_file("", "config.json", &json); + if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) { + save_file("", "config.yaml", &yaml); } } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index c39a4bf..3263f68 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1,88 +1,207 @@ -//! Database helper utilities. -//! -//! This module provides small helpers to open/init sqlite databases and to -//! create a shared (Arc>) connection wrapper callers can -//! reuse. The goal is to centralize the "open and initialize" logic and -//! provide small convenience helpers used by other util modules. - use iota_util::file_util::get_directory; -use rusqlite::{Connection, Error as RusqliteError}; +use once_cell::sync::Lazy; +use r2d2::ManageConnection; +use rusqlite::Connection; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; -/// Returns the file path for a named DB inside the application's data directory. -/// -/// Arguments: -/// - `db_name` : name of the DB (without extension). Example: `"messages"`. -pub fn db_file_path(db_name: &str) -> String { +use crate::storage_error::StorageError; + +const DB_NAME: &str = "messages"; + +/// A simple r2d2 manager for rusqlite connections. +pub struct SqliteManager; + +impl ManageConnection for SqliteManager { + type Connection = Connection; + type Error = rusqlite::Error; + + fn connect(&self) -> Result { + let path = db_file_path(DB_NAME); + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; + conn.busy_timeout(Duration::from_millis(250))?; + Ok(conn) + } + + fn is_valid(&self, conn: &mut Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch("SELECT 1") + } + + fn has_broken(&self, _conn: &mut Connection) -> bool { + false + } +} + +static POOL: Lazy>> = Lazy::new(|| { + let manager = SqliteManager; + let pool = r2d2::Pool::builder() + .max_size(8) + .build(manager) + .expect("Failed to create database connection pool"); + run_migrations(&pool).expect("Failed to run database migrations"); + Arc::new(pool) +}); + +pub fn pool() -> Arc> { + POOL.clone() +} + +pub fn with_db(f: F) -> Result +where + F: FnOnce(&Connection) -> Result, +{ + let conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; + f(&conn) +} + +fn db_file_path(db_name: &str) -> String { let mut p = PathBuf::from(get_directory()); p.push(format!("{db_name}.sqlite3")); p.to_string_lossy().to_string() } -/// Open a sqlite connection to the named DB file (no initialization). -/// -/// Arguments: -/// - `db_name`: name of the DB (without extension). -pub fn open_connection(db_name: &str) -> Result { +fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> { + let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?; + let current_version: i64 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap_or(0); + + if current_version < 1 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + external_user INTEGER NOT NULL, + message_time INTEGER NOT NULL, + content TEXT NOT NULL, + sent_by_self INTEGER NOT NULL, + message_state TEXT NOT NULL, + height INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_messages_lookup + ON messages (storage_owner, external_user, message_time DESC); + + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + user_id INTEGER NOT NULL, + user_name TEXT, + last_message_at INTEGER, + UNIQUE(storage_owner, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_contacts_owner + ON contacts (storage_owner, last_message_at DESC, user_id ASC); + + CREATE TABLE IF NOT EXISTS communities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + address TEXT NOT NULL, + title TEXT NOT NULL, + position TEXT NOT NULL, + UNIQUE(storage_owner, address) + ); + CREATE INDEX IF NOT EXISTS idx_communities_owner + ON communities (storage_owner); + + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + private_key_hash TEXT NOT NULL, + reset_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + display_name TEXT + ); + + CREATE TABLE IF NOT EXISTS trusted_apps ( + user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + app_secret TEXT NOT NULL, + PRIMARY KEY (user_id, app_id) + ); + + PRAGMA user_version = 1; + "#, + )?; + } + + if current_version < 2 { + conn.execute_batch( + r#" + ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0; + PRAGMA user_version = 2; + "#, + )?; + } + + if current_version < 3 { + conn.execute_batch( + r#" + ALTER TABLE messages ADD COLUMN reply_to INTEGER; + PRAGMA user_version = 3; + "#, + )?; + } + + if current_version < 4 { + conn.execute_batch( + r#" + ALTER TABLE messages ADD COLUMN edited_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE messages ADD COLUMN deleted_by_external INTEGER NOT NULL DEFAULT 0; + + CREATE TABLE IF NOT EXISTS message_edits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id), + content_before TEXT NOT NULL, + content_after TEXT NOT NULL, + edited_at INTEGER NOT NULL, + edited_by INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_message_edits_msg + ON message_edits (message_id, edited_at DESC); + + CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id), + user_id INTEGER NOT NULL, + reaction TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(message_id, user_id, reaction) + ); + CREATE INDEX IF NOT EXISTS idx_reactions_msg + ON reactions (message_id, reaction); + + PRAGMA user_version = 4; + "#, + )?; + } + + Ok(()) +} + +pub fn open_connection(db_name: &str) -> Result { let path = db_file_path(db_name); Connection::open(path) } -/// Open a connection and immediately run `init_sql` via `execute_batch`. -/// -/// Arguments: -/// - `db_name`: name of the DB (without extension). -/// - `init_sql`: SQL statements to initialize schema & PRAGMAs (can be multiple). -pub fn open_and_init(db_name: &str, init_sql: &str) -> Result { - let conn = open_connection(db_name)?; - conn.execute_batch(init_sql)?; - Ok(conn) -} - -/// Create a shared, Arc> initialized with the given SQL. -/// -/// This is a convenience wrapper that returns an owned Arc> -/// so caller modules can store it in a `static` or pass it around. -/// -/// Arguments: -/// - `db_name`: DB name (without extension). -/// - `init_sql`: init SQL (eg PRAGMA + CREATE TABLE statements). pub fn create_shared_connection( db_name: &str, init_sql: &str, -) -> Result>, String> { - match open_and_init(db_name, init_sql) { - Ok(conn) => { - // Configure some sensible defaults for concurrency - // Attempt to set a busy timeout to reduce SQLITE_BUSY failures. - let _ = conn.busy_timeout(Duration::from_millis(250)); - Ok(Arc::new(Mutex::new(conn))) - } - Err(e) => Err(format!("Failed to open/init DB '{}': {}", db_name, e)), - } +) -> Result>, String> { + let path = db_file_path(db_name); + let conn = Connection::open(path).map_err(|e| e.to_string())?; + conn.execute_batch(init_sql).map_err(|e| e.to_string())?; + let _ = conn.busy_timeout(Duration::from_millis(250)); + Ok(Arc::new(std::sync::Mutex::new(conn))) } -/// Acquire the Connection from an Arc> and run the provided -/// closure. Converts rusqlite::Error into a String on error. -/// -/// Arguments: -/// - `shared`: Arc> -/// - `f`: closure that receives &Connection and returns Result -/// -/// Returns Ok(T) or Err(String). -pub fn with_conn(shared: &Arc>, f: F) -> Result +pub fn with_conn(shared: &Arc>, f: F) -> Result where - F: FnOnce(&Connection) -> Result, + F: FnOnce(&Connection) -> Result, { - // When invoked from within an async runtime (such as Tokio), taking a blocking - // std::sync::Mutex lock on the runtime thread can cause deadlocks or permanent - // awaits. Detect whether we're running inside a Tokio runtime and, if so, - // execute the blocking lock + database closure using Tokio's blocking helper. - // - // The blocking section returns Result so we can propagate errors - // in the same form as before. if tokio::runtime::Handle::try_current().is_ok() { tokio::task::block_in_place(|| { let guard = shared @@ -98,97 +217,7 @@ where } } -/// Initialize a general-purpose messages+contacts DB and return a shared -/// connection. This helper creates a single DB file that can contain multiple -/// tables (messages, contacts, ...). The SQL here is conservative and intended -/// to be safe if called multiple times. -/// -/// Callers may prefer to call `create_shared_connection("messages", INIT_SQL)` -/// directly, but this convenience is useful for code that expects both tables. -pub fn create_general_messages_db() -> Result>, String> { - // Keep PRAGMA and schema in one multi-statement string so callers only - // need to call a single execute_batch. - const INIT_SQL: &str = r#" - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - external_user INTEGER NOT NULL, - message_time INTEGER NOT NULL, - content TEXT NOT NULL, - sent_by_self INTEGER NOT NULL, - message_state TEXT NOT NULL, - height INTEGER NOT NULL DEFAULT 0 - ); - - CREATE INDEX IF NOT EXISTS idx_messages_lookup - ON messages (storage_owner, external_user, message_time DESC); - - CREATE TABLE IF NOT EXISTS contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - user_id INTEGER NOT NULL, - user_name TEXT, - last_message_at INTEGER, - UNIQUE(storage_owner, user_id) - ); - - CREATE INDEX IF NOT EXISTS idx_contacts_owner - ON contacts (storage_owner, last_message_at DESC, user_id ASC); - - CREATE TABLE IF NOT EXISTS communities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - address TEXT NOT NULL, - title TEXT NOT NULL, - position TEXT NOT NULL, - UNIQUE(storage_owner, address) - ); - - CREATE INDEX IF NOT EXISTS idx_communities_owner - ON communities (storage_owner); - "#; - - match create_shared_connection("messages", INIT_SQL) { - Ok(shared_conn) => { - // Attempt to add the height column for backwards compatibility. - // This will fail if the column already exists, which is expected. - let _ = with_conn(&shared_conn, |conn| { - let _ = conn.execute( - "ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0", - [], - ); - Ok(()) - }); - // Attempt to add the reply_to column for backwards compatibility. - // This will fail if the column already exists, which is expected. - let _ = with_conn(&shared_conn, |conn| { - let _ = conn.execute( - "ALTER TABLE messages ADD COLUMN reply_to INTEGER", - [], - ); - Ok(()) - }); - Ok(shared_conn) - } - Err(e) => Err(e), - } +/// Legacy - kept for e2ee_storage which uses its own DB. +pub fn create_general_messages_db() -> Result>, String> { + create_shared_connection(DB_NAME, "") } - -/* -Example usage: - -// In some util module (at init time, e.g. lazy_static or LazyLock) -static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { - create_general_messages_db().expect("failed to create messages DB") -}); - -// Later, to run a query: -let res: Result, String> = with_conn(&MESSAGES_DB, |conn| { - let mut stmt = conn.prepare("SELECT ...")?; - let rows = stmt.query_map(...)?; - // collect and return Ok(...) -}); -*/ diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 19ed8da..984c9dd 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -16,11 +16,11 @@ use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use mtp::type_map::TypeMap; -use std::collections::HashMap; use std::env; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tokio::sync::{Mutex, RwLock, mpsc, watch}; +use tokio::sync::{Mutex, RwLock, oneshot, watch, Semaphore}; use tokio::task::JoinHandle; use tokio::time::sleep; use uuid::Uuid; @@ -235,13 +235,15 @@ const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); +const MAX_MISSED_PONGS: u32 = 3; +const MAX_CONCURRENT_HANDLERS: usize = 20; // ============================================================================ // Waiting Task System // ============================================================================ pub struct WaitingTask { - pub task: Box, CommunicationValue) -> bool + Send + Sync>, + pub task: Box, CommunicationValue) -> bool + Send + Sync>, pub inserted_at: Instant, } @@ -284,6 +286,7 @@ impl ConnectionState { #[allow(dead_code)] // message_send_times is unused. pub struct OmikronConnection { state: Arc>, + state_watch_tx: watch::Sender, sender: Arc>>>, connection_loop_handle: Arc>>>, pub last_ping: Arc>, @@ -292,16 +295,20 @@ pub struct OmikronConnection { shutdown_tx: Arc>>>, reconnect_on_close: Arc>, auth_failure: Arc>>, - pub app_challenges: Arc>>, - pub app_sessions: Arc>>, + pub app_challenges: Arc>, + pub app_sessions: Arc>, + pub(crate) missed_pongs: Arc, + handler_semaphore: Arc, } impl OmikronConnection { pub fn new() -> Self { let (shutdown_tx, _) = watch::channel(false); + let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected); OmikronConnection { state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + state_watch_tx, sender: Arc::new(RwLock::new(None)), connection_loop_handle: Arc::new(Mutex::new(None)), last_ping: Arc::new(Mutex::new(-1)), @@ -310,11 +317,18 @@ impl OmikronConnection { shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), auth_failure: Arc::new(RwLock::new(None)), - app_challenges: Arc::new(RwLock::new(HashMap::new())), - app_sessions: Arc::new(RwLock::new(HashMap::new())), + app_challenges: Arc::new(DashMap::new()), + app_sessions: Arc::new(DashMap::new()), + missed_pongs: Arc::new(AtomicU32::new(0)), + handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), } } + async fn set_state(&self, new_state: ConnectionState) { + *self.state.write().await = new_state; + let _ = self.state_watch_tx.send(new_state); + } + // ------------------------------------------------------------------------- // Connection Management // ------------------------------------------------------------------------- @@ -364,7 +378,7 @@ impl OmikronConnection { sender.close(); } - *self.state.write().await = ConnectionState::Disconnected; + self.set_state(ConnectionState::Disconnected).await; *self.sender.write().await = None; } @@ -418,7 +432,7 @@ impl OmikronConnection { } async fn connect_once(self: Arc) -> Result<(), String> { - *self.state.write().await = ConnectionState::Connecting; + self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); let keyring = self.load_or_migrate_keyring().await; @@ -467,7 +481,7 @@ impl OmikronConnection { ); *self.reconnect_on_close.write().await = false; *self.auth_failure.write().await = Some(reason.clone()); - *self.state.write().await = ConnectionState::Disconnected; + self.set_state(ConnectionState::Disconnected).await; return Err(reason); } Err(e) => return Err(format!("Connection failed: {}", e)), @@ -482,7 +496,7 @@ impl OmikronConnection { let sender_arc = Arc::new(connection.sender); *self.sender.write().await = Some(sender_arc.clone()); - *self.state.write().await = ConnectionState::Connected { identified: true }; + self.set_state(ConnectionState::Connected { identified: true }).await; // Start read loop let mut receiver = connection.receiver; @@ -507,7 +521,7 @@ impl OmikronConnection { // Wait for read loop to complete let result = read_handle.await; *self.sender.write().await = None; - *self.state.write().await = ConnectionState::Disconnected; + self.set_state(ConnectionState::Disconnected).await; { ACTIVE_TASKS.remove("Omikron Listener"); } @@ -547,7 +561,15 @@ impl OmikronConnection { let legacy = CONFIG.load().keyring.clone(); let keyring = legacy .and_then(|b64| keyring_from_base64(&b64)) - .unwrap_or_else(crypto_helper::generate_keyring); + .unwrap_or_else(|| { + log!( + "WARNING: No existing keyring found. Neither {} nor config.json \ + contain a keyring; generating a new identity. If you already had \ + an Iota identity, restore {} from a backup to avoid losing access.", + IOTA_KEYRING_PATH, IOTA_KEYRING_PATH + ); + crypto_helper::generate_keyring() + }); if let Err(e) = mtp::files::save_keyring(&keyring, IOTA_KEYRING_PATH) { log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); @@ -672,7 +694,23 @@ impl OmikronConnection { let result = receiver.receive().await; match result { Ok(cv) => { - self.clone().handle_message(cv).await; + let msg_id = cv.get_id(); + if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { + if (task.task)(self.clone(), cv.clone()) { + continue; + } + } + if cv.is_type(CommunicationType::Pong) { + self.handle_pong(&cv).await; + continue; + } + + let permit = self.handler_semaphore.clone().acquire_owned().await; + let self_clone = self.clone(); + tokio::spawn(async move { + let _permit = permit; + self_clone.handle_message_impl(cv).await; + }); } Err(e) => { self.fail_all_waiting_tasks(format!( @@ -710,6 +748,17 @@ impl OmikronConnection { break; } + if self.missed_pongs.load(Ordering::Relaxed) > MAX_MISSED_PONGS { + log!( + "Connection appears dead ({} consecutive missed pongs), closing sender", + self.missed_pongs.load(Ordering::Relaxed) + ); + if let Some(sender) = self.sender.read().await.as_ref() { + sender.close(); + } + break; + } + self.flush_pending_chat_secret_forwards().await; self.send_ping().await; } @@ -754,8 +803,109 @@ impl OmikronConnection { } } + async fn forward_message_live( + &self, + message_id: u32, + receiver_id: u64, + sender_id: i64, + timestamp: i64, + content: &str, + height: i64, + reply_to: Option, + ) -> Option { + let mut msg_fields = vec![ + (DataType::Content, DataValue::Str(content.to_string())), + (DataType::SendTime, DataValue::SignedNumber(timestamp as i128)), + (DataType::Height, DataValue::SignedNumber(height as i128)), + ]; + if let Some(rt) = reply_to { + msg_fields.push((DataType::ReplyId, DataValue::UnsignedNumber(rt as u64 as u128))); + } + + let user_forward = CommunicationValue::new(CommunicationType::MessageLive) + .with_id(message_id) + .with_receiver(receiver_id) + .add_typed_default(DataType::SenderId, DataValue::SignedNumber(sender_id as i128)) + .add_typed_default(DataType::Message, typed_container(msg_fields)); + + match self.await_response(&user_forward, Some(Duration::from_secs(3))).await { + Ok(user_resp) => { + let ms_raw = user_resp + .get_data(DataType::MessageState) + .as_string() + .unwrap_or_else(|| "".to_string()); + Some(MessageState::from_str(&ms_raw).upgrade(MessageState::Received)) + } + Err(_) => None, + } + } + + async fn forward_to_remote_iota( + &self, + cv: &CommunicationValue, + sender_id: i64, + receiver_id: i64, + timestamp: i64, + content: &str, + height: i64, + reply_to: Option, + ) { + let mut fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .with_sender(sender_id as u64) + .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) + .add_typed_default(DataType::Content, DataValue::Str(content.to_string())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)); + if let Some(rt) = reply_to { + fw_msg = fw_msg.add_typed_default( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + ); + } + + match self.await_response(&fw_msg, Some(Duration::from_secs(10))).await { + Ok(resp) => { + let ms_raw = resp + .get_data(DataType::MessageState) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + let _ = chat_files::change_message_state( + timestamp, sender_id, receiver_id, ms.clone(), + ); + + let _ = self.send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default(DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128)) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default(DataType::MessageState, DataValue::Str(ms.as_str().to_string())), + ).await; + } + Err(_) => { + let _ = chat_files::change_message_state( + timestamp, sender_id, receiver_id, MessageState::Sent, + ); + + let _ = self.send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default(DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128)) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) + .add_typed_default(DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string())), + ).await; + } + } + } + // ------------------------------------------------------------------------- - // Message Handling (Preserved from original) + // Message Handling — Dispatch // ------------------------------------------------------------------------- pub async fn handle_message(self: Arc, cv: CommunicationValue) { @@ -765,7 +915,6 @@ impl OmikronConnection { let msg_id = cv.get_id(); - // Dispatch waiting task for this message id if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { if (task.task)(self.clone(), cv.clone()) { return; @@ -777,677 +926,579 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::SetChatSecret) { - let sender_id = cv.get_sender().to_string(); - let recipients = match chat_secret_recipients(&cv) { - Some(recipients) => recipients, - None => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; + self.clone().handle_message_impl(cv).await; + } + + async fn handle_message_impl(self: Arc, cv: CommunicationValue) { + macro_rules! dispatch { + ($ty:ident, $method:ident) => { + if cv.is_type(CommunicationType::$ty) { + self.clone().$method(&cv).await; return; } }; - let now = now_millis_i64(); - let chat_id = data_string(&cv, DataType::ChatId); - let secret_id = data_string(&cv, DataType::SecretId); - let version = data_i64(&cv, DataType::VersionNumber); - let wrapping_scheme = data_string(&cv, DataType::WrappingScheme); - let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now); - - let Some((((chat_id, secret_id), version), wrapping_scheme)) = - chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) - else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - for recipient in &recipients { - let recipient_id = recipient.user_id.parse::().unwrap_or(0); - let is_local = iota_storage::users::user_manager::get_user(recipient_id).is_some(); - - if is_local { - if e2ee_storage::put_chat_secret(StoredChatSecret { - user_id: recipient.user_id.clone(), - chat_id: chat_id.clone(), - secret_id: secret_id.clone(), - version, - encrypted_secret: recipient.encrypted_secret.clone(), - kem_ciphertext: recipient.kem_ciphertext.clone(), - wrapping_scheme: wrapping_scheme.clone(), - created_at, - updated_at: now, - }) - .is_err() - { - self.send_message(&error_response( - &cv, - CommunicationType::ErrorInvalidData, - )) - .await; - return; - } - continue; - } - - if recipient.user_id != sender_id { - let forward = set_chat_secret_cv_for_recipient(&cv, recipient); - if !self.forward_chat_secret(&forward).await { - self.store_pending_chat_secret_forward(&forward).await; - } - } - } - - self.send_message(&error_response(&cv, CommunicationType::Success)) - .await; - return; } - if cv.is_type(CommunicationType::GetChatSecret) { - let Some(user_id) = data_string(&cv, DataType::UserId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - if user_id != cv.get_sender().to_string() { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - let Some(chat_id) = data_string(&cv, DataType::ChatId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; + dispatch!(SetChatSecret, handle_set_chat_secret); + dispatch!(GetChatSecret, handle_get_chat_secret); + dispatch!(ChatSecretForward, handle_chat_secret_forward); + dispatch!(AppIdentification, handle_app_identification); + dispatch!(AppChallengeResponse, handle_app_challenge_response); + dispatch!(SaveAppData, handle_save_app_data); + dispatch!(LoadAppData, handle_load_app_data); + dispatch!(CreateApp, handle_create_app); + dispatch!(DeleteApp, handle_delete_app); + dispatch!(ClientConnected, handle_client_connected); + dispatch!(MessageState, handle_message_state); + dispatch!(MessageSend, handle_message_send); + dispatch!(MessageOtherIota, handle_message_other_iota); + dispatch!(MessagesGet, handle_messages_get); + dispatch!(GetChats, handle_get_chats); + dispatch!(AddConversation, handle_add_conversation); + dispatch!(AddCommunity, handle_add_community); + dispatch!(GetCommunities, handle_get_communities); + dispatch!(RemoveCommunity, handle_remove_community); + dispatch!(GlobalSettingsSave, handle_global_settings_save); + dispatch!(GlobalSettingsLoad, handle_global_settings_load); + dispatch!(SettingsSave, handle_settings_save); + dispatch!(SettingsLoad, handle_settings_load); + dispatch!(SettingsList, handle_settings_list); + } - match e2ee_storage::get_chat_secret(ChatSecretQuery { - user_id, - chat_id, - secret_id: data_string(&cv, DataType::SecretId), - }) { - Ok(Some(record)) => { - let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret), - ) - .add_typed_default( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default( - DataType::UpdatedAt, - DataValue::SignedNumber(record.updated_at as i128), - ); - self.send_message(&response).await; - } - Ok(None) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotSet)) - .await - } - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await - } + // ------------------------------------------------------------------------- + // Message Handlers + // ------------------------------------------------------------------------- + + async fn handle_set_chat_secret(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender().to_string(); + let recipients = match chat_secret_recipients(cv) { + Some(recipients) => recipients, + None => { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + return; } + }; + let now = now_millis_i64(); + let chat_id = data_string(cv, DataType::ChatId); + let secret_id = data_string(cv, DataType::SecretId); + let version = data_i64(cv, DataType::VersionNumber); + let wrapping_scheme = data_string(cv, DataType::WrappingScheme); + let created_at = data_i64(cv, DataType::CreatedAt).unwrap_or(now); + + let Some((((chat_id, secret_id), version), wrapping_scheme)) = + chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) + else { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; return; - } + }; - if cv.is_type(CommunicationType::ChatSecretForward) { - let sender_id = cv.get_sender().to_string(); - let recipient_user_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default(); - if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) - || recipient_user_id.is_empty() - || pending_chat_secret_forward_from_cv(&cv).is_none() - { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } + let mut non_local_forwards: Vec = Vec::new(); - let forward = cv - .clone() - .with_receiver(recipient_user_id.parse::().unwrap_or(0)); - if self.forward_chat_secret(&forward).await { - self.send_message(&error_response(&cv, CommunicationType::Success)) - .await; - } else { - self.store_pending_chat_secret_forward(&cv).await; - self.send_message(&error_response(&cv, CommunicationType::Success)) - .await; - } - return; - } + for recipient in &recipients { + let recipient_id = recipient.user_id.parse::().unwrap_or(0); + let is_local = iota_storage::users::user_manager::get_user(recipient_id).is_some(); - if cv.is_type(CommunicationType::AppIdentification) { - let sender_id = cv.get_sender(); - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - - let mut trusted = false; - if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { - if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { - if pub_k == &app_public_key { - trusted = true; - } - } - } - - if trusted { - let challenge = Uuid::new_v4().to_string(); - - self.app_challenges - .write() - .await - .insert(sender_id, challenge.clone()); - self.app_sessions - .write() - .await - .insert(sender_id, (user_id, app_identifier.clone())); - - if let Some(app_pub_bundle) = - iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) + if is_local { + if e2ee_storage::put_chat_secret(StoredChatSecret { + user_id: recipient.user_id.clone(), + chat_id: chat_id.clone(), + secret_id: secret_id.clone(), + version, + encrypted_secret: recipient.encrypted_secret.clone(), + kem_ciphertext: recipient.kem_ciphertext.clone(), + wrapping_scheme: wrapping_scheme.clone(), + created_at, + updated_at: now, + }) + .is_err() { - let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); - - if let Some(keyring) = keyring_from_base64(&kr_str) { - if let Ok(encrypted_challenge) = - crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) - { - let bundle = keyring.public_key_bundle(); - let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - - let res = CommunicationValue::new(CommunicationType::AppChallenge) - .with_id(cv.get_id()) - .with_receiver(sender_id) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) - .add_typed_default( - DataType::Challenge, - DataValue::Str(encrypted_challenge), - ); - - self.send_message(&res).await; - return; - } - } + let _ = self.send_message(&error_response( + cv, + CommunicationType::ErrorInvalidData, + )).await; + return; } + continue; } - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; + if recipient.user_id != sender_id { + non_local_forwards + .push(set_chat_secret_cv_for_recipient(cv, recipient)); + } + } + + if !non_local_forwards.is_empty() { + let mut handles = Vec::new(); + for forward in &non_local_forwards { + let self_clone = self.clone(); + let fwd = forward.clone(); + handles.push(tokio::spawn(async move { + self_clone.forward_chat_secret(&fwd).await + })); + } + + for (forward, handle) in non_local_forwards.into_iter().zip(handles) { + match handle.await { + Ok(true) => {} + _ => self.store_pending_chat_secret_forward(&forward).await, + } + } + } + + let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + } + + async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { + let Some(user_id) = data_string(cv, DataType::UserId) else { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + return; + }; + if user_id != cv.get_sender().to_string() { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorNotFound)).await; + return; + } + let Some(chat_id) = data_string(cv, DataType::ChatId) else { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + return; + }; + + match e2ee_storage::get_chat_secret(ChatSecretQuery { + user_id, + chat_id, + secret_id: data_string(cv, DataType::SecretId), + }) { + Ok(Some(record)) => { + let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) + .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) + .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(record.version as i128), + ) + .add_typed_default( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret), + ) + .add_typed_default( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext), + ) + .add_typed_default( + DataType::WrappingScheme, + DataValue::Str(record.wrapping_scheme), + ) + .add_typed_default( + DataType::CreatedAt, + DataValue::SignedNumber(record.created_at as i128), + ) + .add_typed_default( + DataType::UpdatedAt, + DataValue::SignedNumber(record.updated_at as i128), + ); + let _ = self.send_message(&response).await; + } + Ok(None) => { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorNotSet)).await; + } + Err(_) => { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + } + } + } + + async fn handle_chat_secret_forward(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender().to_string(); + let recipient_user_id = data_string(cv, DataType::RecipientUserId).unwrap_or_default(); + if data_string(cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) + || recipient_user_id.is_empty() + || pending_chat_secret_forward_from_cv(cv).is_none() + { + let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; return; } - if cv.is_type(CommunicationType::AppChallengeResponse) { - let sender_id = cv.get_sender(); - let mut challenges = self.app_challenges.write().await; - if let Some(expected) = challenges.remove(&sender_id) { - if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { - if expected == *response { - let res = - CommunicationValue::new(CommunicationType::AppIdentificationResponse) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; + let forward = cv + .clone() + .with_receiver(recipient_user_id.parse::().unwrap_or(0)); + if self.forward_chat_secret(&forward).await { + let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + } else { + self.store_pending_chat_secret_forward(cv).await; + let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + } + } + + async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender(); + let app_identifier = cv + .get_data(DataType::AppIdentifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataType::AppPublicKey) + .as_str() + .unwrap_or("") + .to_string(); + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + + let mut trusted = false; + if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { + if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { + if pub_k == &app_public_key { + trusted = true; + } + } + } + + if trusted { + let challenge = Uuid::new_v4().to_string(); + + self.app_challenges.insert(sender_id, challenge.clone()); + self.app_sessions.insert(sender_id, (user_id, app_identifier.clone())); + + if let Some(app_pub_bundle) = + iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) + { + let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); + + if let Some(keyring) = keyring_from_base64(&kr_str) { + if let Ok(encrypted_challenge) = + crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) + { + let bundle = keyring.public_key_bundle(); + let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); + + let res = CommunicationValue::new(CommunicationType::AppChallenge) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) + .add_typed_default( + DataType::Challenge, + DataValue::Str(encrypted_challenge), + ); + + let _ = self.send_message(&res).await; return; } } } - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; - return; } - if cv.is_type(CommunicationType::SaveAppData) { - let sender_id = cv.get_sender(); - let app_data = cv - .get_data(DataType::AppData) - .as_str() - .unwrap_or("") - .to_string(); + let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) + .with_id(cv.get_id()) + .with_receiver(sender_id); + let _ = self.send_message(&res).await; + } - let sessions = self.app_sessions.read().await; - if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { - iota_storage::users::user_manager::save_app_data( - *user_id, - app_identifier, - &app_data, - ); - } - - let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_id(cv.get_id()) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::LoadAppData) { - let sender_id = cv.get_sender(); - let mut app_data = String::new(); - - let sessions = self.app_sessions.read().await; - if let Some((user_id, app_identifier)) = sessions.get(&sender_id) { - app_data = - iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); - } - - let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_id(cv.get_id()) - .with_receiver(sender_id) - .add_typed_default(DataType::AppData, DataValue::Str(app_data)); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::CreateApp) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() && !app_public_key.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if !user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.insert(app_identifier, app_public_key); - iota_storage::users::user_manager::update_user(user); - } + async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender(); + if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { + if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { + if expected_challenge == *response { + let res = + CommunicationValue::new(CommunicationType::AppIdentificationResponse) + .with_id(cv.get_id()) + .with_receiver(sender_id); + let _ = self.send_message(&res).await; + return; } } - - let res = CommunicationValue::new(CommunicationType::CreateApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&res).await; - return; } + let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) + .with_id(cv.get_id()) + .with_receiver(sender_id); + let _ = self.send_message(&res).await; + } - if cv.is_type(CommunicationType::DeleteApp) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); + async fn handle_save_app_data(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender(); + let app_data = cv + .get_data(DataType::AppData) + .as_str() + .unwrap_or("") + .to_string(); - if !app_identifier.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.remove(&app_identifier); - iota_storage::users::user_manager::update_user(user); - } - } - } - - let res = CommunicationValue::new(CommunicationType::DeleteApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::ClientConnected) { - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; - - let contacts = chats_util::get_users(user_id); - let mut contacts_array = Vec::new(); - - for (i, contact) in contacts.iter().enumerate() { - let mut contact_container = Vec::new(); - contact_container.push(( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )); - contact_container.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), - )); - - if let Some(ref name) = contact.user_name { - contact_container.push((DataType::Username, DataValue::Str(name.clone()))); - } - - let amount = if i < 10 { 20 } else { 1 }; - let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); - - let mut msg_array = Vec::new(); - for m in messages.members() { - let message_time = m["message_time"].as_i64().unwrap_or(0); - let content = m["content"].as_str().unwrap_or("").to_string(); - let sent_by_self = m["sent_by_self"].as_bool().unwrap_or(false); - let height = m["height"].as_i64().unwrap_or(0); - let message_state = m["message_state"].as_str().unwrap_or("").to_string(); - - let mut msg_container = Vec::new(); - msg_container.push(( - DataType::SendTime, - DataValue::SignedNumber(message_time as i128), - )); - msg_container.push((DataType::Content, DataValue::Str(content.clone()))); - msg_container.push((DataType::MessageState, DataValue::Str(message_state))); - msg_container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - msg_container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if sent_by_self { - user_id as u128 - } else { - contact.user_id as u128 - }), - )); - msg_array.push(typed_container(msg_container)); - - if msg_array.len() == 1 { - let sender_id = if sent_by_self { - user_id - } else { - contact.user_id - }; - let mut last_msg = Vec::new(); - last_msg.push((DataType::Content, DataValue::Str(content))); - last_msg.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - contact_container.push((DataType::LastMessage, typed_container(last_msg))); - } - } - contact_container.push((DataType::Messages, DataValue::Array(msg_array))); - contacts_array.push(typed_container(contact_container)); - } - - let resp = CommunicationValue::new(CommunicationType::ClientConnected) - .with_id(cv.get_id()) - .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); - self.send_message(&resp).await; - return; - } - - // ************************************************ // - // Direct messages // - // ************************************************ // - - if cv.is_type(CommunicationType::MessageState) { - let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(id) => id, - _ => return, - }; - - // Parse send_time robustly: accept numeric or string, fallback to current time - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let timestamp_i64 = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; - - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - *sender_id as i64, - MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), + if let Some(session) = self.app_sessions.get(&sender_id) { + let (user_id, app_identifier) = session.value(); + iota_storage::users::user_manager::save_app_data( + *user_id, + app_identifier, + &app_data, ); } - // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. - if cv.is_type(CommunicationType::MessageSend) { - let sender_id: u64 = cv.get_sender(); + let res = CommunicationValue::new(CommunicationType::SaveAppData) + .with_id(cv.get_id()) + .with_receiver(sender_id); + let _ = self.send_message(&res).await; + } - // parse receiver_id (the storage owner for this incoming message) - let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { - s.parse::().unwrap_or(0) - } else { - 0 - }; + async fn handle_load_app_data(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender(); + let mut app_data = String::new(); - // parse send_time robustly (number or string), fallback to now - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let timestamp_i64 = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; - let timestamp_u128 = timestamp_i64 as u128; + if let Some(session) = self.app_sessions.get(&sender_id) { + let (user_id, app_identifier) = session.value(); + app_data = + iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); + } - // content may be missing; default to empty string - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); + let res = CommunicationValue::new(CommunicationType::LoadAppData) + .with_id(cv.get_id()) + .with_receiver(sender_id) + .add_typed_default(DataType::AppData, DataValue::Str(app_data)); + let _ = self.send_message(&res).await; + } - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); + async fn handle_create_app(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataType::AppIdentifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataType::AppPublicKey) + .as_str() + .unwrap_or("") + .to_string(); - let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); + if !app_identifier.is_empty() && !app_public_key.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if !user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.insert(app_identifier, app_public_key); + iota_storage::users::user_manager::update_user(user); + } + } + } - if is_local { - // persist message for the receiver (storage_owner = receiver_id) - chat_files::add_message( - timestamp_u128, - false, - receiver_id as i64, - sender_id as i64, - &content, - height, - reply_to, - ); + let res = CommunicationValue::new(CommunicationType::CreateApp) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + let _ = self.send_message(&res).await; + } + + async fn handle_delete_app(self: Arc, cv: &CommunicationValue) { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataType::AppIdentifier) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.remove(&app_identifier); + iota_storage::users::user_manager::update_user(user); + } + } + } + + let res = CommunicationValue::new(CommunicationType::DeleteApp) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + let _ = self.send_message(&res).await; + } + + async fn handle_client_connected(self: Arc, cv: &CommunicationValue) { + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; + + let contacts = chats_util::get_users(user_id); + let mut contacts_array = Vec::new(); + + for (i, contact) in contacts.iter().enumerate() { + let mut contact_container = Vec::new(); + contact_container.push(( + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )); + contact_container.push(( + DataType::LastMessageAt, + DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), + )); + + if let Some(ref name) = contact.user_name { + contact_container.push((DataType::Username, DataValue::Str(name.clone()))); } - // persist message for the sender (storage_owner = sender_id) + let amount = if i < 10 { 20 } else { 1 }; + let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); + + let mut msg_array = Vec::new(); + for m in &messages { + let mut msg_container = Vec::new(); + msg_container.push(( + DataType::SendTime, + DataValue::SignedNumber(m.message_time as i128), + )); + msg_container.push((DataType::Content, DataValue::Str(m.content.clone()))); + msg_container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); + msg_container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); + msg_container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if m.sent_by_self { + user_id as u128 + } else { + contact.user_id as u128 + }), + )); + msg_array.push(typed_container(msg_container)); + + if msg_array.len() == 1 { + let sender_id = if m.sent_by_self { + user_id + } else { + contact.user_id + }; + let mut last_msg = Vec::new(); + last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); + last_msg.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + contact_container.push((DataType::LastMessage, typed_container(last_msg))); + } + } + contact_container.push((DataType::Messages, DataValue::Array(msg_array))); + contacts_array.push(typed_container(contact_container)); + } + + let resp = CommunicationValue::new(CommunicationType::ClientConnected) + .with_id(cv.get_id()) + .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); + let _ = self.send_message(&resp).await; + } + + async fn handle_message_state(self: Arc, cv: &CommunicationValue) { + let sender_id = &cv.get_sender(); + let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { + Some(id) => id, + _ => return, + }; + + let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { + s.parse::().unwrap_or_else(|_| now_millis_i64()) + } else { + now_millis_i64() + }; + + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + *sender_id as i64, + MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), + ); + } + + async fn handle_message_send(self: Arc, cv: &CommunicationValue) { + let sender_id: u64 = cv.get_sender(); + + let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { + s.parse::().unwrap_or(0) + } else { + 0 + }; + + let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { + s.parse::().unwrap_or_else(|_| now_millis_i64()) + } else { + now_millis_i64() + }; + let timestamp_u128 = timestamp_i64 as u128; + + let content = cv + .get_data(DataType::Content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; + let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); + + let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); + + if is_local { chat_files::add_message( timestamp_u128, - true, - sender_id as i64, + false, receiver_id as i64, + sender_id as i64, &content, height, reply_to, ); + } - // send confirmation back to sender - let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&conf_msg).await; + chat_files::add_message( + timestamp_u128, + true, + sender_id as i64, + receiver_id as i64, + &content, + height, + reply_to, + ); - if !is_local { - let mut fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .with_sender(sender_id as u64) - .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) - .add_typed_default(DataType::Content, DataValue::Str(content)) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ); - if let Some(rt) = reply_to { - fw_msg = fw_msg.add_typed_default( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - ); - } - - let other_iota_resp = self - .clone() - .await_response(&fw_msg, Some(Duration::from_secs(10))) - .await; - - if let Ok(resp) = other_iota_resp { - let ms_raw = resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + let _ = self.send_message(&conf_msg).await; + if !is_local { + self.forward_to_remote_iota( + cv, + sender_id as i64, + receiver_id, + timestamp_i64, + &content, + height, + reply_to, + ) + .await; + } else { + match self + .forward_message_live( + cv.get_id(), + receiver_id as u64, + sender_id as i64, + timestamp_i64, + &content, + height, + reply_to, + ) + .await + { + Some(ms) => { let _ = chat_files::change_message_state( timestamp_i64, - sender_id as i64, - receiver_id as i64, - ms.clone(), - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } else { - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id as i64, - MessageState::Sent, - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - return; - } else { - // Build a live-delivery message for the local client (recipient) - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ) - .add_typed_default(DataType::Message, { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }); - - // Attempt delivery and await a response from the local client - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(3))) - .await; - - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - // update stored message state for receiver - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, + receiver_id, sender_id as i64, ms.clone(), ); - - // update stored message state for sender let _ = chat_files::change_message_state( timestamp_i64, sender_id as i64, - receiver_id as i64, + receiver_id, ms.clone(), ); - - // notify original sender about the delivered/read state (if read receipts are enabled) if is_read_receipts_enabled().await { - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) + let _ = self + .send_message( + &CommunicationValue::new( + CommunicationType::MessageState, + ) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) @@ -1463,37 +1514,36 @@ impl OmikronConnection { DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), - ) - .await; + ) + .await; } - } else { - // Delivery failed or timed out; mark as Sent + } + None => { let _ = chat_files::change_message_state( timestamp_i64, - receiver_id as i64, + receiver_id, sender_id as i64, MessageState::Sent, ); - let _ = chat_files::change_message_state( timestamp_i64, sender_id as i64, - receiver_id as i64, + receiver_id, MessageState::Sent, ); - - // Send push notification to Omega since user is offline - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ); - self.send_message(&push_msg).await; - - // notify sender - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) + let push_msg = + CommunicationValue::new(CommunicationType::PushNotification) + .with_receiver(receiver_id as u64) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ); + let _ = self.send_message(&push_msg).await; + let _ = self + .send_message( + &CommunicationValue::new( + CommunicationType::MessageState, + ) .with_id(cv.get_id()) .with_receiver(sender_id as u64) .with_sender(receiver_id as u64) @@ -1507,91 +1557,61 @@ impl OmikronConnection { ) .add_typed_default( DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), + DataValue::Str( + MessageState::Sent.as_str().to_string(), + ), ), - ) - .await; + ) + .await; } - return; } } + } - if cv.is_type(CommunicationType::MessageOtherIota) { - let sender_id = &cv.get_sender(); - let receiver_id = &cv.get_receiver(); + async fn handle_message_other_iota(self: Arc, cv: &CommunicationValue) { + let sender_id = &cv.get_sender(); + let receiver_id = &cv.get_receiver(); - // parse send_time safely (number or string), fallback to now - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let timestamp = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; + let timestamp = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { + s.parse::().unwrap_or_else(|_| now_millis_i64()) + } else { + now_millis_i64() + }; - // content may be missing or non-string; default to empty string - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); + let content = cv + .get_data(DataType::Content) + .as_str() + .unwrap_or("") + .to_string(); - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); + let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; + let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - chat_files::add_message( - timestamp as u128, - false, - *receiver_id as i64, + chat_files::add_message( + timestamp as u128, + false, + *receiver_id as i64, + *sender_id as i64, + &content, + height, + reply_to, + ); + + match self + .forward_message_live( + cv.get_id(), + *receiver_id, *sender_id as i64, + timestamp, &content, height, reply_to, - ); - - // Build user_forward using the parsed numeric timestamp and safe content string - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(cv.get_id()) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default(DataType::Message, { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }); - - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(3))) - .await; - - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - + ) + .await + { + Some(ms) => { let _ = change_message_state( timestamp, *receiver_id as i64, @@ -1599,9 +1619,8 @@ impl OmikronConnection { ms.clone(), ); - // notify original sender about the delivered/read state (if read receipts are enabled) if is_read_receipts_enabled().await { - self.send_message( + let _ = self.send_message( &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) @@ -1618,11 +1637,10 @@ impl OmikronConnection { DataType::MessageState, DataValue::Str(ms.as_str().to_string()), ), - ) - .await; + ).await; } - } else { - // Delivery timed out/failed — update stored state and notify sender with numeric timestamp + } + None => { let _ = chat_files::change_message_state( timestamp, *receiver_id as i64, @@ -1630,16 +1648,16 @@ impl OmikronConnection { MessageState::Sent, ); - // Send push notification to Omega since user is offline - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ); - self.send_message(&push_msg).await; + let push_msg = + CommunicationValue::new(CommunicationType::PushNotification) + .with_receiver(*receiver_id) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(*sender_id as i128), + ); + let _ = self.send_message(&push_msg).await; - self.send_message( + let _ = self.send_message( &CommunicationValue::new(CommunicationType::MessageState) .with_id(cv.get_id()) .with_receiver(*sender_id) @@ -1656,225 +1674,223 @@ impl OmikronConnection { DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string()), ), - ) - .await; + ).await; } - return; } + } - if cv.is_type(CommunicationType::MessagesGet) { - let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); - let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages( - my_id as i64, - partner_id as i64, - offset as i64, - amount as i64, - ); - let mut msg_array: Vec = Vec::new(); - for m in messages.members() { - let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); - let content: String = m["content"].as_str().unwrap_or("").to_string(); - let sent_by_self: bool = m["sent_by_self"].as_bool().unwrap_or(false); - let height: i64 = m["height"].as_i64().unwrap_or(0); - let sender_id: i64 = if sent_by_self { - my_id as i64 + async fn handle_messages_get(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); + let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages( + my_id as i64, + partner_id as i64, + offset as i64, + amount as i64, + ); + let mut msg_array: Vec = Vec::new(); + for m in &messages { + let sender_id: i64 = if m.sent_by_self { + my_id as i64 + } else { + if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { + s.parse::().unwrap_or(partner_id as i64) } else { - if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { - s.parse::().unwrap_or(partner_id as i64) - } else { - partner_id as i64 - } - }; - let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); - - let mut container = Vec::new(); - container.push(( - DataType::SendTime, - DataValue::SignedNumber(message_time as i128), - )); - container.push((DataType::Content, DataValue::Str(content))); - container.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - container.push((DataType::MessageState, DataValue::Str(message_state))); - container.push((DataType::Height, DataValue::SignedNumber(height as i128))); - container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if sent_by_self { - my_id as u128 - } else { - partner_id as u128 - }), - )); - if let Some(rt) = m["reply_to"].as_i64() { - container.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); + partner_id as i64 } - msg_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::MessagesGet) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); - - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::GetChats) { - let user_id = cv.get_sender(); - let users = chats_util::get_users(user_id as i64); - let mut user_array = Vec::new(); - for user in users { - let mut container = Vec::new(); - container.push(( - DataType::UserId, - DataValue::SignedNumber(user.user_id as i128), - )); - if let Some(name) = user.user_name { - container.push((DataType::Username, DataValue::Str(name))); - } - if let Some(ts) = user.last_message_at { - container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); - } - user_array.push(typed_container(container)); - } - let resp = CommunicationValue::new(CommunicationType::GetChats) - .with_id(cv.get_id()) - .with_receiver(user_id) - .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::AddConversation) { - let user_id = cv.get_sender(); - let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(n) => n as i64, - None => cv - .get_data(DataType::ChatPartnerId) - .as_str() - .unwrap_or("0") - .parse() - .unwrap_or(0), - }; - let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); - - if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { - contact.user_name = Some(name.to_string()); - } - - contact.set_last_message_at( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64, - ); - mod_user(user_id as i64, &contact); - let resp = CommunicationValue::new(CommunicationType::AddConversation) - .with_id(cv.get_id()) - .with_receiver(user_id); - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::AddCommunity) { - CommunitiesUtil::add_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::CommunityTitle) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::Position) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::AddCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::GetCommunities) { - let mut comm_array = Vec::new(); - for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataType, DataValue)> = Vec::new(); - if let Some(address) = c["address"].as_str() { - container.push(( - DataType::CommunityAddress, - DataValue::Str(address.to_string()), - )); - } - if let Some(title) = c["title"].as_str() { - container.push((DataType::CommunityTitle, DataValue::Str(title.to_string()))); - } - if let Some(position) = c["position"].as_str() { - container.push((DataType::Position, DataValue::Str(position.to_string()))); - } - comm_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::GetCommunities) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::RemoveCommunity) { - CommunitiesUtil::remove_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - self.send_message(&resp).await; - return; - } - - if cv.is_type(CommunicationType::GlobalSettingsSave) { - let my_id = cv.get_sender(); - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ); - self.send_message(&response).await; - return; }; - save_file( - &format!("users/{}", my_id), - "global.settings", - settings_value, - ); + let mut container = Vec::new(); + container.push(( + DataType::SendTime, + DataValue::SignedNumber(m.message_time as i128), + )); + container.push((DataType::Content, DataValue::Str(m.content.clone()))); + container.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); + container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); + container.push(( + DataType::SenderId, + DataValue::UnsignedNumber(if m.sent_by_self { + my_id as u128 + } else { + partner_id as u128 + }), + )); + if let Some(rt) = m.reply_to { + container.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); + } + msg_array.push(typed_container(container)); + } - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) + let resp = CommunicationValue::new(CommunicationType::MessagesGet) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); + + let _ = self.send_message(&resp).await; + } + + async fn handle_get_chats(self: Arc, cv: &CommunicationValue) { + let user_id = cv.get_sender(); + let users = chats_util::get_users(user_id as i64); + let mut user_array = Vec::new(); + for user in users { + let mut container = Vec::new(); + container.push(( + DataType::UserId, + DataValue::SignedNumber(user.user_id as i128), + )); + if let Some(name) = user.user_name { + container.push((DataType::Username, DataValue::Str(name))); + } + if let Some(ts) = user.last_message_at { + container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); + } + user_array.push(typed_container(container)); + } + let resp = CommunicationValue::new(CommunicationType::GetChats) + .with_id(cv.get_id()) + .with_receiver(user_id) + .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); + let _ = self.send_message(&resp).await; + } + + async fn handle_add_conversation(self: Arc, cv: &CommunicationValue) { + let user_id = cv.get_sender(); + let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { + Some(n) => n as i64, + None => cv + .get_data(DataType::ChatPartnerId) + .as_str() + .unwrap_or("0") + .parse() + .unwrap_or(0), + }; + let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); + + if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { + contact.user_name = Some(name.to_string()); + } + + contact.set_last_message_at(now_millis_i64()); + mod_user(user_id as i64, &contact); + let resp = CommunicationValue::new(CommunicationType::AddConversation) + .with_id(cv.get_id()) + .with_receiver(user_id); + let _ = self.send_message(&resp).await; + } + + async fn handle_add_community(self: Arc, cv: &CommunicationValue) { + CommunitiesUtil::add_community( + cv.get_sender() as i64, + cv.get_data(DataType::CommunityAddress) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataType::CommunityTitle) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataType::Position) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::AddCommunity) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + let _ = self.send_message(&resp).await; + } + + async fn handle_get_communities(self: Arc, cv: &CommunicationValue) { + let mut comm_array = Vec::new(); + for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { + let mut container: Vec<(DataType, DataValue)> = Vec::new(); + container.push(( + DataType::CommunityAddress, + DataValue::Str(c.address.clone()), + )); + container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); + container.push((DataType::Position, DataValue::Str(c.position.clone()))); + comm_array.push(typed_container(container)); + } + + let resp = CommunicationValue::new(CommunicationType::GetCommunities) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); + let _ = self.send_message(&resp).await; + } + + async fn handle_remove_community(self: Arc, cv: &CommunicationValue) { + CommunitiesUtil::remove_community( + cv.get_sender() as i64, + cv.get_data(DataType::CommunityAddress) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + let _ = self.send_message(&resp).await; + } + + async fn handle_global_settings_save(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) .with_receiver(my_id) - .with_id(cv.get_id()); + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings payload".to_string()), + ); + let _ = self.send_message(&response).await; + return; + }; + + save_file( + &format!("users/{}", my_id), + "global.settings", + settings_value, + ); + + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) + .with_receiver(my_id) + .with_id(cv.get_id()); + + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + let _ = self.send_message(&response).await; + } + + async fn handle_global_settings_load(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let path = format!("users/{}", my_id); + let name = "global.settings"; + + if !has_file(&path, name) { + let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Path, DataValue::Str(name.to_string())); if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { response = response.add_typed_default( @@ -1883,126 +1899,182 @@ impl OmikronConnection { ); } - self.send_message(&response).await; + let _ = self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::GlobalSettingsLoad) { - let my_id = cv.get_sender(); - let path = format!("users/{}", my_id); - let name = "global.settings"; + let settings_value_str = load_file(&path, name); + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); - if !has_file(&path, name) { - let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Path, DataValue::Str(name.to_string())); + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } + let _ = self.send_message(&response).await; + } - self.send_message(&response).await; - return; - } - - let settings_value_str = load_file(&path, name); - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) + async fn handle_settings_save(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( + .add_typed_default( + DataType::Message, + DataValue::Str("Missing session_id".to_string()), + ); + let _ = self.send_message(&response).await; + return; + }; + if session_id == 0 || session_id > 1_000_000 { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ) + .add_typed_default( DataType::SessionId, DataValue::SignedNumber(session_id as i128), ); - } + let _ = self.send_message(&response).await; + return; + } + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + let _ = self.send_message(&response).await; + return; + }; + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings payload".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + let _ = self.send_message(&response).await; + return; + }; - self.send_message(&response).await; + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid settings_name".to_string()), + ) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + let _ = self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::SettingsSave) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - self.send_message(&response).await; - return; - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - }; - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - }; + save_file( + &format!("users/{}/settings/{}/", my_id, session_id), + &format!("{}.settings", settings_name), + settings_value, + ); - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - } - - save_file( - &format!("users/{}/settings/{}/", my_id, session_id), - &format!("{}.settings", settings_name), - settings_value, + let response = CommunicationValue::new(CommunicationType::SettingsSave) + .with_receiver(my_id) + .with_id(cv.get_id()) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), ); - let response = CommunicationValue::new(CommunicationType::SettingsSave) - .with_receiver(my_id) + let _ = self.send_message(&response).await; + } + + async fn handle_settings_load(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing session_id".to_string()), + ); + let _ = self.send_message(&response).await; + return; + }; + if session_id == 0 || session_id > 1_000_000 { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ); + let _ = self.send_message(&response).await; + return; + } + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + let _ = self.send_message(&response).await; + return; + }; + + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid settings_name".to_string()), + ) .add_typed_default( DataType::SettingsName, DataValue::Str(settings_name.to_string()), @@ -2011,87 +2083,16 @@ impl OmikronConnection { DataType::SessionId, DataValue::SignedNumber(session_id as i128), ); - - self.send_message(&response).await; + let _ = self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::SettingsLoad) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - self.send_message(&response).await; - return; - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - }; - - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - } - - let settings_file = format!("{}.settings", settings_name); - let settings_path = format!("users/{}/settings/{}/", my_id, session_id); - if !has_file(&settings_path, &settings_file) { - let response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - self.send_message(&response).await; - return; - } - - let settings_value_str = load_file(&settings_path, &settings_file); - let response = CommunicationValue::new(CommunicationType::SettingsLoad) + let settings_file = format!("{}.settings", settings_name); + let settings_path = format!("users/{}/settings/{}/", my_id, session_id); + if !has_file(&settings_path, &settings_file) { + let response = CommunicationValue::new(CommunicationType::ErrorNotFound) .with_id(cv.get_id()) .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) .add_typed_default( DataType::SettingsName, DataValue::Str(settings_name.to_string()), @@ -2100,59 +2101,78 @@ impl OmikronConnection { DataType::SessionId, DataValue::SignedNumber(session_id as i128), ); - - self.send_message(&response).await; + let _ = self.send_message(&response).await; return; } - if cv.is_type(CommunicationType::SettingsList) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - self.send_message(&response).await; - return; - }; + let settings_value_str = load_file(&settings_path, &settings_file); + let response = CommunicationValue::new(CommunicationType::SettingsLoad) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); - let settings = get_children(&format!("users/{}/settings/{}/", my_id, session_id)); - let mut settings_json = Vec::new(); - for s in settings { - let s = s.replace(".settings", ""); - if s.is_empty() { - continue; - } - let _ = settings_json.push(DataValue::Str(s)); - } - let response = CommunicationValue::new(CommunicationType::SettingsList) + let _ = self.send_message(&response).await; + } + + async fn handle_settings_list(self: Arc, cv: &CommunicationValue) { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) .with_id(cv.get_id()) .with_receiver(my_id) - .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), + DataType::Message, + DataValue::Str("Missing session_id".to_string()), ); - - self.send_message(&response).await; + let _ = self.send_message(&response).await; + return; + }; + if session_id == 0 || session_id > 1_000_000 { + let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ); + let _ = self.send_message(&response).await; return; } + + let settings = get_children(&format!("users/{}/settings/{}/", my_id, session_id)); + let mut settings_json = Vec::new(); + for s in settings { + let s = s.replace(".settings", ""); + if s.is_empty() { + continue; + } + let _ = settings_json.push(DataValue::Str(s)); + } + let response = CommunicationValue::new(CommunicationType::SettingsList) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + + let _ = self.send_message(&response).await; } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- - pub async fn send_message(&self, cv: &CommunicationValue) { - if let Err(err) = self.send_message_result(cv).await { - log_t!("send_message_failed", err); - } - } - - async fn send_message_result(&self, cv: &CommunicationValue) -> Result<(), String> { + pub async fn send_message(&self, cv: &CommunicationValue) -> Result<(), String> { let sender_guard = self.sender.read().await; if let Some(sender) = sender_guard.as_ref() { if !sender.is_open() { @@ -2216,24 +2236,21 @@ impl OmikronConnection { cv: &CommunicationValue, timeout_duration: Option, ) -> Result { - let (tx, mut rx) = mpsc::channel(1); + let (tx, rx) = oneshot::channel(); let msg_id = cv.get_id(); WAITING_TASKS.insert( msg_id, WaitingTask { task: Box::new(move |_, response_cv| { - let inner_tx = tx.clone(); - tokio::spawn(async move { - let _ = inner_tx.send(response_cv).await; - }); + let _ = tx.send(response_cv); true }), inserted_at: Instant::now(), }, ); - if let Err(send_err) = self.send_message_result(cv).await { + if let Err(send_err) = self.send_message(cv).await { WAITING_TASKS.remove(&msg_id); return Err(format!( "Request send failed (msg_id={}, reason={})", @@ -2243,8 +2260,8 @@ impl OmikronConnection { let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); - match tokio::time::timeout(timeout, rx.recv()).await { - Ok(Some(response_cv)) => { + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(response_cv)) => { let is_error = response_cv.is_type(CommunicationType::Error) || response_cv.is_type(CommunicationType::ErrorInternal) || response_cv.is_type(CommunicationType::ErrorNotFound) @@ -2265,7 +2282,7 @@ impl OmikronConnection { Ok(response_cv) } } - Ok(_) => { + Ok(Err(_)) => { WAITING_TASKS.remove(&msg_id); Err("Channel closed while awaiting response".to_string()) } @@ -2284,27 +2301,30 @@ impl OmikronConnection { } pub async fn await_connection(&self, timeout_duration: Option) -> Result<(), String> { - if self.state.read().await.is_connected() { + let mut rx = self.state_watch_tx.subscribe(); + if rx.borrow().is_connected() { return Ok(()); } let timeout = timeout_duration.unwrap_or(CONNECTION_TIMEOUT); - let start = Instant::now(); - loop { - if self.state.read().await.is_connected() { - return Ok(()); + let result: Result<(), String> = tokio::time::timeout(timeout, async { + loop { + rx.changed().await.map_err(|_| "State watch channel closed".to_string())?; + if rx.borrow().is_connected() { + return Ok(()); + } } + }) + .await + .map_err(|_| { + format!( + "Connection not established within {} seconds", + timeout.as_secs() + ) + })?; - if start.elapsed() >= timeout { - return Err(format!( - "Connection not established within {} seconds", - timeout.as_secs() - )); - } - - sleep(Duration::from_millis(100)).await; - } + result } pub async fn has_auth_failure(&self) -> bool { diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index 9c81c60..2155b6c 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -2,6 +2,7 @@ use crate::omikron_connection::OmikronConnection; use dashmap::DashMap; use iota_state::APP_STATE; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use std::sync::atomic::Ordering; use std::sync::LazyLock; use std::time::Instant; use tokio::time::Duration; @@ -16,6 +17,8 @@ impl OmikronConnection { PING_TIMES.retain(|_, v| v.elapsed() < Duration::from_secs(30)); + self.missed_pongs.fetch_add(1, Ordering::Relaxed); + let ping_message = CommunicationValue::new(CommunicationType::Ping) .with_id(id) .add_typed_default( @@ -23,10 +26,12 @@ impl OmikronConnection { DataValue::Array(vec![DataValue::SignedNumber(*self.last_ping.lock().await as i128)]), ); - self.send_message(&ping_message).await; + let _ = self.send_message(&ping_message).await; } pub async fn handle_pong(&self, cv: &CommunicationValue) { + self.missed_pongs.store(0, Ordering::Relaxed); + let id = cv.get_id(); if let Some((_, send_time)) = PING_TIMES.remove(&id) { From f82500ea7dd0013ad3efd7f0b4620e20ee028f2a Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:28:15 +0200 Subject: [PATCH 081/119] [Upd] mtp update --- Cargo.lock | 599 ++++---- Cargo.toml | 18 +- client/Cargo.toml | 1 + client/src/client_connection.rs | 593 +------- iota-connection/Cargo.toml | 9 + iota-connection/src/connection_handler.rs | 36 + iota-connection/src/lib.rs | 3 + iota-connection/src/message_common.rs | 137 ++ iota-connection/src/message_handlers.rs | 766 ++++++++++ iota-core/src/main.rs | 29 +- iota-storage/src/users/user_manager.rs | 12 +- iota-storage/src/util/chat_files.rs | 168 ++- iota-storage/src/util/db.rs | 111 +- iota-storage/src/util/e2ee_storage.rs | 6 +- iota-storage/src/util/mod.rs | 1 + iota-storage/src/util/settings.rs | 163 +++ iota-util/Cargo.toml | 5 +- iota-util/src/crypto_helper.rs | 4 +- iota-util/src/crypto_util.rs | 21 +- omikron-connector/Cargo.toml | 1 + omikron-connector/src/omikron_connection.rs | 1460 +++++++------------ type-maps.yaml | 3 +- web-server/Cargo.toml | 8 +- web-server/src/lib.rs | 139 +- 24 files changed, 2514 insertions(+), 1779 deletions(-) create mode 100644 iota-connection/Cargo.toml create mode 100644 iota-connection/src/connection_handler.rs create mode 100644 iota-connection/src/lib.rs create mode 100644 iota-connection/src/message_common.rs create mode 100644 iota-connection/src/message_handlers.rs create mode 100644 iota-storage/src/util/settings.rs diff --git a/Cargo.lock b/Cargo.lock index cc8ec44..04277be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" dependencies = [ "actix-rt", - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "crossbeam-channel", "futures-core", @@ -31,7 +31,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-sink", @@ -54,7 +54,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.13.0", + "bitflags 2.13.1", "brotli", "bytes", "bytestring", @@ -89,7 +89,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -211,7 +211,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -244,7 +244,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -329,9 +329,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -372,7 +372,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -384,7 +384,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -396,18 +396,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.1", ] [[package]] @@ -433,9 +433,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -444,9 +444,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", @@ -499,9 +499,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -556,9 +556,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -601,9 +601,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -619,9 +619,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -701,6 +701,7 @@ dependencies = [ "hyper", "hyper-util", "iota-auth", + "iota-connection", "iota-logger", "iota-state", "iota-storage", @@ -711,7 +712,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -905,7 +906,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -1000,7 +1001,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1023,7 +1024,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1034,7 +1035,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1082,9 +1083,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "zeroize", @@ -1118,7 +1119,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1140,7 +1141,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -1175,7 +1176,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1297,10 +1298,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] -name = "fastrand" -version = "2.4.1" +name = "fastbloom" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" @@ -1398,9 +1411,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1413,9 +1426,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1423,15 +1436,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1440,38 +1453,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1587,6 +1600,62 @@ dependencies = [ "tracing", ] +[[package]] +name = "h3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http 1.4.2", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "h3-datagram" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" +dependencies = [ + "bytes", + "h3", + "pin-project-lite", +] + +[[package]] +name = "h3-quinn" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" +dependencies = [ + "bytes", + "futures", + "h3", + "h3-datagram", + "quinn", + "tokio", + "tokio-util", +] + +[[package]] +name = "h3-webtransport" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7" +dependencies = [ + "bytes", + "futures-util", + "h3", + "h3-datagram", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1636,7 +1705,7 @@ dependencies = [ "http 1.4.2", "httpdate", "mime", - "sha1 0.10.6", + "sha1 0.10.7", ] [[package]] @@ -1725,9 +1794,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http 1.4.2", @@ -1735,9 +1804,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1822,7 +1891,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -2013,7 +2082,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2042,7 +2111,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -2094,7 +2163,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -2116,6 +2185,15 @@ dependencies = [ "zip", ] +[[package]] +name = "iota-connection" +version = "0.1.0" +dependencies = [ + "iota-storage", + "iota-util", + "mtp", +] + [[package]] name = "iota-core" version = "0.1.0" @@ -2180,7 +2258,7 @@ dependencies = [ "mtp", "once_cell", "r2d2", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -2190,7 +2268,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "uuid", "walkdir", @@ -2245,7 +2323,6 @@ dependencies = [ "base64", "hex", "mtp", - "mtp-crypto", "reqwest", "sysinfo", "tokio", @@ -2315,7 +2392,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -2330,7 +2407,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2349,7 +2426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2387,7 +2464,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -2461,7 +2538,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2516,9 +2593,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ "hashbrown 0.17.1", ] @@ -2604,9 +2681,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2669,8 +2746,8 @@ dependencies = [ [[package]] name = "mtp" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "mtp-client", "mtp-codec", @@ -2678,50 +2755,52 @@ dependencies = [ "mtp-crypto", "mtp-files", "mtp-host", + "mtp-transport", "mtp-type-map", + "mtp-webserver", ] [[package]] name = "mtp-client" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand 0.8.6", + "rand 0.10.2", "tokio", ] [[package]] name = "mtp-codec" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "base64", "byteorder", "mtp-common", "mtp-crypto", "mtp-type-map", - "rand 0.8.6", + "rand 0.10.2", ] [[package]] name = "mtp-common" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "quinn", "rustls", - "thiserror 2.0.18", + "thiserror 2.0.19", "wtransport", ] [[package]] name = "mtp-crypto" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "base64", "chacha20poly1305", @@ -2730,59 +2809,93 @@ dependencies = [ "hkdf 0.13.0", "ml-dsa", "mlkem-tls", - "rand_core 0.6.4", + "rand 0.10.2", + "rand_core 0.10.1", + "rustls", "serde", "sha2 0.11.0", "thiserror 1.0.69", + "tokio", "zeroize", ] [[package]] name = "mtp-files" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "mtp-crypto", + "rand 0.10.2", "thiserror 1.0.69", + "zeroize", ] [[package]] name = "mtp-host" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand 0.8.6", + "rand 0.8.7", "tokio", + "tracing", + "wtransport", ] [[package]] name = "mtp-transport" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ - "log", + "async-trait", "mtp-codec", "mtp-common", + "mtp-crypto", "rcgen", "rustls", "rustls-native-certs", + "sha2 0.11.0", "tokio", + "tracing", "wtransport", ] [[package]] name = "mtp-type-map" -version = "0.1.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#2126a142f44634ccfdcaa297633fdb63b1d9b46d" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" dependencies = [ "serde", "serde_yaml", ] +[[package]] +name = "mtp-webserver" +version = "0.2.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +dependencies = [ + "async-trait", + "bytes", + "h3", + "h3-quinn", + "h3-webtransport", + "http 1.4.2", + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-host", + "mtp-transport", + "quinn", + "rand 0.10.2", + "rustls", + "thiserror 2.0.19", + "tokio", + "tracing", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2806,7 +2919,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2862,7 +2975,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2898,7 +3011,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2913,9 +3026,9 @@ dependencies = [ [[package]] name = "octets" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" +checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" [[package]] name = "oid-registry" @@ -2933,13 +3046,14 @@ dependencies = [ "base64", "dashmap", "hex", + "iota-connection", "iota-logger", "iota-state", "iota-storage", "iota-util", "json", "mtp", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "reqwest", "sha2 0.10.9", @@ -2962,9 +3076,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.6" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "is-wsl", "libc", @@ -2976,7 +3090,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -2992,7 +3106,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3051,7 +3165,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -3094,7 +3208,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3185,7 +3299,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3224,7 +3338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3237,7 +3351,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3266,7 +3380,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3291,7 +3405,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0", ] @@ -3346,7 +3460,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3417,9 +3531,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -3453,9 +3567,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3468,13 +3582,14 @@ checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -3488,6 +3603,7 @@ checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", + "fastbloom", "getrandom 0.4.3", "lru-slab", "rand 0.10.2", @@ -3496,8 +3612,9 @@ dependencies = [ "rustc-hash", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3512,16 +3629,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3551,25 +3668,15 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -3591,16 +3698,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3616,15 +3713,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -3662,7 +3750,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -3672,7 +3760,7 @@ dependencies = [ "palette", "serde", "strum 0.28.0", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3727,7 +3815,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "hashbrown 0.17.1", "indoc", "instability", @@ -3762,14 +3850,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3779,9 +3867,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3859,7 +3947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -3868,7 +3956,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3907,7 +3995,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3916,9 +4004,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", @@ -4057,7 +4145,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4093,9 +4181,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4103,22 +4191,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.1", ] [[package]] @@ -4161,9 +4249,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4282,15 +4370,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4332,9 +4420,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4357,7 +4445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] @@ -4420,7 +4508,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4432,7 +4520,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4454,9 +4542,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f" dependencies = [ "proc-macro2", "quote", @@ -4480,7 +4579,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4503,7 +4602,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4537,7 +4636,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "parking_lot", "rustix", "signal-hook", @@ -4573,7 +4672,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.13.0", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4618,11 +4717,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -4633,18 +4732,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.1", ] [[package]] @@ -4691,9 +4790,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4706,9 +4805,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -4716,20 +4815,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4754,9 +4853,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -4800,7 +4899,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.4.2", @@ -4844,7 +4943,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4864,9 +4963,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", @@ -4874,9 +4973,9 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.4", - "sha1 0.10.6", - "thiserror 2.0.18", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror 2.0.19", ] [[package]] @@ -4986,9 +5085,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5120,7 +5219,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5137,7 +5236,13 @@ dependencies = [ name = "web-server" version = "0.1.0" dependencies = [ + "bytes", + "http 1.4.2", + "iota-logger", + "iota-state", + "iota-util", "mtp", + "tokio", ] [[package]] @@ -5189,7 +5294,7 @@ dependencies = [ "once_cell", "open", "pnet", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "ratatui", "reqwest", @@ -5213,9 +5318,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -5376,7 +5481,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5387,7 +5492,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5552,8 +5657,8 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "sha2 0.11.0", - "socket2 0.6.4", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.19", "time", "tokio", "tracing", @@ -5570,7 +5675,7 @@ checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.18", + "thiserror 2.0.19", "url", ] @@ -5612,7 +5717,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -5645,7 +5750,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -5666,7 +5771,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5686,7 +5791,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -5707,7 +5812,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5740,7 +5845,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5763,7 +5868,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1 0.10.6", + "sha1 0.10.7", "time", "zeroize", "zopfli", @@ -5772,15 +5877,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index c62208e..ff17473 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,19 @@ [workspace] -members = ["iota-storage", "client", "iota-auth", "other-iota", "iota-updater", "iota-terms", "iota-state", "iota-cli", "iota-core", "omikron-connector", "web-server", "web-ui", "iota-logger", "iota-util"] +members = [ + "iota-storage", + "iota-connection", + "client", + "iota-auth", + "other-iota", + "iota-updater", + "iota-terms", + "iota-state", + "iota-cli", + "iota-core", + "omikron-connector", + "web-server", + "web-ui", + "iota-logger", + "iota-util", +] resolver = "3" diff --git a/client/Cargo.toml b/client/Cargo.toml index c29f662..bdf08ad 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client"] } +iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index f1474ec..95c66cc 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -1,160 +1,22 @@ use dashmap::DashMap; +use iota_connection::message_common::*; +use iota_connection::message_handlers; use iota_logger::{log_cv_in, log_cv_out, log_t}; use iota_state::SHUTDOWN; -use iota_storage::users::contact::Contact; -use iota_storage::util::chat_files::{MessageState, change_message_state}; -use iota_storage::util::chats_util::{get_user, mod_user}; -use iota_storage::util::communities_util::CommunitiesUtil; +use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::CONFIG; -use iota_storage::util::e2ee_storage::{self, ChatSecretQuery, StoredChatSecret}; -use iota_storage::util::{chat_files, chats_util}; +use iota_storage::util::e2ee_storage::{self, StoredChatSecret}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; -use iota_util::file_util::{get_children, load_file, save_file}; use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::type_map::TypeMap; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tokio::task::JoinHandle; use uuid::Uuid; -fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { - use mtp::type_map::{DataTypeId, TypeMap}; - let tm = TypeMap::latest(); - DataValue::Container( - items - .into_iter() - .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) - .collect(), - ) -} - -fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .as_str() - .map(|s| s.to_string()) - .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) - .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) -} - -fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .as_number() - .and_then(|n| i64::try_from(n).ok()) - .or_else(|| { - cv.get_data(dt) - .as_signed_number() - .and_then(|n| i64::try_from(n).ok()) - }) - .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) -} - -#[derive(Debug, Clone)] -struct ChatSecretRecipient { - user_id: String, - encrypted_secret: Vec, - kem_ciphertext: Vec, -} - -fn recipient_from_value(value: &DataValue) -> Option { - let tm = TypeMap::latest(); - let user_id = value - .get_field(DataType::UserId.to_id(&tm))? - .as_str() - .map(|s| s.to_string()) - .or_else(|| { - value - .get_field(DataType::UserId.to_id(&tm))? - .as_number() - .map(|n| n.to_string()) - })?; - let encrypted_secret = value - .get_field(DataType::EncryptedSecret.to_id(&tm))? - .as_bytes()?; - let kem_ciphertext = value - .get_field(DataType::KemCiphertext.to_id(&tm))? - .as_bytes()?; - - Some(ChatSecretRecipient { - user_id, - encrypted_secret, - kem_ciphertext, - }) -} - -fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { - let recipients = cv.get_data(DataType::Recipients).as_array()?; - let parsed = recipients - .iter() - .map(recipient_from_value) - .collect::>>()?; - - if parsed.is_empty() { - None - } else { - Some(parsed) - } -} - -fn set_chat_secret_cv_for_recipient( - source: &CommunicationValue, - recipient: &ChatSecretRecipient, -) -> CommunicationValue { - let recipient_value = typed_container(vec![ - (DataType::UserId, DataValue::Str(recipient.user_id.clone())), - ( - DataType::EncryptedSecret, - DataValue::Bytes(recipient.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(recipient.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_id(source.get_id()) - .with_sender(source.get_sender()) - .with_receiver(recipient.user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) - .add_typed_default( - DataType::SecretId, - source.get_data(DataType::SecretId).clone(), - ) - .add_typed_default( - DataType::VersionNumber, - source.get_data(DataType::VersionNumber).clone(), - ) - .add_typed_default( - DataType::WrappingScheme, - source.get_data(DataType::WrappingScheme).clone(), - ) - .add_typed_default( - DataType::CreatedAt, - source.get_data(DataType::CreatedAt).clone(), - ) - .add_typed_default( - DataType::Recipients, - DataValue::Array(vec![recipient_value]), - ) -} - -fn now_millis_i64() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - CommunicationValue::new(ty) - .with_id(request.get_id()) - .with_receiver(request.get_sender()) -} - // ============================================================================ // Waiting Task System // ============================================================================ @@ -223,7 +85,7 @@ impl ClientConnection { } if let Some(sender) = self.sender.read().await.as_ref() { - sender.close(); + sender.close().await; } *self.sender.write().await = None; @@ -235,12 +97,9 @@ impl ClientConnection { async fn handle_ping(self: Arc, cv: CommunicationValue) { // Update our ping if provided if let DataValue::SignedNumber(last_ping) = cv.get_data(DataType::LastPing) { - let current = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); + let current = now_millis_i64(); let mut ping_guard = self.ping.write().await; - *ping_guard = current as i64 - *last_ping as i64; + *ping_guard = current - *last_ping as i64; } // Send pong response @@ -324,70 +183,8 @@ impl ClientConnection { } if cv.is_type(CommunicationType::GetChatSecret) { - let Some(user_id) = data_string(&cv, DataType::UserId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let sender_id = cv.get_sender().to_string(); - if user_id != sender_id { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - let Some(chat_id) = data_string(&cv, DataType::ChatId) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - match e2ee_storage::get_chat_secret(ChatSecretQuery { - user_id, - chat_id, - secret_id: data_string(&cv, DataType::SecretId), - }) { - Ok(Some(record)) => { - let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret), - ) - .add_typed_default( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default( - DataType::UpdatedAt, - DataValue::SignedNumber(record.updated_at as i128), - ); - self.send_message(&response).await; - } - Ok(None) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorNotSet)) - .await; - } - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - } - } + self.send_message(&message_handlers::handle_get_chat_secret(&cv)) + .await; return; } @@ -434,126 +231,20 @@ impl ClientConnection { } if cv.is_type(CommunicationType::CreateApp) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() && !app_public_key.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if !user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.insert(app_identifier, app_public_key); - iota_storage::users::user_manager::update_user(user); - } - } - } - - let res = CommunicationValue::new(CommunicationType::CreateApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&res).await; + self.send_message(&message_handlers::handle_create_app(&cv)) + .await; return; } if cv.is_type(CommunicationType::DeleteApp) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.remove(&app_identifier); - iota_storage::users::user_manager::update_user(user); - } - } - } - - let res = CommunicationValue::new(CommunicationType::DeleteApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - self.send_message(&res).await; + self.send_message(&message_handlers::handle_delete_app(&cv)) + .await; return; } if cv.is_type(CommunicationType::ClientConnected) { - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; - - let contacts = chats_util::get_users(user_id); - let mut contacts_array = Vec::new(); - - for (i, contact) in contacts.iter().enumerate() { - let mut contact_container = Vec::new(); - contact_container.push(( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )); - contact_container.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), - )); - - if let Some(ref name) = contact.user_name { - contact_container.push((DataType::Username, DataValue::Str(name.clone()))); - } - - let amount = if i < 10 { 20 } else { 1 }; - let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); - - let mut msg_array = Vec::new(); - for m in &messages { - let mut msg_container = Vec::new(); - msg_container.push(( - DataType::SendTime, - DataValue::SignedNumber(m.message_time as i128), - )); - msg_container.push((DataType::Content, DataValue::Str(m.content.clone()))); - msg_container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); - msg_container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); - msg_container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if m.sent_by_self { - user_id as u128 - } else { - contact.user_id as u128 - }), - )); - msg_array.push(typed_container(msg_container)); - - if msg_array.len() == 1 { - let sender_id = if m.sent_by_self { - user_id - } else { - contact.user_id - }; - let mut last_msg = Vec::new(); - last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); - last_msg.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - contact_container.push((DataType::LastMessage, typed_container(last_msg))); - } - } - contact_container.push((DataType::Messages, DataValue::Array(msg_array))); - contacts_array.push(typed_container(contact_container)); - } - - let resp = CommunicationValue::new(CommunicationType::ClientConnected) - .with_id(cv.get_id()) - .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_client_connected(&cv)) + .await; return; } @@ -562,32 +253,32 @@ impl ClientConnection { // ************************************************ // if cv.is_type(CommunicationType::MessageState) { - let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(id) => id, - _ => return, - }; + message_handlers::handle_message_state(&cv); + return; + } - // Parse send_time robustly: accept numeric or string, fallback to current time - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let timestamp_i64 = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; + if cv.is_type(CommunicationType::MessageEdit) { + self.send_message(&message_handlers::handle_message_edit(&cv)) + .await; + return; + } - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - *sender_id as i64, - MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), - ); + if cv.is_type(CommunicationType::MessageReactionAdd) { + self.send_message(&message_handlers::handle_message_reaction(&cv, true)) + .await; + return; + } + + if cv.is_type(CommunicationType::MessageReactionRemove) { + self.send_message(&message_handlers::handle_message_reaction(&cv, false)) + .await; + return; + } + + if cv.is_type(CommunicationType::MessageDeleteLive) { + self.send_message(&message_handlers::handle_message_delete(&cv)) + .await; + return; } // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. @@ -603,10 +294,7 @@ impl ClientConnection { // parse send_time safely (number or string), fallback to now let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; + let now_i64 = now_millis_i64(); let timestamp = if let Some(n) = send_time_val.as_number() { n as i64 } else if let Some(s) = send_time_val.as_str() { @@ -732,175 +420,38 @@ impl ClientConnection { } if cv.is_type(CommunicationType::MessagesGet) { - let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); - let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages( - my_id as i64, - partner_id as i64, - offset as i64, - amount as i64, - ); - let mut msg_array: Vec = Vec::new(); - for m in &messages { - let sender_id: i64 = if m.sent_by_self { - my_id as i64 - } else { - if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { - s.parse::().unwrap_or(partner_id as i64) - } else { - partner_id as i64 - } - }; - - let mut container = Vec::new(); - container.push(( - DataType::SendTime, - DataValue::SignedNumber(m.message_time as i128), - )); - container.push((DataType::Content, DataValue::Str(m.content.clone()))); - container.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); - container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); - container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if m.sent_by_self { - my_id as u128 - } else { - partner_id as u128 - }), - )); - msg_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::MessagesGet) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); - - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_messages_get(&cv)) + .await; return; } if cv.is_type(CommunicationType::GetChats) { - let user_id = cv.get_sender(); - let users = chats_util::get_users(user_id as i64); - let mut user_array = Vec::new(); - for user in users { - let mut container = Vec::new(); - container.push(( - DataType::UserId, - DataValue::SignedNumber(user.user_id as i128), - )); - if let Some(name) = user.user_name { - container.push((DataType::Username, DataValue::Str(name))); - } - if let Some(ts) = user.last_message_at { - container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); - } - user_array.push(typed_container(container)); - } - let resp = CommunicationValue::new(CommunicationType::GetChats) - .with_id(cv.get_id()) - .with_receiver(user_id) - .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_get_chats(&cv)) + .await; return; } if cv.is_type(CommunicationType::AddConversation) { - let user_id = cv.get_sender(); - let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(n) => n as i64, - None => cv - .get_data(DataType::ChatPartnerId) - .as_str() - .unwrap_or("0") - .parse() - .unwrap_or(0), - }; - let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); - - if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { - contact.user_name = Some(name.to_string()); - } - - contact.set_last_message_at( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64, - ); - mod_user(user_id as i64, &contact); - let resp = CommunicationValue::new(CommunicationType::AddConversation) - .with_id(cv.get_id()) - .with_receiver(user_id); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_add_conversation(&cv)) + .await; return; } if cv.is_type(CommunicationType::AddCommunity) { - CommunitiesUtil::add_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::CommunityTitle) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::Position) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::AddCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_add_community(&cv)) + .await; return; } if cv.is_type(CommunicationType::GetCommunities) { - let mut comm_array = Vec::new(); - for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataType, DataValue)> = Vec::new(); - container.push(( - DataType::CommunityAddress, - DataValue::Str(c.address.clone()), - )); - container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); - container.push((DataType::Position, DataValue::Str(c.position.clone()))); - comm_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::GetCommunities) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_get_communities(&cv)) + .await; return; } if cv.is_type(CommunicationType::RemoveCommunity) { - CommunitiesUtil::remove_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - self.send_message(&resp).await; + self.send_message(&message_handlers::handle_remove_community(&cv)) + .await; return; } @@ -909,10 +460,11 @@ impl ClientConnection { let settings_name = cv.get_data(DataType::SettingsName).as_str().unwrap(); let settings_value = cv.get_data(DataType::Payload).as_str().unwrap(); - save_file( - &format!("users/{}/settings/", my_id), - &format!("{}.settings", settings_name), - &settings_value, + let _ = iota_storage::util::settings::save( + my_id as i64, + iota_storage::util::settings::GLOBAL_SESSION_ID, + settings_name, + settings_value, ); let response = CommunicationValue::new(CommunicationType::SettingsSave) @@ -926,10 +478,14 @@ impl ClientConnection { if cv.is_type(CommunicationType::SettingsLoad) { let my_id = cv.get_sender(); let settings_name = cv.get_data(DataType::SettingsName).as_string().unwrap(); - let settings_value_str = load_file( - &format!("users/{}/settings/", my_id), - &format!("{}.settings", settings_name), - ); + let settings_value_str = iota_storage::util::settings::load( + my_id as i64, + iota_storage::util::settings::GLOBAL_SESSION_ID, + &settings_name, + ) + .ok() + .flatten() + .unwrap_or_default(); let response = CommunicationValue::new(CommunicationType::SettingsLoad) .with_id(cv.get_id()) .with_receiver(my_id) @@ -942,15 +498,12 @@ impl ClientConnection { if cv.is_type(CommunicationType::SettingsList) { let my_id = cv.get_sender(); - let settings = get_children(&format!("users/{}/settings/", my_id)); - let mut settings_json = Vec::new(); - for s in settings { - let s = s.replace(".settings", ""); - if s.is_empty() { - continue; - } - let _ = settings_json.push(DataValue::Str(s)); - } + let settings = iota_storage::util::settings::list( + my_id as i64, + iota_storage::util::settings::GLOBAL_SESSION_ID, + ) + .unwrap_or_default(); + let settings_json = settings.into_iter().map(DataValue::Str).collect(); let response = CommunicationValue::new(CommunicationType::SettingsList) .with_id(cv.get_id()) .with_receiver(my_id) @@ -997,7 +550,7 @@ impl ClientConnection { if !sender.is_open() { drop(sender_guard); if let Some(sender) = self.sender.write().await.take() { - sender.close(); + sender.close().await; } return Err("connection closed".to_string()); } diff --git a/iota-connection/Cargo.toml b/iota-connection/Cargo.toml new file mode 100644 index 0000000..65eafad --- /dev/null +++ b/iota-connection/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "iota-connection" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-storage = { path = "../iota-storage" } +iota-util = { path = "../iota-util" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-connection/src/connection_handler.rs b/iota-connection/src/connection_handler.rs new file mode 100644 index 0000000..6a20bb1 --- /dev/null +++ b/iota-connection/src/connection_handler.rs @@ -0,0 +1,36 @@ +use mtp::codec::CommunicationValue; +use std::future::Future; +use std::time::Duration; + +/// Unified interface for all connection types (Omikron, Direct, future modes). +/// +/// Provides the common messaging API that the rest of the codebase uses, +/// regardless of whether the connection goes through Omikron or is direct. +pub trait ConnectionHandler: Send + Sync { + /// Send a message to the remote end. + fn send_message( + &self, + cv: &CommunicationValue, + ) -> impl Future> + Send; + + /// Send a message and wait for a correlated response. + /// + /// The implementation correlates requests/responses by message ID and + /// enforces the given `timeout`. Returns an error on timeout or if the + /// connection drops while waiting. + fn await_response( + &self, + cv: &CommunicationValue, + timeout: Option, + ) -> impl Future> + Send; + + /// Returns `true` when the connection is alive and ready for traffic. + fn is_connected(&self) -> impl Future + Send; + + /// Returns `true` when the connection has completed identification / + /// registration and is fully operational. + fn is_identified(&self) -> impl Future + Send; + + /// Gracefully tear down the connection. + fn stop(&self) -> impl Future + Send; +} diff --git a/iota-connection/src/lib.rs b/iota-connection/src/lib.rs new file mode 100644 index 0000000..601ca7b --- /dev/null +++ b/iota-connection/src/lib.rs @@ -0,0 +1,3 @@ +pub mod connection_handler; +pub mod message_common; +pub mod message_handlers; diff --git a/iota-connection/src/message_common.rs b/iota-connection/src/message_common.rs new file mode 100644 index 0000000..2ab9733 --- /dev/null +++ b/iota-connection/src/message_common.rs @@ -0,0 +1,137 @@ +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::type_map::TypeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { + use mtp::type_map::{DataTypeId, TypeMap}; + let tm = TypeMap::latest(); + DataValue::Container( + items + .into_iter() + .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) + .collect(), + ) +} + +pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_str() + .map(|s| s.to_string()) + .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) + .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) +} + +pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { + cv.get_data(dt) + .as_number() + .and_then(|n| i64::try_from(n).ok()) + .or_else(|| { + cv.get_data(dt) + .as_signed_number() + .and_then(|n| i64::try_from(n).ok()) + }) + .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) +} + +#[derive(Debug, Clone)] +pub struct ChatSecretRecipient { + pub user_id: String, + pub encrypted_secret: Vec, + pub kem_ciphertext: Vec, +} + +pub fn recipient_from_value(value: &DataValue) -> Option { + let tm = TypeMap::latest(); + let user_id = value + .get_field(DataType::UserId.try_to_id(&tm)?)? + .as_str() + .map(|s| s.to_string()) + .or_else(|| { + value + .get_field(DataType::UserId.try_to_id(&tm)?)? + .as_number() + .map(|n| n.to_string()) + })?; + let encrypted_secret = value + .get_field(DataType::EncryptedSecret.try_to_id(&tm)?)? + .as_bytes()?; + let kem_ciphertext = value + .get_field(DataType::KemCiphertext.try_to_id(&tm)?)? + .as_bytes()?; + + Some(ChatSecretRecipient { + user_id, + encrypted_secret, + kem_ciphertext, + }) +} + +pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { + let recipients = cv.get_data(DataType::Recipients).as_array()?; + let parsed = recipients + .iter() + .map(recipient_from_value) + .collect::>>()?; + + if parsed.is_empty() { + None + } else { + Some(parsed) + } +} + +pub fn set_chat_secret_cv_for_recipient( + source: &CommunicationValue, + recipient: &ChatSecretRecipient, +) -> CommunicationValue { + let recipient_value = typed_container(vec![ + (DataType::UserId, DataValue::Str(recipient.user_id.clone())), + ( + DataType::EncryptedSecret, + DataValue::Bytes(recipient.encrypted_secret.clone()), + ), + ( + DataType::KemCiphertext, + DataValue::Bytes(recipient.kem_ciphertext.clone()), + ), + ]); + + CommunicationValue::new(CommunicationType::SetChatSecret) + .with_id(source.get_id()) + .with_sender(source.get_sender()) + .with_receiver(recipient.user_id.parse::().unwrap_or(0)) + .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) + .add_typed_default( + DataType::SecretId, + source.get_data(DataType::SecretId).clone(), + ) + .add_typed_default( + DataType::VersionNumber, + source.get_data(DataType::VersionNumber).clone(), + ) + .add_typed_default( + DataType::WrappingScheme, + source.get_data(DataType::WrappingScheme).clone(), + ) + .add_typed_default( + DataType::CreatedAt, + source.get_data(DataType::CreatedAt).clone(), + ) + .add_typed_default( + DataType::Recipients, + DataValue::Array(vec![recipient_value]), + ) +} + +pub fn now_millis_i64() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { + CommunicationValue::new(ty) + .with_id(request.get_id()) + .with_receiver(request.get_sender()) +} diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs new file mode 100644 index 0000000..b3c08a9 --- /dev/null +++ b/iota-connection/src/message_handlers.rs @@ -0,0 +1,766 @@ +use crate::message_common::*; +use iota_storage::util::chat_files::{self, MessageState}; +use iota_storage::util::chats_util::{self, get_user, mod_user}; +use iota_storage::util::communities_util::CommunitiesUtil; +use iota_storage::util::e2ee_storage::{self, ChatSecretQuery}; +use iota_storage::util::settings; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + +pub struct MessageMutation { + pub sender_id: i64, + pub partner_id: i64, + pub send_time: i64, +} + +pub fn message_mutation(cv: &CommunicationValue) -> Result { + let sender_id = i64::try_from(cv.get_sender()) + .map_err(|_| error_response(cv, CommunicationType::ErrorInvalidData))?; + let partner_id = data_i64(cv, DataType::ChatPartnerId) + .filter(|id| *id > 0) + .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; + let send_time = data_i64(cv, DataType::SendTime) + .filter(|time| *time > 0) + .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; + + Ok(MessageMutation { + sender_id, + partner_id, + send_time, + }) +} + +pub fn success_response(cv: &CommunicationValue) -> CommunicationValue { + error_response(cv, CommunicationType::Success) +} + +pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { + let mutation = match message_mutation(cv) { + Ok(mutation) => mutation, + Err(response) => return response, + }; + let Some(content) = cv.get_data(DataType::Content).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + + match chat_files::edit_message( + mutation.sender_id, + mutation.partner_id, + mutation.send_time, + mutation.sender_id, + content, + ) { + Ok(()) => success_response(cv), + Err(_) => error_response(cv, CommunicationType::ErrorNotFound), + } +} + +pub fn handle_message_reaction(cv: &CommunicationValue, add: bool) -> CommunicationValue { + let mutation = match message_mutation(cv) { + Ok(mutation) => mutation, + Err(response) => return response, + }; + let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + if reaction.is_empty() || reaction.len() > 64 { + return error_response(cv, CommunicationType::ErrorInvalidData); + } + + let result = if add { + chat_files::add_reaction( + mutation.sender_id, + mutation.partner_id, + mutation.send_time, + mutation.sender_id, + reaction, + ) + } else { + chat_files::remove_reaction( + mutation.sender_id, + mutation.partner_id, + mutation.send_time, + mutation.sender_id, + reaction, + ) + }; + + match result { + Ok(()) => success_response(cv), + Err(_) => error_response(cv, CommunicationType::ErrorNotFound), + } +} + +pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue { + let mutation = match message_mutation(cv) { + Ok(mutation) => mutation, + Err(response) => return response, + }; + + match chat_files::delete_message(mutation.sender_id, mutation.partner_id, mutation.send_time) { + Ok(()) => success_response(cv), + Err(_) => error_response(cv, CommunicationType::ErrorNotFound), + } +} + +fn stored_message_value( + message: &chat_files::StoredMessage, + storage_owner: i64, + partner_id: i64, +) -> DataValue { + let mut fields = vec![ + ( + DataType::SendTime, + DataValue::SignedNumber(message.message_time as i128), + ), + (DataType::Content, DataValue::Str(message.content.clone())), + ( + DataType::MessageState, + DataValue::Str(message.message_state.clone()), + ), + ( + DataType::Height, + DataValue::SignedNumber(message.height as i128), + ), + ( + DataType::SenderId, + DataValue::UnsignedNumber(if message.sent_by_self { + storage_owner as u128 + } else { + partner_id as u128 + }), + ), + ]; + if message.edited { + fields.push((DataType::Edited, DataValue::Bool(true))); + } + if let Some(reply_to) = message.reply_to { + fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(reply_to as u64 as u128), + )); + } + if !message.reactions.is_empty() { + let reactions = message + .reactions + .iter() + .map(|reaction| { + typed_container(vec![ + ( + DataType::Reaction, + DataValue::Str(reaction.reaction.clone()), + ), + ( + DataType::SenderId, + DataValue::SignedNumber(reaction.user_id as i128), + ), + ]) + }) + .collect(); + fields.push((DataType::Reactions, DataValue::Array(reactions))); + } + typed_container(fields) +} + +pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { + let Some(user_id) = data_string(cv, DataType::UserId) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + if user_id != cv.get_sender().to_string() { + return error_response(cv, CommunicationType::ErrorNotFound); + } + let Some(chat_id) = data_string(cv, DataType::ChatId) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + + match e2ee_storage::get_chat_secret(ChatSecretQuery { + user_id, + chat_id, + secret_id: data_string(cv, DataType::SecretId), + }) { + Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) + .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) + .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(record.version as i128), + ) + .add_typed_default( + DataType::EncryptedSecret, + DataValue::Bytes(record.encrypted_secret), + ) + .add_typed_default( + DataType::KemCiphertext, + DataValue::Bytes(record.kem_ciphertext), + ) + .add_typed_default( + DataType::WrappingScheme, + DataValue::Str(record.wrapping_scheme), + ) + .add_typed_default( + DataType::CreatedAt, + DataValue::SignedNumber(record.created_at as i128), + ) + .add_typed_default( + DataType::UpdatedAt, + DataValue::SignedNumber(record.updated_at as i128), + ), + Ok(None) => error_response(cv, CommunicationType::ErrorNotSet), + Err(_) => error_response(cv, CommunicationType::ErrorInvalidData), + } +} + +pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataType::AppIdentifier) + .as_str() + .unwrap_or("") + .to_string(); + let app_public_key = cv + .get_data(DataType::AppPublicKey) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() && !app_public_key.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if !user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.insert(app_identifier, app_public_key); + iota_storage::users::user_manager::update_user(user); + } + } + } + + CommunicationValue::new(CommunicationType::CreateApp) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) +} + +pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { + let sender_id = cv.get_sender() as i64; + let app_identifier = cv + .get_data(DataType::AppIdentifier) + .as_str() + .unwrap_or("") + .to_string(); + + if !app_identifier.is_empty() { + if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + if user.trusted_apps.contains_key(&app_identifier) { + user.trusted_apps.remove(&app_identifier); + iota_storage::users::user_manager::update_user(user); + } + } + } + + CommunicationValue::new(CommunicationType::DeleteApp) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) +} + +pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + + let contacts = chats_util::get_users(user_id); + let mut contacts_array = Vec::new(); + + for (i, contact) in contacts.iter().enumerate() { + let mut contact_container = Vec::new(); + contact_container.push(( + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )); + contact_container.push(( + DataType::LastMessageAt, + DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), + )); + + if let Some(ref name) = contact.user_name { + contact_container.push((DataType::Username, DataValue::Str(name.clone()))); + } + + let amount = if i < 10 { 20 } else { 1 }; + let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); + + let mut msg_array = Vec::new(); + for m in &messages { + msg_array.push(stored_message_value(m, user_id, contact.user_id)); + + if msg_array.len() == 1 { + let sender_id = if m.sent_by_self { + user_id + } else { + contact.user_id + }; + let mut last_msg = Vec::new(); + last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); + last_msg.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + contact_container.push((DataType::LastMessage, typed_container(last_msg))); + } + } + contact_container.push((DataType::Messages, DataValue::Array(msg_array))); + contacts_array.push(typed_container(contact_container)); + } + + CommunicationValue::new(CommunicationType::ClientConnected) + .with_id(cv.get_id()) + .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)) +} + +pub fn handle_message_state(cv: &CommunicationValue) { + let sender_id = &cv.get_sender(); + let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { + Some(id) => id, + _ => return, + }; + + let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { + s.parse::().unwrap_or_else(|_| now_millis_i64()) + } else { + now_millis_i64() + }; + + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + *sender_id as i64, + MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), + ); +} + +pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { + let my_id = cv.get_sender(); + let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); + let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages( + my_id as i64, + partner_id as i64, + offset as i64, + amount as i64, + ); + let mut msg_array: Vec = Vec::new(); + for m in &messages { + msg_array.push(stored_message_value(m, my_id as i64, partner_id as i64)); + } + + CommunicationValue::new(CommunicationType::MessagesGet) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Messages, DataValue::Array(msg_array)) +} + +pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { + let user_id = cv.get_sender(); + let users = chats_util::get_users(user_id as i64); + let mut user_array = Vec::new(); + for user in users { + let mut container = Vec::new(); + container.push(( + DataType::UserId, + DataValue::SignedNumber(user.user_id as i128), + )); + if let Some(name) = user.user_name { + container.push((DataType::Username, DataValue::Str(name))); + } + if let Some(ts) = user.last_message_at { + container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); + } + user_array.push(typed_container(container)); + } + CommunicationValue::new(CommunicationType::GetChats) + .with_id(cv.get_id()) + .with_receiver(user_id) + .add_typed_default(DataType::UserIds, DataValue::Array(user_array)) +} + +pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { + let user_id = cv.get_sender(); + let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { + Some(n) => n as i64, + None => cv + .get_data(DataType::ChatPartnerId) + .as_str() + .unwrap_or("0") + .parse() + .unwrap_or(0), + }; + let mut contact = get_user(user_id as i64, other_id) + .unwrap_or(iota_storage::users::contact::Contact::new(other_id)); + + if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { + contact.user_name = Some(name.to_string()); + } + + contact.set_last_message_at(now_millis_i64()); + mod_user(user_id as i64, &contact); + CommunicationValue::new(CommunicationType::AddConversation) + .with_id(cv.get_id()) + .with_receiver(user_id) +} + +pub fn handle_add_community(cv: &CommunicationValue) -> CommunicationValue { + CommunitiesUtil::add_community( + cv.get_sender() as i64, + cv.get_data(DataType::CommunityAddress) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataType::CommunityTitle) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataType::Position) + .as_str() + .unwrap() + .to_string(), + ); + CommunicationValue::new(CommunicationType::AddCommunity) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) +} + +pub fn handle_get_communities(cv: &CommunicationValue) -> CommunicationValue { + let mut comm_array = Vec::new(); + for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { + let mut container: Vec<(DataType, DataValue)> = Vec::new(); + container.push(( + DataType::CommunityAddress, + DataValue::Str(c.address.clone()), + )); + container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); + container.push((DataType::Position, DataValue::Str(c.position.clone()))); + comm_array.push(typed_container(container)); + } + + CommunicationValue::new(CommunicationType::GetCommunities) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_typed_default(DataType::Communities, DataValue::Array(comm_array)) +} + +pub fn handle_remove_community(cv: &CommunicationValue) -> CommunicationValue { + CommunitiesUtil::remove_community( + cv.get_sender() as i64, + cv.get_data(DataType::CommunityAddress) + .as_str() + .unwrap() + .to_string(), + ); + CommunicationValue::new(CommunicationType::RemoveCommunity) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) +} + +pub fn handle_global_settings_save(cv: &CommunicationValue) -> CommunicationValue { + let my_id = cv.get_sender(); + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings payload".to_string()), + ); + }; + + if settings::save_global(my_id as i64, settings_value).is_err() { + return error_response(cv, CommunicationType::ErrorInvalidData); + } + + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) + .with_receiver(my_id) + .with_id(cv.get_id()); + + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + response +} + +pub fn handle_global_settings_load(cv: &CommunicationValue) -> CommunicationValue { + let my_id = cv.get_sender(); + let Ok(settings_value) = settings::load_global(my_id as i64) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(settings_value_str) = settings_value else { + let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Path, + DataValue::Str("global.settings".to_string()), + ); + + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + return response; + }; + + let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); + + if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { + response = response.add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + response +} + +pub fn handle_settings_save( + cv: &CommunicationValue, + _expected_session_id: i128, +) -> CommunicationValue { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing session_id".to_string()), + ); + }; + if session_id == 0 || session_id > 1_000_000 { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + }; + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + }; + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings payload".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + }; + + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid settings_name".to_string()), + ) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + if settings::save( + my_id as i64, + session_id as i64, + settings_name, + settings_value, + ) + .is_err() + { + return error_response(cv, CommunicationType::ErrorInvalidData); + } + + CommunicationValue::new(CommunicationType::SettingsSave) + .with_receiver(my_id) + .with_id(cv.get_id()) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) +} + +pub fn handle_settings_load( + cv: &CommunicationValue, + _expected_session_id: i128, +) -> CommunicationValue { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing session_id".to_string()), + ); + }; + if session_id == 0 || session_id > 1_000_000 { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing settings_name".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + }; + + if !settings_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + || settings_name.contains("..") + { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid settings_name".to_string()), + ) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + let Ok(settings_value) = settings::load(my_id as i64, session_id as i64, settings_name) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(settings_value_str) = settings_value else { + return CommunicationValue::new(CommunicationType::ErrorNotFound) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + }; + + CommunicationValue::new(CommunicationType::SettingsLoad) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) + .add_typed_default( + DataType::SettingsName, + DataValue::Str(settings_name.to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) +} + +pub fn handle_settings_list( + cv: &CommunicationValue, + _expected_session_id: i128, +) -> CommunicationValue { + let my_id = cv.get_sender(); + let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Missing session_id".to_string()), + ); + }; + if session_id == 0 || session_id > 1_000_000 { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default( + DataType::Message, + DataValue::Str("Invalid session_id".to_string()), + ) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ); + } + + let Ok(settings) = settings::list(my_id as i64, session_id as i64) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let settings_json = settings.into_iter().map(DataValue::Str).collect(); + CommunicationValue::new(CommunicationType::SettingsList) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) +} diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 2baf39b..fd794e6 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -88,6 +88,9 @@ async fn main() { if let Err(_) = user_manager::load_users().await { log_t!("user_load_failed"); } + if let Err(e) = iota_storage::util::settings::migrate_legacy_files() { + log!("Failed to migrate legacy settings: {}", e); + } let mut sb = "".to_string(); @@ -102,7 +105,11 @@ async fn main() { } log!( "IOTA ID: {}", - CONFIG.load().iota_id.map(|id| id.to_string()).unwrap_or_else(|| "N/A".to_string()) + CONFIG + .load() + .iota_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "N/A".to_string()) ); log!("User IDS: {}", sb); @@ -121,7 +128,7 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ - let _port = CONFIG.load().port; + let port = CONFIG.load().port; let mut _ip = "0.0.0.0".to_string(); for iface in pnet::datalink::interfaces() { let iface: NetworkInterface = iface; @@ -133,17 +140,6 @@ async fn main() { } } } - /* Community port activation is used for activating the port for communities. - * Code is currently commented because communities have not been implemented yet. - if start(port).await { - log_t!("community_active", ip, port.to_string()); - } else { - if port < 1024 { - log_t!("community_start_error_admin", port.to_string()); - } else { - log_t!("community_start_error", port.to_string()); - } - } */ if !has_dir("web") { download_and_extract_zip( "https://omega.tensamin.net/api/download/iota_frontend", @@ -151,6 +147,9 @@ async fn main() { ) .await; } + if !web_server::start(port).await { + log!("Failed to start the MTP web server on port {}", port); + } let _ = omikron::omikron_connection::get_omikron_connection().await; log_t!("setup_completed"); @@ -162,7 +161,9 @@ async fn main() { if OMIKRON_CONNECTION.has_auth_failure().await { if let Some(reason) = OMIKRON_CONNECTION.get_auth_failure().await { log!("Authentication failed: {}", reason); - log!("Use /reconnect to try again or /regenerate private-key to create a new key pair"); + log!( + "Use /reconnect to try again or /regenerate private-key to create a new key pair" + ); OMIKRON_CONNECTION.clear_auth_failure().await; } } diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 68afe5e..67c4e6a 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -3,8 +3,8 @@ use crate::util::db; use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64}; use iota_util::file_util::{load_file, save_file}; -use rusqlite::params; use rand_core::{OsRng, RngCore}; +use rusqlite::params; pub fn add_user(user: UserProfile) { if let Err(e) = db::with_db(|conn| { @@ -166,9 +166,8 @@ pub fn get_users() -> Vec { fn load_trusted_apps(user_id: i64) -> std::collections::HashMap { match db::with_db(|conn| { - let mut stmt = conn.prepare( - "SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1", - )?; + let mut stmt = + conn.prepare("SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1")?; let rows = stmt.query_map(params![user_id], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) })?; @@ -191,7 +190,10 @@ fn load_trusted_apps(user_id: i64) -> std::collections::HashMap pub fn remove_user(user_id: i64) { if let Err(e) = db::with_db(|conn| { - conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; Ok(()) }) { diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index 4aba87d..b09d750 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,7 +1,7 @@ +use crate::storage_error::StorageError; use crate::util::db; use iota_logger::log; use rusqlite::params; -use crate::storage_error::StorageError; #[derive(PartialEq, Debug, Clone)] pub enum MessageState { @@ -53,7 +53,13 @@ pub struct StoredMessage { pub message_state: String, pub height: i64, pub reply_to: Option, - pub reactions: Vec, + pub reactions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredReaction { + pub reaction: String, + pub user_id: i64, } /* @@ -66,6 +72,48 @@ pub fn edit_message( message_time: i64, editor_id: i64, new_content: &str, +) -> Result<(), StorageError> { + update_message_content( + storage_owner, + external_user, + message_time, + editor_id, + new_content, + true, + ) +} + +/* Applies an edit received from the message sender to the recipient's copy. */ +pub fn apply_remote_edit( + storage_owner: i64, + external_user: i64, + message_time: i64, + editor_id: i64, + new_content: &str, +) -> Result<(), StorageError> { + if editor_id != external_user { + return Err(StorageError::Other( + "Remote editor does not match chat partner".into(), + )); + } + + update_message_content( + storage_owner, + external_user, + message_time, + editor_id, + new_content, + false, + ) +} + +fn update_message_content( + storage_owner: i64, + external_user: i64, + message_time: i64, + editor_id: i64, + new_content: &str, + require_sent_by_self: bool, ) -> Result<(), StorageError> { db::with_db(|conn| { let msg = conn.query_row( @@ -86,11 +134,16 @@ pub fn edit_message( )?; let (msg_id, old_content, sent_by_self) = msg; - if sent_by_self != 1 { + if require_sent_by_self && sent_by_self != 1 { return Err(StorageError::Other( "Only the original sender can edit this message".into(), )); } + if !require_sent_by_self && sent_by_self != 0 { + return Err(StorageError::Other( + "Remote edits may only update received messages".into(), + )); + } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -118,7 +171,11 @@ pub fn edit_message( }) } -pub fn hard_delete_message(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { +pub fn hard_delete_message( + storage_owner: i64, + external_user: i64, + message_time: i64, +) -> Result<(), StorageError> { db::with_db(|conn| { let msg_id: i64 = conn.query_row( r#" @@ -130,18 +187,79 @@ pub fn hard_delete_message(storage_owner: i64, external_user: i64, message_time: |row| row.get(0), )?; - conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?; - conn.execute("DELETE FROM reactions WHERE message_id = ?1", params![msg_id])?; + conn.execute( + "DELETE FROM message_edits WHERE message_id = ?1", + params![msg_id], + )?; + conn.execute( + "DELETE FROM reactions WHERE message_id = ?1", + params![msg_id], + )?; conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; Ok(()) }) } +/* Deletes a message from the sender's local copy after checking ownership. */ +pub fn delete_message( + storage_owner: i64, + external_user: i64, + message_time: i64, +) -> Result<(), StorageError> { + ensure_message_direction(storage_owner, external_user, message_time, true)?; + hard_delete_message(storage_owner, external_user, message_time) +} + +/* Flags the recipient's local copy after validating its sender, preserving its history. */ +pub fn apply_remote_delete( + storage_owner: i64, + external_user: i64, + message_time: i64, + sender_id: i64, +) -> Result<(), StorageError> { + if sender_id != external_user { + return Err(StorageError::Other( + "Remote sender does not match chat partner".into(), + )); + } + ensure_message_direction(storage_owner, external_user, message_time, false)?; + flag_deleted_by_external(storage_owner, external_user, message_time) +} + +fn ensure_message_direction( + storage_owner: i64, + external_user: i64, + message_time: i64, + expected_sent_by_self: bool, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let sent_by_self: i64 = conn.query_row( + r#" + SELECT sent_by_self FROM messages + WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + ORDER BY id DESC LIMIT 1 + "#, + params![storage_owner, external_user, message_time], + |row| row.get(0), + )?; + if (sent_by_self != 0) != expected_sent_by_self { + return Err(StorageError::Other( + "Message sender is not authorized".into(), + )); + } + Ok(()) + }) +} + /* * Marks a message as deleted by the external user rather than removing the row, * so the storage owner still sees a tombstone in the UI. */ -pub fn flag_deleted_by_external(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { +pub fn flag_deleted_by_external( + storage_owner: i64, + external_user: i64, + message_time: i64, +) -> Result<(), StorageError> { db::with_db(|conn| { let affected = conn.execute( r#" @@ -163,7 +281,11 @@ pub fn flag_deleted_by_external(storage_owner: i64, external_user: i64, message_ * the UI still shows the "edited" indicator. Only the own user should * call this. */ -pub fn delete_edit_history(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> { +pub fn delete_edit_history( + storage_owner: i64, + external_user: i64, + message_time: i64, +) -> Result<(), StorageError> { db::with_db(|conn| { let msg_id: i64 = conn.query_row( r#" @@ -175,7 +297,10 @@ pub fn delete_edit_history(storage_owner: i64, external_user: i64, message_time: |row| row.get(0), )?; - conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?; + conn.execute( + "DELETE FROM message_edits WHERE message_id = ?1", + params![msg_id], + )?; Ok(()) }) } @@ -339,26 +464,39 @@ pub fn change_message_state( .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) } -fn load_reactions(conn: &rusqlite::Connection, msg_ids: &[i64]) -> std::collections::HashMap> { +fn load_reactions( + conn: &rusqlite::Connection, + msg_ids: &[i64], +) -> std::collections::HashMap> { if msg_ids.is_empty() { return std::collections::HashMap::new(); } - let placeholders: Vec = msg_ids.iter().enumerate() + let placeholders: Vec = msg_ids + .iter() + .enumerate() .map(|(i, _)| format!("?{}", i + 1)) .collect(); let query = format!( - "SELECT message_id, reaction || ':' || COUNT(*) FROM reactions WHERE message_id IN ({}) GROUP BY message_id, reaction", + "SELECT message_id, reaction, user_id FROM reactions WHERE message_id IN ({}) ORDER BY created_at ASC, id ASC", placeholders.join(", ") ); - let mut map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut map: std::collections::HashMap> = + std::collections::HashMap::new(); if let Ok(mut stmt) = conn.prepare(&query) { - let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids.iter() + let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids + .iter() .map(|id| id as &dyn rusqlite::types::ToSql) .collect(); if let Ok(rows) = stmt.query_map(params.as_slice(), |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + Ok(( + row.get::<_, i64>(0)?, + StoredReaction { + reaction: row.get(1)?, + user_id: row.get(2)?, + }, + )) }) { for row in rows.flatten() { map.entry(row.0).or_default().push(row.1); diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 3263f68..dfe9d2d 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -64,6 +64,30 @@ fn db_file_path(db_name: &str) -> String { fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> { let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?; + run_migrations_on_connection(&conn) +} + +/* + * Older builds could apply a schema change without advancing user_version. + * Check each added column so those databases can resume upgrading. + */ +fn add_column_if_missing( + conn: &Connection, + column: &str, + definition: &str, +) -> Result<(), StorageError> { + let mut statement = + conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; + let exists = statement.exists([column])?; + + if !exists { + conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?; + } + + Ok(()) +} + +fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { let current_version: i64 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .unwrap_or(0); @@ -78,8 +102,7 @@ fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> message_time INTEGER NOT NULL, content TEXT NOT NULL, sent_by_self INTEGER NOT NULL, - message_state TEXT NOT NULL, - height INTEGER NOT NULL DEFAULT 0 + message_state TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_lookup ON messages (storage_owner, external_user, message_time DESC); @@ -129,29 +152,28 @@ fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> } if current_version < 2 { - conn.execute_batch( - r#" - ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0; - PRAGMA user_version = 2; - "#, - )?; + add_column_if_missing(conn, "height", "height INTEGER NOT NULL DEFAULT 0")?; + conn.execute_batch("PRAGMA user_version = 2;")?; } if current_version < 3 { - conn.execute_batch( - r#" - ALTER TABLE messages ADD COLUMN reply_to INTEGER; - PRAGMA user_version = 3; - "#, - )?; + add_column_if_missing(conn, "reply_to", "reply_to INTEGER")?; + conn.execute_batch("PRAGMA user_version = 3;")?; } if current_version < 4 { + add_column_if_missing( + conn, + "edited_count", + "edited_count INTEGER NOT NULL DEFAULT 0", + )?; + add_column_if_missing( + conn, + "deleted_by_external", + "deleted_by_external INTEGER NOT NULL DEFAULT 0", + )?; conn.execute_batch( r#" - ALTER TABLE messages ADD COLUMN edited_count INTEGER NOT NULL DEFAULT 0; - ALTER TABLE messages ADD COLUMN deleted_by_external INTEGER NOT NULL DEFAULT 0; - CREATE TABLE IF NOT EXISTS message_edits ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL REFERENCES messages(id), @@ -179,6 +201,24 @@ fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> )?; } + if current_version < 5 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS settings ( + user_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + name TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (user_id, session_id, name) + ); + CREATE INDEX IF NOT EXISTS idx_settings_lookup + ON settings (user_id, session_id, name); + + PRAGMA user_version = 5; + "#, + )?; + } + Ok(()) } @@ -221,3 +261,40 @@ where pub fn create_general_messages_db() -> Result>, String> { create_shared_connection(DB_NAME, "") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch( + r#" + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + external_user INTEGER NOT NULL, + message_time INTEGER NOT NULL, + content TEXT NOT NULL, + sent_by_self INTEGER NOT NULL, + message_state TEXT NOT NULL + ); + ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0; + PRAGMA user_version = 1; + "#, + )?; + + run_migrations_on_connection(&conn)?; + + let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + assert_eq!(version, 5); + for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { + let mut statement = + conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; + assert!(statement.exists([column])?); + } + + Ok(()) + } +} diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index d26a092..ad12661 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -1,5 +1,5 @@ use crate::util::db; -use rusqlite::{params, OptionalExtension}; +use rusqlite::{OptionalExtension, params}; use std::sync::{Arc, LazyLock, Mutex}; pub type StorageError = String; @@ -226,9 +226,7 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result, -) -> rusqlite::Result { +fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(PendingChatSecretForward { recipient_user_id: row.get(0)?, chat_id: row.get(1)?, diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index d5b2e4d..2fa8220 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -4,3 +4,4 @@ pub mod communities_util; pub mod config_util; pub mod db; pub mod e2ee_storage; +pub mod settings; diff --git a/iota-storage/src/util/settings.rs b/iota-storage/src/util/settings.rs new file mode 100644 index 0000000..6fae52b --- /dev/null +++ b/iota-storage/src/util/settings.rs @@ -0,0 +1,163 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use iota_util::file_util::get_directory; +use rusqlite::{OptionalExtension, params}; +use std::fs; +use std::path::Path; + +pub const GLOBAL_SESSION_ID: i64 = 0; +const GLOBAL_SETTINGS_NAME: &str = "__global__"; + +pub fn save(user_id: i64, session_id: i64, name: &str, payload: &str) -> Result<(), StorageError> { + db::with_db(|conn| { + conn.execute( + "INSERT INTO settings (user_id, session_id, name, payload) VALUES (?1, ?2, ?3, ?4)\n ON CONFLICT(user_id, session_id, name) DO UPDATE SET payload = excluded.payload", + params![user_id, session_id, name, payload], + )?; + Ok(()) + }) +} + +pub fn load(user_id: i64, session_id: i64, name: &str) -> Result, StorageError> { + db::with_db(|conn| { + conn.query_row( + "SELECT payload FROM settings WHERE user_id = ?1 AND session_id = ?2 AND name = ?3", + params![user_id, session_id, name], + |row| row.get(0), + ) + .optional() + .map_err(StorageError::from) + }) +} + +pub fn list(user_id: i64, session_id: i64) -> Result, StorageError> { + db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT name FROM settings WHERE user_id = ?1 AND session_id = ?2 ORDER BY name", + )?; + let rows = statement.query_map(params![user_id, session_id], |row| row.get(0))?; + rows.collect::, _>>() + .map_err(StorageError::from) + }) +} + +pub fn save_global(user_id: i64, payload: &str) -> Result<(), StorageError> { + save(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME, payload) +} + +pub fn load_global(user_id: i64) -> Result, StorageError> { + load(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME) +} + +pub fn migrate_legacy_files() -> Result<(), StorageError> { + let users_dir = Path::new(&get_directory()).join("users"); + let Ok(users) = fs::read_dir(users_dir) else { + return Ok(()); + }; + + for user_entry in users { + let user_entry = user_entry?; + let Ok(user_id) = user_entry.file_name().to_string_lossy().parse::() else { + continue; + }; + let user_dir = user_entry.path(); + + migrate_file_if_missing( + user_id, + GLOBAL_SESSION_ID, + GLOBAL_SETTINGS_NAME, + &user_dir.join("global.settings"), + )?; + + let settings_dir = user_dir.join("settings"); + let Ok(settings_entries) = fs::read_dir(settings_dir) else { + continue; + }; + for settings_entry in settings_entries { + let settings_entry = settings_entry?; + let path = settings_entry.path(); + if path.is_file() { + if let Some(name) = setting_name(&path) { + migrate_file_if_missing(user_id, GLOBAL_SESSION_ID, &name, &path)?; + } + continue; + } + + let Ok(session_id) = settings_entry.file_name().to_string_lossy().parse::() else { + continue; + }; + let Ok(device_settings) = fs::read_dir(path) else { + continue; + }; + for setting_entry in device_settings { + let setting_entry = setting_entry?; + let path = setting_entry.path(); + if let Some(name) = setting_name(&path) { + migrate_file_if_missing(user_id, session_id, &name, &path)?; + } + } + } + } + + Ok(()) +} + +fn setting_name(path: &Path) -> Option { + (path.extension()?.to_str()? == "settings").then(|| { + path.file_stem() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string() + }) +} + +fn migrate_file_if_missing( + user_id: i64, + session_id: i64, + name: &str, + path: &Path, +) -> Result<(), StorageError> { + if !path.is_file() || load(user_id, session_id, name)?.is_some() { + return Ok(()); + } + let payload = fs::read_to_string(path)?; + save(user_id, session_id, name, &payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + #[test] + fn settings_schema_supports_user_and_session_keys() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch( + "CREATE TABLE settings ( + user_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + name TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (user_id, session_id, name) + );", + )?; + + conn.execute( + "INSERT INTO settings VALUES (?1, ?2, ?3, ?4)", + params![7, 11, "theme", "dark"], + )?; + conn.execute( + "INSERT INTO settings VALUES (?1, ?2, ?3, ?4)", + params![7, 12, "theme", "light"], + )?; + + let payload: String = conn.query_row( + "SELECT payload FROM settings WHERE user_id = 7 AND session_id = 11 AND name = 'theme'", + [], + |row| row.get(0), + )?; + assert_eq!(payload, "dark"); + + Ok(()) + } +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index e88cebf..23ed0b7 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -4,8 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -mtp-crypto = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["pqc"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ + "crypto" +] } reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs index 2909c04..9375eb4 100644 --- a/iota-util/src/crypto_helper.rs +++ b/iota-util/src/crypto_helper.rs @@ -1,5 +1,5 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp_crypto::{Keyring, PublicKeyBundle}; +use mtp::crypto::{Keyring, PublicKeyBundle}; pub fn generate_keyring() -> Keyring { Keyring::generate() @@ -24,5 +24,5 @@ pub fn public_key_bundle_from_base64(s: &str) -> Option { } pub fn hex_hash(input: &str) -> String { - hex::encode(mtp_crypto::sha256(input.as_bytes())) + hex::encode(mtp::crypto::sha256(input.as_bytes())) } diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs index defc41c..010ceae 100644 --- a/iota-util/src/crypto_util.rs +++ b/iota-util/src/crypto_util.rs @@ -1,5 +1,5 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp_crypto::{EncryptionType, Keyring, PublicKeyBundle, encrypt_for, decrypt_with}; +use mtp::crypto::{EncryptionType, Keyring, PublicKeyBundle, decrypt_with, encrypt_for}; #[derive(Clone, Copy, Debug)] pub enum DataFormat { @@ -22,13 +22,8 @@ pub fn encrypt( .map_err(|e| format!("encryption error: {:?}", e)) } -pub fn decrypt( - ciphertext: &[u8], - aad: &[u8], - keyring: &Keyring, -) -> Result, String> { - decrypt_with(ciphertext, keyring, aad) - .map_err(|e| format!("decryption error: {:?}", e)) +pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result, String> { + decrypt_with(ciphertext, keyring, aad).map_err(|e| format!("decryption error: {:?}", e)) } pub fn encrypt_challenge( @@ -39,12 +34,10 @@ pub fn encrypt_challenge( Ok(STANDARD.encode(&blob)) } -pub fn decrypt_challenge( - encrypted: &str, - keyring: &Keyring, -) -> Result { - let blob = - STANDARD.decode(encrypted).map_err(|e| format!("base64 decode error: {}", e))?; +pub fn decrypt_challenge(encrypted: &str, keyring: &Keyring) -> Result { + let blob = STANDARD + .decode(encrypted) + .map_err(|e| format!("base64 decode error: {}", e))?; let pt = decrypt(&blob, b"challenge", keyring)?; String::from_utf8(pt).map_err(|e| format!("utf8 decode error: {}", e)) } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index caaab62..f083794 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 984c9dd..e68a12f 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,159 +1,27 @@ use dashmap::DashMap; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::{ACTIVE_TASKS, SHUTDOWN}; -use iota_storage::users::contact::Contact; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; -use iota_storage::util::chats_util::{self, get_user, mod_user}; -use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::config_util::{CONFIG, modify_config}; -use iota_storage::util::e2ee_storage::{ - self, ChatSecretQuery, PendingChatSecretForward, StoredChatSecret, -}; +use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; -use iota_util::file_util::{get_children, has_file, load_file, save_file}; use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; -use mtp::type_map::TypeMap; use std::env; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tokio::sync::{Mutex, RwLock, oneshot, watch, Semaphore}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; use uuid::Uuid; use crate::omega_discovery; -fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { - use mtp::type_map::{DataTypeId, TypeMap}; - let tm = TypeMap::latest(); - DataValue::Container( - items - .into_iter() - .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) - .collect(), - ) -} - -fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .as_str() - .map(|s| s.to_string()) - .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) - .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) -} - -fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .as_number() - .and_then(|n| i64::try_from(n).ok()) - .or_else(|| { - cv.get_data(dt) - .as_signed_number() - .and_then(|n| i64::try_from(n).ok()) - }) - .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) -} - -#[derive(Debug, Clone)] -struct ChatSecretRecipient { - user_id: String, - encrypted_secret: Vec, - kem_ciphertext: Vec, -} - -fn recipient_from_value(value: &DataValue) -> Option { - let tm = TypeMap::latest(); - let user_id = value - .get_field(DataType::UserId.to_id(&tm))? - .as_str() - .map(|s| s.to_string()) - .or_else(|| { - value - .get_field(DataType::UserId.to_id(&tm))? - .as_number() - .map(|n| n.to_string()) - })?; - let encrypted_secret = value - .get_field(DataType::EncryptedSecret.to_id(&tm))? - .as_bytes()?; - let kem_ciphertext = value - .get_field(DataType::KemCiphertext.to_id(&tm))? - .as_bytes()?; - - Some(ChatSecretRecipient { - user_id, - encrypted_secret, - kem_ciphertext, - }) -} - -fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { - let recipients = cv.get_data(DataType::Recipients).as_array()?; - let parsed = recipients - .iter() - .map(recipient_from_value) - .collect::>>()?; - - if parsed.is_empty() { - None - } else { - Some(parsed) - } -} - -fn set_chat_secret_cv_for_recipient( - source: &CommunicationValue, - recipient: &ChatSecretRecipient, -) -> CommunicationValue { - let recipient_value = typed_container(vec![ - (DataType::UserId, DataValue::Str(recipient.user_id.clone())), - ( - DataType::EncryptedSecret, - DataValue::Bytes(recipient.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(recipient.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_id(source.get_id()) - .with_sender(source.get_sender()) - .with_receiver(recipient.user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) - .add_typed_default( - DataType::SecretId, - source.get_data(DataType::SecretId).clone(), - ) - .add_typed_default( - DataType::VersionNumber, - source.get_data(DataType::VersionNumber).clone(), - ) - .add_typed_default( - DataType::WrappingScheme, - source.get_data(DataType::WrappingScheme).clone(), - ) - .add_typed_default( - DataType::CreatedAt, - source.get_data(DataType::CreatedAt).clone(), - ) - .add_typed_default( - DataType::Recipients, - DataValue::Array(vec![recipient_value]), - ) -} - -fn now_millis_i64() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} +use iota_connection::message_common::*; +use iota_connection::message_handlers; fn pending_chat_secret_forward_from_cv( cv: &CommunicationValue, @@ -212,12 +80,6 @@ fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationVal .add_typed_default(DataType::Recipients, DataValue::Array(vec![recipient])) } -fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - CommunicationValue::new(ty) - .with_id(request.get_id()) - .with_receiver(request.get_sender()) -} - // Helper function to check if read receipts are enabled globally async fn is_read_receipts_enabled() -> bool { CONFIG.load().read_receipts_enabled @@ -451,6 +313,7 @@ impl OmikronConnection { .with_policy(Policy { send_mode: SendMode::SingleStreamPerMessage, max_message_size: 1_000_000_000, + handshake_max_message_size: 1_000_000_000, close_frame_len: u32::MAX, application_close_code: 0, open_stream_timeout: Duration::from_millis(2_000), @@ -460,9 +323,11 @@ impl OmikronConnection { keep_alive_interval: Some(Duration::from_secs(6)), max_idle_timeout: Some(Duration::from_secs(30)), force_close_delay: Duration::from_millis(300), - max_transient_recv_errors: 20, - transient_recv_backoff: Duration::from_millis(100), receiver_queue_capacity: 1000, + max_concurrent_stream_tasks: 10, + persistent_stream_max_retries: 5, + persistent_stream_retry_backoff: Duration::from_secs(5), + max_frames_per_stream: None, }); let connection = match Client::auth_connect_or_register( @@ -496,7 +361,8 @@ impl OmikronConnection { let sender_arc = Arc::new(connection.sender); *self.sender.write().await = Some(sender_arc.clone()); - self.set_state(ConnectionState::Connected { identified: true }).await; + self.set_state(ConnectionState::Connected { identified: true }) + .await; // Start read loop let mut receiver = connection.receiver; @@ -554,7 +420,7 @@ impl OmikronConnection { * that still read it directly. */ async fn load_or_migrate_keyring(&self) -> Keyring { - if let Ok(kr) = mtp::files::load_keyring(IOTA_KEYRING_PATH) { + if let Ok(kr) = mtp::files::load_keyring_raw(IOTA_KEYRING_PATH) { return kr; } @@ -566,12 +432,13 @@ impl OmikronConnection { "WARNING: No existing keyring found. Neither {} nor config.json \ contain a keyring; generating a new identity. If you already had \ an Iota identity, restore {} from a backup to avoid losing access.", - IOTA_KEYRING_PATH, IOTA_KEYRING_PATH + IOTA_KEYRING_PATH, + IOTA_KEYRING_PATH ); crypto_helper::generate_keyring() }); - if let Err(e) = mtp::files::save_keyring(&keyring, IOTA_KEYRING_PATH) { + if let Err(e) = mtp::files::save_keyring_raw(&keyring, IOTA_KEYRING_PATH) { log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); } @@ -815,20 +682,32 @@ impl OmikronConnection { ) -> Option { let mut msg_fields = vec![ (DataType::Content, DataValue::Str(content.to_string())), - (DataType::SendTime, DataValue::SignedNumber(timestamp as i128)), + ( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ), (DataType::Height, DataValue::SignedNumber(height as i128)), ]; if let Some(rt) = reply_to { - msg_fields.push((DataType::ReplyId, DataValue::UnsignedNumber(rt as u64 as u128))); + msg_fields.push(( + DataType::ReplyId, + DataValue::UnsignedNumber(rt as u64 as u128), + )); } let user_forward = CommunicationValue::new(CommunicationType::MessageLive) .with_id(message_id) .with_receiver(receiver_id) - .add_typed_default(DataType::SenderId, DataValue::SignedNumber(sender_id as i128)) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ) .add_typed_default(DataType::Message, typed_container(msg_fields)); - match self.await_response(&user_forward, Some(Duration::from_secs(3))).await { + match self + .await_response(&user_forward, Some(Duration::from_secs(3))) + .await + { Ok(user_resp) => { let ms_raw = user_resp .get_data(DataType::MessageState) @@ -856,7 +735,10 @@ impl OmikronConnection { .with_sender(sender_id as u64) .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) .add_typed_default(DataType::Content, DataValue::Str(content.to_string())) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)); + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ); if let Some(rt) = reply_to { fw_msg = fw_msg.add_typed_default( DataType::ReplyId, @@ -864,7 +746,10 @@ impl OmikronConnection { ); } - match self.await_response(&fw_msg, Some(Duration::from_secs(10))).await { + match self + .await_response(&fw_msg, Some(Duration::from_secs(10))) + .await + { Ok(resp) => { let ms_raw = resp .get_data(DataType::MessageState) @@ -872,34 +757,58 @@ impl OmikronConnection { .unwrap_or_else(|| "".to_string()); let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - let _ = chat_files::change_message_state( - timestamp, sender_id, receiver_id, ms.clone(), - ); + let _ = + chat_files::change_message_state(timestamp, sender_id, receiver_id, ms.clone()); - let _ = self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default(DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128)) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) - .add_typed_default(DataType::MessageState, DataValue::Str(ms.as_str().to_string())), - ).await; + let _ = self + .send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), + ) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; } Err(_) => { let _ = chat_files::change_message_state( - timestamp, sender_id, receiver_id, MessageState::Sent, + timestamp, + sender_id, + receiver_id, + MessageState::Sent, ); - let _ = self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default(DataType::ChatPartnerId, DataValue::SignedNumber(receiver_id as i128)) - .add_typed_default(DataType::SendTime, DataValue::SignedNumber(timestamp as i128)) - .add_typed_default(DataType::MessageState, DataValue::Str(MessageState::Sent.as_str().to_string())), - ).await; + let _ = self + .send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), + ) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; } } } @@ -951,6 +860,12 @@ impl OmikronConnection { dispatch!(ClientConnected, handle_client_connected); dispatch!(MessageState, handle_message_state); dispatch!(MessageSend, handle_message_send); + dispatch!(MessageEdit, handle_message_edit); + dispatch!(MessageEditLive, handle_message_edit_live); + dispatch!(MessageReactionAdd, handle_message_reaction_add); + dispatch!(MessageReactionRemove, handle_message_reaction_remove); + dispatch!(MessageReactionLive, handle_message_reaction_live); + dispatch!(MessageDeleteLive, handle_message_delete_live); dispatch!(MessageOtherIota, handle_message_other_iota); dispatch!(MessagesGet, handle_messages_get); dispatch!(GetChats, handle_get_chats); @@ -974,7 +889,9 @@ impl OmikronConnection { let recipients = match chat_secret_recipients(cv) { Some(recipients) => recipients, None => { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; return; } }; @@ -988,7 +905,9 @@ impl OmikronConnection { let Some((((chat_id, secret_id), version), wrapping_scheme)) = chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) else { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; return; }; @@ -1012,18 +931,16 @@ impl OmikronConnection { }) .is_err() { - let _ = self.send_message(&error_response( - cv, - CommunicationType::ErrorInvalidData, - )).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; return; } continue; } if recipient.user_id != sender_id { - non_local_forwards - .push(set_chat_secret_cv_for_recipient(cv, recipient)); + non_local_forwards.push(set_chat_secret_cv_for_recipient(cv, recipient)); } } @@ -1045,68 +962,15 @@ impl OmikronConnection { } } - let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::Success)) + .await; } async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { - let Some(user_id) = data_string(cv, DataType::UserId) else { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; - return; - }; - if user_id != cv.get_sender().to_string() { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorNotFound)).await; - return; - } - let Some(chat_id) = data_string(cv, DataType::ChatId) else { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; - return; - }; - - match e2ee_storage::get_chat_secret(ChatSecretQuery { - user_id, - chat_id, - secret_id: data_string(cv, DataType::SecretId), - }) { - Ok(Some(record)) => { - let response = CommunicationValue::new(CommunicationType::ChatSecretResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret), - ) - .add_typed_default( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default( - DataType::UpdatedAt, - DataValue::SignedNumber(record.updated_at as i128), - ); - let _ = self.send_message(&response).await; - } - Ok(None) => { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorNotSet)).await; - } - Err(_) => { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; - } - } + let _ = self + .send_message(&message_handlers::handle_get_chat_secret(cv)) + .await; } async fn handle_chat_secret_forward(self: Arc, cv: &CommunicationValue) { @@ -1116,7 +980,9 @@ impl OmikronConnection { || recipient_user_id.is_empty() || pending_chat_secret_forward_from_cv(cv).is_none() { - let _ = self.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; return; } @@ -1124,10 +990,14 @@ impl OmikronConnection { .clone() .with_receiver(recipient_user_id.parse::().unwrap_or(0)); if self.forward_chat_secret(&forward).await { - let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::Success)) + .await; } else { self.store_pending_chat_secret_forward(cv).await; - let _ = self.send_message(&error_response(cv, CommunicationType::Success)).await; + let _ = self + .send_message(&error_response(cv, CommunicationType::Success)) + .await; } } @@ -1158,7 +1028,8 @@ impl OmikronConnection { let challenge = Uuid::new_v4().to_string(); self.app_challenges.insert(sender_id, challenge.clone()); - self.app_sessions.insert(sender_id, (user_id, app_identifier.clone())); + self.app_sessions + .insert(sender_id, (user_id, app_identifier.clone())); if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) @@ -1199,10 +1070,9 @@ impl OmikronConnection { if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { if expected_challenge == *response { - let res = - CommunicationValue::new(CommunicationType::AppIdentificationResponse) - .with_id(cv.get_id()) - .with_receiver(sender_id); + let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) + .with_id(cv.get_id()) + .with_receiver(sender_id); let _ = self.send_message(&res).await; return; } @@ -1224,11 +1094,7 @@ impl OmikronConnection { if let Some(session) = self.app_sessions.get(&sender_id) { let (user_id, app_identifier) = session.value(); - iota_storage::users::user_manager::save_app_data( - *user_id, - app_identifier, - &app_data, - ); + iota_storage::users::user_manager::save_app_data(*user_id, app_identifier, &app_data); } let res = CommunicationValue::new(CommunicationType::SaveAppData) @@ -1243,8 +1109,7 @@ impl OmikronConnection { if let Some(session) = self.app_sessions.get(&sender_id) { let (user_id, app_identifier) = session.value(); - app_data = - iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); + app_data = iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); } let res = CommunicationValue::new(CommunicationType::LoadAppData) @@ -1255,147 +1120,281 @@ impl OmikronConnection { } async fn handle_create_app(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() && !app_public_key.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if !user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.insert(app_identifier, app_public_key); - iota_storage::users::user_manager::update_user(user); - } - } - } - - let res = CommunicationValue::new(CommunicationType::CreateApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - let _ = self.send_message(&res).await; + let _ = self + .send_message(&message_handlers::handle_create_app(cv)) + .await; } async fn handle_delete_app(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender() as i64; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { - if user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.remove(&app_identifier); - iota_storage::users::user_manager::update_user(user); - } - } - } - - let res = CommunicationValue::new(CommunicationType::DeleteApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - let _ = self.send_message(&res).await; + let _ = self + .send_message(&message_handlers::handle_delete_app(cv)) + .await; } async fn handle_client_connected(self: Arc, cv: &CommunicationValue) { - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - let _session_id = cv.get_data(DataType::SessionId).as_number().unwrap_or(0) as i64; - - let contacts = chats_util::get_users(user_id); - let mut contacts_array = Vec::new(); - - for (i, contact) in contacts.iter().enumerate() { - let mut contact_container = Vec::new(); - contact_container.push(( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )); - contact_container.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), - )); - - if let Some(ref name) = contact.user_name { - contact_container.push((DataType::Username, DataValue::Str(name.clone()))); - } - - let amount = if i < 10 { 20 } else { 1 }; - let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); - - let mut msg_array = Vec::new(); - for m in &messages { - let mut msg_container = Vec::new(); - msg_container.push(( - DataType::SendTime, - DataValue::SignedNumber(m.message_time as i128), - )); - msg_container.push((DataType::Content, DataValue::Str(m.content.clone()))); - msg_container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); - msg_container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); - msg_container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if m.sent_by_self { - user_id as u128 - } else { - contact.user_id as u128 - }), - )); - msg_array.push(typed_container(msg_container)); - - if msg_array.len() == 1 { - let sender_id = if m.sent_by_self { - user_id - } else { - contact.user_id - }; - let mut last_msg = Vec::new(); - last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); - last_msg.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - contact_container.push((DataType::LastMessage, typed_container(last_msg))); - } - } - contact_container.push((DataType::Messages, DataValue::Array(msg_array))); - contacts_array.push(typed_container(contact_container)); - } - - let resp = CommunicationValue::new(CommunicationType::ClientConnected) - .with_id(cv.get_id()) - .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_client_connected(cv)) + .await; } async fn handle_message_state(self: Arc, cv: &CommunicationValue) { - let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(id) => id, + message_handlers::handle_message_state(cv); + } + + fn mutation_live_message( + ty: CommunicationType, + request: &CommunicationValue, + mutation: &message_handlers::MessageMutation, + extra: Vec<(DataType, DataValue)>, + ) -> CommunicationValue { + let mut message = CommunicationValue::new(ty) + .with_id(request.get_id()) + .with_sender(mutation.sender_id as u64) + .with_receiver(mutation.partner_id as u64) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(mutation.sender_id as i128), + ) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(mutation.send_time as i128), + ); + for (data_type, value) in extra { + message = message.add_typed_default(data_type, value); + } + message + } + + async fn persist_and_deliver_remote_edit(&self, cv: &CommunicationValue) { + let sender_id = match i64::try_from(cv.get_sender()) { + Ok(sender_id) => sender_id, + Err(_) => return, + }; + let receiver_id = match i64::try_from(cv.get_receiver()) { + Ok(receiver_id) if receiver_id > 0 => receiver_id, _ => return, }; - - let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() + let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { + return; }; + let Some(content) = cv.get_data(DataType::Content).as_str() else { + return; + }; + if chat_files::apply_remote_edit(receiver_id, sender_id, send_time, sender_id, content) + .is_ok() + { + let _ = self.send_message(cv).await; + } + } - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id as i64, - *sender_id as i64, - MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), + async fn persist_and_deliver_remote_reaction(&self, cv: &CommunicationValue, add: bool) { + let sender_id = match i64::try_from(cv.get_sender()) { + Ok(sender_id) => sender_id, + Err(_) => return, + }; + let receiver_id = match i64::try_from(cv.get_receiver()) { + Ok(receiver_id) if receiver_id > 0 => receiver_id, + _ => return, + }; + let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { + return; + }; + let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { + return; + }; + if reaction.is_empty() || reaction.len() > 64 { + return; + } + let result = if add { + chat_files::add_reaction(receiver_id, sender_id, send_time, sender_id, reaction) + } else { + chat_files::remove_reaction(receiver_id, sender_id, send_time, sender_id, reaction) + }; + if result.is_ok() { + let _ = self.send_message(cv).await; + } + } + + async fn persist_and_deliver_remote_delete(&self, cv: &CommunicationValue) { + let sender_id = match i64::try_from(cv.get_sender()) { + Ok(sender_id) => sender_id, + Err(_) => return, + }; + let receiver_id = match i64::try_from(cv.get_receiver()) { + Ok(receiver_id) if receiver_id > 0 => receiver_id, + _ => return, + }; + let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { + return; + }; + if chat_files::apply_remote_delete(receiver_id, sender_id, send_time, sender_id).is_ok() { + let _ = self.send_message(cv).await; + } + } + + async fn handle_message_edit(self: Arc, cv: &CommunicationValue) { + let response = message_handlers::handle_message_edit(cv); + if !response.is_type(CommunicationType::Success) { + let _ = self.send_message(&response).await; + return; + } + let Ok(mutation) = message_handlers::message_mutation(cv) else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let Some(content) = cv.get_data(DataType::Content).as_str() else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let live = Self::mutation_live_message( + CommunicationType::MessageEditLive, + cv, + &mutation, + vec![(DataType::Content, DataValue::Str(content.to_string()))], ); + if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() + && chat_files::apply_remote_edit( + mutation.partner_id, + mutation.sender_id, + mutation.send_time, + mutation.sender_id, + content, + ) + .is_err() + { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) + .await; + return; + } + let _ = self.send_message(&response).await; + let _ = self.send_message(&live).await; + } + + async fn handle_message_edit_live(self: Arc, cv: &CommunicationValue) { + self.persist_and_deliver_remote_edit(cv).await; + } + + async fn handle_message_reaction_add(self: Arc, cv: &CommunicationValue) { + self.handle_message_reaction(cv, true).await; + } + + async fn handle_message_reaction_remove(self: Arc, cv: &CommunicationValue) { + self.handle_message_reaction(cv, false).await; + } + + async fn handle_message_reaction(self: Arc, cv: &CommunicationValue, add: bool) { + let response = message_handlers::handle_message_reaction(cv, add); + if !response.is_type(CommunicationType::Success) { + let _ = self.send_message(&response).await; + return; + } + let Ok(mutation) = message_handlers::message_mutation(cv) else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let live = Self::mutation_live_message( + CommunicationType::MessageReactionLive, + cv, + &mutation, + vec![ + (DataType::Reaction, DataValue::Str(reaction.to_string())), + ( + DataType::SenderId, + DataValue::SignedNumber(mutation.sender_id as i128), + ), + (DataType::Accepted, DataValue::Bool(add)), + ], + ); + if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() { + let result = if add { + chat_files::add_reaction( + mutation.partner_id, + mutation.sender_id, + mutation.send_time, + mutation.sender_id, + reaction, + ) + } else { + chat_files::remove_reaction( + mutation.partner_id, + mutation.sender_id, + mutation.send_time, + mutation.sender_id, + reaction, + ) + }; + if result.is_err() { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) + .await; + return; + } + } + let _ = self.send_message(&response).await; + let _ = self.send_message(&live).await; + } + + async fn handle_message_reaction_live(self: Arc, cv: &CommunicationValue) { + let add = cv.get_data(DataType::Accepted).as_bool().unwrap_or(true); + self.persist_and_deliver_remote_reaction(cv, add).await; + } + + async fn handle_message_delete_live(self: Arc, cv: &CommunicationValue) { + let sender_id = match i64::try_from(cv.get_sender()) { + Ok(sender_id) => sender_id, + Err(_) => return, + }; + if iota_storage::users::user_manager::get_user(sender_id).is_none() { + self.persist_and_deliver_remote_delete(cv).await; + return; + } + + let response = message_handlers::handle_message_delete(cv); + if !response.is_type(CommunicationType::Success) { + let _ = self.send_message(&response).await; + return; + } + let Ok(mutation) = message_handlers::message_mutation(cv) else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let live = Self::mutation_live_message( + CommunicationType::MessageDeleteLive, + cv, + &mutation, + Vec::new(), + ); + if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() + && chat_files::apply_remote_delete( + mutation.partner_id, + mutation.sender_id, + mutation.send_time, + mutation.sender_id, + ) + .is_err() + { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) + .await; + return; + } + let _ = self.send_message(&response).await; + let _ = self.send_message(&live).await; } async fn handle_message_send(self: Arc, cv: &CommunicationValue) { @@ -1496,24 +1495,22 @@ impl OmikronConnection { if is_read_receipts_enabled().await { let _ = self .send_message( - &CommunicationValue::new( - CommunicationType::MessageState, - ) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), + ) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(ms.as_str().to_string()), + ), ) .await; } @@ -1531,36 +1528,31 @@ impl OmikronConnection { receiver_id, MessageState::Sent, ); - let push_msg = - CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ); + let push_msg = CommunicationValue::new(CommunicationType::PushNotification) + .with_receiver(receiver_id as u64) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + ); let _ = self.send_message(&push_msg).await; let _ = self .send_message( - &CommunicationValue::new( - CommunicationType::MessageState, - ) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str( - MessageState::Sent.as_str().to_string(), + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp_i64 as i128), + ) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(receiver_id as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(MessageState::Sent.as_str().to_string()), ), - ), ) .await; } @@ -1620,24 +1612,26 @@ impl OmikronConnection { ); if is_read_receipts_enabled().await { - let _ = self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ).await; + let _ = self + .send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*sender_id as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; } } None => { @@ -1648,524 +1642,102 @@ impl OmikronConnection { MessageState::Sent, ); - let push_msg = - CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ); + let push_msg = CommunicationValue::new(CommunicationType::PushNotification) + .with_receiver(*receiver_id) + .add_typed_default( + DataType::SenderId, + DataValue::SignedNumber(*sender_id as i128), + ); let _ = self.send_message(&push_msg).await; - let _ = self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ).await; + let _ = self + .send_message( + &CommunicationValue::new(CommunicationType::MessageState) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_typed_default( + DataType::SendTime, + DataValue::SignedNumber(timestamp as i128), + ) + .add_typed_default( + DataType::ChatPartnerId, + DataValue::SignedNumber(*receiver_id as i128), + ) + .add_typed_default( + DataType::MessageState, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; } } } async fn handle_messages_get(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); - let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages( - my_id as i64, - partner_id as i64, - offset as i64, - amount as i64, - ); - let mut msg_array: Vec = Vec::new(); - for m in &messages { - let sender_id: i64 = if m.sent_by_self { - my_id as i64 - } else { - if let Some(n) = cv.get_data(DataType::ChatPartnerId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ChatPartnerId).as_str() { - s.parse::().unwrap_or(partner_id as i64) - } else { - partner_id as i64 - } - }; - - let mut container = Vec::new(); - container.push(( - DataType::SendTime, - DataValue::SignedNumber(m.message_time as i128), - )); - container.push((DataType::Content, DataValue::Str(m.content.clone()))); - container.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - container.push((DataType::MessageState, DataValue::Str(m.message_state.clone()))); - container.push((DataType::Height, DataValue::SignedNumber(m.height as i128))); - container.push(( - DataType::SenderId, - DataValue::UnsignedNumber(if m.sent_by_self { - my_id as u128 - } else { - partner_id as u128 - }), - )); - if let Some(rt) = m.reply_to { - container.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - msg_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::MessagesGet) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Messages, DataValue::Array(msg_array)); - - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_messages_get(cv)) + .await; } async fn handle_get_chats(self: Arc, cv: &CommunicationValue) { - let user_id = cv.get_sender(); - let users = chats_util::get_users(user_id as i64); - let mut user_array = Vec::new(); - for user in users { - let mut container = Vec::new(); - container.push(( - DataType::UserId, - DataValue::SignedNumber(user.user_id as i128), - )); - if let Some(name) = user.user_name { - container.push((DataType::Username, DataValue::Str(name))); - } - if let Some(ts) = user.last_message_at { - container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); - } - user_array.push(typed_container(container)); - } - let resp = CommunicationValue::new(CommunicationType::GetChats) - .with_id(cv.get_id()) - .with_receiver(user_id) - .add_typed_default(DataType::UserIds, DataValue::Array(user_array)); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_get_chats(cv)) + .await; } async fn handle_add_conversation(self: Arc, cv: &CommunicationValue) { - let user_id = cv.get_sender(); - let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(n) => n as i64, - None => cv - .get_data(DataType::ChatPartnerId) - .as_str() - .unwrap_or("0") - .parse() - .unwrap_or(0), - }; - let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); - - if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { - contact.user_name = Some(name.to_string()); - } - - contact.set_last_message_at(now_millis_i64()); - mod_user(user_id as i64, &contact); - let resp = CommunicationValue::new(CommunicationType::AddConversation) - .with_id(cv.get_id()) - .with_receiver(user_id); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_add_conversation(cv)) + .await; } async fn handle_add_community(self: Arc, cv: &CommunicationValue) { - CommunitiesUtil::add_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::CommunityTitle) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::Position) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::AddCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_add_community(cv)) + .await; } async fn handle_get_communities(self: Arc, cv: &CommunicationValue) { - let mut comm_array = Vec::new(); - for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataType, DataValue)> = Vec::new(); - container.push(( - DataType::CommunityAddress, - DataValue::Str(c.address.clone()), - )); - container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); - container.push((DataType::Position, DataValue::Str(c.position.clone()))); - comm_array.push(typed_container(container)); - } - - let resp = CommunicationValue::new(CommunicationType::GetCommunities) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Communities, DataValue::Array(comm_array)); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_get_communities(cv)) + .await; } async fn handle_remove_community(self: Arc, cv: &CommunicationValue) { - CommunitiesUtil::remove_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - ); - let resp = CommunicationValue::new(CommunicationType::RemoveCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); - let _ = self.send_message(&resp).await; + let _ = self + .send_message(&message_handlers::handle_remove_community(cv)) + .await; } async fn handle_global_settings_save(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ); - let _ = self.send_message(&response).await; - return; - }; - - save_file( - &format!("users/{}", my_id), - "global.settings", - settings_value, - ); - - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) - .with_receiver(my_id) - .with_id(cv.get_id()); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - let _ = self.send_message(&response).await; + let _ = self + .send_message(&message_handlers::handle_global_settings_save(cv)) + .await; } async fn handle_global_settings_load(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let path = format!("users/{}", my_id); - let name = "global.settings"; - - if !has_file(&path, name) { - let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Path, DataValue::Str(name.to_string())); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - let _ = self.send_message(&response).await; - return; - } - - let settings_value_str = load_file(&path, name); - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - let _ = self.send_message(&response).await; + let _ = self + .send_message(&message_handlers::handle_global_settings_load(cv)) + .await; } async fn handle_settings_save(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - let _ = self.send_message(&response).await; - return; - }; - if session_id == 0 || session_id > 1_000_000 { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - } - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - }; - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - }; - - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - } - - save_file( - &format!("users/{}/settings/{}/", my_id, session_id), - &format!("{}.settings", settings_name), - settings_value, - ); - - let response = CommunicationValue::new(CommunicationType::SettingsSave) - .with_receiver(my_id) - .with_id(cv.get_id()) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - - let _ = self.send_message(&response).await; + let _ = self + .send_message(&message_handlers::handle_settings_save(cv, 0)) + .await; } async fn handle_settings_load(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - let _ = self.send_message(&response).await; - return; - }; - if session_id == 0 || session_id > 1_000_000 { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ); - let _ = self.send_message(&response).await; - return; - } - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - }; - - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - } - - let settings_file = format!("{}.settings", settings_name); - let settings_path = format!("users/{}/settings/{}/", my_id, session_id); - if !has_file(&settings_path, &settings_file) { - let response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - let _ = self.send_message(&response).await; - return; - } - - let settings_value_str = load_file(&settings_path, &settings_file); - let response = CommunicationValue::new(CommunicationType::SettingsLoad) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - - let _ = self.send_message(&response).await; + let _ = self + .send_message(&message_handlers::handle_settings_load(cv, 0)) + .await; } async fn handle_settings_list(self: Arc, cv: &CommunicationValue) { - let my_id = cv.get_sender(); - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - let _ = self.send_message(&response).await; - return; - }; - if session_id == 0 || session_id > 1_000_000 { - let response = CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ); - let _ = self.send_message(&response).await; - return; - } - - let settings = get_children(&format!("users/{}/settings/{}/", my_id, session_id)); - let mut settings_json = Vec::new(); - for s in settings { - let s = s.replace(".settings", ""); - if s.is_empty() { - continue; - } - let _ = settings_json.push(DataValue::Str(s)); - } - let response = CommunicationValue::new(CommunicationType::SettingsList) - .with_id(cv.get_id()) - .with_receiver(my_id) - .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - - let _ = self.send_message(&response).await; + let _ = self + .send_message(&message_handlers::handle_settings_list(cv, 0)) + .await; } // ------------------------------------------------------------------------- @@ -2310,7 +1882,9 @@ impl OmikronConnection { let result: Result<(), String> = tokio::time::timeout(timeout, async { loop { - rx.changed().await.map_err(|_| "State watch channel closed".to_string())?; + rx.changed() + .await + .map_err(|_| "State watch channel closed".to_string())?; if rx.borrow().is_connected() { return Ok(()); } @@ -2365,3 +1939,29 @@ pub async fn get_omikron_connection() -> Arc { conn.connect().await; conn } + +impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { + async fn send_message(&self, cv: &CommunicationValue) -> Result<(), String> { + OmikronConnection::send_message(self, cv).await + } + + async fn await_response( + &self, + cv: &CommunicationValue, + timeout: Option, + ) -> Result { + OmikronConnection::await_response(self, cv, timeout).await + } + + async fn is_connected(&self) -> bool { + OmikronConnection::is_connected(self).await + } + + async fn is_identified(&self) -> bool { + OmikronConnection::is_identified(self).await + } + + async fn stop(&self) { + OmikronConnection::stop(self).await + } +} diff --git a/type-maps.yaml b/type-maps.yaml index dddd7a2..ce3d5e6 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -148,6 +148,7 @@ type_maps: MessageReactionAdd: 146 MessageReactionRemove: 147 MessageReactionLive: 148 + MessageDeleteLive: 150 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -169,7 +170,7 @@ type_maps: CallState: 49 ScreenShare: 50 PrivateKeyHash: 51 - Accepted: 52 + # Accepted: 52 now part of default MTP AcceptedProfiles: 53 DeniedProfiles: 54 Content: 55 diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index 74de956..9827408 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,4 +5,10 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } +bytes = "1" +http = "1" +iota-state = { path = "../iota-state" } +iota-util = { path = "../iota-util" } +iota-logger = { path = "../iota-logger" } +tokio = { version = "1.50.0", features = ["full"] } diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index e886571..4dc18ab 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -1,6 +1,133 @@ -// The web server is a TTP host & identification system, -// it "upgrades" connections after identification to -// -// either Own User (Cut down version of the Omikron Connection), -// or Community (Custom Connection), -// or Iota (Custom Connection). +use bytes::Bytes; +use iota_logger::log; +use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_util::file_util::load_file_vec; +use mtp::host::HostConfig; +use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig}; +use std::net::{IpAddr, Ipv4Addr}; +use tokio::time::{Duration, sleep}; + +const CERT_PATH: &str = "certs/cert.pem"; +const KEY_PATH: &str = "certs/cert.key"; + +async fn root(_request: Http3Request, response: Http3Response) -> Http3Response { + static_file("index.html", response).await +} + +async fn static_file(path: &str, response: Http3Response) -> Http3Response { + let file = path.trim_start_matches('/'); + let file = if file.is_empty() { "index.html" } else { file }; + + if file.split('/').any(|component| component == "..") { + return response + .status(http::StatusCode::BAD_REQUEST) + .body("invalid path"); + } + + let path = std::path::Path::new("web").join(file); + let Some(parent) = path.parent().and_then(|path| path.to_str()) else { + return response + .status(http::StatusCode::NOT_FOUND) + .body("not found"); + }; + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return response + .status(http::StatusCode::NOT_FOUND) + .body("not found"); + }; + + match load_file_vec(parent, name) { + Ok(body) => response + .status(http::StatusCode::OK) + .header("content-type", content_type(name)) + .body(Bytes::from(body)), + Err(_) => response + .status(http::StatusCode::NOT_FOUND) + .body("not found"), + } +} + +fn content_type(name: &str) -> &'static str { + match std::path::Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "application/javascript; charset=utf-8", + Some("json") => "application/json", + Some("png") => "image/png", + Some("ico") => "image/x-icon", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + +pub async fn start(port: u16) -> bool { + let certificate = match tokio::fs::read(CERT_PATH).await { + Ok(certificate) => certificate, + Err(error) => { + log!("MTP web server certificate load failed: {}", error); + return false; + } + }; + let key = match tokio::fs::read(KEY_PATH).await { + Ok(key) => key, + Err(error) => { + log!("MTP web server key load failed: {}", error); + return false; + } + }; + + let host_config = HostConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port, certificate, key); + let web_config = match WebServerConfig::new().route("/", root).and_then(|config| { + config.fallback(|request, response| async move { + static_file(request.uri.path(), response).await + }) + }) { + Ok(config) => config, + Err(error) => { + log!("MTP web server route setup failed: {}", error); + return false; + } + }; + + let mut server = match MTPWebServer::new(host_config, web_config).await { + Ok(server) => server, + Err(error) => { + log!("MTP web server startup failed: {}", error); + return false; + } + }; + + log!("MTP web server running on port {}", port); + tokio::spawn(async move { + ACTIVE_TASKS.insert("WebServer".into()); + loop { + tokio::select! { + result = server.accept() => { + match result { + Ok(Some(_connection)) => {} + Ok(None) => break, + Err(error) => log!("MTP webserver connection failed: {}", error), + } + } + _ = wait_for_shutdown() => { + server.shutdown().await; + break; + } + } + } + ACTIVE_TASKS.remove("WebServer"); + }); + true +} + +async fn wait_for_shutdown() { + loop { + if *SHUTDOWN.read().await { + break; + } + sleep(Duration::from_millis(100)).await; + } +} From 36a70e82a0fdaa03eb1ccf7d358781477893bd0f Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:40:33 +0200 Subject: [PATCH 082/119] [Feat] Split Daemon & TUI --- Cargo.lock | 56 ++++++++---- Cargo.toml | 5 +- flake.lock | 43 +++------- flake.nix | 73 ++-------------- iota-cli/Cargo.toml | 21 +++-- iota-cli/src/elements/console_card.rs | 35 ++++---- iota-cli/src/elements/graph_card.rs | 34 ++++++-- iota-cli/src/elements/log_card.rs | 89 +++++-------------- iota-cli/src/input_handler.rs | 11 +-- iota-cli/src/ipc_client.rs | 83 ++++++++++++++++++ iota-cli/src/lib.rs | 1 + iota-cli/src/screens/main_screen.rs | 11 +-- iota-cli/src/ui.rs | 47 +++++++--- iota-core/src/main.rs | 7 +- iota-daemon-lib/Cargo.toml | 15 ++++ iota-daemon-lib/src/command_router.rs | 114 +++++++++++++++++++++++++ iota-daemon-lib/src/daemon_state.rs | 72 ++++++++++++++++ iota-daemon-lib/src/ipc_server.rs | 113 ++++++++++++++++++++++++ iota-daemon-lib/src/lib.rs | 8 ++ iota-daemon-lib/src/log_broadcaster.rs | 21 +++++ iota-daemon/Cargo.toml | 14 +++ iota-daemon/src/main.rs | 58 +++++++++++++ iota-ipc/Cargo.toml | 9 ++ iota-ipc/src/lib.rs | 5 ++ iota-ipc/src/protocol.rs | 42 +++++++++ iota-ipc/src/transport.rs | 59 +++++++++++++ iota-logger/Cargo.toml | 1 + iota-logger/src/lib.rs | 19 ++++- iota-state/Cargo.toml | 5 ++ iota-state/src/lib.rs | 65 +++++++++++++- iota-util/src/crypto_util.rs | 10 ++- iota/Cargo.toml | 8 ++ iota/src/main.rs | 29 +++++++ systemd/iota-daemon.service | 16 ++++ systemd/iota-daemon.socket | 12 +++ 35 files changed, 971 insertions(+), 240 deletions(-) create mode 100644 iota-cli/src/ipc_client.rs create mode 100644 iota-daemon-lib/Cargo.toml create mode 100644 iota-daemon-lib/src/command_router.rs create mode 100644 iota-daemon-lib/src/daemon_state.rs create mode 100644 iota-daemon-lib/src/ipc_server.rs create mode 100644 iota-daemon-lib/src/lib.rs create mode 100644 iota-daemon-lib/src/log_broadcaster.rs create mode 100644 iota-daemon/Cargo.toml create mode 100644 iota-daemon/src/main.rs create mode 100644 iota-ipc/Cargo.toml create mode 100644 iota-ipc/src/lib.rs create mode 100644 iota-ipc/src/protocol.rs create mode 100644 iota-ipc/src/transport.rs create mode 100644 iota/Cargo.toml create mode 100644 iota/src/main.rs create mode 100644 systemd/iota-daemon.service create mode 100644 systemd/iota-daemon.socket diff --git a/Cargo.lock b/Cargo.lock index 04277be..5329cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2085,6 +2085,14 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "iota" +version = "0.1.0" +dependencies = [ + "iota-cli", + "tokio", +] + [[package]] name = "iota-auth" version = "0.1.0" @@ -2151,6 +2159,7 @@ dependencies = [ "hkdf 0.12.4", "hyper", "hyper-util", + "iota-ipc", "iota-logger", "iota-state", "iota-storage", @@ -2178,7 +2187,6 @@ dependencies = [ "tokio", "tokio-tungstenite", "tungstenite", - "uuid", "walkdir", "warp", "x448", @@ -2195,27 +2203,41 @@ dependencies = [ ] [[package]] -name = "iota-core" +name = "iota-daemon" version = "0.1.0" dependencies = [ - "dashmap", - "iota-cli", + "iota-daemon-lib", + "iota-ipc", "iota-logger", "iota-state", "iota-storage", - "iota-terms", - "iota-updater", - "iota-util", - "json", - "mtp", "omikron-connector", - "once_cell", - "pnet", - "ratatui", - "reqwest", "tokio", "web-server", - "web-ui", +] + +[[package]] +name = "iota-daemon-lib" +version = "0.1.0" +dependencies = [ + "iota-ipc", + "iota-logger", + "iota-state", + "iota-storage", + "iota-util", + "mtp", + "omikron-connector", + "sysinfo", + "tokio", +] + +[[package]] +name = "iota-ipc" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", ] [[package]] @@ -2228,6 +2250,7 @@ dependencies = [ "mtp", "once_cell", "ratatui", + "tokio", ] [[package]] @@ -2238,6 +2261,7 @@ dependencies = [ "json", "mtp", "once_cell", + "serde", "sysinfo", "tokio", ] @@ -4805,9 +4829,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", diff --git a/Cargo.toml b/Cargo.toml index ff17473..ecd2457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,10 @@ members = [ "iota-terms", "iota-state", "iota-cli", - "iota-core", + "iota", + "iota-daemon", + "iota-daemon-lib", + "iota-ipc", "omikron-connector", "web-server", "web-ui", diff --git a/flake.lock b/flake.lock index 0d30539..3042264 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1778716662, - "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780243769, - "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", "type": "github" }, "original": { @@ -36,11 +36,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1777168982, - "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "lastModified": 1782614948, + "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", "type": "github" }, "original": { @@ -53,8 +53,7 @@ "inputs": { "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay", - "ttp": "ttp" + "rust-overlay": "rust-overlay" } }, "rust-overlay": { @@ -64,11 +63,11 @@ ] }, "locked": { - "lastModified": 1780543271, - "narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=", + "lastModified": 1784526465, + "narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514", + "rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b", "type": "github" }, "original": { @@ -76,22 +75,6 @@ "repo": "rust-overlay", "type": "github" } - }, - "ttp": { - "flake": false, - "locked": { - "lastModified": 1780494955, - "narHash": "sha256-i2VRRF6yNips3c4JHgfvmvMxb0HTkTCn69lmsKZLHRw=", - "ref": "refs/heads/main", - "rev": "23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2", - "revCount": 125, - "type": "git", - "url": "https://git.methanium.net/tensamin/ttp.git" - }, - "original": { - "type": "git", - "url": "https://git.methanium.net/tensamin/ttp.git" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 8cf53d7..607fb77 100644 --- a/flake.nix +++ b/flake.nix @@ -8,10 +8,6 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; - ttp = { - url = "git+https://git.methanium.net/tensamin/ttp.git"; - flake = false; - }; }; outputs = inputs @ { @@ -19,7 +15,6 @@ nixpkgs, flake-parts, rust-overlay, - ttp, ... }: flake-parts.lib.mkFlake {inherit inputs;} { @@ -47,9 +42,10 @@ packages = { default = self'.packages.iota; iota = pkgs.rustPlatform.buildRustPackage { - pname = "iota"; + pname = "iota-daemon"; version = "0.1.0"; src = ./.; + cargoBuildFlags = ["-p" "iota-daemon"]; cargoLock = { lockFile = ./Cargo.lock; allowBuiltinFetchGit = true; @@ -57,15 +53,9 @@ nativeBuildInputs = with pkgs; [cmake perl pkg-config]; buildInputs = with pkgs; [openssl sqlite]; dontUseCmakeConfigure = true; - preConfigure = '' - if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then - cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json - fi - ''; postInstall = '' - mv $out/bin/iota-core $out/bin/iota for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota" ]; then + if [ "$(basename "$f")" != "iota-daemon" ]; then rm "$f" fi done @@ -95,8 +85,7 @@ then cfg.settingsFile else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); - descriptionText = "Tensamin Iota"; - #+ lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)"; + descriptionText = "Tensamin Iota daemon"; in { options.services.iota = { enable = lib.mkEnableOption "Enable the Iota service."; @@ -132,12 +121,6 @@ description = "Whether to open the firewall for ports used by Iota."; }; - ttpBind = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the TTP/QUIC server to."; - }; - bindAddress = lib.mkOption { type = lib.types.str; default = "0.0.0.0"; @@ -150,12 +133,6 @@ description = "The Iota package to use."; }; - useTmux = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to run Iota inside a tmux session for shared TUI access."; - }; - settings = lib.mkOption { type = lib.types.attrs; default = {}; @@ -181,19 +158,7 @@ users.groups.iota = {}; - systemd.services.iota = let - iotaTmuxCmd = pkgs.writeShellScript "iota-tmux-cmd" '' - mkdir -p ${cfg.dataDir} - echo "[$(date)] Running Iota..." - ${cfg.package}/bin/iota - status=$? - echo "" - echo "[$(date)] Iota exited with status: $status" - echo "Press any key to exit..." - read -r -n 1 - exit $status - ''; - in { + systemd.services.iota = { description = descriptionText; wantedBy = ["multi-user.target"]; after = ["network.target"]; @@ -205,29 +170,7 @@ Group = "iota"; WorkingDirectory = cfg.dataDir; - ExecStart = - if cfg.useTmux - then - pkgs.writeShellScript "iota-start" '' - set -e - export TMUX_TMPDIR=${cfg.dataDir} - ${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDir} - ${pkgs.coreutils}/bin/chown iota:iota ${cfg.dataDir} - - echo "[iota-start] Creating tmux session..." - if ! ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota "${iotaTmuxCmd}"; then - echo "[iota-start] ERROR: tmux new-session failed" - exit 1 - fi - echo "[iota-start] tmux session created, waiting..." - echo "[iota-start] Run 'tmux -S ${cfg.dataDir}/tmux.sock attach -t iota' to attach to the tmux session." - - while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do - sleep 2 - done - echo "[iota-start] tmux session ended" - '' - else "${cfg.package}/bin/iota"; + ExecStart = "${cfg.package}/bin/iota-daemon"; ExecStartPre = [ ("+" @@ -245,6 +188,8 @@ Restart = "always"; RestartSec = "5s"; + RuntimeDirectory = "iota"; + RuntimeDirectoryMode = "0750"; AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; @@ -262,8 +207,8 @@ LockPersonality = true; MemoryDenyWriteExecute = true; Environment = [ - "TTP_BIND=${cfg.ttpBind}" "BIND_ADDRESS=${cfg.bindAddress}" + "IOTA_SOCKET=/run/iota/iota.sock" ]; } // lib.optionalAttrs (cfg.environmentFiles != []) { diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 9fa889f..2c5f868 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -3,16 +3,26 @@ name = "iota-cli" version = "0.1.0" edition = "2024" +[features] +legacy-commands = [ + "dep:iota-logger", + "dep:iota-storage", + "dep:iota-util", + "dep:mtp", + "dep:omikron-connector", +] + [dependencies] -iota-logger = { path = "../iota-logger" } +iota-logger = { path = "../iota-logger", optional = true } iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage" } +iota-storage = { path = "../iota-storage", optional = true } iota-terms = { path = "../iota-terms" } -iota-util = { path = "../iota-util" } -omikron-connector = { path = "../omikron-connector" } + iota-util = { path = "../iota-util", optional = true } +iota-ipc = { path = "../iota-ipc" } +omikron-connector = { path = "../omikron-connector", optional = true } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } actix-web = { version = "4", features = ["rustls-0_23"] } actix-web-actors = "4" @@ -56,7 +66,6 @@ sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" -uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" warp = "*" x448 = { version = "*" } diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 820ae1e..5ece1d9 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,10 +1,17 @@ use crossterm::event::{KeyCode, KeyEvent}; -use iota_logger::{log, log_command, log_cv}; +#[cfg(feature = "legacy-commands")] +use iota_logger::{log, log_cv}; +#[cfg(feature = "legacy-commands")] use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; +#[cfg(feature = "legacy-commands")] use iota_storage::users::{user_manager, user_profile::UserProfile}; +#[cfg(feature = "legacy-commands")] use iota_storage::util::config_util::modify_config; +#[cfg(feature = "legacy-commands")] use iota_util::file_util; +#[cfg(feature = "legacy-commands")] use mtp::codec::{CommunicationType, CommunicationValue}; +#[cfg(feature = "legacy-commands")] use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ Frame, @@ -13,7 +20,6 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use uuid::Uuid; use std::{ any::Any, @@ -25,11 +31,12 @@ use tokio::time::Instant; use crate::{ elements::elements::{Element, InteractableElement, JoinableElement}, interaction_result::InteractionResult, - ui::FPS, + ipc_client::IpcClient, util::borders::draw_block_joins, }; pub struct ConsoleCard { + ipc: Arc, focused: bool, pub title: String, pub content: String, @@ -44,8 +51,9 @@ pub struct ConsoleCard { } impl ConsoleCard { - pub fn new(title: &str, content: &str) -> Self { + pub fn new(title: &str, content: &str, ipc: Arc) -> Self { ConsoleCard { + ipc, focused: false, title: title.to_string(), content: content.to_string(), @@ -290,22 +298,17 @@ impl InteractableElement for ConsoleCard { match key.code { KeyCode::Enter => { if self.content.is_empty() { - log!(""); return InteractionResult::Handled; } let command = self.content.clone(); - let id = Uuid::new_v4(); - let id = id.to_string(); - let id = id.split_at(8).0; - let task_id = format!("command_{}_{}", command, id); - ACTIVE_TASKS.insert(task_id.clone()); - - log_command!("{}", command); - + let ipc = self.ipc.clone(); + let seq = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; tokio::spawn(async move { - run_command(&command).await; - ACTIVE_TASKS.remove(&task_id); + let _ = ipc.send_command(seq, command).await; }); self.content.clear(); @@ -379,6 +382,7 @@ impl InteractableElement for ConsoleCard { } } +#[cfg(feature = "legacy-commands")] pub async fn run_command(command: &str) { let parts = command.split(" ").collect::>(); @@ -503,6 +507,7 @@ pub async fn run_command(command: &str) { } } +#[cfg(feature = "legacy-commands")] pub async fn ping(time: u64) { let conn = OMIKRON_CONNECTION.clone(); diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index c7ec2d6..178ffaa 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -1,7 +1,7 @@ use std::{any::Any, sync::Arc}; use crossterm::event::KeyEvent; -use iota_state::APP_STATE; +use iota_state::ClientState; use ratatui::{ Frame, layout::Rect, @@ -34,11 +34,29 @@ impl GRAPHS { } } - pub fn get_graph(&self) -> Vec<(f64, f64)> { + pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> { match self { - GRAPHS::Ram => APP_STATE.lock().unwrap().with_width(28).ram.clone(), - GRAPHS::Cpu => APP_STATE.lock().unwrap().with_width(28).cpu.clone(), - GRAPHS::Ping => APP_STATE.lock().unwrap().with_width(28).ping.clone(), + GRAPHS::Ram => state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()) + .with_width(28) + .ram + .clone(), + GRAPHS::Cpu => state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()) + .with_width(28) + .cpu + .clone(), + GRAPHS::Ping => state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()) + .with_width(28) + .ping + .clone(), } } @@ -54,6 +72,7 @@ impl GRAPHS { #[allow(unused)] pub struct GraphCard { ui: Arc, + state: ClientState, graph_type: GRAPHS, focused: bool, @@ -66,9 +85,10 @@ pub struct GraphCard { } impl GraphCard { - pub fn new(ui: Arc, graph_type: GRAPHS, title: String) -> Self { + pub fn new(ui: Arc, state: ClientState, graph_type: GRAPHS, title: String) -> Self { Self { ui, + state, graph_type, focused: false, title, @@ -93,7 +113,7 @@ impl Element for GraphCard { fn render(&self, f: &mut Frame, r: Rect) { if self.open { - let graph = self.graph_type.get_graph(); + let graph = self.graph_type.get_graph(&self.state); let unit = self.graph_type.get_unit(); let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index a0ce8fd..c415ba9 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,9 +1,8 @@ -use crate::app_state::APP_STATE; use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::interaction_result::InteractionResult; use crate::util::borders::draw_block_joins; use crossterm::event::{KeyCode, KeyEvent}; -use iota_logger::PrintType; +use iota_state::{ClientState, UiLogEntry}; use ratatui::{ Frame, layout::Rect, @@ -12,60 +11,9 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; use std::any::Any; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Clone, Debug)] -pub struct UiLogEntry { - pub timestamp_ms: u128, - pub sender: PrintType, - pub message: String, - pub is_error: bool, -} - -impl UiLogEntry { - pub fn format_timestamp(&self) -> String { - let secs = (self.timestamp_ms / 1000) as i64; - let hours = (secs / 3600) % 24; - let minutes = (secs / 60) % 60; - let seconds = secs % 60; - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) - } -} - -impl From for UiLogEntry { - fn from(entry: LogEntry) -> Self { - Self { - timestamp_ms: entry.timestamp_ms, - sender: entry.sender, - message: entry.message, - is_error: entry.is_error, - } - } -} - -#[derive(Clone, Debug)] -pub struct LogEntry { - pub timestamp_ms: u128, - pub sender: PrintType, - pub message: String, - pub is_error: bool, -} - -impl LogEntry { - pub fn new(sender: PrintType, message: String, is_error: bool) -> Self { - Self { - timestamp_ms: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(), - sender, - message, - is_error, - } - } -} pub struct LogCard { + state: ClientState, focused: bool, selected: bool, scroll_offset: usize, @@ -76,8 +24,9 @@ pub struct LogCard { } impl LogCard { - pub fn new() -> Self { + pub fn new(state: ClientState) -> Self { Self { + state, focused: false, selected: false, scroll_offset: 0, @@ -89,21 +38,17 @@ impl LogCard { } fn get_logs(&self) -> Vec { - let state = APP_STATE.lock().unwrap(); + let state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); state .get_logs() .iter() .map(|e| UiLogEntry { timestamp_ms: e.timestamp_ms, - sender: match e.sender.as_str() { - "Call" => PrintType::Call, - "Client" => PrintType::Client, - "Iota" => PrintType::Iota, - "Omikron" => PrintType::Omikron, - "Omega" => PrintType::Omega, - "Command" => PrintType::Command, - _ => PrintType::General, - }, + sender: e.sender.clone(), message: e.message.clone(), is_error: e.is_error, }) @@ -196,12 +141,24 @@ impl LogCard { line.push_str(×tamp); } - result.push((line, entry.sender.prefix_color(), entry.is_error)); + result.push((line, Self::sender_color(&entry.sender), entry.is_error)); } result } + fn sender_color(sender: &str) -> Color { + match sender { + "Call" => Color::Magenta, + "Client" => Color::Green, + "Iota" => Color::Yellow, + "Omikron" => Color::Blue, + "Omega" => Color::Cyan, + "Command" => Color::LightGreen, + _ => Color::LightCyan, + } + } + fn build_all_lines( &self, entries: Vec, diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 524ce2e..5cdf4fa 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,14 +1,12 @@ use crate::ui::UI; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; -use iota_state::{RELOAD, SHUTDOWN, UNIQUE}; use std::sync::Arc; -use std::sync::atomic::Ordering; use std::time::Duration; pub fn setup_input_handler(ui: Arc) { tokio::spawn(async move { loop { - if *SHUTDOWN.read().await { + if ui.is_shutdown() { break; } @@ -27,7 +25,6 @@ pub fn setup_input_handler(ui: Arc) { match event_result { Ok(Some(key_event)) => { handle_input(key_event, ui.clone()).await; - UNIQUE.store(true, Ordering::Relaxed); } Ok(_) => {} Err(e) => { @@ -43,11 +40,11 @@ pub async fn handle_input(key: KeyEvent, ui: Arc) { match (key.code, key.modifiers) { (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { - *SHUTDOWN.write().await = true; + ui.request_shutdown(); } (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { - *RELOAD.write().await = true; - *SHUTDOWN.write().await = true; + let _ = ui.send_restart().await; + ui.request_shutdown(); } _ => { ui.handle_input(key).await; diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs new file mode 100644 index 0000000..c594f7e --- /dev/null +++ b/iota-cli/src/ipc_client.rs @@ -0,0 +1,83 @@ +use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; +use iota_state::{ClientState, UiLogEntry}; +use std::io::Result; +use std::path::Path; +use std::sync::Arc; +use tokio::net::UnixStream; +use tokio::net::unix::OwnedWriteHalf; +use tokio::sync::Mutex; + +/* The TUI owns this cache. IPC updates replace daemon snapshots and append + * logs, so rendering never reaches into daemon-owned storage or connections. */ +pub struct IpcClient { + state: ClientState, + writer: Mutex, +} + +impl IpcClient { + pub async fn connect(path: impl AsRef) -> Result> { + let stream = UnixStream::connect(path).await?; + let (mut reader, writer) = stream.into_split(); + let client = Arc::new(Self { + state: ClientState::new(), + writer: Mutex::new(writer), + }); + let reader_client = client.clone(); + tokio::spawn(async move { + while let Ok(message) = read_msg::<_, DaemonMessage>(&mut reader).await { + reader_client.apply(message).await; + } + }); + client.send(ClientMessage::Subscribe).await?; + Ok(client) + } + + pub fn state(&self) -> ClientState { + self.state.clone() + } + + pub async fn send_command(&self, seq: u64, line: String) -> Result<()> { + self.send(ClientMessage::Command { seq, line }).await + } + + async fn send(&self, message: ClientMessage) -> Result<()> { + let mut writer = self.writer.lock().await; + write_msg(&mut *writer, &message).await + } + + async fn apply(&self, message: DaemonMessage) { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + match message { + DaemonMessage::LogEntry(entry) => state.push_log(UiLogEntry { + timestamp_ms: entry.timestamp_ms, + sender: entry.sender, + message: entry.message, + is_error: entry.is_error, + }), + DaemonMessage::StateUpdate(snapshot) => { + state.cpu = snapshot.cpu; + state.ram = snapshot.ram; + state.ping = snapshot.ping; + state.net_up = snapshot.net_up; + state.net_down = snapshot.net_down; + state.sys_info = snapshot.sys_info; + } + DaemonMessage::CommandResult { + success, message, .. + } => state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Command".into(), + message, + is_error: !success, + }), + DaemonMessage::Pong { .. } => {} + } + } +} diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index aaa23ef..6986d72 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -18,5 +18,6 @@ pub mod util { } pub mod app_state; pub mod input_handler; +pub mod ipc_client; pub mod interaction_result; pub mod ui; diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index e10947d..576afeb 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -36,22 +36,23 @@ impl MainScreen { vec![Some(1), Some(4)], ]; - let mut log_card = LogCard::new(); + let state = ui.client_state(); + let mut log_card = LogCard::new(state.clone()); log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); - let mut console_card = ConsoleCard::new("Console", ""); + let mut console_card = ConsoleCard::new("Console", "", ui.ipc()); console_card.set_joins(Borders::TOP); elements.push(Box::new(log_card)); elements.push(Box::new(console_card)); - let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM".into()); + let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()); ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); elements.push(Box::new(ram_graph)); - let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU".into()); + let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()); cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); cpu_graph.set_joins(Borders::TOP); elements.push(Box::new(cpu_graph)); - let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into()); + let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into()); ping_graph.set_joins(Borders::TOP); elements.push(Box::new(ping_graph)); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index a44ee74..f298a92 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,15 +1,17 @@ use crate::{ input_handler::setup_input_handler, interaction_result::InteractionResult, - screens::screens::Screen, + ipc_client::IpcClient, screens::screens::Screen, }; use crossterm::event::KeyEvent; -use iota_state::{ACTIVE_TASKS, SHUTDOWN, UNIQUE}; use once_cell::sync::Lazy; use ratatui::{Terminal, backend::CrosstermBackend, init}; use std::{ collections::VecDeque, io::Stdout, - sync::{Arc, Mutex, atomic::Ordering}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; use tokio::{sync::RwLock, time::Instant}; @@ -19,14 +21,15 @@ use tokio::{sync::RwLock, time::Instant}; pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); pub struct UI { + ipc: Arc, + shutdown: AtomicBool, pub terminal: Arc>>>, screen_stack: Arc>>>, } -pub fn start_tui() -> Arc { - let ui = Arc::new(UI::new()); +pub fn start_tui(ipc: Arc) -> Arc { + let ui = Arc::new(UI::new(ipc)); let uic = ui.clone(); - ACTIVE_TASKS.insert("UI Renderer".to_string()); tokio::spawn(async move { let mut last_render = Instant::now(); @@ -39,11 +42,11 @@ pub fn start_tui() -> Arc { let mut skipped = 0; loop { - if *SHUTDOWN.read().await { + if uic.is_shutdown() { break; } - if skipped > 5 || UNIQUE.load(Ordering::Relaxed) { + if skipped > 5 { uic.render().await; skip_samples.push_back(skipped); @@ -88,27 +91,47 @@ pub fn start_tui() -> Arc { *FPS.write().await = (avg_fps, avg_skips_percentage); last_render = Instant::now(); - UNIQUE.store(false, Ordering::Relaxed); } else { skipped += 1; } tokio::time::sleep(Duration::from_millis(16)).await; } - ACTIVE_TASKS.remove("UI Renderer"); ratatui::restore(); }); setup_input_handler(ui.clone()); ui } impl UI { - pub fn new() -> Self { + pub fn new(ipc: Arc) -> Self { let terminal = init(); Self { + ipc, + shutdown: AtomicBool::new(false), terminal: Arc::new(Mutex::new(terminal)), screen_stack: Arc::new(RwLock::new(Vec::new())), } } + pub fn ipc(&self) -> Arc { + self.ipc.clone() + } + + pub fn client_state(&self) -> iota_state::ClientState { + self.ipc.state() + } + + pub fn is_shutdown(&self) -> bool { + self.shutdown.load(Ordering::Relaxed) + } + + pub fn request_shutdown(&self) { + self.shutdown.store(true, Ordering::Relaxed); + } + + pub async fn send_restart(&self) -> std::io::Result<()> { + self.ipc.send_command(0, "restart".into()).await + } + pub async fn set_screen(&self, screen: Box) { self.screen_stack.write().await.push(screen); } @@ -140,7 +163,7 @@ impl UI { stack.pop(); if stack.is_empty() { - *SHUTDOWN.write().await = true; + self.request_shutdown(); } } InteractionResult::Handled => {} diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index fd794e6..b0a3bb6 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -6,7 +6,7 @@ use tokio::time::{Duration, sleep}; use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; use iota_cli::screens::main_screen::MainScreen; -use iota_cli::ui::start_tui; +use iota_cli::{ipc_client::IpcClient, ui::start_tui}; use iota_logger::{self as logger, language_creator}; use iota_logger::{log, log_t}; use iota_storage::users::user_manager; @@ -22,7 +22,10 @@ async fn main() { *RELOAD.write().await = false; *SHUTDOWN.write().await = false; - let ui = start_tui(); + let ipc = IpcClient::connect("/run/iota/iota.sock") + .await + .expect("iota-daemon must be running before starting iota-core"); + let ui = start_tui(ipc); let (eula, tos_pp) = match consent_state::check(ui.clone()).await { Ok(v) => v, diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml new file mode 100644 index 0000000..abb2245 --- /dev/null +++ b/iota-daemon-lib/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "iota-daemon-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-ipc = { path = "../iota-ipc" } +iota-logger = { path = "../iota-logger" } +iota-state = { path = "../iota-state" } +iota-storage = { path = "../iota-storage" } +iota-util = { path = "../iota-util" } +omikron-connector = { path = "../omikron-connector" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs new file mode 100644 index 0000000..11e2ddd --- /dev/null +++ b/iota-daemon-lib/src/command_router.rs @@ -0,0 +1,114 @@ +use crate::DaemonRuntime; +use iota_ipc::DaemonMessage; +use iota_logger::{log, log_command}; +use iota_storage::users::user_manager; +use iota_storage::util::config_util::modify_config; +use mtp::codec::{CommunicationType, CommunicationValue}; +use omikron_connector::omikron_connection::OMIKRON_CONNECTION; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub struct CommandRouter { + runtime: Arc, +} + +impl CommandRouter { + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } + + pub async fn route(&self, seq: u64, line: String) -> DaemonMessage { + log_command!("{}", line); + let result = self.execute(&line).await; + DaemonMessage::CommandResult { + seq, + success: result.is_ok(), + message: result.unwrap_or_else(|error| error), + } + } + + async fn execute(&self, line: &str) -> Result { + let parts = line + .trim_start_matches('/') + .split_whitespace() + .collect::>(); + match parts.as_slice() { + ["tasks"] => Ok(self + .runtime + .state + .active_tasks + .iter() + .map(|task| task.to_string()) + .collect::>() + .join(", ")), + ["help"] => Ok( + "Available commands: tasks, ping, user, reconnect, regenerate, reload, shutdown" + .into(), + ), + ["ping"] => self.ping(20).await, + ["ping", seconds] => self.ping(seconds.parse::().unwrap_or(20)).await, + ["user", "add", username] => { + let (user, _) = omikron_connector::user_ops::create_user(username).await; + user.map(|user| format!("Created user {}", user.user_id)) + .ok_or_else(|| "User creation failed".into()) + } + ["user", "remove", username] => { + let user = user_manager::get_user_by_username(username) + .ok_or_else(|| "Username does not exist".to_string())?; + let message = CommunicationValue::new(CommunicationType::DeleteUser) + .with_sender(user.user_id as u64); + OMIKRON_CONNECTION + .send_message(&message) + .await + .map_err(|error| error.to_string())?; + user_manager::remove_user(user.user_id); + Ok(format!("Removed user {}", user.user_id)) + } + ["user", "list"] => Ok(user_manager::get_users() + .into_iter() + .map(|user| format!("{} ({})", user.username, user.user_id)) + .collect::>() + .join("\n")), + ["reconnect"] => { + OMIKRON_CONNECTION.reconnect().await; + Ok("Reconnected to Omikron server".into()) + } + ["regenerate", "keys"] => { + modify_config(|config| { + config.public_key = None; + config.private_key = None; + config.iota_id = None; + }); + OMIKRON_CONNECTION.reconnect().await; + Ok("Key pair regenerated and Omikron reconnection requested".into()) + } + ["reload"] | ["restart"] => { + *self.runtime.state.reload.write().await = true; + *self.runtime.state.shutdown.write().await = true; + Ok("Daemon restart requested".into()) + } + ["shutdown"] | ["stop"] => { + *self.runtime.state.shutdown.write().await = true; + Ok("Daemon shutdown requested".into()) + } + _ => Err("Unknown command".into()), + } + } + + async fn ping(&self, seconds: u64) -> Result { + let response = OMIKRON_CONNECTION + .await_response( + &CommunicationValue::new(CommunicationType::Ping), + Some(Duration::from_secs(seconds)), + ) + .await; + match response { + Ok(value) => { + log!("{}", iota_logger::format_cv(&value)); + Ok("Ping response received".into()) + } + Err(error) => Err(format!("Ping error: {error:?}")), + } + } +} diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs new file mode 100644 index 0000000..ef26e63 --- /dev/null +++ b/iota-daemon-lib/src/daemon_state.rs @@ -0,0 +1,72 @@ +use iota_ipc::StateSnapshot; +use iota_state::DaemonState; +use std::sync::Arc; +use std::time::Duration; +use sysinfo::{RefreshKind, System}; + +/* This wrapper exposes daemon state as IPC-safe snapshots while preserving a + * single owned state instance for all daemon subsystems. */ +#[derive(Clone, Default)] +pub struct DaemonRuntime { + pub state: Arc, +} + +impl DaemonRuntime { + pub fn new() -> Self { + Self { + state: Arc::new(DaemonState::new()), + } + } + + pub fn snapshot(&self) -> StateSnapshot { + let state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + StateSnapshot { + cpu: state.cpu.clone(), + ram: state.ram.clone(), + ping: state.ping.clone(), + net_up: state.net_up.clone(), + net_down: state.net_down.clone(), + sys_info: state.sys_info.clone(), + } + } + + pub fn spawn_system_monitor(&self) { + let runtime = self.clone(); + tokio::spawn(async move { + runtime.state.active_tasks.insert("System monitor".into()); + let mut system = System::new_with_specifics(RefreshKind::everything()); + let mut counter = 0.0; + loop { + if *runtime.state.shutdown.read().await { + break; + } + system.refresh_cpu_all(); + system.refresh_memory(); + let cpu = system.global_cpu_usage() as f64; + let total_memory = system.total_memory(); + let ram = if total_memory == 0 { + 0.0 + } else { + system.used_memory() as f64 / total_memory as f64 * 100.0 + }; + { + let mut state = runtime + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_cpu((counter, cpu)); + state.push_ram((counter, ram)); + state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); + } + counter += 1.0; + tokio::time::sleep(Duration::from_millis(500)).await; + } + runtime.state.active_tasks.remove("System monitor"); + }); + } +} diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs new file mode 100644 index 0000000..11f62f4 --- /dev/null +++ b/iota-daemon-lib/src/ipc_server.rs @@ -0,0 +1,113 @@ +use crate::{CommandRouter, DaemonRuntime}; +use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; +use std::io::Result; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{env, os::fd::FromRawFd}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::broadcast; + +pub struct IpcServer { + path: PathBuf, + runtime: Arc, + messages: broadcast::Sender, +} + +impl IpcServer { + pub fn new( + path: impl Into, + runtime: Arc, + messages: broadcast::Sender, + ) -> Self { + Self { + path: path.into(), + runtime, + messages, + } + } + + pub async fn run(self) -> Result<()> { + if let Some(parent) = self.path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let listener = match activated_listener()? { + Some(listener) => listener, + None => { + remove_stale_socket(&self.path).await?; + UnixListener::bind(&self.path)? + } + }; + loop { + let (stream, _) = listener.accept().await?; + let runtime = self.runtime.clone(); + let messages = self.messages.clone(); + tokio::spawn(async move { + let _ = handle_client(stream, runtime, messages).await; + }); + } + } +} + +/* systemd hands the first socket-activated file descriptor to the service as + * descriptor 3. Manual launches continue to bind the configured socket path. */ +fn activated_listener() -> Result> { + let listen_fds = env::var("LISTEN_FDS") + .ok() + .and_then(|value| value.parse::().ok()); + let listen_pid = env::var("LISTEN_PID") + .ok() + .and_then(|value| value.parse::().ok()); + if listen_fds != Some(1) || listen_pid != Some(std::process::id()) { + return Ok(None); + } + let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) }; + UnixListener::from_std(listener).map(Some) +} + +async fn remove_stale_socket(path: &Path) -> Result<()> { + match tokio::fs::symlink_metadata(path).await { + Ok(_) => tokio::fs::remove_file(path).await, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +async fn handle_client( + stream: UnixStream, + runtime: Arc, + messages: broadcast::Sender, +) -> Result<()> { + let (mut reader, mut writer) = stream.into_split(); + let mut outgoing = messages.subscribe(); + let initial = DaemonMessage::StateUpdate(runtime.snapshot()); + write_msg(&mut writer, &initial).await?; + let writer_task = tokio::spawn(async move { + while let Ok(message) = outgoing.recv().await { + if write_msg(&mut writer, &message).await.is_err() { + break; + } + } + }); + let router = CommandRouter::new(runtime.clone()); + loop { + match read_msg::<_, ClientMessage>(&mut reader).await { + Ok(ClientMessage::Command { seq, line }) => { + let result = router.route(seq, line).await; + let _ = messages.send(result); + } + Ok(ClientMessage::Subscribe) => { + let _ = messages.send(DaemonMessage::StateUpdate(runtime.snapshot())); + } + Ok(ClientMessage::Ping { seq }) => { + let _ = messages.send(DaemonMessage::Pong { seq }); + } + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(error) => { + writer_task.abort(); + return Err(error); + } + } + } + writer_task.abort(); + Ok(()) +} diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs new file mode 100644 index 0000000..7f10773 --- /dev/null +++ b/iota-daemon-lib/src/lib.rs @@ -0,0 +1,8 @@ +pub mod command_router; +pub mod daemon_state; +pub mod ipc_server; +pub mod log_broadcaster; + +pub use command_router::CommandRouter; +pub use daemon_state::DaemonRuntime; +pub use ipc_server::IpcServer; diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs new file mode 100644 index 0000000..1ce3c88 --- /dev/null +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -0,0 +1,21 @@ +use iota_ipc::{DaemonMessage, LogEntry}; +use iota_logger::subscribe; +use tokio::sync::broadcast; + +/* The daemon adapts logger output to the wire protocol so the logger stays + * independent from both the socket implementation and TUI state. */ +pub fn spawn(message_tx: broadcast::Sender) { + let Some(mut logs) = subscribe() else { + return; + }; + tokio::spawn(async move { + while let Ok(entry) = logs.recv().await { + let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry { + timestamp_ms: entry.timestamp_ms, + sender: entry.sender, + message: entry.message, + is_error: entry.is_error, + })); + } + }); +} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml new file mode 100644 index 0000000..e5d5864 --- /dev/null +++ b/iota-daemon/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "iota-daemon" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-daemon-lib = { path = "../iota-daemon-lib" } +iota-ipc = { path = "../iota-ipc" } +iota-logger = { path = "../iota-logger" } +iota-state = { path = "../iota-state" } +iota-storage = { path = "../iota-storage" } +omikron-connector = { path = "../omikron-connector" } +web-server = { path = "../web-server" } +tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs new file mode 100644 index 0000000..2cdb8ff --- /dev/null +++ b/iota-daemon/src/main.rs @@ -0,0 +1,58 @@ +use iota_daemon_lib::{DaemonRuntime, IpcServer, log_broadcaster}; +use iota_logger::{self as logger, log, log_t}; +use iota_storage::users::user_manager; +use iota_storage::util::config_util::CONFIG; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::broadcast; + +fn socket_path() -> PathBuf { + std::env::var_os("IOTA_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock")) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + logger::startup(); + iota_storage::util::config_util::load_config(); + if user_manager::load_users().await.is_err() { + log_t!("user_load_failed"); + } + let runtime = Arc::new(DaemonRuntime::new()); + runtime.spawn_system_monitor(); + let (messages, _) = broadcast::channel(512); + log_broadcaster::spawn(messages.clone()); + let state_updates = runtime.clone(); + let state_messages = messages.clone(); + tokio::spawn(async move { + loop { + if *state_updates.state.shutdown.read().await { + break; + } + let _ = state_messages.send(iota_ipc::DaemonMessage::StateUpdate( + state_updates.snapshot(), + )); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); + + let port = CONFIG.load().port; + if !web_server::start(port).await { + log!("Failed to start the MTP web server on port {}", port); + } + let _ = omikron_connector::omikron_connection::get_omikron_connection().await; + + let server = IpcServer::new(socket_path(), runtime.clone(), messages); + tokio::spawn(async move { + if let Err(error) = server.run().await { + eprintln!("iota-daemon IPC server failed: {error}"); + } + }); + log!("iota-daemon started"); + while !*runtime.state.shutdown.read().await { + tokio::time::sleep(Duration::from_millis(250)).await; + } + log!("iota-daemon stopping"); +} diff --git a/iota-ipc/Cargo.toml b/iota-ipc/Cargo.toml new file mode 100644 index 0000000..8fb9e01 --- /dev/null +++ b/iota-ipc/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "iota-ipc" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.53.1", features = ["io-util", "macros", "rt"] } diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs new file mode 100644 index 0000000..8cbd6fa --- /dev/null +++ b/iota-ipc/src/lib.rs @@ -0,0 +1,5 @@ +pub mod protocol; +pub mod transport; + +pub use protocol::{ClientMessage, DaemonMessage, LogEntry, StateSnapshot}; +pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs new file mode 100644 index 0000000..09a723b --- /dev/null +++ b/iota-ipc/src/protocol.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ClientMessage { + Command { seq: u64, line: String }, + Subscribe, + Ping { seq: u64 }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum DaemonMessage { + LogEntry(LogEntry), + StateUpdate(StateSnapshot), + CommandResult { + seq: u64, + success: bool, + message: String, + }, + Pong { + seq: u64, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LogEntry { + pub timestamp_ms: u128, + pub sender: String, + pub message: String, + pub is_error: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StateSnapshot { + pub cpu: Vec<(f64, f64)>, + pub ram: Vec<(f64, f64)>, + pub ping: Vec<(f64, f64)>, + pub net_up: Vec<(f64, f64)>, + pub net_down: Vec<(f64, f64)>, + pub sys_info: String, +} diff --git a/iota-ipc/src/transport.rs b/iota-ipc/src/transport.rs new file mode 100644 index 0000000..811b0ee --- /dev/null +++ b/iota-ipc/src/transport.rs @@ -0,0 +1,59 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::io::{Error, ErrorKind, Result}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const MAX_MESSAGE_SIZE: usize = 1024 * 1024; + +/* Length-prefixing preserves message boundaries on a byte stream and bounds + * allocations before JSON is deserialized. */ +pub async fn write_msg(writer: &mut W, message: &T) -> Result<()> +where + W: AsyncWrite + Unpin, + T: Serialize, +{ + let payload = + serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + let len = u32::try_from(payload.len()) + .map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?; + writer.write_u32(len).await?; + writer.write_all(&payload).await?; + writer.flush().await +} + +pub async fn read_msg(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, + T: DeserializeOwned, +{ + let len = reader.read_u32().await? as usize; + if len > MAX_MESSAGE_SIZE { + return Err(Error::new( + ErrorKind::InvalidData, + "IPC message exceeds limit", + )); + } + let mut payload = vec![0; len]; + reader.read_exact(&mut payload).await?; + serde_json::from_slice(&payload).map_err(|error| Error::new(ErrorKind::InvalidData, error)) +} + +#[cfg(test)] +mod tests { + use super::{read_msg, write_msg}; + use crate::protocol::ClientMessage; + + #[tokio::test] + async fn round_trips_framed_messages() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + let message = ClientMessage::Command { + seq: 4, + line: "help".into(), + }; + write_msg(&mut writer, &message) + .await + .expect("write succeeds"); + let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds"); + assert!(matches!(received, ClientMessage::Command { seq: 4, line } if line == "help")); + } +} diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index 23f4a66..530f2f6 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -12,3 +12,4 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } ratatui = "0.30.0" json = "0.12.4" once_cell = "1.21.4" +tokio = { version = "1.50.0", features = ["sync"] } diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 01ebbff..486a04c 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -10,11 +10,13 @@ use std::{ use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version}; use ratatui::style::Color; -use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; +use iota_state::{UNIQUE, UiLogEntry}; +use tokio::sync::broadcast; pub mod language_creator; pub mod language_manager; static LOGGER: OnceLock> = OnceLock::new(); +static LOG_BROADCASTER: OnceLock> = OnceLock::new(); #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[allow(unused)] @@ -51,9 +53,15 @@ struct LogMessage { message: Option, } +/* The logger owns file persistence while consumers receive rendered entries + * through a process-local broadcast subscription. */ pub fn startup() { let (tx, rx) = mpsc::channel::(); - LOGGER.set(tx).expect("Logger already initialized"); + if LOGGER.set(tx).is_err() { + return; + } + let (broadcast_tx, _) = broadcast::channel(512); + let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); thread::spawn(move || { let working_dir = iota_util::file_util::get_directory(); @@ -106,12 +114,15 @@ pub fn startup() { is_error: msg.is_error, }; - let mut state = APP_STATE.lock().unwrap(); - state.push_log(entry); + let _ = broadcast_tx.send(entry); } }); } +pub fn subscribe() -> Option> { + LOG_BROADCASTER.get().map(broadcast::Sender::subscribe) +} + fn format_timestamp_inline(timestamp_ms: u128) -> String { let secs = (timestamp_ms / 1000) as i64; let hours = (secs / 3600) % 24; diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index e246e12..4275556 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -3,6 +3,10 @@ name = "iota-state" version = "0.1.0" edition = "2024" +[features] +default = ["legacy-globals"] +legacy-globals = [] + [dependencies] dashmap = "6.1.0" once_cell = "1.21.3" @@ -10,3 +14,4 @@ tokio = { version = "1.50.0", features = ["full"] } json = "*" sysinfo = "0.38.3" mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +serde = { version = "1", features = ["derive"] } diff --git a/iota-state/src/lib.rs b/iota-state/src/lib.rs index 1e5d1c2..3b29d08 100644 --- a/iota-state/src/lib.rs +++ b/iota-state/src/lib.rs @@ -1,13 +1,67 @@ use dashmap::DashSet; use json::{JsonValue, object}; +#[cfg(feature = "legacy-globals")] use once_cell::sync::Lazy; use std::collections::VecDeque; -use std::sync::{Arc, LazyLock, Mutex, atomic::AtomicBool}; +#[cfg(feature = "legacy-globals")] +use std::sync::LazyLock; +use std::sync::{Arc, Mutex, atomic::AtomicBool}; +#[cfg(feature = "legacy-globals")] use std::thread; +#[cfg(feature = "legacy-globals")] use std::time::Duration; +#[cfg(feature = "legacy-globals")] use sysinfo::{RefreshKind, System}; use tokio::sync::RwLock; +/* Process-owned daemon state and TUI-local state must be separate because IPC, + * rather than shared memory, is the boundary between the two binaries. */ +#[derive(Clone)] +pub struct DaemonState { + pub app: Arc>, + pub shutdown: Arc>, + pub reload: Arc>, + pub active_tasks: Arc>, +} + +impl DaemonState { + pub fn new() -> Self { + Self { + app: Arc::new(Mutex::new(AppState::new())), + shutdown: Arc::new(RwLock::new(false)), + reload: Arc::new(RwLock::new(false)), + active_tasks: Arc::new(DashSet::new()), + } + } +} + +impl Default for DaemonState { + fn default() -> Self { + Self::new() + } +} + +/* The TUI keeps only the daemon data it renders. This state is never shared + * with the daemon and is populated from daemon IPC messages. */ +#[derive(Clone)] +pub struct ClientState { + pub app: Arc>, +} + +impl ClientState { + pub fn new() -> Self { + Self { + app: Arc::new(Mutex::new(AppState::new())), + } + } +} + +impl Default for ClientState { + fn default() -> Self { + Self::new() + } +} + pub const MAX_POINTS: usize = 1000; pub const MAX_LOGS: usize = 100; @@ -148,13 +202,22 @@ impl AppState { } } +#[cfg(feature = "legacy-globals")] +#[deprecated(note = "use DaemonState or ClientState")] pub static APP_STATE: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); +#[cfg(feature = "legacy-globals")] +#[deprecated(note = "use DaemonState")] pub static SHUTDOWN: Lazy> = Lazy::new(|| RwLock::new(false)); +#[cfg(feature = "legacy-globals")] +#[deprecated(note = "use DaemonState")] pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); +#[cfg(feature = "legacy-globals")] +#[deprecated(note = "use DaemonState")] pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); +#[cfg(feature = "legacy-globals")] pub fn setup() { ACTIVE_TASKS.insert("System info loader".to_string()); tokio::spawn(async move { diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs index 010ceae..64056d9 100644 --- a/iota-util/src/crypto_util.rs +++ b/iota-util/src/crypto_util.rs @@ -42,10 +42,12 @@ pub fn decrypt_challenge(encrypted: &str, keyring: &Keyring) -> Result String { +pub fn export(data: &[u8], format: DataFormat) -> Result { match format { - DataFormat::Raw => String::from_utf8_lossy(data).to_string(), - DataFormat::Base64 => STANDARD.encode(data), - DataFormat::Hex => hex::encode(data), + DataFormat::Raw => { + String::from_utf8(data.to_vec()).map_err(|error| format!("utf8 decode error: {error}")) + } + DataFormat::Base64 => Ok(STANDARD.encode(data)), + DataFormat::Hex => Ok(hex::encode(data)), } } diff --git a/iota/Cargo.toml b/iota/Cargo.toml new file mode 100644 index 0000000..32525e2 --- /dev/null +++ b/iota/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "iota" +version = "0.1.0" +edition = "2024" + +[dependencies] +iota-cli = { path = "../iota-cli" } +tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota/src/main.rs b/iota/src/main.rs new file mode 100644 index 0000000..845cb83 --- /dev/null +++ b/iota/src/main.rs @@ -0,0 +1,29 @@ +use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui}; +use std::path::PathBuf; + +fn socket_path() -> PathBuf { + std::env::var_os("IOTA_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock")) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + let path = socket_path(); + let ipc = match IpcClient::connect(&path).await { + Ok(client) => client, + Err(error) => { + eprintln!( + "Cannot connect to iota-daemon at {}: {error}", + path.display() + ); + std::process::exit(1); + } + }; + let ui = start_tui(ipc); + ui.set_screen(Box::new(MainScreen::new(ui.clone()).await)) + .await; + while !ui.is_shutdown() { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service new file mode 100644 index 0000000..3f3b819 --- /dev/null +++ b/systemd/iota-daemon.service @@ -0,0 +1,16 @@ +[Unit] +Description=Tensamin Iota daemon +After=network-online.target +Wants=network-online.target +Requires=iota-daemon.socket + +[Service] +Type=simple +ExecStart=/usr/bin/iota-daemon +Restart=on-failure +RuntimeDirectory=iota +RuntimeDirectoryMode=0750 +Environment=IOTA_SOCKET=/run/iota/iota.sock + +[Install] +WantedBy=multi-user.target diff --git a/systemd/iota-daemon.socket b/systemd/iota-daemon.socket new file mode 100644 index 0000000..22ab691 --- /dev/null +++ b/systemd/iota-daemon.socket @@ -0,0 +1,12 @@ +[Unit] +Description=Tensamin Iota daemon IPC socket + +[Socket] +ListenStream=/run/iota/iota.sock +SocketMode=0660 +SocketUser=iota +SocketGroup=iota +RemoveOnStop=true + +[Install] +WantedBy=sockets.target From 56aad3a0231d63ed458a717c27ce55bb863fb67c Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Tue, 21 Jul 2026 23:05:34 +0200 Subject: [PATCH 083/119] [Fix] IPC, cli, daemon --- .forgejo/workflows/release.yml | 36 +- .gitignore | 5 + Cargo.lock | 11 +- config.json | 1 - dockerfile | 8 +- flake.nix | 69 ++- iota-cli/Cargo.toml | 3 +- iota-cli/src/elements/console_card.rs | 188 +------ iota-cli/src/ipc_client.rs | 590 ++++++++++++++++++-- iota-cli/src/screens/main_screen.rs | 80 ++- iota-core/Cargo.toml | 1 + iota-core/src/main.rs | 2 +- iota-daemon-lib/Cargo.toml | 4 + iota-daemon-lib/src/command_router.rs | 153 +++-- iota-daemon-lib/src/daemon_state.rs | 108 +++- iota-daemon-lib/src/ipc_server.rs | 191 ++++++- iota-daemon-lib/src/lib.rs | 2 +- iota-daemon/Cargo.toml | 1 + iota-daemon/src/main.rs | 84 ++- iota-ipc/src/lib.rs | 11 +- iota-ipc/src/protocol.rs | 124 +++- iota-ipc/src/transport.rs | 13 +- iota.mk | Bin 5713 -> 0 bytes iota/Cargo.toml | 1 + iota/src/main.rs | 4 +- omikron-connector/Cargo.toml | 1 + omikron-connector/src/omikron_connection.rs | 26 +- omikron-connector/src/user_ops.rs | 3 - systemd/iota-daemon.service | 12 +- systemd/iota-daemon.socket | 1 + web-server/Cargo.toml | 4 +- web-server/src/lib.rs | 18 +- 32 files changed, 1356 insertions(+), 399 deletions(-) delete mode 100644 config.json delete mode 100644 iota.mk diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 06523ce..ec4e2af 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -37,16 +37,19 @@ jobs: DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin" - - name: Build & Push + - name: Build & Push Docker image run: | nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" - - name: Build release binary + - name: Build release binaries run: | set -eu - nix build .#iota --print-build-logs - install -Dm755 result/bin/iota dist/iota + nix build .#iota-daemon --print-build-logs + install -Dm755 result/bin/iota-daemon dist/iota-daemon + + nix build .#iota-ui --print-build-logs + install -Dm755 result/bin/iota-ui dist/iota-ui - name: Read release metadata id: version @@ -55,7 +58,7 @@ jobs: run: | set -eu - VERSION="$(nix eval --raw .#iota.version)" + VERSION="$(nix eval --raw .#iota-daemon.version)" SHORT_SHA="$(git rev-parse --short=7 HEAD)" case "$RELEASE_TYPE" in @@ -73,18 +76,15 @@ jobs: ;; esac - ASSET_PATH="dist/iota" - ASSET_NAME="iota" - test -x "$ASSET_PATH" - echo "version=$VERSION" >> "$FORGEJO_OUTPUT" echo "tag=$TAG" >> "$FORGEJO_OUTPUT" echo "title=$TAG" >> "$FORGEJO_OUTPUT" echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" - echo "asset_path=$ASSET_PATH" >> "$FORGEJO_OUTPUT" - echo "asset_name=$ASSET_NAME" >> "$FORGEJO_OUTPUT" - - name: Create release and upload binary + test -x "dist/iota-daemon" + test -x "dist/iota-ui" + + - name: Create release and upload binaries env: TOKEN: ${{ forgejo.token }} API: ${{ forgejo.api_url }} @@ -93,8 +93,6 @@ jobs: TAG: ${{ steps.version.outputs.tag }} TITLE: ${{ steps.version.outputs.title }} PRERELEASE: ${{ steps.version.outputs.prerelease }} - ASSET_PATH: ${{ steps.version.outputs.asset_path }} - ASSET_NAME: ${{ steps.version.outputs.asset_name }} DESCRIPTION: ${{ inputs.description }} run: | nix-shell -p curl jq --run ' @@ -122,7 +120,11 @@ jobs: RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" fi - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \ + curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \ -H "Authorization: token $TOKEN" \ - -F "attachment=@$ASSET_PATH" - ' \ No newline at end of file + -F "attachment=@dist/iota-daemon" + + curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@dist/iota-ui" + ' diff --git a/.gitignore b/.gitignore index 257c89e..9fb3aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ target logs agreements languages/ +.envrc +.direnv +config.json +*.mk +*.sqlite* diff --git a/Cargo.lock b/Cargo.lock index 5329cde..96c66bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,6 +2091,7 @@ version = "0.1.0" dependencies = [ "iota-cli", "tokio", + "tokio-util", ] [[package]] @@ -2186,6 +2187,7 @@ dependencies = [ "sysinfo", "tokio", "tokio-tungstenite", + "tokio-util", "tungstenite", "walkdir", "warp", @@ -2213,6 +2215,7 @@ dependencies = [ "iota-storage", "omikron-connector", "tokio", + "tokio-util", "web-server", ] @@ -2220,15 +2223,19 @@ dependencies = [ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ + "dashmap", "iota-ipc", "iota-logger", "iota-state", "iota-storage", "iota-util", + "libc", "mtp", "omikron-connector", "sysinfo", "tokio", + "tokio-util", + "uuid", ] [[package]] @@ -3082,6 +3089,7 @@ dependencies = [ "reqwest", "sha2 0.10.9", "tokio", + "tokio-util", "uuid", "x448", ] @@ -4898,6 +4906,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5263,10 +5272,10 @@ dependencies = [ "bytes", "http 1.4.2", "iota-logger", - "iota-state", "iota-util", "mtp", "tokio", + "tokio-util", ] [[package]] diff --git a/config.json b/config.json deleted file mode 100644 index 03a370e..0000000 --- a/config.json +++ /dev/null @@ -1 +0,0 @@ -{"keyring":"BMAGjI76myen0MXtUh7jwKfZBzJNeBofMRcVcDTJVSZy5IXAiR+f9GrbpQdIdYMyEgSRlbr6oaiqIBkhaSJZkI6YaIUFyIn4aRvPhG//ET/1NwLLyLw/1ripyKgAzAEFC3lGaLn0U7AS93vPuTxxMm5koKR7hzG40rTjEkcu1w6j1lpMupmFKqwpyj4WC2koVybhln8G0MjO947yXE0Tk47GsnWr0SENM7b79AlXNZvL9AMsGQbdgYqjIbMRZWthKY4P6EiZoWKFuYJrixeaVWSr+mCACgRDaMT+os+w+jw56oNCpFbBuswFR3iF8I3p9JdbiTl0ya1HwrngZJlUub9KxRgfCz5ZRhjzqKZ7Kgwhy7S4Nx2EFH4UhEaf+Y3QrGHitn7YCZXB5U0YGT+cqYJe8br77KiLW5rvjG2RaoaoA4AF8jiJ2LjWo1UrZDWL+hmiISuyWy3jZE7njI6oqXTZMb5oMSnB5r4KCyy/nA4DEMiTcLIcRgyUAXx1sDGX2Qc4yXwXqoMxnDzlCBFrRwn3EFbLYRNGpUM4y8Az4AROtTwTJq714RU9mc9v2V7jW7uCxYCMWWG2N3RIcppCJRbuwy1nWEPxNGEs8lp0wgDaSU1exz4WBldSCLD/mcImlCNiC174FVBgfLjM7Hp8oKnhCSziaTmAQ8mm5300Spw+A66VvCvGxheosy9BuTCpBKe9lJ/XdLrfnHxud1CoYgh9ymtGXDfkZ2H1c70XyC70+US/mFhfK6osMMDqOsd5eIRF43E/0oQfUcL8CBxEFcbDRyumSbEZpz42hCb9ccCzJZf6x6Bd9mzUeZYBlibrHJ+TKA6I+quB9CD1dljIQjTxQyLC91vbayoPuQ9iF2DKoEnicCNhhzLzZHRqE2oIE3MirDsCmMGno0+Cs7aqF8mvC1jBuzIQwlotOW7CYLjORmWLOrbbGMfV+XtOogEDWVYrxSe3dUOZ2x3fcQzGiVXjGjv2eTYQgHafDJUs6hbtDCqKUYXjzILF7MkpGx0EGYGhEQBYkMfQ7GQ8fI1ppYbpVUYh6m6zIWnWXII29aZOGldKAwO6uSjBwDI9d7lN1JgVR35Iu4Wj5zJj6ro9QxJ/mnSTwINSdj4SRo7JKIgWgXkeoS5VGM4Tq4umnIiQPFPXObrXBhQH8Lezsqyq83qeQFydy699zMz/WACflUua8Hhlgk6lMV0VfB3yBoJQx1WRxhGQm44CeBgt18u/Wm+ZTFJ+IR/+4yUg+qRhuc74k83LsCorFSSfymE4UDY7XJbAuZgCFVTs6TjzVCS4+YMdC4Row2p83J+CsYUAYoCnV67GNKEtwxH7XAJZFyKkc3b/kEPffDYJ9Ep7yrEy5AEqaWaT7B8f6U/6Nw4feykfinaruFPGplduEY9P6sLQ4Fvn+wDtA5EMwbkXQLwJSkGv3GOHBWbIWY+HmWnUW6lGPJd9O8US88SFIG0e8BdW0I9IaiAuvCVgpw8ImLJDswndSGV4sJ0A0Un4qIQrzCcszAR+GKArhMXcGo1SSndo9MxROyg1uJFwfJM9BQMZJjAE2+7LaIyaAGIf+/zEGWBoFhPYQqXu13laMW/npdMRUR40YydPhBRuMkXMeiJaTDFYCYCLmHASQghali1XJXH2TMJOh59iC2RiuJeTAZD7IFy/gaQtOLCxoHHz6L0kODXCdJB0xkr8CETSkgf7fKKlxSC8+n31IqRCq1tsgqssahhRBRFy+aQ4NzhVypvdoKiFqo3XmbRPhl3QxGHweaw9PMcq55au2SuttA9kZUcRo0C9PAppO3kLkTSjNamLWBj36Lmuo2KNlQjm9AS2CCwixxOSIWBj+SNCiWl3TF/0uhb6NEiKk3hSHDYBmbT+mK7KbIMqWM/Yemw9SyOdlI3KiEGRuj2BWzoy+ARSGxyCZ4HxGryAaRtWo8AwSaKD+Dz97LcEYUTC5Ja4qE/2yLzNtVJimTiFpkJLaojVEieWyZOdks8smBwTqz9I1aiE4GqX0no1/C4aYxtauMSWGRh9KV/1dLnEcgRExTvf6GIzEH+/kEi4Rh7AJLGuvKZT9kwEm7Nb9Sw4oUmHlZhyh5Q/gGChQZ3cDDFLdi4TBkePuAk10Dz2u575m5GvwULlN0NaJ3hx46KvyWbaVw84pIYuNg1Dq1wXWx0dOM+djL7LyXrQvJaD+spC+b0+N0o0MYcmvHgsG0VYm4IORMR7qAZg3HiBzF8Sdp1+Fz6jJG/ffDJTSmPtYSQDTIwctXrtpmw2+wOVW3G/hsf8RS7kNVSFxUnzrKTjmjovCFOaO8KEigz/CpPre6iUiVHX+w9ZIMBX8zScuLoObArc9Bn3Ykt2IcCoJaPW94V6pcgCUlzGi5lWo4cAx7pcSlL0pTRkk5AbcYxq+74kNKm4FFwB+rfPnE1R2QxyWTbi1psFgHJGchjypQBGeU1fUg19RCfz5HAL5YCCuL+Z4a+QiIGbdBrgSzBgUI6Hg1oG+Kp7IqJehG5/ekuMZ3MtNo9f1MLB603zUmcV+CE9gliIGFtVpzCwNlyRIbICwpT/1kqEjHFiK2jh4AgaWJ8ZqWg/mb6/qBz2BsqEqEVm+AL8BQsBOjtxh8uD3Aoat7mSApxNgrKKSh0Y5Emv1woxhUjueSFu5zRBWayaYl9oSkbMGZTPMGAYyT3vVDn2PKWEV7t8ND27PBktUM6/8ifisKI/K8ZTqDG7N6qioz3iY6sCZ2+Fq6jUBjQwqx34qYfRmHXMsX6198Fv0rrG2WZmEWc6QTJG60S0Ez90oAf72LathCal5MMRICSWEsx8BJpugX0ccaEgxZ6yJ5N2wa4FEzvDZXxL1LAJY5A05TvEB7fNQUSS9TnSc0+tu88ZhMKYMQjpMjnncoH+iL/VIRrlC4rxeRshoxHQsDD/23GzWz+n9buPiFwkQ1T+eCc/Q0XRIgrqPFmlIizSfAPhaQcNdcmfRkIrdosFNXxg95ciKAD14mD6ynjRZwYLSC+YKgCXQm7g1mOu1aGUBJkcl1nlxrnVQFudlpq3+RPCgS8g6JCCxFHrkgoBeqWZS6KfoxiOdZm6uj8LIzah6bFn2BFKC4xTEDQRGwxCHBFF5DKqd69ExqTFcLWD53/d0HFLmBfv1V7ESnzinKK2mQGpqss0VwdVsy16tLv1EqgGjI76myen0MXtUh7jwKfZBzJNeBofMRcVcDTJVSZy5IXAiR+f9GrbpQdIdYMyEgSRlbr6oaiqIBkhaSJZkI6YaIUFyIn4aRvPhG//ET/1NwLLyLw/1ripyKgAzAEFC3lGaLn0U7AS93vPuTxxMm5koKR7hzG40rTjEkcu1w6j1lpMupmFKqwpyj4WC2koVybhln8G0MjO947yXE0Tk47GsnWr0SENM7b79AlXNZvL9AMsGQbdgYqjIbMRZWthKY4P6EiZoWKFuYJrixeaVWSr+mCACgRDaMT+os+w+jw56oNCpFbBuswFR3iF8I3p9JdbiTl0ya1HwrngZJlUub9KxRgfCz5ZRhjzqKZ7Kgwhy7S4Nx2EFH4UhEaf+Y3QrGHitn7YCZXB5U0YGT+cqYJe8br77KiLW5rvjG2RaoaoA4AF8jiJ2LjWo1UrZDWL+hmiISuyWy3jZE7njI6oqXTZMb5oMSnB5r4KCyy/nA4DEMiTcLIcRgyUAXx1sDGX2Qc4yXwXqoMxnDzlCBFrRwn3EFbLYRNGpUM4y8Az4AROtTwTJq714RU9mc9v2V7jW7uCxYCMWWG2N3RIcppCJRbuwy1nWEPxNGEs8lp0wgDaSU1exz4WBldSCLD/mcImlCNiC174FVBgfLjM7Hp8oKnhCSziaTmAQ8mm5300Spw+A66VvCvGxheosy9BuTCpBKe9lJ/XdLrfnHxud1CoYgh9ymtGXDfkZ2H1c70XyC70+US/mFhfK6osMMDqOsd5eIRF43E/0oQfUcL8CBxEFcbDRyumSbEZpz42hCb9ccCzJZf6x6Bd9mzUeZYBlibrHJ+TKA6I+quB9CD1dljIQjTxQyLC91vbayoPuQ9iF2DKoEnicCNhhzLzZHRqE2oIE3MirDsCmMGno0+Cs7aqF8mvC1jBuzIQwlotOW7CYLjORmWLOrbbGMfV+XtOogEDWVYrxSe3dUOZ2x3fcQzGiVXjGjv2eTYQgHafDJUs6hbtDCqKUYXjzILF7MkpGx0EGYGhEQBYkMfQ7GQ8fI1ppYbpVUYh6m6zIWnWXII29aZOGldKAwO6uSjBwDI9d7lN1JgVR35Iu4Wj5zJj6ro9QxJ/mnSTwINSdj4SRo7JKIgWgXkeoS5VGM4Tq4umnIiQPFPXObrXBhQH8Lezsqyq83qeQFydy699zMz/WACflUua8Hhlgk6lMV0VfB3yBoJQx1WRxhGQm44CeBgt18u/Wm+ZTFJ+IR/+4yUg+qRhuc74k83LsCorFSSfymE4UDY7XJbAuZgCFVTs6TjzVCS4+YMdC4Row2p83J+CsYUAYoCnV67GNKEtwxH7XAJZFyKkc3b/kEPffDYJ9Ep7yrEy5AEqaWaT7B8f6U/6Nw4feykfinaruFPGplduEY9P6sLQ4Fvn+wDtA5EMwbkXQLwJSkGv3GOHBWbIWY+HmWnUW6lGPJd9O8US88SFIG0e8BdW0I9IaiAuvCVgpw8ImLJDswndSGV4sJ0A0Un4qIQrzCcszAR+GKArhMXcGo1SSndo9MxROyg1uJFwfJM9BQMZJjAE2+7LaIyaAGIf+/zEGWBoFu+riy8UUa4SXlhFlkzX0LxFF3GjDM8Ig7Z2e5dQPjhTF1E2/gR7C+el3nZRt3rb4xZF9OMFK1MOEdxBoJorubl0sqA89NULY0ePfMRef3eqe4PsNwcCSlUyLzqWwM10uQegMln/lXI/TJfBe4wuQ0IrPW3GBv+60A1pKSEmgyeT9RLSupHZGxbZHz0C5wocLbcpelkae/caApr7vUp8Kt3npl89ZM3xVj6HSb/8UgolC3yXfwG/W9ssCzmtQ4vC6HRhzM29MGBjZxMeLbYY+jrbI23L/iIgnKVeCenTh3wIVcoEKfkRvs8sAj1EjicL/YMJ0InKoiOrFC4CjMcclcuMw30Y8FmUcXxZjqltqbw0lhyuGHJTcUdesostQBapvrDkXTRK6hViPJehixApUEeTTLSUJDu9Nw2/LmOTEkYn0+sVDmkyjyYvaPmH/pvzuDIizEnYkRR/PKopmXhtnuis2UEh9U57qkNfJc7aj/OL245w062GpHpd2ynssXfbq87mjo4kfXwWACVAuxf7RWlbDGTfEF2wYeY+EjaouU137mA66uWPFhE3gyd9pvr2BsljYmcn3h7YStAMVWPe2m1Rtqlcbaez+O0g8fdG2elJgMt3asOhOzgJPIi19wRORZjoPPqqlZiwS1mu2m4EX91hJK1ZUT/d9UckuhaFVSyKAFo4edEKS+AkGsB6OBPuuvwxzwgYCv11eXcX5evB+b0mpEB+KPj5wkjWQcK51w5/Ondv+YPzgv1QN5SJznlz3kTn22P+qT/Zohz8Esy8EHZclpQ+lg+aF78ybvq3A9LKqiABSIX6ojLlQ0zODOm8UeTg18y8j8ofn5ivy9z3D1ihX7wwkSenDkDK8Fi+9wKTconAfFBoH4LINDHwiQuUUQ6BgmD6xpB4df7UdKowuODZO7nOQRy4mQVyrJMlvf8Hm54zw4vURSI4T5kFhbBizqcWEVftE+dki4Qtxxtd0rbUeXsxfykdgDrfkiGs7IoJL+cFQrY4rea1650/NLvQqk5/BILe0JybYPkzJLlFqHrL/mX1nG1RZJMfq8ChH1WECs9cZehKRFg7umjCEBXsr7+P713HqtVti33HYlQIAJEawbsM5Fj56/nxQiKVabUAykQ5krDq5OhLtn/z2IQAexjHmBTmkSLVWGl8P7JY4BfFQeAL/mW5TxVxf1MR7UE/GY1Re214zkrX+aa5tdWq0QKRa1xm5iepHYKgbAeU3D2jOwVzfhMWs3vDe1o4HjQrgC2+065TpcY57kizDEr1sX1mDvpaZBpTMBvyVdwpQsdSSUK3lruHkvYW5ATV4sdhPUua2ATk/gAVHC2WkFcxxiesCqkhSlib6YgolYDzVJu21dtR1mxMJed8FADYB7ICL7JgFBpGcit0Da0Khs1DAfCq1VT1cgHvXymzsjQPUKCFsixNKbk7fzByrU2IYOokjPAi4vA9jwYMsQ6fedoM2uyQT2Ro4nJ1DhGH5DaGdsi3nwKs6ceOadRSc43uYoVEyzpFcQyHAqBMYnz5e6tSTCqynK5cd5EwzJl/ShHhfRNH3nWoFWnYYenD4SWDqvKmyvLuxq4gISAw3onDRJsYde9M6gGvG6lWZtH3g/94qqH2g6RugRM5n/+5riMiN5AYY5LCMgkHeM4eOur5HKymjw1EVGKnPsX2m00bSkWSrTwUiiv3ogjmzM+F52CU7+BjYhdnaGdW+3zDCxfL4LIC/10MJD4Btt5vsdZwnXQdDizb/OmuVkIp47fPDR2Ni8LlWB3HPLcSQiE2B9RBKSeDoC5rr02SvS6be9oB9gYigmEp3QkhxfNdszW0Fw7rIWmzRRumRid6zqJRK39gH1f6XHFIJkTluZPqMdbHG6ZFSMxevrpNwUsjO/R8FYUYLw6ntj4VhU54T3s6Zh/kYLKi+qiVwPkv7RchRxB1gsnUgGBhA5nuU1AHtoABZorru7Wfrc9PaYty6mPrbyNKE56OCkhL/U5xqcSVKaZuLij5Qc60KzN0ixumiCPmKWaCSwK5rrjdKjD0OhTz5gmFmmaEisuvh94o1N6tEe2vYFHVTQ0biDq8/AbmahaSLU1TAcCjIznvl77nLBG1MJdMIH4kzyIw7uhSz/m/aET88RxIvc399PuTlXyOuysCfPBig1AMiRnpBWPhIlh9KxFj53P8QtfZgfBrEU/bHe27bkxFWkwGUxJSKeqHcrt8H7NzqaQbw7vdGXB6TO5saqz8qX1oJUzF0Fiuq0ce2nLeC5tpGWNswQ/WHEJmyJPGUAGCYyWE88qi2AKmJ/XBgZnugSCmtHjDQ6iuHt3+a9ZWUEl0ZzEf03PgOHCu/fjr7yAlk62+s9CW5L6YWx2ZQlrbvKWik8N76JS5lerB+TYIPqy4uYw+1UVzVC2yjmuqdnBU1Mm922FQ+JdZ4tmHQpHdswXiQSwvZkLN3MAle2yRQbIgmBcAUaI+rFBw4wUYM3UtAjjQoyKA2ATtRs7539E7KjjgNmpeHY/8ezDH+VqHscdOp1vncoMWI7AGgdai9LRnsGttSMddGMr2tgizkL6ucvIX5P8Oi57actaPRjeMRIsSyop9HQblrQhffJ1y0GlKwxI1gZQCqf955zn5P1uGCHGSh07qvP0e1qySPOb6rGkNhRV5xpPQ+Ik9YJBtx23j6kGwRI0DBFucoHJzRXsdgZZdn7MJ//jztsNzgVpx65QT8AAEeEX4qHHR8AVDqq49MrCQihEAIFXhH997tCM+mQuv4UGlq0pcTU3lxXZU/iFXBcKGNM57ACCjaD7MAxirp1DcoqFvmBAoogV577OS9UCCo+4cgL0MewAgXmP0NeyVgq5Dg60GcRXJ5P+4ulE/8wQX5boc1bhFEYY="} \ No newline at end of file diff --git a/dockerfile b/dockerfile index 5f07197..fcbfafa 100644 --- a/dockerfile +++ b/dockerfile @@ -4,7 +4,7 @@ FROM rust:latest AS builder WORKDIR /app COPY . . -RUN cargo build --release +RUN cargo build --release -p iota-daemon # Runtime stage FROM debian:sid @@ -13,8 +13,10 @@ WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/iota-core . +COPY --from=builder /app/target/release/iota-daemon . + +RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota EXPOSE 1984 -CMD ["./iota-core"] +CMD ["./iota-daemon"] diff --git a/flake.nix b/flake.nix index 607fb77..f811ec9 100644 --- a/flake.nix +++ b/flake.nix @@ -38,10 +38,13 @@ rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"]; }; + commonBuildInputs = with pkgs; [openssl sqlite]; + commonNativeBuildInputs = with pkgs; [cmake perl pkg-config]; in { packages = { - default = self'.packages.iota; - iota = pkgs.rustPlatform.buildRustPackage { + default = self'.packages.iota-daemon; + + iota-daemon = pkgs.rustPlatform.buildRustPackage { pname = "iota-daemon"; version = "0.1.0"; src = ./.; @@ -50,8 +53,8 @@ lockFile = ./Cargo.lock; allowBuiltinFetchGit = true; }; - nativeBuildInputs = with pkgs; [cmake perl pkg-config]; - buildInputs = with pkgs; [openssl sqlite]; + nativeBuildInputs = commonNativeBuildInputs; + buildInputs = commonBuildInputs; dontUseCmakeConfigure = true; postInstall = '' for f in $out/bin/*; do @@ -62,11 +65,36 @@ ''; passthru.dataDir = "/var/lib/iota"; }; + + iota-ui = pkgs.rustPlatform.buildRustPackage { + pname = "iota-ui"; + version = "0.1.0"; + src = ./.; + cargoBuildFlags = ["-p" "iota"]; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; + nativeBuildInputs = commonNativeBuildInputs; + buildInputs = commonBuildInputs; + dontUseCmakeConfigure = true; + postInstall = '' + for f in $out/bin/*; do + if [ "$(basename "$f")" != "iota" ]; then + rm "$f" + fi + done + # Rename to avoid confusion + if [ -f "$out/bin/iota" ]; then + mv "$out/bin/iota" "$out/bin/iota-ui" + fi + ''; + }; }; devShells.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config]; - buildInputs = with pkgs; [openssl sqlite]; + buildInputs = commonBuildInputs; }; }; @@ -78,7 +106,7 @@ ... }: let cfg = config.services.iota; - defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); configFile = if cfg.settingsFile != null @@ -158,14 +186,27 @@ users.groups.iota = {}; - systemd.services.iota = { + systemd.sockets.iota-daemon = { + description = "${descriptionText} IPC socket"; + wantedBy = ["sockets.target"]; + socketConfig = { + ListenStream = "/run/iota/iota.sock"; + SocketMode = "0660"; + SocketUser = "iota"; + SocketGroup = "iota"; + Backlog = 5; + RemoveOnStop = "true"; + }; + }; + + systemd.services.iota-daemon = { description = descriptionText; - wantedBy = ["multi-user.target"]; after = ["network.target"]; + requires = ["iota-daemon.socket"]; serviceConfig = { - Type = "simple"; + Type = "notify"; User = "iota"; Group = "iota"; WorkingDirectory = cfg.dataDir; @@ -186,11 +227,19 @@ '') ]; - Restart = "always"; + Restart = "on-failure"; RestartSec = "5s"; RuntimeDirectory = "iota"; RuntimeDirectoryMode = "0750"; + # Exit code 75 = restart requested + RestartPreventExitStatus = "0"; + RestartForceExitStatus = "75"; + + TimeoutStopSec = "10"; + KillMode = "mixed"; + KillSignal = "SIGTERM"; + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 2c5f868..f79241a 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -17,7 +17,7 @@ iota-logger = { path = "../iota-logger", optional = true } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage", optional = true } iota-terms = { path = "../iota-terms" } - iota-util = { path = "../iota-util", optional = true } +iota-util = { path = "../iota-util", optional = true } iota-ipc = { path = "../iota-ipc" } omikron-connector = { path = "../omikron-connector", optional = true } @@ -64,6 +64,7 @@ strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" walkdir = "2.5.0" diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 5ece1d9..276b95f 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,18 +1,4 @@ use crossterm::event::{KeyCode, KeyEvent}; -#[cfg(feature = "legacy-commands")] -use iota_logger::{log, log_cv}; -#[cfg(feature = "legacy-commands")] -use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; -#[cfg(feature = "legacy-commands")] -use iota_storage::users::{user_manager, user_profile::UserProfile}; -#[cfg(feature = "legacy-commands")] -use iota_storage::util::config_util::modify_config; -#[cfg(feature = "legacy-commands")] -use iota_util::file_util; -#[cfg(feature = "legacy-commands")] -use mtp::codec::{CommunicationType, CommunicationValue}; -#[cfg(feature = "legacy-commands")] -use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use ratatui::{ Frame, layout::Rect, @@ -47,7 +33,7 @@ pub struct ConsoleCard { cursor: Arc>, last_swap: Arc>, - tab_index: usize, + pending_restore: Arc>>, } impl ConsoleCard { @@ -62,7 +48,7 @@ impl ConsoleCard { joins: Borders::NONE, cursor: Arc::new(Mutex::new(true)), last_swap: Arc::new(Mutex::new(Instant::now())), - tab_index: 0, + pending_restore: Arc::new(Mutex::new(None)), } } @@ -112,12 +98,12 @@ impl ConsoleCard { spans.push(Span::styled(" ", Style::default().fg(Color::White))); } spans.push(Span::styled( - "send command ( for info)", + "send command (/help for info)", Style::default().fg(Color::DarkGray), )); } else { spans.push(Span::styled( - " send command ( for info)", + " send command (/help for info)", Style::default().fg(Color::DarkGray), )); } @@ -295,6 +281,12 @@ impl InteractableElement for ConsoleCard { } fn interact(&mut self, key: KeyEvent) -> InteractionResult { + // Check if a previously failed command should be restored. + if let Some(restored) = self.pending_restore.lock().unwrap().take() { + self.content = restored; + self.cursor_position = self.content.chars().count(); + } + match key.code { KeyCode::Enter => { if self.content.is_empty() { @@ -303,17 +295,15 @@ impl InteractableElement for ConsoleCard { let command = self.content.clone(); let ipc = self.ipc.clone(); - let seq = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; + let restore = self.pending_restore.clone(); tokio::spawn(async move { - let _ = ipc.send_command(seq, command).await; + if ipc.send_command(0, command.clone()).await.is_err() { + *restore.lock().unwrap() = Some(command); + } }); self.content.clear(); self.cursor_position = 0; - self.tab_index = 0; InteractionResult::Handled } KeyCode::Backspace => { @@ -350,14 +340,7 @@ impl InteractableElement for ConsoleCard { self.cursor_position = self.content.chars().count(); InteractionResult::Handled } - KeyCode::Tab => { - if let Some(prefix) = self.current_prefix() { - if prefix == "/" { - self.tab_index = self.tab_index.saturating_add(1); - } - } - InteractionResult::Handled - } + KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, _ => { if let Some(c) = key.code.as_char() { self.insert_at_cursor(c); @@ -381,144 +364,3 @@ impl InteractableElement for ConsoleCard { self.focused = f; } } - -#[cfg(feature = "legacy-commands")] -pub async fn run_command(command: &str) { - let parts = command.split(" ").collect::>(); - - match parts.as_slice() { - ["tasks"] => { - let active_tasks: Vec = - ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect(); - let info = if *SHUTDOWN.read().await && *RELOAD.read().await { - "Rebooting, " - } else if *SHUTDOWN.read().await { - "Shutting , " - } else { - "" - }; - log!("{}Active tasks: {:?}", info, active_tasks); - } - ["fps"] => { - let (fps, skips) = *FPS.read().await; - log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips); - } - - ["help"] => { - log!("Available commands: tasks, fps, ping, user, reconnect, regenerate"); - } - - ["help", "tasks"] => { - log!("Tasks command usage: tasks"); - } - ["help", "fps"] => { - log!("FPS command usage: fps"); - } - ["help", "ping"] => { - log!("Ping command usage: ping [time]"); - } - ["help", "user"] => { - log!("User command usage: user add | user remove | user list"); - } - ["help", "reconnect"] => { - log!("Reconnect command usage: reconnect. Retry connecting to the Omikron server"); - } - ["help", "regenerate"] => { - log!( - "Regenerate command usage: regenerate keys. Generate a new Iota key pair and reconnect" - ); - } - - ["ping"] => { - ping(20).await; - } - ["ping", time] => { - let time = time.parse::().unwrap_or(20); - ping(time).await; - } - ["user", "add", username] => { - if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await - { - log!("Created user {}", user.user_id); - } else { - log!("User creation: Failed to create user. See errors above."); - } - } - ["user", "remove", username] => { - if let Some(user) = user_manager::get_user_by_username(username) { - let msg = CommunicationValue::new(CommunicationType::DeleteUser) - .with_sender(user.user_id as u64); - let _ = OMIKRON_CONNECTION.send_message(&msg).await; - user_manager::remove_user(user.user_id); - log!("Removed user {}", user.user_id); - } else { - log!("User removal: Username doesn't exist"); - } - } - ["user", "list"] => { - let users: Vec = user_manager::get_users(); - for user in users { - let storage = file_util::get_designed_storage(user.user_id); - log!( - "> Username: {}, ID: {}, created at: {}, storage: {}", - user.username, - user.user_id, - user.created_at, - storage - ); - } - } - ["user", "info", username] => { - if let Some(user) = user_manager::get_user_by_username(username) { - user_manager::remove_user(user.user_id); - log!("Removed user {}", user.user_id); - } else { - log!("User info: Username doesn't exist"); - } - } - ["reconnect"] => { - log!("Reconnecting to Omikron server..."); - OMIKRON_CONNECTION.reconnect().await; - log!("Reconnected to Omikron server"); - } - ["regenerate", "keys"] => { - log!("Regenerating Iota key pair..."); - modify_config(|cfg| { - cfg.public_key = None; - cfg.private_key = None; - cfg.iota_id = None; - }); - log!("Key pair regenerated. Reconnecting to Omikron server..."); - OMIKRON_CONNECTION.reconnect().await; - log!("Reconnected with new key pair"); - } - ["reload"] | ["restart"] => { - log!("Restarting"); - *RELOAD.write().await = true; - *SHUTDOWN.write().await = true; - } - ["shutdown"] | ["stop"] => { - log!("Shutting down"); - *SHUTDOWN.write().await = true; - } - _ => { - log!("Unknown command"); - } - } -} - -#[cfg(feature = "legacy-commands")] -pub async fn ping(time: u64) { - let conn = OMIKRON_CONNECTION.clone(); - - let response_cv = conn - .await_response( - &CommunicationValue::new(CommunicationType::Ping), - Some(Duration::from_secs(time)), - ) - .await; - match response_cv { - Ok(response) => log_cv!(response), - Err(err) => log!("Ping error: {:?}", err), - } -} diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index c594f7e..018cfa2 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -1,43 +1,472 @@ -use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; +use iota_ipc::{ + ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, + RequestEnvelope, ResponseResult, read_msg, write_msg, +}; use iota_state::{ClientState, UiLogEntry}; +use std::collections::HashMap; use std::io::Result; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use tokio::net::UnixStream; -use tokio::net::unix::OwnedWriteHalf; -use tokio::sync::Mutex; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{Mutex, oneshot, watch}; + +const INITIAL_BACKOFF: Duration = Duration::from_millis(200); +const MAX_BACKOFF: Duration = Duration::from_secs(10); +const MAX_RECONNECT_ATTEMPTS: u32 = 50; + +/// Connection state exposed to the UI. +#[derive(Clone, Debug)] +pub enum IpcConnectionState { + Connecting, + Connected, + Reconnecting { attempt: u32 }, + Incompatible { message: String }, + Disconnected, +} + +/// Pending request awaiting a response. +struct PendingRequest { + response_tx: oneshot::Sender, +} /* The TUI owns this cache. IPC updates replace daemon snapshots and append * logs, so rendering never reaches into daemon-owned storage or connections. */ pub struct IpcClient { state: ClientState, writer: Mutex, + next_request_id: AtomicU64, + pending: Mutex>, + connection_state: watch::Sender, + path: PathBuf, } impl IpcClient { pub async fn connect(path: impl AsRef) -> Result> { - let stream = UnixStream::connect(path).await?; + let path = path.as_ref().to_path_buf(); + let stream = Self::try_connect(&path).await?; let (mut reader, writer) = stream.into_split(); + + let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting); let client = Arc::new(Self { state: ClientState::new(), writer: Mutex::new(writer), + next_request_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + connection_state: conn_state_tx, + path: path.clone(), }); + + // --- Handshake: send Hello, read HelloAck --- + { + let mut w = client.writer.lock().await; + write_msg( + &mut *w, + &ClientMessage::Hello { + supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], + }, + ) + .await?; + } + match read_msg::<_, DaemonMessage>(&mut reader).await { + Ok(DaemonMessage::HelloAck(ack)) => { + if ack.protocol_version < MIN_PROTOCOL_VERSION { + let _ = client + .connection_state + .send(IpcConnectionState::Incompatible { + message: format!( + "Daemon protocol {} < required {}", + ack.protocol_version, MIN_PROTOCOL_VERSION + ), + }); + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "Protocol version mismatch: daemon={}, minimum={}", + ack.protocol_version, MIN_PROTOCOL_VERSION + ), + )); + } + } + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Expected HelloAck from daemon", + )); + } + Err(e) => return Err(e), + } + + let _ = client.connection_state.send(IpcConnectionState::Connected); + + // Start reader task (continues reading after handshake) let reader_client = client.clone(); tokio::spawn(async move { - while let Ok(message) = read_msg::<_, DaemonMessage>(&mut reader).await { - reader_client.apply(message).await; - } + reader_client.read_loop(reader).await; }); - client.send(ClientMessage::Subscribe).await?; + + // Subscribe to events + client + .send(ClientMessage::Subscribe { + log_classes: vec![], + metric_interval_ms: Some(500), + }) + .await?; + Ok(client) } + /// Try to connect with retries for socket activation. + pub async fn connect_or_activate(path: impl AsRef) -> Result> { + let path = path.as_ref().to_path_buf(); + let max_attempts = 30; + for attempt in 0..max_attempts { + match Self::connect(&path).await { + Ok(client) => return Ok(client), + Err(error) => { + if attempt < max_attempts - 1 { + let delay = Duration::from_millis(100 + attempt as u64 * 100); + tokio::time::sleep(delay).await; + continue; + } + return Err(error); + } + } + } + unreachable!() + } + + async fn try_connect(path: &Path) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + match UnixStream::connect(path).await { + Ok(stream) => return Ok(stream), + Err(error) => { + if tokio::time::Instant::now() >= deadline { + return Err(error); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + } + } + + /// Start the reconnection actor. + pub fn spawn_reconnector(self: &Arc) { + let client = self.clone(); + tokio::spawn(async move { + client.reconnection_loop().await; + }); + } + + async fn reconnection_loop(self: Arc) { + let mut rx = self.connection_status(); + + loop { + // Wait until the connection enters the Disconnected state. + loop { + let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected); + if disconnected { + break; + } + if rx.changed().await.is_err() { + return; // sender dropped + } + } + + let mut backoff = INITIAL_BACKOFF; + let mut attempt: u32 = 0; + + // Attempt reconnection until success or max attempts. + loop { + tokio::time::sleep(backoff).await; + attempt += 1; + + if attempt > MAX_RECONNECT_ATTEMPTS { + let _ = self + .connection_state + .send(IpcConnectionState::Incompatible { + message: "Max reconnection attempts exceeded".into(), + }); + return; + } + + let _ = self + .connection_state + .send(IpcConnectionState::Reconnecting { attempt }); + + match Self::try_connect(&self.path).await { + Ok(stream) => { + let (mut reader, writer) = stream.into_split(); + *self.writer.lock().await = writer; + + // Re-handshake + { + let mut w = self.writer.lock().await; + if write_msg( + &mut *w, + &ClientMessage::Hello { + supported_versions: vec![ + MIN_PROTOCOL_VERSION, + PROTOCOL_VERSION, + ], + }, + ) + .await + .is_err() + { + let _ = self + .connection_state + .send(IpcConnectionState::Disconnected); + break; + } + } + + // Read HelloAck + match read_msg::<_, DaemonMessage>(&mut reader).await { + Ok(DaemonMessage::HelloAck(ack)) => { + if ack.protocol_version < MIN_PROTOCOL_VERSION { + let _ = self.connection_state.send( + IpcConnectionState::Incompatible { + message: format!( + "Daemon protocol {} < required {}", + ack.protocol_version, MIN_PROTOCOL_VERSION + ), + }, + ); + return; + } + } + _ => { + let _ = self + .connection_state + .send(IpcConnectionState::Disconnected); + break; + } + } + + // Clear pending requests with connection-lost errors + { + let mut pending = self.pending.lock().await; + for (_, request) in pending.drain() { + let _ = request.response_tx.send( + ResponseResult::Error( + iota_ipc::IpcErrorCode::Disconnected, + ), + ); + } + } + + let _ = self.connection_state.send(IpcConnectionState::Connected); + + // Start new reader loop + let reader_client = self.clone(); + tokio::spawn(async move { + reader_client.read_loop(reader).await; + }); + + // Resubscribe + let _ = self + .send(ClientMessage::Subscribe { + log_classes: vec![], + metric_interval_ms: Some(500), + }) + .await; + + // Successfully reconnected; go back to waiting for + // the next disconnect. + break; + } + Err(_) => { + backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); + } + } + } + } + } + + async fn read_loop(self: Arc, mut reader: OwnedReadHalf) { + loop { + match read_msg::<_, DaemonMessage>(&mut reader).await { + Ok(message) => self.apply(message).await, + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { + let _ = self.connection_state.send(IpcConnectionState::Disconnected); + break; + } + Err(_) => { + let _ = self.connection_state.send(IpcConnectionState::Disconnected); + break; + } + } + } + } + pub fn state(&self) -> ClientState { self.state.clone() } - pub async fn send_command(&self, seq: u64, line: String) -> Result<()> { - self.send(ClientMessage::Command { seq, line }).await + pub fn connection_status(&self) -> watch::Receiver { + self.connection_state.subscribe() + } + + pub fn connection_status_snapshot(&self) -> IpcConnectionState { + self.connection_state.borrow().clone() + } + + pub async fn send_request(&self, request: LocalRequest) -> Result { + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + + { + let mut pending = self.pending.lock().await; + pending.insert(request_id, PendingRequest { response_tx }); + } + + let envelope = RequestEnvelope { + request_id, + protocol_version: PROTOCOL_VERSION, + request, + }; + self.send(ClientMessage::Request(envelope)).await?; + + match tokio::time::timeout(Duration::from_secs(30), response_rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), + Err(_) => { + self.pending.lock().await.remove(&request_id); + Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)) + } + } + } + + /// Parse a legacy console command string into a typed request. + pub fn parse_console_command(line: &str) -> Option { + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); + match parts.as_slice() { + ["help"] => None, + ["tasks"] => Some(LocalRequest::ListTasks), + ["user", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), + ["user", "remove", user_id_str] => { + let user_id = user_id_str.parse::().ok()?; + Some(LocalRequest::RemoveUser { user_id }) + } + ["user", "list"] => Some(LocalRequest::ListUsers), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon), + ["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon), + _ => None, + } + } + + /// Legacy command interface: parse text command, send as typed request. + pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> { + let trimmed = line.trim_start_matches('/').trim(); + + // Handle ping as a direct Ping message (not a LocalRequest). + if trimmed == "ping" || trimmed.starts_with("ping ") { + let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed); + if let Err(e) = self.send(ClientMessage::Ping { seq }).await { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Console".into(), + message: format!("Failed to send ping: {}", e), + is_error: true, + }); + return Err(e); + } + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Console".into(), + message: "Ping sent".into(), + is_error: false, + }); + return Ok(()); + } + + if let Some(request) = Self::parse_console_command(&line) { + match self.send_request(request).await { + Ok(result) => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + let message = match &result { + ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Error(code) => format!("Error: {:?}", code), + }; + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Command".into(), + message, + is_error: matches!(&result, ResponseResult::Error(_)), + }); + Ok(()) + } + Err(e) => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Console".into(), + message: format!("Failed to send: {}", e), + is_error: true, + }); + Err(e) + } + } + } else { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Console".into(), + message: if line.trim() == "help" { + "Available commands: tasks, ping, user, reconnect, regenerate, restart, stop" + .into() + } else { + format!("Unknown command: {}", line) + }, + is_error: false, + }); + Ok(()) + } } async fn send(&self, message: ClientMessage) -> Result<()> { @@ -46,19 +475,26 @@ impl IpcClient { } async fn apply(&self, message: DaemonMessage) { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); match message { - DaemonMessage::LogEntry(entry) => state.push_log(UiLogEntry { - timestamp_ms: entry.timestamp_ms, - sender: entry.sender, - message: entry.message, - is_error: entry.is_error, - }), + DaemonMessage::LogEntry(entry) => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: entry.timestamp_ms, + sender: entry.sender, + message: entry.message, + is_error: entry.is_error, + }); + } DaemonMessage::StateUpdate(snapshot) => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); state.cpu = snapshot.cpu; state.ram = snapshot.ram; state.ping = snapshot.ping; @@ -66,18 +502,106 @@ impl IpcClient { state.net_down = snapshot.net_down; state.sys_info = snapshot.sys_info; } - DaemonMessage::CommandResult { - success, message, .. - } => state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Command".into(), - message, - is_error: !success, - }), + DaemonMessage::MetricSample(sample) => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(cpu) = sample.cpu { + let idx = state.cpu.len() as f64; + state.cpu.push((idx, cpu)); + if state.cpu.len() > iota_state::MAX_POINTS { + state.cpu.remove(0); + } + } + if let Some(ram) = sample.ram { + let idx = state.ram.len() as f64; + state.ram.push((idx, ram)); + if state.ram.len() > iota_state::MAX_POINTS { + state.ram.remove(0); + } + } + if let Some(ping) = sample.ping { + state.push_ping_val(ping); + } + if let Some(net_up) = sample.net_up { + let idx = state.net_up.len() as f64; + state.net_up.push((idx, net_up)); + if state.net_up.len() > iota_state::MAX_POINTS { + state.net_up.remove(0); + } + } + if let Some(net_down) = sample.net_down { + let idx = state.net_down.len() as f64; + state.net_down.push((idx, net_down)); + if state.net_down.len() > iota_state::MAX_POINTS { + state.net_down.remove(0); + } + } + } + DaemonMessage::Response(response) => { + let mut pending = self.pending.lock().await; + if let Some(request) = pending.remove(&response.request_id) { + let _ = request.response_tx.send(response.result); + } else { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + let message = match &response.result { + ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Error(code) => format!("Error: {:?}", code), + }; + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Command".into(), + message, + is_error: matches!(&response.result, ResponseResult::Error(_)), + }); + } + } + DaemonMessage::HelloAck(_) => {} DaemonMessage::Pong { .. } => {} + DaemonMessage::LifecycleEvent(event) => match event { + iota_ipc::LifecycleEvent::Shutdown { reason } => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "Daemon".into(), + message: format!("Daemon shutting down: {}", reason), + is_error: true, + }); + } + _ => {} + }, + DaemonMessage::Gap { skipped } => { + let mut state = self + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_log(UiLogEntry { + timestamp_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + sender: "System".into(), + message: format!("Skipped {} messages, resynchronizing", skipped), + is_error: false, + }); + } } } } diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 576afeb..49828c2 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -6,6 +6,7 @@ use crate::{ log_card::LogCard, }, interaction_result::InteractionResult, + ipc_client::IpcConnectionState, screens::screens::{NavDirection, Screen}, ui::UI, }; @@ -16,6 +17,7 @@ use ratatui::{ layout::{Constraint, Layout, Margin, Rect}, widgets::{Block, Borders}, }; +use tokio::sync::watch; use std::{any::Any, sync::Arc}; @@ -24,6 +26,7 @@ pub struct MainScreen { nav_grid: Vec>>, selected_coords: (usize, usize), graphs_open: bool, + connection_status_rx: watch::Receiver, } impl MainScreen { @@ -58,11 +61,14 @@ impl MainScreen { let graphs_open = true; + let connection_status_rx = ui.ipc().connection_status(); + let mut screen = MainScreen { elements, nav_grid, selected_coords: (1, 0), graphs_open, + connection_status_rx, }; screen.focus_current(); screen @@ -147,6 +153,40 @@ impl MainScreen { self.focus_current(); } + + /// Cycle focus between unique elements in the navigation grid. + fn navigate_focus(&mut self, forward: bool) { + // Collect unique elements in grid order. + let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col) + let mut seen: Vec> = Vec::new(); + for (y, row) in self.nav_grid.iter().enumerate() { + for (x, elem_opt) in row.iter().enumerate() { + if elem_opt.is_some() && !seen.contains(elem_opt) { + seen.push(*elem_opt); + positions.push((y, x)); + } + } + } + + let current = self.selected_coords; + let current_pos = positions + .iter() + .position(|&(r, c)| r == current.0 && c == current.1); + + let next_pos = if let Some(idx) = current_pos { + if forward { + (idx + 1) % positions.len() + } else { + (idx + positions.len() - 1) % positions.len() + } + } else { + 0 + }; + + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = positions[next_pos]; + self.focus_current(); + } } impl Screen for MainScreen { @@ -159,7 +199,21 @@ impl Screen for MainScreen { } fn render(&self, f: &mut Frame, rect: Rect) { - let main_block = Block::default().title("Main").borders(Borders::ALL); + let status = self.connection_status_rx.borrow(); + let status_text = match &*status { + IpcConnectionState::Connected => "Connected".to_string(), + IpcConnectionState::Connecting => "Connecting...".to_string(), + IpcConnectionState::Reconnecting { attempt } => { + format!("Reconnecting (attempt {})...", attempt) + } + IpcConnectionState::Incompatible { message } => { + format!("Incompatible: {}", message) + } + IpcConnectionState::Disconnected => "Disconnected".to_string(), + }; + let main_block = Block::default() + .title(format!("Main [{}]", status_text)) + .borders(Borders::ALL); f.render_widget(main_block, rect); let inner = rect.inner(Margin { @@ -215,10 +269,14 @@ impl Screen for MainScreen { fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { match event.code { - KeyCode::Up => self.navigate(NavDirection::Up), - KeyCode::Down => self.navigate(NavDirection::Down), - KeyCode::Left => self.navigate(NavDirection::Left), - KeyCode::Right => self.navigate(NavDirection::Right), + KeyCode::Tab => { + self.navigate_focus(true); + return InteractionResult::Handled; + } + KeyCode::BackTab => { + self.navigate_focus(false); + return InteractionResult::Handled; + } KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { self.graphs_open = !self.graphs_open; for element in self.elements.iter_mut() { @@ -232,7 +290,17 @@ impl Screen for MainScreen { let (y, x) = self.selected_coords; if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) { if let Some(el) = self.elements.get_mut(*index) { - return el.interact(event); + let result = el.interact(event); + if matches!(result, InteractionResult::Unhandled) { + match event.code { + KeyCode::Up => self.navigate(NavDirection::Up), + KeyCode::Down => self.navigate(NavDirection::Down), + KeyCode::Left => self.navigate(NavDirection::Left), + KeyCode::Right => self.navigate(NavDirection::Right), + _ => {} + } + } + return result; } } } diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 3f7a5f6..06a58f0 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -24,3 +24,4 @@ pnet = "0.35.0" ratatui = "0.30.0" reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index b0a3bb6..35f9077 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -153,7 +153,7 @@ async fn main() { if !web_server::start(port).await { log!("Failed to start the MTP web server on port {}", port); } - let _ = omikron::omikron_connection::get_omikron_connection().await; + let _ = omikron::omikron_connection::get_omikron_connection(tokio_util::sync::CancellationToken::new()).await; log_t!("setup_completed"); loop { diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index abb2245..10fd67b 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -11,5 +11,9 @@ iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +dashmap = "6.1.0" +libc = "0.2" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +uuid = { version = "*", features = ["v4"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 11e2ddd..53f4327 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,5 +1,7 @@ use crate::DaemonRuntime; -use iota_ipc::DaemonMessage; +use iota_ipc::{ + IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult, +}; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; use iota_storage::util::config_util::modify_config; @@ -8,6 +10,8 @@ use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use std::sync::Arc; use std::time::Duration; +use crate::daemon_state::ShutdownReason; + #[derive(Clone)] pub struct CommandRouter { runtime: Arc, @@ -18,85 +22,124 @@ impl CommandRouter { Self { runtime } } - pub async fn route(&self, seq: u64, line: String) -> DaemonMessage { - log_command!("{}", line); - let result = self.execute(&line).await; - DaemonMessage::CommandResult { - seq, - success: result.is_ok(), - message: result.unwrap_or_else(|error| error), + pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { + log_command!("{:?}", request); + let result = self.execute(request).await; + ResponseEnvelope { + request_id, + result, } } - async fn execute(&self, line: &str) -> Result { - let parts = line + /// Parse a legacy console command string into a typed request. + pub fn parse_console_command(line: &str) -> Option { + let parts: Vec<&str> = line .trim_start_matches('/') .split_whitespace() - .collect::>(); + .collect(); match parts.as_slice() { - ["tasks"] => Ok(self - .runtime - .state - .active_tasks - .iter() - .map(|task| task.to_string()) - .collect::>() - .join(", ")), - ["help"] => Ok( - "Available commands: tasks, ping, user, reconnect, regenerate, reload, shutdown" - .into(), - ), - ["ping"] => self.ping(20).await, - ["ping", seconds] => self.ping(seconds.parse::().unwrap_or(20)).await, - ["user", "add", username] => { - let (user, _) = omikron_connector::user_ops::create_user(username).await; - user.map(|user| format!("Created user {}", user.user_id)) - .ok_or_else(|| "User creation failed".into()) - } + ["help"] => None, + ["tasks"] => Some(LocalRequest::ListTasks), + ["ping", _] | ["ping"] => None, + ["user", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), ["user", "remove", username] => { - let user = user_manager::get_user_by_username(username) - .ok_or_else(|| "Username does not exist".to_string())?; + let user = user_manager::get_user_by_username(username)?; + Some(LocalRequest::RemoveUser { + user_id: user.user_id, + }) + } + ["user", "list"] => Some(LocalRequest::ListUsers), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon), + ["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon), + _ => None, + } + } + + async fn execute(&self, request: LocalRequest) -> ResponseResult { + match request { + LocalRequest::GetStatus => { + let phase = self.runtime.current_startup_phase(); + let degraded = self.runtime.degraded_reason.borrow().clone(); + let tasks: Vec = self + .runtime + .state + .active_tasks + .iter() + .map(|task| task.to_string()) + .collect(); + let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", ")); + if let Some(reason) = degraded { + info.push_str(&format!(", Degraded: {}", reason)); + } + ResponseResult::Ok(info) + } + LocalRequest::ListTasks => { + let tasks: Vec = self + .runtime + .state + .active_tasks + .iter() + .map(|task| task.to_string()) + .collect(); + ResponseResult::Ok(tasks.join(", ")) + } + LocalRequest::ListUsers => { + let users: Vec = user_manager::get_users() + .into_iter() + .map(|user| format!("{} ({})", user.username, user.user_id)) + .collect(); + ResponseResult::Ok(users.join("\n")) + } + LocalRequest::CreateUser { username } => { + match omikron_connector::user_ops::create_user(&username).await { + (Some(user), _) => { + ResponseResult::Ok(format!("Created user {}", user.user_id)) + } + _ => ResponseResult::Error(IpcErrorCode::StorageFailure), + } + } + LocalRequest::RemoveUser { user_id } => { + let user = match user_manager::get_user(user_id) { + Some(user) => user, + None => return ResponseResult::Error(IpcErrorCode::NotFound), + }; let message = CommunicationValue::new(CommunicationType::DeleteUser) .with_sender(user.user_id as u64); - OMIKRON_CONNECTION - .send_message(&message) - .await - .map_err(|error| error.to_string())?; + if let Err(_e) = OMIKRON_CONNECTION.send_message(&message).await { + return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); + } user_manager::remove_user(user.user_id); - Ok(format!("Removed user {}", user.user_id)) + ResponseResult::Ok(format!("Removed user {}", user.user_id)) } - ["user", "list"] => Ok(user_manager::get_users() - .into_iter() - .map(|user| format!("{} ({})", user.username, user.user_id)) - .collect::>() - .join("\n")), - ["reconnect"] => { + LocalRequest::ReconnectOmikron => { OMIKRON_CONNECTION.reconnect().await; - Ok("Reconnected to Omikron server".into()) + ResponseResult::Ok("Reconnected to Omikron server".into()) } - ["regenerate", "keys"] => { + LocalRequest::RotateIotaIdentity => { modify_config(|config| { config.public_key = None; config.private_key = None; config.iota_id = None; }); OMIKRON_CONNECTION.reconnect().await; - Ok("Key pair regenerated and Omikron reconnection requested".into()) + ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into()) } - ["reload"] | ["restart"] => { - *self.runtime.state.reload.write().await = true; - *self.runtime.state.shutdown.write().await = true; - Ok("Daemon restart requested".into()) + LocalRequest::RestartDaemon => { + self.runtime.shutdown(ShutdownReason::Restart); + ResponseResult::Ok("Daemon restart requested".into()) } - ["shutdown"] | ["stop"] => { - *self.runtime.state.shutdown.write().await = true; - Ok("Daemon shutdown requested".into()) + LocalRequest::StopDaemon => { + self.runtime.shutdown(ShutdownReason::Stop); + ResponseResult::Ok("Daemon shutdown requested".into()) } - _ => Err("Unknown command".into()), } } - async fn ping(&self, seconds: u64) -> Result { + pub async fn ping(&self, seconds: u64) -> Result { let response = OMIKRON_CONNECTION .await_response( &CommunicationValue::new(CommunicationType::Ping), diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs index ef26e63..aa998b6 100644 --- a/iota-daemon-lib/src/daemon_state.rs +++ b/iota-daemon-lib/src/daemon_state.rs @@ -3,21 +3,123 @@ use iota_state::DaemonState; use std::sync::Arc; use std::time::Duration; use sysinfo::{RefreshKind, System}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +/// Reason the daemon is shutting down. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShutdownReason { + Stop, + Restart, + Fatal(String), +} + +impl ShutdownReason { + pub fn exit_code(&self) -> i32 { + match self { + ShutdownReason::Stop => 0, + ShutdownReason::Restart => 75, + ShutdownReason::Fatal(_) => 1, + } + } +} + +/// Tracks the lifecycle phase of the daemon for IPC visibility. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartupPhase { + Starting, + MigratingStorage, + LoadingUsers, + StartingServices, + Ready, + Degraded, + Stopping, +} + +impl From for iota_ipc::StartupPhase { + fn from(phase: StartupPhase) -> Self { + match phase { + StartupPhase::Starting => iota_ipc::StartupPhase::Starting, + StartupPhase::MigratingStorage => iota_ipc::StartupPhase::MigratingStorage, + StartupPhase::LoadingUsers => iota_ipc::StartupPhase::LoadingUsers, + StartupPhase::StartingServices => iota_ipc::StartupPhase::StartingServices, + StartupPhase::Ready => iota_ipc::StartupPhase::Ready, + StartupPhase::Degraded => iota_ipc::StartupPhase::Degraded, + StartupPhase::Stopping => iota_ipc::StartupPhase::Stopping, + } + } +} /* This wrapper exposes daemon state as IPC-safe snapshots while preserving a - * single owned state instance for all daemon subsystems. */ -#[derive(Clone, Default)] + * single owned state instance for all daemon subsystems. The cancellation token + * is the single lifecycle signal — all subsystems check it instead of a + * separate boolean. */ pub struct DaemonRuntime { pub state: Arc, + pub cancellation: CancellationToken, + pub shutdown_tx: watch::Sender>, + pub startup_phase: watch::Sender, + pub degraded_reason: watch::Sender>, +} + +impl Clone for DaemonRuntime { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + cancellation: self.cancellation.clone(), + shutdown_tx: self.shutdown_tx.clone(), + startup_phase: self.startup_phase.clone(), + degraded_reason: self.degraded_reason.clone(), + } + } +} + +impl Default for DaemonRuntime { + fn default() -> Self { + Self::new() + } } impl DaemonRuntime { pub fn new() -> Self { + let (shutdown_tx, _) = watch::channel(None); + let (startup_phase, _) = watch::channel(StartupPhase::Starting); + let (degraded_reason, _) = watch::channel(None); Self { state: Arc::new(DaemonState::new()), + cancellation: CancellationToken::new(), + shutdown_tx, + startup_phase, + degraded_reason, } } + pub fn shutdown(&self, reason: ShutdownReason) { + self.cancellation.cancel(); + let _ = self.shutdown_tx.send(Some(reason)); + } + + pub fn shutdown_reason(&self) -> Option { + self.shutdown_tx.borrow().clone() + } + + pub fn is_shutting_down(&self) -> bool { + self.cancellation.is_cancelled() + } + + pub fn set_startup_phase(&self, phase: StartupPhase) { + let _ = self.startup_phase.send(phase); + } + + pub fn current_startup_phase(&self) -> StartupPhase { + *self.startup_phase.borrow() + } + + pub fn mark_degraded(&self, reason: String) { + let _ = self.degraded_reason.send(Some(reason.clone())); + let _ = self.startup_phase.send(StartupPhase::Degraded); + } + pub fn snapshot(&self) -> StateSnapshot { let state = self .state @@ -41,7 +143,7 @@ impl DaemonRuntime { let mut system = System::new_with_specifics(RefreshKind::everything()); let mut counter = 0.0; loop { - if *runtime.state.shutdown.read().await { + if runtime.is_shutting_down() { break; } system.refresh_cpu_all(); diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index 11f62f4..2e3f6e9 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,28 +1,42 @@ use crate::{CommandRouter, DaemonRuntime}; -use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; +use iota_ipc::{ + ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, + write_msg, +}; +use iota_logger::log; use std::io::Result; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{env, os::fd::FromRawFd}; use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, mpsc, watch}; +use uuid::Uuid; + +/// Per-client outbound queue capacity. +const CLIENT_CHANNEL_SIZE: usize = 256; + +/// Maximum handshake retries before giving up. +const MAX_HANDSHAKE_RETRIES: u32 = 10; pub struct IpcServer { path: PathBuf, runtime: Arc, - messages: broadcast::Sender, + log_tx: broadcast::Sender, + state_rx: watch::Sender, } impl IpcServer { pub fn new( path: impl Into, runtime: Arc, - messages: broadcast::Sender, + log_tx: broadcast::Sender, + state_rx: watch::Sender, ) -> Self { Self { path: path.into(), runtime, - messages, + log_tx, + state_rx, } } @@ -38,11 +52,14 @@ impl IpcServer { } }; loop { - let (stream, _) = listener.accept().await?; + let (stream, _addr) = listener.accept().await?; let runtime = self.runtime.clone(); - let messages = self.messages.clone(); + let log_tx = self.log_tx.clone(); + let state_rx = self.state_rx.clone(); tokio::spawn(async move { - let _ = handle_client(stream, runtime, messages).await; + if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await { + eprintln!("IPC client error: {error}"); + } }); } } @@ -72,34 +89,159 @@ async fn remove_stale_socket(path: &Path) -> Result<()> { } } +#[derive(Clone, Debug)] +struct PeerIdentity { + pid: i32, + uid: u32, + gid: u32, +} + +fn peer_credentials(stream: &UnixStream) -> PeerIdentity { + #[cfg(target_os = "linux")] + { + use std::os::unix::io::AsRawFd; + unsafe { + let mut cred: libc::ucred = std::mem::zeroed(); + let mut len = std::mem::size_of::() as libc::socklen_t; + let fd = stream.as_raw_fd(); + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut libc::c_void, + &mut len, + ); + PeerIdentity { + pid: cred.pid, + uid: cred.uid, + gid: cred.gid, + } + } + } + #[cfg(not(target_os = "linux"))] + { + PeerIdentity { + pid: 0, + uid: 0, + gid: 0, + } + } +} + async fn handle_client( stream: UnixStream, runtime: Arc, - messages: broadcast::Sender, + log_tx: broadcast::Sender, + _state_rx: watch::Sender, ) -> Result<()> { + let peer = peer_credentials(&stream); let (mut reader, mut writer) = stream.into_split(); - let mut outgoing = messages.subscribe(); - let initial = DaemonMessage::StateUpdate(runtime.snapshot()); - write_msg(&mut writer, &initial).await?; - let writer_task = tokio::spawn(async move { - while let Ok(message) = outgoing.recv().await { - if write_msg(&mut writer, &message).await.is_err() { + let (directed_tx, directed_rx) = mpsc::channel::(CLIENT_CHANNEL_SIZE); + + // --- Handshake --- + let mut negotiated_version: Option = None; + for _ in 0..MAX_HANDSHAKE_RETRIES { + match read_msg::<_, ClientMessage>(&mut reader).await { + Ok(ClientMessage::Hello { supported_versions }) => { + let version = supported_versions + .iter() + .copied() + .find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) + .unwrap_or(PROTOCOL_VERSION); + negotiated_version = Some(version); + let instance_id = Uuid::new_v4().to_string(); + let ack = DaemonMessage::HelloAck(HelloAck { + protocol_version: version, + daemon_version: env!("CARGO_PKG_VERSION").to_string(), + instance_id, + startup_phase: runtime.current_startup_phase().into(), + capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], + }); + write_msg(&mut writer, &ack).await?; break; } + Ok(_) => { + // Unexpected first message — send error and close. + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Expected Hello as first message", + )); + } + Err(e) => return Err(e), } - }); + } + let _version = negotiated_version.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "Handshake failed after retries") + })?; + + log!("IPC client connected (pid={}, uid={})", peer.pid, peer.uid); + + // --- Send initial state snapshot --- + let initial = DaemonMessage::StateUpdate(runtime.snapshot()); + let _ = directed_tx.send(initial).await; + + // --- Writer task: merge directed responses + shared log events --- + let mut log_rx = log_tx.subscribe(); + let directed_for_writer = directed_tx.clone(); + let writer_task = { + let runtime = runtime.clone(); + tokio::spawn(async move { + let mut directed_rx = directed_rx; + loop { + tokio::select! { + // Directed messages (responses to this client's requests) + msg = directed_rx.recv() => { + match msg { + Some(message) => { + if write_msg(&mut writer, &message).await.is_err() { + break; + } + } + None => break, + } + } + // Shared log events + result = log_rx.recv() => { + match result { + Ok(message) => { + if write_msg(&mut writer, &message).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await; + // Then send current snapshot for resync + let _ = directed_for_writer.send( + DaemonMessage::StateUpdate(runtime.snapshot()) + ).await; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + }) + }; + + // --- Reader loop --- let router = CommandRouter::new(runtime.clone()); loop { match read_msg::<_, ClientMessage>(&mut reader).await { - Ok(ClientMessage::Command { seq, line }) => { - let result = router.route(seq, line).await; - let _ = messages.send(result); + Ok(ClientMessage::Request(envelope)) => { + let response = router.route(envelope.request_id, envelope.request).await; + let _ = directed_tx.send(DaemonMessage::Response(response)).await; } - Ok(ClientMessage::Subscribe) => { - let _ = messages.send(DaemonMessage::StateUpdate(runtime.snapshot())); + Ok(ClientMessage::Subscribe { .. }) => { + let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); + let _ = directed_tx.send(snapshot).await; } Ok(ClientMessage::Ping { seq }) => { - let _ = messages.send(DaemonMessage::Pong { seq }); + let _ = directed_tx.send(DaemonMessage::Pong { seq }).await; + } + Ok(ClientMessage::Hello { .. }) => { + // Re-handshake on existing connection: treat as resubscribe + let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); + let _ = directed_tx.send(snapshot).await; } Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(error) => { @@ -109,5 +251,10 @@ async fn handle_client( } } writer_task.abort(); + log!( + "IPC client disconnected (pid={}, uid={})", + peer.pid, + peer.uid + ); Ok(()) } diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 7f10773..48f8c68 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -4,5 +4,5 @@ pub mod ipc_server; pub mod log_broadcaster; pub use command_router::CommandRouter; -pub use daemon_state::DaemonRuntime; +pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase}; pub use ipc_server::IpcServer; diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index e5d5864..620b413 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -12,3 +12,4 @@ iota-storage = { path = "../iota-storage" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 2cdb8ff..b9404f1 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,12 +1,11 @@ -use iota_daemon_lib::{DaemonRuntime, IpcServer, log_broadcaster}; +use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster}; use iota_logger::{self as logger, log, log_t}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tokio::sync::broadcast; - +use tokio::sync::{broadcast, watch}; fn socket_path() -> PathBuf { std::env::var_os("IOTA_SOCKET") .map(PathBuf::from) @@ -17,42 +16,79 @@ fn socket_path() -> PathBuf { async fn main() { logger::startup(); iota_storage::util::config_util::load_config(); + + let runtime = Arc::new(DaemonRuntime::new()); + runtime.set_startup_phase(StartupPhase::LoadingUsers); + if user_manager::load_users().await.is_err() { log_t!("user_load_failed"); } - let runtime = Arc::new(DaemonRuntime::new()); + + // --- IPC infrastructure --- + let (log_tx, _) = broadcast::channel(512); + log_broadcaster::spawn(log_tx.clone()); + let (state_tx, _state_rx) = watch::channel(iota_ipc::StateSnapshot::default()); + + // --- Start IPC server early (before services) so clients can see startup phases --- + runtime.set_startup_phase(StartupPhase::StartingServices); + let ipc_server = IpcServer::new( + socket_path(), + runtime.clone(), + log_tx.clone(), + state_tx.clone(), + ); + tokio::spawn(async move { + if let Err(error) = ipc_server.run().await { + eprintln!("iota-daemon IPC server failed: {error}"); + } + }); + log!("iota-daemon IPC server started"); + + // --- System monitor --- runtime.spawn_system_monitor(); - let (messages, _) = broadcast::channel(512); - log_broadcaster::spawn(messages.clone()); - let state_updates = runtime.clone(); - let state_messages = messages.clone(); + + // --- State update publisher (watch-based, no full broadcast per tick) --- + let state_publisher = runtime.clone(); tokio::spawn(async move { loop { - if *state_updates.state.shutdown.read().await { + if state_publisher.is_shutting_down() { break; } - let _ = state_messages.send(iota_ipc::DaemonMessage::StateUpdate( - state_updates.snapshot(), - )); + let snapshot = state_publisher.snapshot(); + let _ = state_tx.send(snapshot); tokio::time::sleep(Duration::from_millis(500)).await; } }); + // --- Web server --- let port = CONFIG.load().port; - if !web_server::start(port).await { + if !web_server::start(port, runtime.cancellation.clone()).await { log!("Failed to start the MTP web server on port {}", port); + runtime.mark_degraded("MTP web server failed to start".into()); } - let _ = omikron_connector::omikron_connection::get_omikron_connection().await; - let server = IpcServer::new(socket_path(), runtime.clone(), messages); - tokio::spawn(async move { - if let Err(error) = server.run().await { - eprintln!("iota-daemon IPC server failed: {error}"); - } - }); - log!("iota-daemon started"); - while !*runtime.state.shutdown.read().await { - tokio::time::sleep(Duration::from_millis(250)).await; + // --- Omikron connection --- + let omikron_result = + omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone()) + .await; + if omikron_result.is_none() { + runtime.mark_degraded("Omikron connection unavailable".into()); } - log!("iota-daemon stopping"); + + runtime.set_startup_phase(StartupPhase::Ready); + log!("iota-daemon started (phase: Ready)"); + + // --- Main lifecycle loop --- + runtime.cancellation.cancelled().await; + + let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop); + log!("iota-daemon shutting down (reason: {:?})", reason); + runtime.set_startup_phase(StartupPhase::Stopping); + + // Wait a moment for in-flight operations to complete + tokio::time::sleep(Duration::from_millis(500)).await; + + let exit_code = reason.exit_code(); + log!("iota-daemon exited (code: {})", exit_code); + std::process::exit(exit_code); } diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 8cbd6fa..11105c2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -1,5 +1,14 @@ pub mod protocol; pub mod transport; -pub use protocol::{ClientMessage, DaemonMessage, LogEntry, StateSnapshot}; +pub use protocol::{ + ClientMessage, DaemonMessage, HelloAck, LogEntry, StateSnapshot, MetricSample, + RequestEnvelope, ResponseEnvelope, ResponseResult, LocalRequest, IpcErrorCode, + ConnectionStatus, StartupPhase, LifecycleEvent, +}; pub use transport::{read_msg, write_msg}; + +/// Current IPC protocol version. +pub const PROTOCOL_VERSION: u16 = 2; +/// Minimum protocol version this daemon understands. +pub const MIN_PROTOCOL_VERSION: u16 = 2; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 09a723b..35a93d6 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -1,28 +1,123 @@ use serde::{Deserialize, Serialize}; +// --------------------------------------------------------------------------- +// Client → Daemon +// --------------------------------------------------------------------------- + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ClientMessage { - Command { seq: u64, line: String }, - Subscribe, + Hello { supported_versions: Vec }, + Subscribe { log_classes: Vec, metric_interval_ms: Option }, + Request(RequestEnvelope), Ping { seq: u64 }, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RequestEnvelope { + pub request_id: u64, + pub protocol_version: u16, + pub request: LocalRequest, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum LocalRequest { + GetStatus, + ListTasks, + ListUsers, + CreateUser { username: String }, + RemoveUser { user_id: i64 }, + ReconnectOmikron, + RotateIotaIdentity, + RestartDaemon, + StopDaemon, +} + +// --------------------------------------------------------------------------- +// Daemon → Client +// --------------------------------------------------------------------------- + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum DaemonMessage { + HelloAck(HelloAck), LogEntry(LogEntry), StateUpdate(StateSnapshot), - CommandResult { - seq: u64, - success: bool, - message: String, - }, - Pong { - seq: u64, - }, + MetricSample(MetricSample), + Response(ResponseEnvelope), + Pong { seq: u64 }, + LifecycleEvent(LifecycleEvent), + Gap { skipped: u64 }, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct HelloAck { + pub protocol_version: u16, + pub daemon_version: String, + pub instance_id: String, + pub startup_phase: StartupPhase, + pub capabilities: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ResponseEnvelope { + pub request_id: u64, + pub result: ResponseResult, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ResponseResult { + Ok(String), + Error(IpcErrorCode), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IpcErrorCode { + InvalidRequest, + NotFound, + Conflict, + StorageFailure, + OmikronUnavailable, + UnsupportedVersion, + NotReady, + Disconnected, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum LifecycleEvent { + StateChanged(ConnectionStatus), + Shutdown { reason: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionStatus { + Connected, + Reconnecting, + Degraded, + Disconnected, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StartupPhase { + Starting, + MigratingStorage, + LoadingUsers, + StartingServices, + Ready, + Degraded, + Stopping, +} + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct LogEntry { pub timestamp_ms: u128, @@ -40,3 +135,12 @@ pub struct StateSnapshot { pub net_down: Vec<(f64, f64)>, pub sys_info: String, } + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct MetricSample { + pub cpu: Option, + pub ram: Option, + pub ping: Option, + pub net_up: Option, + pub net_down: Option, +} diff --git a/iota-ipc/src/transport.rs b/iota-ipc/src/transport.rs index 811b0ee..2793451 100644 --- a/iota-ipc/src/transport.rs +++ b/iota-ipc/src/transport.rs @@ -41,19 +41,20 @@ where #[cfg(test)] mod tests { use super::{read_msg, write_msg}; - use crate::protocol::ClientMessage; + use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope}; #[tokio::test] async fn round_trips_framed_messages() { let (mut writer, mut reader) = tokio::io::duplex(1024); - let message = ClientMessage::Command { - seq: 4, - line: "help".into(), - }; + let message = ClientMessage::Request(RequestEnvelope { + request_id: 4, + protocol_version: 2, + request: LocalRequest::GetStatus, + }); write_msg(&mut writer, &message) .await .expect("write succeeds"); let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds"); - assert!(matches!(received, ClientMessage::Command { seq: 4, line } if line == "help")); + assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4)); } } diff --git a/iota.mk b/iota.mk deleted file mode 100644 index 61083565cb37fe02c651ab466a776954201300aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5713 zcmeYb@%3h8Il$J__iMKL@(V}b2FX1>u>2;wk#B{RyrH;gfyv2GwW24j2Rh~Ff62PN zl-;AW*+__G;?!Ng7Oq&OAgP$C6gi=9Mn)^^iOwIH(&t<9{|nlGHD@|~Vvqf`9V<_) zU^v6b%3bM}vGYst2BGiO=Xcr^8s()dSW?|?xZ~26$3pIU*ZCG-i}KkuvsG)2<|#Wd z?o5qvwTIK{*)E(o_r32^jIZ$IzGIt8S6@`*HQx673un0L?9*SEbtKvDHg+vm+$@-y zov7Kz|H5PD!lc%nP1)VzvqDo=|4L}!VsXwm@^8`k4ZmzGUo|@|2|Kv!46A!Z>xbT# zU#3TQT9%w#>wakGgOr&eJNJ7Xm5}GQi*%Ftykc3k7LVfTEj!F*TSV$aTHNOU?7gri z@zJ)r8=O-QKJ}H5w4bxGDemL0-)~lQN6&iSlRGi1Z3S}!>nDrO8#}Ho4%JRE?fxaX zNKt!JwC>{+zvn%DD^`}=G~AbAsCn?&J}z#Z{d4%31x`#Z*d*h|Glj9Hbc5mao9q@R zYs6PI8_uzL$|0ET&iP#+>~x~A+frwX(+7+nu=s7Y5msCG^`WTk%=7s-;~q!vZaUh~ z6PdWpyu_numXoU3yTiKa5zZe?5_LXBl^kNY<>?!D+)j)wJcwh%|Cxu>rYI+I$Ndlu zNT}Iy=1o=2f|ULy7r55?X zLw`7ATttr@cGq6!xlwYtomq?8-@*f%Rj2kO> zuVoP@oJ>ACD;@eCeLGu=egGX zwwIx9im&oED`sAcX)^n|%ug!ZiHjBLwy`d*nK>R#uuyLIt%qvTh+Y@LPb zXO&Do&>U1|C*;<5Qlmqxu~KfKUZ})5;nm&C=5$Q33BGQ*>pGhV`-knDH?3LqxoVz6 z%-qxKYtNkdAHgtxs`so96{$^rOATX1Yh*vMH3b|Gop?-e!t6e#3JKlor}szY&-4kZ zQsLK3Pi53B7)-lr#?3}?Q8uI3)#pe)}9Y338xmz+0 zXVu)9-?XunA*o?`__|{z3v~|*{*Gaa6jxeOT=sv0^Zgn#&M#ior#2crVbsb@oBT#z z{-ysfb3XZMP5G{})jNWZEep>R?Dv0l=)!~O=f4@=GEd|=xKrF=52u&o`a8+(tZ64A z``c$`UWs1mW;4Ck`l!(7BdrR#av#LQF7$h3Dd_D{O<2y)F=LbSX3o1FsTCXMGF3-yB+tn8OqEiUk6{F7L-&jVAt#QgX}aO6 zh2MM*`L)ka;!a80F?}-Qgx?A=`x}?&T5Q<3pz!mHy($)_he{@t9P|3a;c{sb`|p}X zOOGn-`BnQ>X^GS7=$xk2I$08dtb#>9msprvgr1sxcfpF*RlV0|Zt-u6y>KM)L**J< zo8wx~r>(oGy><(KN~*iyVu!spT$$FD+!IX}o38ARkof*$=eos7y;C`!ePP+gp`&zM zc#>j5@=s-_&dhS3_%FM}ewlc5O|A%%F=L#$<=>2Tr*fLLBF^8a%CYrUo;#)YREOil zUAB$URz^Qqf}~}d(i=ZY?P*cUH$$A@yk|C(oU9UT21k@M?RHt1DU_WKF+RW%@@?Dp@*e$B}8061AG~UrTl# zDPnOsYJLAjlCePj{s|sC+~f|ZY+SczS@1U>mf4%5zv@^l^lYCxqo{p~eM7=R$GLZS z486E~WMb1xIacC|efI?}EYvzz!_j#RH;OSn_fcqU$`Y;K*n z-C~ve`!zUj8`W(Tt ztPizx@%-nS{JMI@l+M8Gzxg8-4upR;nX_XTUk=xuFOuJryvr01tWaHi?R#t0(i2QU zF~_=RhAnPqIKC^!E9lEola$F5qzikpe(zH;S-C?bhVj?-^K*OyZ}JpHnmxKUo3)|H ztw`e2QUr#uVewtBp)RLpyB;uyJRR#JS1m)jZ1Dd!CmBu?7C53&4av$Q3Aca4edZW~G6fOGpl zsXyAV$X@$c@Cw7-=BpMhwtbYmnkhZMb@hrXY$gV)Wq+(}zc{1x%*MK{-w) z3R@pet?|CHfiro6$y4hi?Ay;ex=i|Nd8ycc?e6oEEr(_pa=bLMd|uS}uVepJMX9IU zT^}o@6&DL$*kJJgcH!n|`{iGE_jkmoIEVbJP`7t>y{N?X$|iEDlFp?X=7*W=yrn1S zyE$o>b+ek*Bz&K)q`~m@QNpiN6&KUlxIOe|XfaH8%6o7vdEM28Q&?uoOpkneZ0A*n z=(*EoZT~5Js8L_x#e}9Kfv+cVF;*>|>Ah(FVu`-enY(t`b1R!Ie7Q0GhM*UBPq2WA zpfrz@jG*fiqgCbWU5+g|TClbGdHvlBh2As7-(QV8;#Ko#&Z2EI8CR}4Z4%BNx>>hs z%kHm2D@I#Tqb;b>7Sw19YOuDT-mmV~7YSS^6c^z-&FA`sJ+9(~i+Rp-G;b@bo*rOl z5iA~P_K&5S`}xv)Wr5qPZa)@t{qmSqJD5-Kj^lz^+BjH14rlMN2`sA-d zmv&9ODJ^zW-j?Y(myGUq&8kSL>hDravwrXOs?oapd|AA0%Gr-$cI}?~{{(TVa@S0+ zXWSosTZh|nt#kLG7bS^j&h9lxNKO}))7>WV%j&jr?&*I@3UijmalX9VUc(W3ibeCM z;J))ZOtvn4>fC>uIWKgcTBN*MM31TGxXjekJ%?*0K15C_tcmPfnY(h2$uyaD5=Fs< z?s1#CbsfZ3?%VJr*2L?TXp+tJh1~+00q&E1woFm6-fPagUoUyGkemAD*P?uxM*V8~ z89&?q&HlW@Na>8{jfo=lHmfveR^-lmvF4_u;#a@wRnGCM=Wg|X?!MhuaCvRplB(F- znr}9i-(G$0Szn(@ZH*X%s>5#a->#X_JSq1DVmBl{vlB8~vD3HwU4qrCr~P7r=FRH0 z%YJ=hJDHr6u6|GMhSvq2(Byl!as#)mjLBWT`Nvy@kKf&HzVvK3U7mG#p|u64O~=;n zEPk#tUfBFvHFd@Y@5ptx@>t^UCaSEB479)d)m>$mSZkL$$>586wXn zDqW4ptg+t|@j(2j;{)!0sXP5e3+sag-#Xe$_6AnxR-E&?{&U&RtyfoFWSW>AllDw~ zrEJrJ9QG-9Y!_Rz7S{=jZLU6C9c3YBqTQgo@AA6frN=Dad2HtK`ns_;jqg`fid3+H z^rz4}noh@qJe{^r+uc6to7fYUtB;N++Ir8r!SduEgQ$$|ve#Mug!8`z9v+O$j1WZbjN9ylc7I&N?%GSamhzYZ2r7c+Jh5 zO!xy9v~JSz)!b=aZ&0+>wpbi-TcY&6&nw3D(ksK#E`D$RU$JW8 zx8@~zjl!1m|LL-a5Bptnm|OhxgH25TV|i5U7`NTa-*~NHZiy_P&h0-h z*M&K0KHh$wSGKqN(9;On<2KucoD|L2uQ+O|H!sl3Uhg|;uiotHTa4e>l$sJX?{X?0 z{T#d5bc;COYsJjXuF}ig)T_=d3e>JokPrVAQ|O`Q@^t6qSBBS)OD}WvI1{&Tm+wJu zW$Q0BqOB78e9O1liMIMx_*Yw{$v;WhwCLB0sRw@QzZF+>7btByd8HvCk$L93-~je* z4UB1BuXk^qzxKR;W_Qu6jZ3deu2)ydcE8StW=MUSnEU`(t zzQK$K7Asr6pT6(8j^I{<={^c|D(969-n|Gq|8swa%b$-j9(&LJ{qlSA)SABC+DtVc zl9~f}IwfDSCO=e)sMQuseqQ{?>H5va57~nLw`Je%&hv4N@?i@W3etSlUbMSLesl54 zCDMm?-<2$=@_Cn&wdT*t+6+~nqZcC9t#+5YRdkPgcBW)<&O!cbGEQkHCLar6Y)V#b z`Fv{84W?!4Uk^6UeAlS3Y)i#q=N0SZ?*7ZZ78c-Hl5Qw}x%h!a!MeXcUcXmRoxFD6 z<_puF?3)oSJJTuZ_MWASCLgYTF=gk}R|kKZaoDZdv9rhSs%vqG?xw!%Rb>SsS5EG| zofz`PHwMAdHq;JU1^*A0Y zaq8PPj?EMHtt zS3bA=X&>FjQ8=mH@712aa@W>OvU&DvO(t)vXyviV7k+fwCQQgZp8NQf;|7;rW|rtV z3yO+et7RLf#m?W%`Txh~ZHJ2+qY7V75&poyQsMezMd8H{tj?>}*&1z_&?U&A5c*L5 ze)Se*yP4eUA382w?G@we`}Am8$Unt!) PathBuf { #[tokio::main(flavor = "multi_thread")] async fn main() { let path = socket_path(); - let ipc = match IpcClient::connect(&path).await { + let ipc = match IpcClient::connect_or_activate(&path).await { Ok(client) => client, Err(error) => { eprintln!( "Cannot connect to iota-daemon at {}: {error}", path.display() ); + eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running."); std::process::exit(1); } }; + ipc.spawn_reconnector(); let ui = start_tui(ipc); ui.set_screen(Box::new(MainScreen::new(ui.clone()).await)) .await; diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index f083794..7526c63 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -19,6 +19,7 @@ dashmap = "6.2.1" json = "*" reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" hex = "*" diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e68a12f..45257c2 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,6 +1,6 @@ use dashmap::DashMap; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; -use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_state::ACTIVE_TASKS; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; @@ -16,6 +16,7 @@ use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; +use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::omega_discovery; @@ -161,10 +162,15 @@ pub struct OmikronConnection { pub app_sessions: Arc>, pub(crate) missed_pongs: Arc, handler_semaphore: Arc, + cancellation: CancellationToken, } impl OmikronConnection { pub fn new() -> Self { + Self::with_cancellation(CancellationToken::new()) + } + + pub fn with_cancellation(cancellation: CancellationToken) -> Self { let (shutdown_tx, _) = watch::channel(false); let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected); @@ -183,6 +189,7 @@ impl OmikronConnection { app_sessions: Arc::new(DashMap::new()), missed_pongs: Arc::new(AtomicU32::new(0)), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), + cancellation, } } @@ -237,7 +244,7 @@ impl OmikronConnection { } if let Some(sender) = self.sender.read().await.as_ref() { - sender.close(); + sender.close().await; } self.set_state(ConnectionState::Disconnected).await; @@ -250,7 +257,7 @@ impl OmikronConnection { let mut shutdown_rx = shutdown_rx; loop { - if *shutdown_rx.borrow() || *SHUTDOWN.read().await { + if *shutdown_rx.borrow() || self.cancellation.is_cancelled() { log_t!("omikron_connection_loop_shutdown"); break; } @@ -621,7 +628,7 @@ impl OmikronConnection { self.missed_pongs.load(Ordering::Relaxed) ); if let Some(sender) = self.sender.read().await.as_ref() { - sender.close(); + sender.close().await; } break; } @@ -1750,7 +1757,7 @@ impl OmikronConnection { if !sender.is_open() { drop(sender_guard); if let Some(sender) = self.sender.write().await.take() { - sender.close(); + sender.close().await; } self.fail_all_waiting_tasks(format!( "Send failed: connection closed (connection_id={})", @@ -1933,11 +1940,12 @@ pub static OMIKRON_CONNECTION: LazyLock> = LazyLock::new( conn }); -pub async fn get_omikron_connection() -> Arc { - let conn = OMIKRON_CONNECTION.clone(); - +pub async fn get_omikron_connection( + cancellation: CancellationToken, +) -> Option> { + let conn = Arc::new(OmikronConnection::with_cancellation(cancellation)); conn.connect().await; - conn + Some(conn) } impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index d02ffbe..6d52dd5 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,6 +1,5 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; -use iota_state::{RELOAD, SHUTDOWN}; use iota_storage::users::user_manager::{add_user, save_users}; use iota_storage::users::user_profile::UserProfile; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; @@ -81,8 +80,6 @@ pub async fn create_user(username: &str) -> (Option, Option log_t!("User creation: Response returned none"); return (None, None); } - *SHUTDOWN.write().await = true; - *RELOAD.write().await = true; log!("Created User"); save_file( "", diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index 3f3b819..b61e5e2 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -5,12 +5,22 @@ Wants=network-online.target Requires=iota-daemon.socket [Service] -Type=simple +Type=notify ExecStart=/usr/bin/iota-daemon Restart=on-failure +RestartSec=5s RuntimeDirectory=iota RuntimeDirectoryMode=0750 Environment=IOTA_SOCKET=/run/iota/iota.sock +# Exit code 75 = restart requested (daemon-specific convention) +RestartPreventExitStatus=0 +RestartForceExitStatus=75 + +# Graceful shutdown +TimeoutStopSec=10 +KillMode=mixed +KillSignal=SIGTERM + [Install] WantedBy=multi-user.target diff --git a/systemd/iota-daemon.socket b/systemd/iota-daemon.socket index 22ab691..b06a209 100644 --- a/systemd/iota-daemon.socket +++ b/systemd/iota-daemon.socket @@ -6,6 +6,7 @@ ListenStream=/run/iota/iota.sock SocketMode=0660 SocketUser=iota SocketGroup=iota +Backlog=5 RemoveOnStop=true [Install] diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index 9827408..be6b65a 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } bytes = "1" http = "1" -iota-state = { path = "../iota-state" } -iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } +iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index 4dc18ab..f5214ab 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -1,11 +1,10 @@ use bytes::Bytes; use iota_logger::log; -use iota_state::{ACTIVE_TASKS, SHUTDOWN}; use iota_util::file_util::load_file_vec; use mtp::host::HostConfig; use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig}; use std::net::{IpAddr, Ipv4Addr}; -use tokio::time::{Duration, sleep}; +use tokio_util::sync::CancellationToken; const CERT_PATH: &str = "certs/cert.pem"; const KEY_PATH: &str = "certs/cert.key"; @@ -63,7 +62,7 @@ fn content_type(name: &str) -> &'static str { } } -pub async fn start(port: u16) -> bool { +pub async fn start(port: u16, cancellation: CancellationToken) -> bool { let certificate = match tokio::fs::read(CERT_PATH).await { Ok(certificate) => certificate, Err(error) => { @@ -102,7 +101,6 @@ pub async fn start(port: u16) -> bool { log!("MTP web server running on port {}", port); tokio::spawn(async move { - ACTIVE_TASKS.insert("WebServer".into()); loop { tokio::select! { result = server.accept() => { @@ -112,22 +110,12 @@ pub async fn start(port: u16) -> bool { Err(error) => log!("MTP webserver connection failed: {}", error), } } - _ = wait_for_shutdown() => { + _ = cancellation.cancelled() => { server.shutdown().await; break; } } } - ACTIVE_TASKS.remove("WebServer"); }); true } - -async fn wait_for_shutdown() { - loop { - if *SHUTDOWN.read().await { - break; - } - sleep(Duration::from_millis(100)).await; - } -} From 8b158108bb45b03b4582bb0c9d3adb94c78cc1b2 Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Thu, 23 Jul 2026 23:13:02 +0200 Subject: [PATCH 084/119] [WIP] Daemon & CLI --- Cargo.lock | 183 +++-- Cargo.toml | 4 + README.md | 36 + client/src/client_connection.rs | 6 +- flake.nix | 4 +- iota-auth/src/lib.rs | 1 + iota-cli/Cargo.toml | 8 + iota-cli/src/controls/action.rs | 7 + iota-cli/src/controls/button.rs | 72 ++ iota-cli/src/controls/checkbox_group.rs | 135 ++++ iota-cli/src/controls/choice.rs | 42 ++ iota-cli/src/controls/mod.rs | 6 + iota-cli/src/controls/navigation.rs | 6 + iota-cli/src/controls/radio_group.rs | 194 ++++++ iota-cli/src/elements/console_card.rs | 133 ++-- iota-cli/src/elements/elements.rs | 6 +- iota-cli/src/elements/graph_card.rs | 74 ++- iota-cli/src/elements/log_card.rs | 118 +++- iota-cli/src/input_handler.rs | 77 ++- iota-cli/src/ipc_client.rs | 703 ++++++++++++-------- iota-cli/src/layout/fit.rs | 52 ++ iota-cli/src/layout/mod.rs | 2 + iota-cli/src/layout/text_measure.rs | 10 + iota-cli/src/lib.rs | 8 +- iota-cli/src/render_context.rs | 6 + iota-cli/src/screens/daemon_setup.rs | 287 ++++++++ iota-cli/src/screens/main_screen.rs | 67 +- iota-cli/src/screens/md_viewer.rs | 116 ++-- iota-cli/src/screens/screens.rs | 4 +- iota-cli/src/screens/terms_checker.rs | 64 +- iota-cli/src/screens/terms_updater.rs | 35 +- iota-cli/src/theme/config.rs | 163 +++++ iota-cli/src/theme/mod.rs | 12 + iota-cli/src/theme/model.rs | 149 +++++ iota-cli/src/theme/name.rs | 47 ++ iota-cli/src/theme/presets.rs | 301 +++++++++ iota-cli/src/ui.rs | 333 +++++++--- iota-cli/src/util/borders.rs | 25 +- iota-cli/src/util/buttons.rs | 168 +---- iota-cli/tests/button_layout.rs | 15 + iota-cli/tests/choice_rendering.rs | 71 ++ iota-cli/tests/control_state.rs | 109 +++ iota-cli/tests/layout_fit.rs | 45 ++ iota-core/Cargo.toml | 1 + iota-core/src/consent_state.rs | 25 +- iota-core/src/lib.rs | 1 + iota-core/src/main.rs | 55 +- iota-daemon-lib/Cargo.toml | 4 + iota-daemon-lib/src/command_router.rs | 101 ++- iota-daemon-lib/src/daemon_state.rs | 199 ++++-- iota-daemon-lib/src/deployment.rs | 37 ++ iota-daemon-lib/src/ipc_server.rs | 371 ++++++++--- iota-daemon-lib/src/lib.rs | 5 + iota-daemon-lib/src/services.rs | 23 + iota-daemon-lib/src/task_registry.rs | 40 ++ iota-daemon-lib/tests/command_router.rs | 52 ++ iota-daemon-lib/tests/daemon_health.rs | 29 + iota-daemon-lib/tests/shutdown.rs | 40 ++ iota-daemon/Cargo.toml | 1 + iota-daemon/src/main.rs | 216 ++++-- iota-installer/Cargo.toml | 11 + iota-installer/src/lib.rs | 187 ++++++ iota-ipc/src/lib.rs | 7 +- iota-ipc/src/protocol.rs | 131 +++- iota-logger/src/language_manager.rs | 66 +- iota-logger/src/lib.rs | 8 +- iota-paths/Cargo.toml | 4 + iota-paths/src/lib.rs | 156 +++++ iota-process-manager/Cargo.toml | 8 + iota-process-manager/src/lib.rs | 459 +++++++++++++ iota-state/src/lib.rs | 55 +- iota-storage/src/users/user_manager.rs | 2 +- iota-storage/src/util/config_util.rs | 51 ++ iota-updater/Cargo.toml | 3 +- iota-updater/src/lib.rs | 164 +---- iota-updater/src/main.rs | 21 + iota-updater/src/manifest.rs | 83 +++ iota-updater/src/transaction.rs | 79 +++ iota-util/Cargo.toml | 1 + iota-util/src/file_util.rs | 5 +- iota/Cargo.toml | 5 + iota/src/cli_args.rs | 154 +++++ iota/src/daemon_setup_flow.rs | 241 +++++++ iota/src/local_daemon.rs | 120 ++++ iota/src/main.rs | 307 ++++++++- iota/src/startup_error.rs | 92 +++ omikron-connector/Cargo.toml | 1 + omikron-connector/src/client.rs | 43 ++ omikron-connector/src/lib.rs | 4 + omikron-connector/src/omikron_connection.rs | 116 +++- omikron-connector/src/ping_pong_task.rs | 9 +- omikron-connector/src/user_ops.rs | 27 +- src/.DS_Store | Bin 6148 -> 0 bytes src/util/auto_update.rs | 135 ---- systemd/iota-daemon.service | 13 +- systemd/iota-daemon.socket | 5 +- systemd/sysusers.d/iota.conf | 2 + web-server/src/lib.rs | 203 +++--- web-ui/src/api.rs | 34 +- web-ui/src/server.rs | 19 +- 100 files changed, 6529 insertions(+), 1606 deletions(-) create mode 100644 iota-cli/src/controls/action.rs create mode 100644 iota-cli/src/controls/button.rs create mode 100644 iota-cli/src/controls/checkbox_group.rs create mode 100644 iota-cli/src/controls/choice.rs create mode 100644 iota-cli/src/controls/mod.rs create mode 100644 iota-cli/src/controls/navigation.rs create mode 100644 iota-cli/src/controls/radio_group.rs create mode 100644 iota-cli/src/layout/fit.rs create mode 100644 iota-cli/src/layout/mod.rs create mode 100644 iota-cli/src/layout/text_measure.rs create mode 100644 iota-cli/src/render_context.rs create mode 100644 iota-cli/src/screens/daemon_setup.rs create mode 100644 iota-cli/src/theme/config.rs create mode 100644 iota-cli/src/theme/mod.rs create mode 100644 iota-cli/src/theme/model.rs create mode 100644 iota-cli/src/theme/name.rs create mode 100644 iota-cli/src/theme/presets.rs create mode 100644 iota-cli/tests/button_layout.rs create mode 100644 iota-cli/tests/choice_rendering.rs create mode 100644 iota-cli/tests/control_state.rs create mode 100644 iota-cli/tests/layout_fit.rs create mode 100644 iota-core/src/lib.rs create mode 100644 iota-daemon-lib/src/deployment.rs create mode 100644 iota-daemon-lib/src/services.rs create mode 100644 iota-daemon-lib/src/task_registry.rs create mode 100644 iota-daemon-lib/tests/command_router.rs create mode 100644 iota-daemon-lib/tests/daemon_health.rs create mode 100644 iota-daemon-lib/tests/shutdown.rs create mode 100644 iota-installer/Cargo.toml create mode 100644 iota-installer/src/lib.rs create mode 100644 iota-paths/Cargo.toml create mode 100644 iota-paths/src/lib.rs create mode 100644 iota-process-manager/Cargo.toml create mode 100644 iota-process-manager/src/lib.rs create mode 100644 iota-updater/src/main.rs create mode 100644 iota-updater/src/manifest.rs create mode 100644 iota-updater/src/transaction.rs create mode 100644 iota/src/cli_args.rs create mode 100644 iota/src/daemon_setup_flow.rs create mode 100644 iota/src/local_daemon.rs create mode 100644 iota/src/startup_error.rs create mode 100644 omikron-connector/src/client.rs delete mode 100644 src/.DS_Store delete mode 100644 src/util/auto_update.rs create mode 100644 systemd/sysusers.d/iota.conf diff --git a/Cargo.lock b/Cargo.lock index 96c66bb..10d0d16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ "foldhash", "futures-core", "futures-util", - "impl-more 0.3.1", + "impl-more 0.3.2", "itoa", "language-tags", "log", @@ -407,7 +407,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 3.0.1", + "syn 3.0.3", ] [[package]] @@ -1558,9 +1558,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" @@ -1839,9 +1839,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2040,9 +2040,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "impl-more" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" +checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021" [[package]] name = "indexmap" @@ -2090,6 +2090,11 @@ name = "iota" version = "0.1.0" dependencies = [ "iota-cli", + "iota-core", + "iota-installer", + "iota-ipc", + "iota-paths", + "iota-process-manager", "tokio", "tokio-util", ] @@ -2162,6 +2167,8 @@ dependencies = [ "hyper-util", "iota-ipc", "iota-logger", + "iota-paths", + "iota-process-manager", "iota-state", "iota-storage", "iota-terms", @@ -2180,15 +2187,19 @@ dependencies = [ "rusqlite", "rustls", "rustls-pemfile", + "serde", "serde_json", + "serde_yaml", "sha2 0.10.9", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", + "tempfile", "tokio", "tokio-tungstenite", "tokio-util", "tungstenite", + "unicode-width", "walkdir", "warp", "x448", @@ -2204,6 +2215,31 @@ dependencies = [ "mtp", ] +[[package]] +name = "iota-core" +version = "0.1.0" +dependencies = [ + "dashmap", + "iota-cli", + "iota-logger", + "iota-state", + "iota-storage", + "iota-terms", + "iota-updater", + "iota-util", + "json", + "mtp", + "omikron-connector", + "once_cell", + "pnet", + "ratatui", + "reqwest", + "tokio", + "tokio-util", + "web-server", + "web-ui", +] + [[package]] name = "iota-daemon" version = "0.1.0" @@ -2211,6 +2247,7 @@ dependencies = [ "iota-daemon-lib", "iota-ipc", "iota-logger", + "iota-paths", "iota-state", "iota-storage", "omikron-connector", @@ -2223,6 +2260,7 @@ dependencies = [ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ + "async-trait", "dashmap", "iota-ipc", "iota-logger", @@ -2233,11 +2271,23 @@ dependencies = [ "mtp", "omikron-connector", "sysinfo", + "tempfile", "tokio", "tokio-util", "uuid", ] +[[package]] +name = "iota-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "iota-paths", + "serde_json", + "tempfile", + "zip", +] + [[package]] name = "iota-ipc" version = "0.1.0" @@ -2260,6 +2310,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "iota-paths" +version = "0.1.0" + +[[package]] +name = "iota-process-manager" +version = "0.1.0" +dependencies = [ + "async-trait", + "tokio", +] + [[package]] name = "iota-state" version = "0.1.0" @@ -2324,6 +2386,7 @@ dependencies = [ "aes-gcm", "anyhow", "base64", + "ed25519-dalek", "hex", "hkdf 0.12.4", "iota-logger", @@ -2334,9 +2397,9 @@ dependencies = [ "rand_core 0.6.4", "ratatui", "reqwest", - "self-replace", "semver", "serde", + "serde_json", "sha2 0.10.9", "sysinfo", "tempfile", @@ -2353,6 +2416,7 @@ version = "0.1.0" dependencies = [ "base64", "hex", + "iota-paths", "mtp", "reqwest", "sysinfo", @@ -2543,9 +2607,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2778,7 +2842,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "mtp-client", "mtp-codec", @@ -2794,7 +2858,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "mtp-codec", "mtp-common", @@ -2807,7 +2871,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "base64", "byteorder", @@ -2820,7 +2884,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "quinn", "rustls", @@ -2831,7 +2895,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "base64", "chacha20poly1305", @@ -2853,7 +2917,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -2864,7 +2928,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "mtp-codec", "mtp-common", @@ -2879,7 +2943,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "async-trait", "mtp-codec", @@ -2897,7 +2961,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "serde", "serde_yaml", @@ -2906,7 +2970,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49" +source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" dependencies = [ "async-trait", "bytes", @@ -2914,6 +2978,9 @@ dependencies = [ "h3-quinn", "h3-webtransport", "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", "mtp-codec", "mtp-common", "mtp-crypto", @@ -2924,6 +2991,8 @@ dependencies = [ "rustls", "thiserror 2.0.19", "tokio", + "tokio-rustls", + "tokio-stream", "tracing", ] @@ -3074,6 +3143,7 @@ dependencies = [ name = "omikron-connector" version = "0.1.0" dependencies = [ + "async-trait", "base64", "dashmap", "hex", @@ -3303,9 +3373,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -3313,9 +3383,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -3323,9 +3393,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", @@ -3336,9 +3406,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", ] @@ -4194,17 +4264,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self-replace" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" -dependencies = [ - "fastrand", - "tempfile", - "windows-sys 0.52.0", -] - [[package]] name = "semver" version = "1.0.28" @@ -4238,14 +4297,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.1", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -4585,9 +4644,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.1" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -4775,14 +4834,14 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.1", + "syn 3.0.3", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "libc", @@ -4802,9 +4861,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4883,6 +4942,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.30.0" @@ -4899,14 +4969,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -5789,18 +5860,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index ecd2457..b56f6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,5 +18,9 @@ members = [ "web-ui", "iota-logger", "iota-util", + "iota-process-manager", + "iota-paths", + "iota-installer", + "iota-core", ] resolver = "3" diff --git a/README.md b/README.md index f5f05a7..4a278f3 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,39 @@ A lightweight, Rust-based TUI and service orchestrator for Tensamin IOTA. Iota manages users and stores their messages and communities. It can be run in a centralised, decentralised or hybrid mode. The Iota is a work in progress. + +## Terminal themes + +The TUI defaults to the ANSI theme. Select a theme for one invocation with `--theme`: + +```text +iota --theme monospace +iota --theme binary status +``` + +The available names are `monospace`, `binary`, `ansi`, and `surface`. Theme selection uses this precedence: `--theme`, `IOTA_THEME`, then `ui.yaml` in Iota's configuration directory. For example: + +```text +IOTA_THEME=surface iota +``` + +On Linux, the configuration file defaults to `~/.config/iota/ui.yaml` (or `$XDG_CONFIG_HOME/iota/ui.yaml` when set): + +```yaml +theme: surface +``` + +An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start. + +# Linux daemon installation + +The system-managed daemon runs as the dedicated `iota` account and listens on +`/run/iota/iota.sock` through socket activation. Operator access is granted +through the `iota-operators` group. After installing, add an account with: + +```text +usermod -aG iota-operators USER +``` + +The user must start a new login session before supplementary group membership +is visible. `IOTA_SOCKET` remains authoritative for custom deployments. diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 95c66cc..b20dca3 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -2,7 +2,6 @@ use dashmap::DashMap; use iota_connection::message_common::*; use iota_connection::message_handlers; use iota_logger::{log_cv_in, log_cv_out, log_t}; -use iota_state::SHUTDOWN; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::CONFIG; use iota_storage::util::e2ee_storage::{self, StoredChatSecret}; @@ -31,6 +30,7 @@ pub struct ClientConnection { shutdown_tx: Arc>>>, pub waiting_tasks: DashMap, CommunicationValue) -> bool + Send + Sync>>, + shutdown: Arc>, } impl ClientConnection { @@ -45,6 +45,7 @@ impl ClientConnection { u32, Box, CommunicationValue) -> bool + Send + Sync>, >, + shutdown: Arc>, ) -> Self { Self { sender, @@ -54,6 +55,7 @@ impl ClientConnection { connection_id, shutdown_tx, waiting_tasks, + shutdown, } } @@ -61,7 +63,7 @@ impl ClientConnection { let self_clone = self.clone(); tokio::spawn(async move { while let Ok(cv) = self_clone.receiver.receive().await { - if *SHUTDOWN.read().await { + if *self_clone.shutdown.read().await { return; } diff --git a/flake.nix b/flake.nix index f811ec9..2e560e2 100644 --- a/flake.nix +++ b/flake.nix @@ -196,6 +196,7 @@ SocketGroup = "iota"; Backlog = 5; RemoveOnStop = "true"; + NonBlocking = true; }; }; @@ -206,7 +207,7 @@ serviceConfig = { - Type = "notify"; + Type = "simple"; User = "iota"; Group = "iota"; WorkingDirectory = cfg.dataDir; @@ -258,6 +259,7 @@ Environment = [ "BIND_ADDRESS=${cfg.bindAddress}" "IOTA_SOCKET=/run/iota/iota.sock" + "IOTA_DATA_DIR=${cfg.dataDir}" ]; } // lib.optionalAttrs (cfg.environmentFiles != []) { diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs index e69de29..8b13789 100644 --- a/iota-auth/src/lib.rs +++ b/iota-auth/src/lib.rs @@ -0,0 +1 @@ + diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index f79241a..54666e1 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -19,6 +19,8 @@ iota-storage = { path = "../iota-storage", optional = true } iota-terms = { path = "../iota-terms" } iota-util = { path = "../iota-util", optional = true } iota-ipc = { path = "../iota-ipc" } +iota-process-manager = { path = "../iota-process-manager" } +iota-paths = { path = "../iota-paths" } omikron-connector = { path = "../omikron-connector", optional = true } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } @@ -59,6 +61,8 @@ rusqlite = "0.39.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" sha2 = "0.10.9" strum = "0.27.2" strum_macros = "0.27.2" @@ -71,3 +75,7 @@ walkdir = "2.5.0" warp = "*" x448 = { version = "*" } zip = "6.0.0" +unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/iota-cli/src/controls/action.rs b/iota-cli/src/controls/action.rs new file mode 100644 index 0000000..4fb3c27 --- /dev/null +++ b/iota-cli/src/controls/action.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlAction { + FocusNext, + FocusPrevious, + Select, + Activate, +} diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs new file mode 100644 index 0000000..1f35bf5 --- /dev/null +++ b/iota-cli/src/controls/button.rs @@ -0,0 +1,72 @@ +use crate::theme::ResolvedTheme; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + text::Span, + widgets::{Block, Borders, Paragraph}, +}; +use unicode_width::UnicodeWidthStr; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonIntent { + Primary, + Neutral, + Cancel, + Destructive, +} +pub struct ActionButton<'a> { + pub label: &'a str, + pub intent: ButtonIntent, + pub focused: bool, + pub enabled: bool, +} +pub fn render_button( + frame: &mut Frame, + area: Rect, + button: ActionButton<'_>, + theme: &ResolvedTheme, +) { + let style = if !button.enabled { + theme.buttons.disabled + } else { + match (button.intent, button.focused) { + (ButtonIntent::Primary, true) => theme.buttons.primary_focused, + (ButtonIntent::Primary, false) => theme.buttons.primary, + (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, + (ButtonIntent::Neutral, false) => theme.buttons.neutral, + (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, + (ButtonIntent::Cancel, false) => theme.buttons.cancel, + (ButtonIntent::Destructive, _) => theme.buttons.destructive, + } + }; + frame.render_widget( + Paragraph::new(Span::styled(button.label, style)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + area, + ); +} +pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option> { + let required = minimums + .iter() + .try_fold(0u16, |total, width| total.checked_add(*width))?; + if required > available { + return None; + } + if minimums.is_empty() { + return Some(Vec::new()); + } + let extra = available - required; + let count = minimums.len() as u16; + Some( + minimums + .iter() + .enumerate() + .map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count)) + .collect(), + ) +} +pub fn button_minimum_width(label: &str) -> u16 { + UnicodeWidthStr::width(label) + .saturating_add(2) + .min(u16::MAX as usize) as u16 +} diff --git a/iota-cli/src/controls/checkbox_group.rs b/iota-cli/src/controls/checkbox_group.rs new file mode 100644 index 0000000..f985510 --- /dev/null +++ b/iota-cli/src/controls/checkbox_group.rs @@ -0,0 +1,135 @@ +use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; +use std::{collections::HashSet, hash::Hash}; + +pub struct CheckboxItem { + pub value: T, + pub label: String, + pub description: Option, + pub enabled: bool, + pub disabled_reason: Option, +} +pub struct CheckboxGroup { + items: Vec>, + selected: HashSet, + focused_index: usize, + focus_policy: DisabledFocusPolicy, + wrap_navigation: bool, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheckboxGroupError { + Empty, + DuplicateValue, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheckboxChange { + Selected(T), + Deselected(T), + IgnoredDisabled(T), + NoItem, +} +impl CheckboxGroup { + pub fn new( + items: Vec>, + selected: impl IntoIterator, + ) -> Result { + let mut values = HashSet::new(); + if items.iter().any(|item| !values.insert(item.value.clone())) { + return Err(CheckboxGroupError::DuplicateValue); + } + let selected = selected + .into_iter() + .filter(|value| values.contains(value)) + .collect(); + let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0); + Ok(Self { + items, + selected, + focused_index, + focus_policy: DisabledFocusPolicy::Skip, + wrap_navigation: true, + }) + } + pub fn items(&self) -> &[CheckboxItem] { + &self.items + } + pub fn selected(&self) -> &HashSet { + &self.selected + } + pub fn focused_item(&self) -> Option<&CheckboxItem> { + self.items.get(self.focused_index) + } + pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { + self.focus_policy = policy; + } + pub fn set_wrap_navigation(&mut self, wrap: bool) { + self.wrap_navigation = wrap; + } + pub fn focus_next(&mut self) { + self.move_focus(true); + } + pub fn focus_previous(&mut self) { + self.move_focus(false); + } + fn move_focus(&mut self, forward: bool) { + if self.items.is_empty() { + return; + } + for step in 1..=self.items.len() { + let current = self.focused_index as isize; + let delta = if forward { + step as isize + } else { + -(step as isize) + }; + let raw = current + delta; + let next = if self.wrap_navigation { + raw.rem_euclid(self.items.len() as isize) as usize + } else if raw < 0 || raw >= self.items.len() as isize { + return; + } else { + raw as usize + }; + if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { + self.focused_index = next; + return; + } + } + } + pub fn toggle_focused(&mut self) -> CheckboxChange { + let Some(item) = self.items.get(self.focused_index) else { + return CheckboxChange::NoItem; + }; + let value = item.value.clone(); + if !item.enabled { + return CheckboxChange::IgnoredDisabled(value); + } + if self.selected.remove(&value) { + CheckboxChange::Deselected(value) + } else { + self.selected.insert(value.clone()); + CheckboxChange::Selected(value) + } + } + pub fn set_enabled(&mut self, value: &T, enabled: bool) { + if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) { + item.enabled = enabled; + } + } + pub fn set_selected(&mut self, value: T, selected: bool) { + if selected { + self.selected.insert(value); + } else { + self.selected.remove(&value); + } + } + pub fn visual_state(&self, value: &T) -> ChoiceVisualState { + let item = self.items.iter().position(|item| &item.value == value); + ChoiceVisualState { + selected: self.selected.contains(value), + focused: item == Some(self.focused_index), + enabled: item + .and_then(|index| self.items.get(index)) + .is_some_and(|item| item.enabled), + } + } +} diff --git a/iota-cli/src/controls/choice.rs b/iota-cli/src/controls/choice.rs new file mode 100644 index 0000000..713971b --- /dev/null +++ b/iota-cli/src/controls/choice.rs @@ -0,0 +1,42 @@ +use crate::theme::ResolvedTheme; +use ratatui::text::{Line, Span}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChoiceKind { + Checkbox, + Radio, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChoiceVisualState { + pub selected: bool, + pub focused: bool, + pub enabled: bool, +} +pub fn render_choice_line<'a>( + label: &'a str, + kind: ChoiceKind, + state: ChoiceVisualState, + theme: &'a ResolvedTheme, +) -> Line<'a> { + let item = match (state.selected, state.focused, state.enabled) { + (_, true, false) => &theme.choices.focused_disabled, + (true, false, false) => &theme.choices.selected_disabled, + (false, false, false) => &theme.choices.disabled, + (true, true, true) => &theme.choices.focused_selected, + (true, false, true) => &theme.choices.selected, + (false, true, true) => &theme.choices.focused, + (false, false, true) => &theme.choices.normal, + }; + let marker = match (kind, state.selected) { + (ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected, + (ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected, + (ChoiceKind::Radio, false) => theme.markers.radio_unselected, + (ChoiceKind::Radio, true) => theme.markers.radio_selected, + }; + Line::from(vec![ + Span::styled(item.prefix, item.label), + Span::styled(marker, item.marker), + Span::raw(" "), + Span::styled(label, item.label), + Span::styled(item.suffix, item.label), + ]) +} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs new file mode 100644 index 0000000..8af4977 --- /dev/null +++ b/iota-cli/src/controls/mod.rs @@ -0,0 +1,6 @@ +pub mod action; +pub mod button; +pub mod checkbox_group; +pub mod choice; +pub mod navigation; +pub mod radio_group; diff --git a/iota-cli/src/controls/navigation.rs b/iota-cli/src/controls/navigation.rs new file mode 100644 index 0000000..0f292c5 --- /dev/null +++ b/iota-cli/src/controls/navigation.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DisabledFocusPolicy { + Include, + #[default] + Skip, +} diff --git a/iota-cli/src/controls/radio_group.rs b/iota-cli/src/controls/radio_group.rs new file mode 100644 index 0000000..9aaacb5 --- /dev/null +++ b/iota-cli/src/controls/radio_group.rs @@ -0,0 +1,194 @@ +use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; + +pub struct RadioItem { + pub value: T, + pub label: String, + pub description: Option, + pub enabled: bool, + pub disabled_reason: Option, +} +pub struct RadioGroup { + items: Vec>, + selected: T, + default: T, + focused_index: usize, + focus_policy: DisabledFocusPolicy, + wrap_navigation: bool, + disabled_selection_policy: DisabledSelectionPolicy, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadioGroupError { + Empty, + DefaultMissing, + DefaultDisabled, + NoEnabledItems, + SelectedItemDisabled, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadioChange { + Changed { previous: T, selected: T }, + Unchanged(T), + IgnoredDisabled(T), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisabledSelectionPolicy { + UseConfiguredDefault, + UseFirstEnabled, + ReturnError, +} +impl RadioGroup { + pub fn new( + items: Vec>, + observed: Option, + default: T, + ) -> Result { + if items.is_empty() { + return Err(RadioGroupError::Empty); + } + let default_item = items + .iter() + .find(|item| item.value == default) + .ok_or(RadioGroupError::DefaultMissing)?; + if !default_item.enabled { + return Err(RadioGroupError::DefaultDisabled); + } + let focused_index = items + .iter() + .position(|item| item.enabled) + .ok_or(RadioGroupError::NoEnabledItems)?; + let selected = observed + .filter(|value| { + items + .iter() + .any(|item| item.enabled && item.value == *value) + }) + .unwrap_or_else(|| default.clone()); + Ok(Self { + items, + selected, + default, + focused_index, + focus_policy: DisabledFocusPolicy::Skip, + wrap_navigation: true, + disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault, + }) + } + pub fn items(&self) -> &[RadioItem] { + &self.items + } + pub fn selected(&self) -> &T { + &self.selected + } + pub fn focused_item(&self) -> &RadioItem { + &self.items[self.focused_index] + } + pub fn focus_next(&mut self) { + self.move_focus(true); + } + pub fn focus_previous(&mut self) { + self.move_focus(false); + } + fn move_focus(&mut self, forward: bool) { + for step in 1..=self.items.len() { + let raw = self.focused_index as isize + + if forward { + step as isize + } else { + -(step as isize) + }; + let next = if self.wrap_navigation { + raw.rem_euclid(self.items.len() as isize) as usize + } else if raw < 0 || raw >= self.items.len() as isize { + return; + } else { + raw as usize + }; + if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { + self.focused_index = next; + return; + } + } + } + pub fn select_focused(&mut self) -> RadioChange { + let item = self.focused_item(); + let enabled = item.enabled; + let value = item.value.clone(); + if !enabled { + return RadioChange::IgnoredDisabled(value); + } + if value == self.selected { + RadioChange::Unchanged(self.selected.clone()) + } else { + let previous = std::mem::replace(&mut self.selected, value); + RadioChange::Changed { + previous, + selected: self.selected.clone(), + } + } + } + pub fn visual_state(&self, value: &T) -> ChoiceVisualState { + let item = self.items.iter().position(|item| &item.value == value); + ChoiceVisualState { + selected: &self.selected == value, + focused: item == Some(self.focused_index), + enabled: item + .and_then(|index| self.items.get(index)) + .is_some_and(|item| item.enabled), + } + } + pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) { + self.disabled_selection_policy = policy; + } + pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { + self.focus_policy = policy; + } + pub fn set_wrap_navigation(&mut self, wrap: bool) { + self.wrap_navigation = wrap; + } + pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> { + let Some(index) = self.items.iter().position(|item| &item.value == value) else { + return Ok(()); + }; + if self.items[index].enabled == enabled { + return Ok(()); + } + if !enabled + && self + .items + .iter() + .enumerate() + .all(|(other, item)| other == index || !item.enabled) + { + return Err(RadioGroupError::NoEnabledItems); + } + if !enabled && self.selected == *value { + let replacement = match self.disabled_selection_policy { + DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self + .items + .iter() + .find(|item| item.enabled && item.value == self.default) + .map(|item| item.value.clone()), + DisabledSelectionPolicy::UseConfiguredDefault => None, + DisabledSelectionPolicy::UseFirstEnabled => self + .items + .iter() + .enumerate() + .find(|(other, item)| *other != index && item.enabled) + .map(|(_, item)| item.value.clone()), + DisabledSelectionPolicy::ReturnError => { + return Err(RadioGroupError::SelectedItemDisabled); + } + }; + self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?; + } + self.items[index].enabled = enabled; + if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip + { + self.focus_next(); + } + Ok(()) + } + pub fn default(&self) -> &T { + &self.default + } +} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 276b95f..af69a24 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -2,7 +2,6 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, - style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; @@ -18,6 +17,7 @@ use crate::{ elements::elements::{Element, InteractableElement, JoinableElement}, interaction_result::InteractionResult, ipc_client::IpcClient, + render_context::RenderContext, util::borders::draw_block_joins, }; @@ -34,6 +34,7 @@ pub struct ConsoleCard { cursor: Arc>, last_swap: Arc>, pending_restore: Arc>>, + pending_confirmation: Option, } impl ConsoleCard { @@ -49,6 +50,7 @@ impl ConsoleCard { cursor: Arc::new(Mutex::new(true)), last_swap: Arc::new(Mutex::new(Instant::now())), pending_restore: Arc::new(Mutex::new(None)), + pending_confirmation: None, } } @@ -85,26 +87,25 @@ impl ConsoleCard { } } - fn cursor_spans(&self) -> Vec> { + fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { let cursor_visible = self.cursor_visible(); - let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray); let mut spans = Vec::new(); if self.content.is_empty() { if self.focused { if cursor_visible { - spans.push(Span::styled(" ", cursor_style)); + Self::push_cursor(&mut spans, theme); } else { - spans.push(Span::styled(" ", Style::default().fg(Color::White))); + spans.push(Span::styled(" ", theme.console.text)); } spans.push(Span::styled( "send command (/help for info)", - Style::default().fg(Color::DarkGray), + theme.console.hint, )); } else { spans.push(Span::styled( " send command (/help for info)", - Style::default().fg(Color::DarkGray), + theme.console.hint, )); } return spans; @@ -119,52 +120,64 @@ impl ConsoleCard { if prefix_len > 0 && before.len() >= prefix_len { let prefix = &before[..prefix_len]; let rest = &before[prefix_len..]; - spans.push(Span::styled( - prefix.to_string(), - Self::style_for_part(true, false, false), - )); + spans.push(Span::styled(prefix.to_string(), theme.console.prefix)); if !rest.is_empty() { - spans.push(Span::styled( - rest.to_string(), - Style::default().fg(Color::White), - )); + spans.push(Span::styled(rest.to_string(), theme.console.text)); } } else if !before.is_empty() { - spans.push(Span::styled( - before.clone(), - Style::default().fg(Color::White), - )); + spans.push(Span::styled(before.clone(), theme.console.text)); } if cursor_visible { - spans.push(Span::styled(" ", cursor_style)); + Self::push_cursor(&mut spans, theme); } if !after.is_empty() { - spans.push(Span::styled(after, Style::default().fg(Color::White))); + spans.push(Span::styled(after, theme.console.text)); } spans } - fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style { - if is_error { - return Style::default().fg(Color::Red); + fn push_cursor(spans: &mut Vec>, theme: &crate::theme::ResolvedTheme) { + match &theme.console.cursor { + crate::theme::CursorPresentation::StyledCell(style) => { + spans.push(Span::styled(" ", *style)) + } + crate::theme::CursorPresentation::Character { glyph, style } => { + spans.push(Span::styled(*glyph, *style)) + } } - - if is_hint { - return Style::default().fg(Color::DarkGray); - } - - if is_prefix { - return Style::default().fg(Color::DarkGray); - } - - Style::default().fg(Color::White) } - fn render_cursor_spans(&self) -> Vec> { - self.cursor_spans() + fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + if let Some(command) = &self.pending_confirmation { + return vec![Span::styled( + format!("Confirm `{command}`? [y/N]"), + theme.console.confirmation, + )]; + } + self.cursor_spans(theme) + } + + fn is_destructive(command: &str) -> bool { + matches!( + command.trim_start_matches('/').trim(), + "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" + ) || command + .trim_start_matches('/') + .trim_start() + .starts_with("user remove ") + } + + fn dispatch_command(&self, command: String) { + let ipc = self.ipc.clone(); + let restore = self.pending_restore.clone(); + tokio::spawn(async move { + if ipc.send_command(0, command.clone()).await.is_err() { + *restore.lock().unwrap() = Some(command); + } + }); } fn move_cursor_left(&mut self) { @@ -212,28 +225,34 @@ impl Element for ConsoleCard { self } - fn render(&self, f: &mut Frame, r: Rect) { + fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { let block = Block::default() .borders(self.borders) .title(self.title.clone()) - .title_style(Style::default().fg(Color::White)) + .title_style(context.theme.console.title) .border_style(if self.focused { - Style::default().fg(Color::Yellow) + context.theme.console.focused_border } else { - Style::default() + context.theme.console.border }) - .style(if self.focused { - Style::default().fg(Color::White) - } else { - Style::default() - }); + .style(context.theme.console.text); - let spans = self.render_cursor_spans(); + let spans = self.render_cursor_spans(context.theme); let par = Paragraph::new(Line::from(spans)) .block(block) .scroll((0, 0)); f.render_widget(par, r); - draw_block_joins(f, r, self.borders, self.joins); + draw_block_joins( + f, + r, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); } } @@ -287,6 +306,13 @@ impl InteractableElement for ConsoleCard { self.cursor_position = self.content.chars().count(); } + if let Some(command) = self.pending_confirmation.take() { + if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) { + self.dispatch_command(command); + } + return InteractionResult::Handled; + } + match key.code { KeyCode::Enter => { if self.content.is_empty() { @@ -294,16 +320,13 @@ impl InteractableElement for ConsoleCard { } let command = self.content.clone(); - let ipc = self.ipc.clone(); - let restore = self.pending_restore.clone(); - tokio::spawn(async move { - if ipc.send_command(0, command.clone()).await.is_err() { - *restore.lock().unwrap() = Some(command); - } - }); - self.content.clear(); self.cursor_position = 0; + if Self::is_destructive(&command) { + self.pending_confirmation = Some(command); + } else { + self.dispatch_command(command); + } InteractionResult::Handled } KeyCode::Backspace => { diff --git a/iota-cli/src/elements/elements.rs b/iota-cli/src/elements/elements.rs index abe9ab9..1af14cc 100644 --- a/iota-cli/src/elements/elements.rs +++ b/iota-cli/src/elements/elements.rs @@ -3,14 +3,16 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::Borders}; -use crate::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{ + interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen, +}; #[allow(unused)] pub trait Element: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, r: Rect); + fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>); } #[allow(unused)] diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index 178ffaa..0902b6d 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -5,7 +5,6 @@ use iota_state::ClientState; use ratatui::{ Frame, layout::Rect, - style::{Color, Style}, widgets::{ Block, Borders, canvas::{Canvas, Line}, @@ -15,6 +14,7 @@ use ratatui::{ use crate::{ elements::elements::{Element, InteractableElement, JoinableElement}, interaction_result::InteractionResult, + render_context::RenderContext, ui::UI, util::borders::draw_block_joins, }; @@ -26,43 +26,30 @@ pub enum GRAPHS { } impl GRAPHS { - pub fn get_color(&self) -> Color { + pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color { match self { - GRAPHS::Ram => Color::Blue, - GRAPHS::Cpu => Color::Red, - GRAPHS::Ping => Color::Green, + GRAPHS::Ram => theme.graphs.ram, + GRAPHS::Cpu => theme.graphs.cpu, + GRAPHS::Ping => theme.graphs.ping, } } pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> { + let state = match state.app.try_lock() { + Ok(state) => state, + Err(_) => return Vec::new(), + }; match self { - GRAPHS::Ram => state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()) - .with_width(28) - .ram - .clone(), - GRAPHS::Cpu => state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()) - .with_width(28) - .cpu - .clone(), - GRAPHS::Ping => state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()) - .with_width(28) - .ping - .clone(), + GRAPHS::Ram => state.with_width(28).ram.clone(), + GRAPHS::Cpu => state.with_width(28).cpu.clone(), + GRAPHS::Ping => state.with_width(28).ping.clone(), } } pub fn get_unit(&self) -> String { match self { - GRAPHS::Ram => "MB".to_string(), + // Memory is collected as a percentage of total RAM, not MiB. + GRAPHS::Ram => "%".to_string(), GRAPHS::Cpu => "%".to_string(), GRAPHS::Ping => "ms".to_string(), } @@ -111,19 +98,24 @@ impl Element for GraphCard { self } - fn render(&self, f: &mut Frame, r: Rect) { + fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { if self.open { let graph = self.graph_type.get_graph(&self.state); let unit = self.graph_type.get_unit(); let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); + let max_x = if max_x <= min_x { min_x + 1.0 } else { max_x }; let min_y = graph .iter() .map(|(_, y)| *y) .filter(|y| *y > 0.0) .min_by(|a, b| a.total_cmp(b)) .unwrap_or(0.0); - let max_y = graph.iter().map(|(_, y)| *y).fold(-1.0, f64::max); + let max_y = graph.iter().map(|(_, y)| *y).fold(0.0, f64::max); + let y_upper = match self.graph_type { + GRAPHS::Cpu | GRAPHS::Ram => 100.0, + GRAPHS::Ping => (max_y * 1.2).max(10.0), + }; let block = Block::default() .title(format!( @@ -136,15 +128,15 @@ impl Element for GraphCard { )) .borders(self.borders) .border_style(if self.focused { - Style::default().fg(Color::Yellow) + context.theme.graphs.focused_border } else { - Style::default() + context.theme.graphs.border }); let canvas = Canvas::default() .block(block) .x_bounds([min_x, max_x]) - .y_bounds([0.0, 100.0]) + .y_bounds([0.0, y_upper]) .paint(|ctx| { for (x, y) in &graph { ctx.draw(&Line { @@ -152,7 +144,7 @@ impl Element for GraphCard { y1: 0.0, x2: *x, y2: *y, - color: self.graph_type.get_color(), + color: self.graph_type.get_color(context.theme), }); } }); @@ -162,13 +154,23 @@ impl Element for GraphCard { .title("") .borders(self.borders) .border_style(if self.focused { - Style::default().fg(Color::Yellow) + context.theme.graphs.focused_border } else { - Style::default() + context.theme.graphs.border }); f.render_widget(block, r); } - draw_block_joins(f, r, self.borders, self.joins); + draw_block_joins( + f, + r, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index c415ba9..23c7107 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,16 +1,54 @@ use crate::elements::elements::{Element, InteractableElement, JoinableElement}; -use crate::interaction_result::InteractionResult; use crate::util::borders::draw_block_joins; +use crate::{interaction_result::InteractionResult, render_context::RenderContext}; use crossterm::event::{KeyCode, KeyEvent}; use iota_state::{ClientState, UiLogEntry}; use ratatui::{ Frame, layout::Rect, - style::{Color, Style}, + style::Style, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; use std::any::Any; +use unicode_width::UnicodeWidthChar; + +#[derive(Clone, Copy)] +enum LogSource { + Call, + Client, + Iota, + Omikron, + Omega, + Command, + Other, +} + +impl LogSource { + fn from_sender(sender: &str) -> Self { + match sender { + "Call" => Self::Call, + "Client" => Self::Client, + "Iota" => Self::Iota, + "Omikron" => Self::Omikron, + "Omega" => Self::Omega, + "Command" => Self::Command, + _ => Self::Other, + } + } + + fn style(self, theme: &crate::theme::ResolvedTheme) -> Style { + match self { + Self::Call => theme.logs.call, + Self::Client => theme.logs.client, + Self::Iota => theme.logs.iota, + Self::Omikron => theme.logs.omikron, + Self::Omega => theme.logs.omega, + Self::Command => theme.logs.command, + Self::Other => theme.logs.other, + } + } +} pub struct LogCard { state: ClientState, @@ -38,11 +76,10 @@ impl LogCard { } fn get_logs(&self) -> Vec { - let state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let state = match self.state.app.try_lock() { + Ok(state) => state, + Err(_) => return Vec::new(), + }; state .get_logs() .iter() @@ -64,7 +101,7 @@ impl LogCard { let mut last_boundary = 0usize; for (idx, ch) in s.char_indices() { - let char_width = if ch.is_ascii() { 1 } else { 2 }; + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); if current_width + char_width > max_width { if last_boundary == 0 { return idx + ch.len_utf8(); @@ -78,7 +115,7 @@ impl LogCard { s.len() } - fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> { + fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> { let mut result = Vec::new(); let timestamp = entry.format_timestamp(); @@ -141,29 +178,17 @@ impl LogCard { line.push_str(×tamp); } - result.push((line, Self::sender_color(&entry.sender), entry.is_error)); + result.push((line, LogSource::from_sender(&entry.sender), entry.is_error)); } result } - fn sender_color(sender: &str) -> Color { - match sender { - "Call" => Color::Magenta, - "Client" => Color::Green, - "Iota" => Color::Yellow, - "Omikron" => Color::Blue, - "Omega" => Color::Cyan, - "Command" => Color::LightGreen, - _ => Color::LightCyan, - } - } - fn build_all_lines( &self, entries: Vec, width: usize, - ) -> Vec<(String, Color, bool)> { + ) -> Vec<(String, LogSource, bool)> { let mut lines = Vec::new(); for entry in entries { @@ -269,23 +294,33 @@ impl Element for LogCard { self } - fn render(&self, f: &mut Frame, area: Rect) { + fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { let entries = self.get_logs(); let block = Block::default() .title(self.build_title()) .borders(self.borders) .border_style(if self.focused { - Style::default().fg(Color::Yellow) + context.theme.logs.focused_border } else { - Style::default() + context.theme.logs.border }); let inner_area = block.inner(area); f.render_widget(block, area); if inner_area.width == 0 || inner_area.height == 0 { - draw_block_joins(f, area, self.borders, self.joins); + draw_block_joins( + f, + area, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); return; } @@ -298,7 +333,7 @@ impl Element for LogCard { let rendered_lines: Vec = visible_lines .iter() - .map(|(line, prefix_color, is_error)| { + .map(|(line, source, is_error)| { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); @@ -306,24 +341,25 @@ impl Element for LogCard { if !prefix.is_empty() { spans.push(Span::styled( prefix.to_string(), - Style::default().fg(*prefix_color), + source.style(context.theme), )); } let (content, timestamp) = Self::split_timestamp_suffix(rest); - let text_color = if *is_error { Color::Red } else { Color::White }; + let text_style = if *is_error { + context.theme.logs.error + } else { + context.theme.logs.text + }; if !content.is_empty() { - spans.push(Span::styled( - content.to_string(), - Style::default().fg(text_color), - )); + spans.push(Span::styled(content.to_string(), text_style)); } if !timestamp.is_empty() { spans.push(Span::styled( timestamp.to_string(), - Style::default().fg(Color::DarkGray), + context.theme.logs.timestamp, )); } @@ -341,7 +377,17 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - draw_block_joins(f, area, self.borders, self.joins); + draw_block_joins( + f, + area, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); } } diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 5cdf4fa..3080480 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -2,52 +2,59 @@ use crate::ui::UI; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; -pub fn setup_input_handler(ui: Arc) { +pub fn setup_input_handler(ui: Arc) -> JoinHandle> { tokio::spawn(async move { + let cancellation = ui.cancellation_token(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let worker_cancellation = cancellation.clone(); + let worker = tokio::task::spawn_blocking(move || -> Result<(), String> { + while !worker_cancellation.is_cancelled() { + if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? { + tx.send(read().map_err(|e| e.to_string())?) + .map_err(|_| "input session closed".to_string())?; + } + } + Ok(()) + }); loop { if ui.is_shutdown() { break; } - let event_result = tokio::task::spawn_blocking(|| { - if let Ok(true) = poll(Duration::from_millis(100)) { - read().ok().and_then(|ev| match ev { - Event::Key(key) if key.kind == KeyEventKind::Press => Some(key), - _ => None, - }) - } else { - None - } - }) - .await; - - match event_result { - Ok(Some(key_event)) => { - handle_input(key_event, ui.clone()).await; - } - Ok(_) => {} - Err(e) => { - eprintln!("Input task error: {}", e); - tokio::time::sleep(Duration::from_millis(10)).await; - } + tokio::select! { + event = rx.recv() => match event { + Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, + Some(Event::Resize(_, _)) => ui.invalidate(), + Some(Event::Paste(text)) => ui.handle_paste(text).await, + Some(_) => {}, + None => break, + }, + _ = cancellation.cancelled() => break, } } - }); + let result = match worker.await { + Ok(result) => result, + Err(error) if error.is_cancelled() => Ok(()), + Err(error) => Err(format!("input worker failed: {error}")), + }; + if result.is_err() { + ui.request_shutdown(); + } + result + }) } pub async fn handle_input(key: KeyEvent, ui: Arc) { - match (key.code, key.modifiers) { - (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) - | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { - ui.request_shutdown(); - } - (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { - let _ = ui.send_restart().await; - ui.request_shutdown(); - } - _ => { - ui.handle_input(key).await; - } + if matches!( + key.code, + crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c') + ) && key.modifiers.contains(KeyModifiers::CONTROL) + { + ui.request_shutdown(); + } else { + ui.handle_input(key).await; } } diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 018cfa2..186d756 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -1,21 +1,25 @@ use iota_ipc::{ - ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, + ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, RequestEnvelope, ResponseResult, read_msg, write_msg, }; use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; use std::io::Result; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::net::UnixStream; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; use tokio::sync::{Mutex, oneshot, watch}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; const INITIAL_BACKOFF: Duration = Duration::from_millis(200); const MAX_BACKOFF: Duration = Duration::from_secs(10); const MAX_RECONNECT_ATTEMPTS: u32 = 50; +const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); /// Connection state exposed to the UI. #[derive(Clone, Debug)] @@ -24,96 +28,114 @@ pub enum IpcConnectionState { Connected, Reconnecting { attempt: u32 }, Incompatible { message: String }, + Failed { message: String }, Disconnected, } +/// Daemon information shown by the UI. This is separate from socket connectivity: +/// a connected daemon may still be starting or degraded. +#[derive(Clone, Debug, Default)] +pub struct DaemonStatus { + pub version: String, + pub instance_id: String, + pub startup_phase: Option, + pub degraded_reason: Option, + pub lifecycle: Option, + pub health: iota_ipc::HealthStatus, + pub deployment_mode: Option, + pub supervisor: Option, + pub components: std::collections::BTreeMap, +} + /// Pending request awaiting a response. struct PendingRequest { response_tx: oneshot::Sender, } +struct ActiveWriter { + generation: u64, + writer: OwnedWriteHalf, +} + +struct NegotiatedConnection { + reader: OwnedReadHalf, + writer: OwnedWriteHalf, + ack: HelloAck, + buffered_messages: Vec, +} + /* The TUI owns this cache. IPC updates replace daemon snapshots and append * logs, so rendering never reaches into daemon-owned storage or connections. */ pub struct IpcClient { state: ClientState, - writer: Mutex, + writer: Mutex>, + next_generation: AtomicU64, next_request_id: AtomicU64, pending: Mutex>, connection_state: watch::Sender, + daemon_status: watch::Sender, path: PathBuf, + reconnector_started: AtomicBool, + cancellation: CancellationToken, + background_tasks: StdMutex>>, } impl IpcClient { pub async fn connect(path: impl AsRef) -> Result> { let path = path.as_ref().to_path_buf(); - let stream = Self::try_connect(&path).await?; - let (mut reader, writer) = stream.into_split(); + let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; + Self::connect_until(&path, deadline).await + } - let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting); + async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result> { + let path = path.to_path_buf(); + let stream = Self::connect_stream(&path, deadline).await?; + let negotiated = Self::negotiate_stream(stream, deadline).await?; + + // These values are visible before MainScreen subscribes. Do not + // publish the handshake into a channel with no retained receiver. + let initial_status = DaemonStatus { + version: negotiated.ack.daemon_version.clone(), + instance_id: negotiated.ack.instance_id.clone(), + startup_phase: Some(negotiated.ack.startup_phase), + degraded_reason: None, + lifecycle: Some(negotiated.ack.lifecycle), + health: negotiated.ack.health, + deployment_mode: Some(negotiated.ack.deployment_mode), + supervisor: Some(negotiated.ack.supervisor), + components: std::collections::BTreeMap::new(), + }; + let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); + let (daemon_status_tx, _) = watch::channel(initial_status); let client = Arc::new(Self { state: ClientState::new(), - writer: Mutex::new(writer), + writer: Mutex::new(Some(ActiveWriter { + generation: 1, + writer: negotiated.writer, + })), + next_generation: AtomicU64::new(2), next_request_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), connection_state: conn_state_tx, + daemon_status: daemon_status_tx, path: path.clone(), + reconnector_started: AtomicBool::new(false), + cancellation: CancellationToken::new(), + background_tasks: StdMutex::new(Vec::new()), }); - // --- Handshake: send Hello, read HelloAck --- - { - let mut w = client.writer.lock().await; - write_msg( - &mut *w, - &ClientMessage::Hello { - supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], - }, - ) - .await?; + // Apply messages received while waiting for subscription confirmation + // before exposing the connection to the UI. + for message in negotiated.buffered_messages { + client.apply(message).await; } - match read_msg::<_, DaemonMessage>(&mut reader).await { - Ok(DaemonMessage::HelloAck(ack)) => { - if ack.protocol_version < MIN_PROTOCOL_VERSION { - let _ = client - .connection_state - .send(IpcConnectionState::Incompatible { - message: format!( - "Daemon protocol {} < required {}", - ack.protocol_version, MIN_PROTOCOL_VERSION - ), - }); - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - format!( - "Protocol version mismatch: daemon={}, minimum={}", - ack.protocol_version, MIN_PROTOCOL_VERSION - ), - )); - } - } - Ok(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Expected HelloAck from daemon", - )); - } - Err(e) => return Err(e), - } - - let _ = client.connection_state.send(IpcConnectionState::Connected); // Start reader task (continues reading after handshake) let reader_client = client.clone(); - tokio::spawn(async move { - reader_client.read_loop(reader).await; + let task = tokio::spawn(async move { + reader_client.read_loop(negotiated.reader, 1).await; }); - - // Subscribe to events - client - .send(ClientMessage::Subscribe { - log_classes: vec![], - metric_interval_ms: Some(500), - }) - .await?; + client.background_tasks.lock().unwrap().push(task); Ok(client) } @@ -121,165 +143,191 @@ impl IpcClient { /// Try to connect with retries for socket activation. pub async fn connect_or_activate(path: impl AsRef) -> Result> { let path = path.as_ref().to_path_buf(); - let max_attempts = 30; - for attempt in 0..max_attempts { - match Self::connect(&path).await { + let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; + let mut last_error = None; + while tokio::time::Instant::now() < deadline { + match Self::connect_until(&path, deadline).await { Ok(client) => return Ok(client), Err(error) => { - if attempt < max_attempts - 1 { - let delay = Duration::from_millis(100 + attempt as u64 * 100); - tokio::time::sleep(delay).await; - continue; - } - return Err(error); + last_error = Some(error); + tokio::time::sleep(Duration::from_millis(250)).await; } } } - unreachable!() + Err(last_error.unwrap_or_else(|| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon") + })) } - async fn try_connect(path: &Path) -> Result { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result { + tokio::time::timeout_at(deadline, UnixStream::connect(path)) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out connecting to daemon", + ) + })? + } + + async fn negotiate_stream( + stream: UnixStream, + deadline: tokio::time::Instant, + ) -> Result { + let (mut reader, mut writer) = stream.into_split(); + tokio::time::timeout_at( + deadline, + write_msg( + &mut writer, + &ClientMessage::Hello { + supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], + }, + ), + ) + .await + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello") + })??; + let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)) + .await + { + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out waiting for IPC HelloAck", + )); + } + Ok(Ok(DaemonMessage::HelloAck(ack))) => ack, + Ok(Ok(_)) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Expected HelloAck as the first daemon message", + )); + } + Ok(Err(error)) => return Err(error), + }; + if !Self::is_compatible_version(ack.protocol_version) { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "Unsupported daemon protocol version {}", + ack.protocol_version + ), + )); + } + tokio::time::timeout_at( + deadline, + write_msg( + &mut writer, + &ClientMessage::Subscribe { + log_classes: vec![], + metric_interval_ms: Some(500), + }, + ), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out sending IPC subscription", + ) + })??; + + // The daemon may send its initial StateUpdate before the acknowledgement. + // Keep draining until the subscription itself is confirmed, otherwise a + // UI can report Connected while no state stream exists yet. + let mut buffered_messages = Vec::new(); loop { - match UnixStream::connect(path).await { - Ok(stream) => return Ok(stream), - Err(error) => { - if tokio::time::Instant::now() >= deadline { - return Err(error); - } - tokio::time::sleep(Duration::from_millis(200)).await; + match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await + { + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out waiting for IPC subscription acknowledgement", + )); } + Ok(Ok(DaemonMessage::Subscribed)) => break, + Ok(Ok(message)) => buffered_messages.push(message), + Ok(Err(error)) => return Err(error), } } + + Ok(NegotiatedConnection { + reader, + writer, + ack, + buffered_messages, + }) } /// Start the reconnection actor. pub fn spawn_reconnector(self: &Arc) { + if self.reconnector_started.swap(true, Ordering::AcqRel) { + return; + } let client = self.clone(); - tokio::spawn(async move { + let task = tokio::spawn(async move { client.reconnection_loop().await; }); + self.background_tasks.lock().unwrap().push(task); } async fn reconnection_loop(self: Arc) { let mut rx = self.connection_status(); loop { - // Wait until the connection enters the Disconnected state. - loop { - let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected); - if disconnected { - break; - } - if rx.changed().await.is_err() { - return; // sender dropped + while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) { + if tokio::select! { + changed = rx.changed() => changed.is_err(), + _ = self.cancellation.cancelled() => true, + } { + return; } } let mut backoff = INITIAL_BACKOFF; - let mut attempt: u32 = 0; - - // Attempt reconnection until success or max attempts. - loop { - tokio::time::sleep(backoff).await; - attempt += 1; - - if attempt > MAX_RECONNECT_ATTEMPTS { - let _ = self - .connection_state - .send(IpcConnectionState::Incompatible { - message: "Max reconnection attempts exceeded".into(), - }); + for attempt in 1..=MAX_RECONNECT_ATTEMPTS { + if self.cancellation.is_cancelled() { return; } - let _ = self .connection_state .send(IpcConnectionState::Reconnecting { attempt }); - - match Self::try_connect(&self.path).await { - Ok(stream) => { - let (mut reader, writer) = stream.into_split(); - *self.writer.lock().await = writer; - - // Re-handshake - { - let mut w = self.writer.lock().await; - if write_msg( - &mut *w, - &ClientMessage::Hello { - supported_versions: vec![ - MIN_PROTOCOL_VERSION, - PROTOCOL_VERSION, - ], - }, - ) - .await - .is_err() - { - let _ = self - .connection_state - .send(IpcConnectionState::Disconnected); - break; - } - } - - // Read HelloAck - match read_msg::<_, DaemonMessage>(&mut reader).await { - Ok(DaemonMessage::HelloAck(ack)) => { - if ack.protocol_version < MIN_PROTOCOL_VERSION { - let _ = self.connection_state.send( - IpcConnectionState::Incompatible { - message: format!( - "Daemon protocol {} < required {}", - ack.protocol_version, MIN_PROTOCOL_VERSION - ), - }, - ); - return; - } - } - _ => { - let _ = self - .connection_state - .send(IpcConnectionState::Disconnected); - break; - } - } - - // Clear pending requests with connection-lost errors - { - let mut pending = self.pending.lock().await; - for (_, request) in pending.drain() { - let _ = request.response_tx.send( - ResponseResult::Error( - iota_ipc::IpcErrorCode::Disconnected, - ), - ); - } - } - - let _ = self.connection_state.send(IpcConnectionState::Connected); - - // Start new reader loop - let reader_client = self.clone(); - tokio::spawn(async move { - reader_client.read_loop(reader).await; - }); - - // Resubscribe - let _ = self - .send(ClientMessage::Subscribe { - log_classes: vec![], - metric_interval_ms: Some(500), - }) - .await; - - // Successfully reconnected; go back to waiting for - // the next disconnect. + tokio::select! { + _ = tokio::time::sleep(backoff) => {}, + _ = self.cancellation.cancelled() => return, + } + let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; + let result = async { + let stream = Self::connect_stream(&self.path, deadline).await?; + Self::negotiate_stream(stream, deadline).await + } + .await; + match result { + Ok(connection) => { + self.install_connection(connection).await; break; } - Err(_) => { + Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { + let _ = self + .connection_state + .send(IpcConnectionState::Incompatible { + message: error.to_string(), + }); + return; + } + Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => { + let _ = self.connection_state.send(IpcConnectionState::Failed { + message: format!("Reconnect failed after {attempt} attempts: {error}"), + }); + return; + } + Err(error) => { + eprintln!( + "IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}", + self.path.display(), + error.kind() + ); backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); } } @@ -287,16 +335,61 @@ impl IpcClient { } } - async fn read_loop(self: Arc, mut reader: OwnedReadHalf) { - loop { - match read_msg::<_, DaemonMessage>(&mut reader).await { - Ok(message) => self.apply(message).await, - Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { - let _ = self.connection_state.send(IpcConnectionState::Disconnected); - break; + async fn install_connection(self: &Arc, connection: NegotiatedConnection) { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + *self.writer.lock().await = Some(ActiveWriter { + generation, + writer: connection.writer, + }); + self.update_hello_ack(connection.ack); + for message in connection.buffered_messages { + self.apply(message).await; + } + let _ = self.connection_state.send(IpcConnectionState::Connected); + let client = self.clone(); + let task = tokio::spawn(async move { + client.read_loop(connection.reader, generation).await; + }); + self.background_tasks.lock().unwrap().push(task); + } + + async fn fail_pending_requests(&self) { + let mut pending = self.pending.lock().await; + for (_, request) in pending.drain() { + let _ = request + .response_tx + .send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)); + } + } + + async fn mark_disconnected(&self, generation: u64) { + let removed = { + let mut writer = self.writer.lock().await; + match writer.as_ref() { + Some(active) if active.generation == generation => { + writer.take(); + true } - Err(_) => { - let _ = self.connection_state.send(IpcConnectionState::Disconnected); + _ => false, + } + }; + if removed { + let _ = self.connection_state.send(IpcConnectionState::Disconnected); + self.fail_pending_requests().await; + } + } + + async fn read_loop(self: Arc, mut reader: OwnedReadHalf, generation: u64) { + loop { + let result = tokio::select! { + result = read_msg::<_, DaemonMessage>(&mut reader) => result, + _ = self.cancellation.cancelled() => break, + }; + match result { + Ok(message) => self.apply(message).await, + Err(error) => { + eprintln!("IPC reader for generation {generation} stopped: {error}"); + self.mark_disconnected(generation).await; break; } } @@ -315,6 +408,65 @@ impl IpcClient { self.connection_state.borrow().clone() } + pub fn daemon_status(&self) -> watch::Receiver { + self.daemon_status.subscribe() + } + + /// Stop the IPC reader/reconnector and release the socket writer. This + /// is deliberately bounded so UI shutdown cannot hang on a peer. + pub async fn shutdown(&self) { + self.cancellation.cancel(); + self.writer.lock().await.take(); + let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap()); + for mut task in tasks { + if tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .is_err() + { + task.abort(); + } + } + } + + fn is_compatible_version(version: u16) -> bool { + (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version) + } + + fn update_hello_ack(&self, ack: HelloAck) { + self.daemon_status.send_modify(|status| { + status.version = ack.daemon_version; + status.instance_id = ack.instance_id; + status.startup_phase = Some(ack.startup_phase); + status.lifecycle = Some(ack.lifecycle); + status.health = ack.health; + status.deployment_mode = Some(ack.deployment_mode); + status.supervisor = Some(ack.supervisor); + }); + } + + fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { + match code { + iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", + iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.", + iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.", + iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.", + iota_ipc::IpcErrorCode::OmikronUnavailable => { + "Omikron is unavailable; try reconnecting." + } + iota_ipc::IpcErrorCode::UnsupportedVersion => { + "CLI and daemon versions are incompatible." + } + iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.", + iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.", + iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.", + iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.", + iota_ipc::IpcErrorCode::Unauthorized => { + "The daemon rejected this operation as unauthorized." + } + iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.", + } + } + pub async fn send_request(&self, request: LocalRequest) -> Result { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); let (response_tx, response_rx) = oneshot::channel(); @@ -329,14 +481,17 @@ impl IpcClient { protocol_version: PROTOCOL_VERSION, request, }; - self.send(ClientMessage::Request(envelope)).await?; + if let Err(error) = self.send(ClientMessage::Request(envelope)).await { + self.pending.lock().await.remove(&request_id); + return Err(error); + } match tokio::time::timeout(Duration::from_secs(30), response_rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), Err(_) => { self.pending.lock().await.remove(&request_id); - Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)) + Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout)) } } } @@ -357,8 +512,12 @@ impl IpcClient { ["user", "list"] => Some(LocalRequest::ListUsers), ["reconnect"] => Some(LocalRequest::ReconnectOmikron), ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon), - ["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + }), _ => None, } } @@ -371,11 +530,7 @@ impl IpcClient { if trimmed == "ping" || trimmed.starts_with("ping ") { let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed); if let Err(e) = self.send(ClientMessage::Ping { seq }).await { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -387,11 +542,7 @@ impl IpcClient { }); return Err(e); } - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -407,14 +558,10 @@ impl IpcClient { if let Some(request) = Self::parse_console_command(&line) { match self.send_request(request).await { Ok(result) => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; let message = match &result { ResponseResult::Ok(msg) => msg.clone(), - ResponseResult::Error(code) => format!("Error: {:?}", code), + ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() @@ -428,11 +575,7 @@ impl IpcClient { Ok(()) } Err(e) => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -446,19 +589,15 @@ impl IpcClient { } } } else { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Console".into(), - message: if line.trim() == "help" { - "Available commands: tasks, ping, user, reconnect, regenerate, restart, stop" + message: if trimmed == "help" { + "Commands: status, tasks, ping, user add , user remove , user list, reconnect, regenerate keys, restart, stop" .into() } else { format!("Unknown command: {}", line) @@ -470,18 +609,43 @@ impl IpcClient { } async fn send(&self, message: ClientMessage) -> Result<()> { - let mut writer = self.writer.lock().await; - write_msg(&mut *writer, &message).await + let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; + let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock()) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out acquiring IPC writer", + ) + })?; + let active = writer_guard.as_mut().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotConnected, + "IPC connection is not active", + ) + })?; + let generation = active.generation; + let write_result = + tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message)) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Timed out writing IPC message", + ) + }) + .and_then(|result| result); + drop(writer_guard); + if write_result.is_err() { + self.mark_disconnected(generation).await; + } + write_result } async fn apply(&self, message: DaemonMessage) { match message { DaemonMessage::LogEntry(entry) => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: entry.timestamp_ms, sender: entry.sender, @@ -490,11 +654,16 @@ impl IpcClient { }); } DaemonMessage::StateUpdate(snapshot) => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + // Never hold a watch borrow while sending to that same + // channel: send waits for outstanding Ref guards. + self.daemon_status.send_modify(|status| { + status.startup_phase = Some(snapshot.startup_phase); + status.degraded_reason = snapshot.degraded_reason.clone(); + status.lifecycle = Some(snapshot.lifecycle); + status.health = snapshot.overall_health; + status.components = snapshot.components.clone(); + }); + let mut state = self.state.app.lock().await; state.cpu = snapshot.cpu; state.ram = snapshot.ram; state.ping = snapshot.ping; @@ -503,41 +672,21 @@ impl IpcClient { state.sys_info = snapshot.sys_info; } DaemonMessage::MetricSample(sample) => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; if let Some(cpu) = sample.cpu { - let idx = state.cpu.len() as f64; - state.cpu.push((idx, cpu)); - if state.cpu.len() > iota_state::MAX_POINTS { - state.cpu.remove(0); - } + state.push_cpu((0.0, cpu)); } if let Some(ram) = sample.ram { - let idx = state.ram.len() as f64; - state.ram.push((idx, ram)); - if state.ram.len() > iota_state::MAX_POINTS { - state.ram.remove(0); - } + state.push_ram((0.0, ram)); } if let Some(ping) = sample.ping { state.push_ping_val(ping); } if let Some(net_up) = sample.net_up { - let idx = state.net_up.len() as f64; - state.net_up.push((idx, net_up)); - if state.net_up.len() > iota_state::MAX_POINTS { - state.net_up.remove(0); - } + state.push_net_up((0.0, net_up)); } if let Some(net_down) = sample.net_down { - let idx = state.net_down.len() as f64; - state.net_down.push((idx, net_down)); - if state.net_down.len() > iota_state::MAX_POINTS { - state.net_down.remove(0); - } + state.push_net_down((0.0, net_down)); } } DaemonMessage::Response(response) => { @@ -545,14 +694,10 @@ impl IpcClient { if let Some(request) = pending.remove(&response.request_id) { let _ = request.response_tx.send(response.result); } else { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; let message = match &response.result { ResponseResult::Ok(msg) => msg.clone(), - ResponseResult::Error(code) => format!("Error: {:?}", code), + ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() @@ -565,15 +710,12 @@ impl IpcClient { }); } } - DaemonMessage::HelloAck(_) => {} + DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack), + DaemonMessage::Subscribed => {} DaemonMessage::Pong { .. } => {} DaemonMessage::LifecycleEvent(event) => match event { iota_ipc::LifecycleEvent::Shutdown { reason } => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -584,14 +726,19 @@ impl IpcClient { is_error: true, }); } - _ => {} + iota_ipc::LifecycleEvent::StateChanged(status) => { + self.daemon_status.send_modify(|daemon_status| { + daemon_status.degraded_reason = match status { + iota_ipc::ConnectionStatus::Degraded => { + Some("A daemon dependency is degraded".into()) + } + _ => None, + }; + }); + } }, DaemonMessage::Gap { skipped } => { - let mut state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); + let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/iota-cli/src/layout/fit.rs b/iota-cli/src/layout/fit.rs new file mode 100644 index 0000000..3908272 --- /dev/null +++ b/iota-cli/src/layout/fit.rs @@ -0,0 +1,52 @@ +use ratatui::layout::Rect; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequiredSize { + pub width: u16, + pub height: u16, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FitLevel { + Preferred, + Compact, + Fallback, +} +pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel { + if area.width >= preferred.width && area.height >= preferred.height { + FitLevel::Preferred + } else if area.width >= compact.width && area.height >= compact.height { + FitLevel::Compact + } else { + FitLevel::Fallback + } +} +pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect { + let width = area.width.min(maximum.width); + let height = area.height.min(maximum.height); + Rect { + x: area.x.saturating_add(area.width.saturating_sub(width) / 2), + y: area + .y + .saturating_add(area.height.saturating_sub(height) / 2), + width, + height, + } +} +pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option { + let height = area.height.checked_sub(top)?.checked_sub(bottom)?; + Some(Rect { + x: area.x, + y: area.y.checked_add(top)?, + width: area.width, + height, + }) +} +pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option { + let width = area.width.checked_sub(horizontal.checked_mul(2)?)?; + let height = area.height.checked_sub(vertical.checked_mul(2)?)?; + Some(Rect { + x: area.x.checked_add(horizontal)?, + y: area.y.checked_add(vertical)?, + width, + height, + }) +} diff --git a/iota-cli/src/layout/mod.rs b/iota-cli/src/layout/mod.rs new file mode 100644 index 0000000..f629920 --- /dev/null +++ b/iota-cli/src/layout/mod.rs @@ -0,0 +1,2 @@ +pub mod fit; +pub mod text_measure; diff --git a/iota-cli/src/layout/text_measure.rs b/iota-cli/src/layout/text_measure.rs new file mode 100644 index 0000000..0ba471e --- /dev/null +++ b/iota-cli/src/layout/text_measure.rs @@ -0,0 +1,10 @@ +use unicode_width::UnicodeWidthStr; +pub fn wrapped_line_count(text: &str, width: u16) -> u16 { + if width == 0 { + return 0; + } + text.split('\n') + .map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize) + .sum::() + .min(u16::MAX as usize) as u16 +} diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index 6986d72..f54720b 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -5,6 +5,7 @@ pub mod elements { pub mod log_card; } pub mod screens { + pub mod daemon_setup; pub mod main_screen; pub mod md_viewer; pub mod screens; @@ -17,7 +18,12 @@ pub mod util { pub mod terms_focus; } pub mod app_state; +pub mod controls; pub mod input_handler; -pub mod ipc_client; pub mod interaction_result; +pub mod ipc_client; +pub mod layout; +pub mod render_context; +pub mod theme; pub mod ui; +pub use ui::TuiSession; diff --git a/iota-cli/src/render_context.rs b/iota-cli/src/render_context.rs new file mode 100644 index 0000000..8b7d3c9 --- /dev/null +++ b/iota-cli/src/render_context.rs @@ -0,0 +1,6 @@ +use crate::theme::ResolvedTheme; + +/// Immutable state shared by every component during one render pass. +pub struct RenderContext<'a> { + pub theme: &'a ResolvedTheme, +} diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs new file mode 100644 index 0000000..2dad4ce --- /dev/null +++ b/iota-cli/src/screens/daemon_setup.rs @@ -0,0 +1,287 @@ +use crate::{ + controls::{ + button::{ActionButton, ButtonIntent, render_button}, + choice::{ChoiceKind, render_choice_line}, + radio_group::{RadioGroup, RadioItem}, + }, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::Screen, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::any::Any; +use tokio::sync::oneshot; + +/// Kept on screen while the launcher waits for the daemon's IPC hello. The +/// setup choice screen is intentionally closed before its decision is sent, +/// so without this the terminal would otherwise be blank during startup. +pub struct DaemonStartingScreen; +impl Screen for DaemonStartingScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let popup = crate::layout::fit::centered_rect( + area, + crate::layout::fit::RequiredSize { + width: 48, + height: 5, + }, + ); + frame.render_widget( + Paragraph::new( + "Starting iota-daemon…\nWaiting for its IPC handshake.\nPress Ctrl+C to cancel.", + ) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(" Iota daemon ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal), + ), + popup, + ); + } + fn handle_input(&mut self, _: KeyEvent) -> InteractionResult { + InteractionResult::Handled + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaemonLaunchMode { + Once, + WithUi, + WithSystem, +} +#[derive(Debug, Clone)] +pub struct LaunchOption { + pub mode: DaemonLaunchMode, + pub enabled: bool, + pub reason: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaemonSetupDecision { + Start(DaemonLaunchMode), + Exit, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Options, + Exit, + Action, +} + +/// The launcher owns the actual side effects. This screen only presents the +/// capabilities discovered for this machine, keeping disabled choices visible. +pub struct DaemonSetupScreen { + choices: RadioGroup, + focus: Focus, + sender: Option>, + message: String, +} +impl DaemonSetupScreen { + pub fn new( + options: Vec, + message: impl Into, + sender: oneshot::Sender, + ) -> Result { + let items: Vec> = options + .into_iter() + .map(|o| RadioItem { + value: o.mode, + label: match o.mode { + DaemonLaunchMode::Once => "Start once", + DaemonLaunchMode::WithUi => "Start with Iota UI", + DaemonLaunchMode::WithSystem => "Start with the system", + } + .into(), + description: o.reason, + enabled: o.enabled, + disabled_reason: None, + }) + .collect(); + let default = items + .iter() + .find(|item| item.enabled) + .map(|item| item.value) + .ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?; + let mut choices = RadioGroup::new(items, None, default)?; + choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include); + Ok(Self { + choices, + focus: Focus::Options, + sender: Some(sender), + message: message.into(), + }) + } + fn complete(&mut self, d: DaemonSetupDecision) { + if let Some(tx) = self.sender.take() { + let _ = tx.send(d); + } + } + fn activate(&mut self) -> InteractionResult { + match self.focus { + Focus::Options => { + self.choices.select_focused(); + InteractionResult::Handled + } + Focus::Exit => { + self.complete(DaemonSetupDecision::Exit); + InteractionResult::CloseScreen + } + Focus::Action => { + let choice = *self.choices.selected(); + if self + .choices + .items() + .iter() + .find(|i| i.value == choice) + .is_some_and(|i| i.enabled) + { + self.complete(DaemonSetupDecision::Start(choice)); + InteractionResult::CloseScreen + } else { + InteractionResult::Handled + } + } + } + } + fn next(&mut self) { + self.focus = match self.focus { + Focus::Options => { + self.choices.focus_next(); + if self.choices.focused_item().value == DaemonLaunchMode::Once { + Focus::Exit + } else { + Focus::Options + } + } + Focus::Exit => Focus::Action, + Focus::Action => Focus::Options, + }; + } + fn previous(&mut self) { + self.focus = match self.focus { + Focus::Options => { + self.choices.focus_previous(); + if self.choices.focused_item().value == DaemonLaunchMode::WithSystem { + Focus::Action + } else { + Focus::Options + } + } + Focus::Exit => Focus::Options, + Focus::Action => Focus::Exit, + }; + } +} +impl Screen for DaemonSetupScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let popup = crate::layout::fit::centered_rect( + area, + crate::layout::fit::RequiredSize { + width: 68, + height: 16, + }, + ); + let mut lines = vec![Line::from(self.message.as_str()), Line::from("")]; + for item in self.choices.items() { + lines.push(render_choice_line( + &item.label, + ChoiceKind::Radio, + self.choices.visual_state(&item.value), + context.theme, + )); + if let Some(reason) = &item.description { + lines.push(Line::styled( + format!(" {reason}"), + context.theme.text.muted, + )); + } + } + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(" Iota daemon setup ") + .borders(Borders::ALL), + ), + rows[0], + ); + let b = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + render_button( + frame, + b[0], + ActionButton { + label: "Exit", + intent: ButtonIntent::Cancel, + focused: self.focus == Focus::Exit, + enabled: true, + }, + context.theme, + ); + let selected = *self.choices.selected(); + let enabled = self + .choices + .items() + .iter() + .find(|i| i.value == selected) + .is_some_and(|i| i.enabled); + let label = match selected { + DaemonLaunchMode::Once => "Start once", + DaemonLaunchMode::WithUi => "Save and start", + DaemonLaunchMode::WithSystem => "Configure and start", + }; + render_button( + frame, + b[1], + ActionButton { + label, + intent: if enabled { + ButtonIntent::Primary + } else { + ButtonIntent::Destructive + }, + focused: self.focus == Focus::Action, + enabled, + }, + context.theme, + ); + } + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + match event.code { + KeyCode::Esc => { + self.complete(DaemonSetupDecision::Exit); + InteractionResult::CloseScreen + } + KeyCode::Down | KeyCode::Right | KeyCode::Tab => { + self.next(); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Left | KeyCode::BackTab => { + self.previous(); + InteractionResult::Handled + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + _ => InteractionResult::Unhandled, + } + } +} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 49828c2..19fa093 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -6,7 +6,8 @@ use crate::{ log_card::LogCard, }, interaction_result::InteractionResult, - ipc_client::IpcConnectionState, + ipc_client::{DaemonStatus, IpcConnectionState}, + render_context::RenderContext, screens::screens::{NavDirection, Screen}, ui::UI, }; @@ -27,6 +28,7 @@ pub struct MainScreen { selected_coords: (usize, usize), graphs_open: bool, connection_status_rx: watch::Receiver, + daemon_status_rx: watch::Receiver, } impl MainScreen { @@ -39,10 +41,17 @@ impl MainScreen { vec![Some(1), Some(4)], ]; - let state = ui.client_state(); + let state = ui + .client_state() + .await + .expect("MainScreen requires an attached daemon"); let mut log_card = LogCard::new(state.clone()); log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); - let mut console_card = ConsoleCard::new("Console", "", ui.ipc()); + let ipc = ui + .ipc() + .await + .expect("MainScreen requires an attached daemon"); + let mut console_card = ConsoleCard::new("Console", "", ipc.clone()); console_card.set_joins(Borders::TOP); elements.push(Box::new(log_card)); @@ -61,7 +70,8 @@ impl MainScreen { let graphs_open = true; - let connection_status_rx = ui.ipc().connection_status(); + let connection_status_rx = ipc.connection_status(); + let daemon_status_rx = ipc.daemon_status(); let mut screen = MainScreen { elements, @@ -69,6 +79,7 @@ impl MainScreen { selected_coords: (1, 0), graphs_open, connection_status_rx, + daemon_status_rx, }; screen.focus_current(); screen @@ -198,22 +209,46 @@ impl Screen for MainScreen { self } - fn render(&self, f: &mut Frame, rect: Rect) { - let status = self.connection_status_rx.borrow(); - let status_text = match &*status { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + // A watch Ref blocks senders until it is dropped. Rendering may do + // terminal I/O, so retain only owned snapshots for the whole frame. + let status = self.connection_status_rx.borrow().clone(); + let daemon = self.daemon_status_rx.borrow().clone(); + let status_text = match status { IpcConnectionState::Connected => "Connected".to_string(), IpcConnectionState::Connecting => "Connecting...".to_string(), IpcConnectionState::Reconnecting { attempt } => { format!("Reconnecting (attempt {})...", attempt) } IpcConnectionState::Incompatible { message } => { - format!("Incompatible: {}", message) + format!("Incompatible protocol: {}", message) + } + IpcConnectionState::Failed { message } => { + format!("Connection failed: {}", message) } IpcConnectionState::Disconnected => "Disconnected".to_string(), }; + let readiness = daemon + .startup_phase + .map(|phase| format!("{:?}", phase)) + .unwrap_or_else(|| "Waiting for status".into()); + let health = daemon + .degraded_reason + .as_deref() + .map(|reason| format!(" — {reason}")) + .unwrap_or_default(); + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; let main_block = Block::default() - .title(format!("Main [{}]", status_text)) - .borders(Borders::ALL); + .title(format!( + "Iota{version} [{status_text}; {readiness}{health}]" + )) + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); f.render_widget(main_block, rect); let inner = rect.inner(Margin { @@ -221,7 +256,11 @@ impl Screen for MainScreen { horizontal: 1, }); - let graphs_width = if self.graphs_open { 30 } else { 2 }; + let graphs_width = if self.graphs_open && inner.width >= 70 { + 30 + } else { + 2 + }; let main_width = inner.width.saturating_sub(graphs_width); let horizontal_chunks = Layout::default() @@ -239,11 +278,11 @@ impl Screen for MainScreen { Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); if let Some(log) = self.elements.get(0) { - log.as_element().render(f, left_rows[0]); + log.as_element().render(f, left_rows[0], context); } if let Some(console) = self.elements.get(1) { - console.as_element().render(f, left_rows[1]); + console.as_element().render(f, left_rows[1], context); } let graph_elements: Vec<_> = self @@ -262,7 +301,7 @@ impl Screen for MainScreen { .split(right_area); for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) { - el.as_element().render(f, *area); + el.as_element().render(f, *area, context); } } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index 2faa2f1..aaf6b9d 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -7,11 +7,16 @@ use ratatui::{ }; use std::{any::Any, time::Duration}; -use crate::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::Screen, + theme::{ResolvedTheme, TextSemantics, ThemeName}, +}; pub struct FileViewer { title: String, - text: Vec, + content: String, scroll: u16, scroll_x: u16, } @@ -24,8 +29,8 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect) { - self.draw(f, rect); + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + self.draw(f, rect, context.theme); } fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { @@ -52,7 +57,7 @@ impl FileViewer { pub fn new(title: String, content: &str) -> Self { Self { title, - text: parse_document(content.to_owned()), + content: content.to_owned(), scroll: 0, scroll_x: 0, } @@ -62,7 +67,7 @@ impl FileViewer { terminal .draw(|f| { let area = f.area(); - self.draw(f, area); + self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi)); }) .unwrap(); @@ -77,12 +82,13 @@ impl FileViewer { } terminal } - fn draw(&self, f: &mut Frame, area: Rect) { + fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) { use ratatui::text::Text; let mut rendered_lines = Vec::new(); + let text = parse_document(&self.content, theme); - for display_line in &self.text { + for display_line in &text { if display_line.scrollable { let content: String = display_line .line @@ -153,7 +159,7 @@ impl FileViewer { } } } -fn parse_document(input: String) -> Vec { +fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { let mut lines_vec = Vec::new(); let mut in_code_block = false; let liness: Vec = input.lines().map(String::from).collect(); @@ -172,7 +178,7 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( format!("────────{}────────", code), - Style::default().fg(Color::DarkGray), + theme.markdown.divider, )), scrollable: false, }); @@ -182,10 +188,7 @@ fn parse_document(input: String) -> Vec { if in_code_block { lines_vec.push(DisplayLine { - line: Line::from(Span::styled( - raw.to_string(), - Style::default().fg(Color::Yellow), - )), + line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)), scrollable: false, }); i += 1; @@ -195,9 +198,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("### ").to_string(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -208,9 +215,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("## ").to_string(), - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -221,9 +232,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("# ").to_string(), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -254,13 +269,13 @@ fn parse_document(input: String) -> Vec { } let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::>()); - lines_vec.extend(table_to_lines(table)); + lines_vec.extend(table_to_lines(table, theme)); i = j; continue; } lines_vec.push(DisplayLine { - line: Line::from(parse_inline(raw.as_str())), + line: Line::from(parse_inline(raw.as_str(), theme)), scrollable: false, }); i += 1; @@ -269,7 +284,7 @@ fn parse_document(input: String) -> Vec { lines_vec } -fn parse_inline(input: &str) -> Vec> { +fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { let mut spans = Vec::new(); let mut buf = String::new(); @@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec> { }; if let Some(kind) = toggle { - flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + flush_span( + &mut spans, + &mut buf, + current_style(bold, underline, code, theme), + ); match kind { "bold" => bold = !bold, @@ -308,24 +327,21 @@ fn parse_inline(input: &str) -> Vec> { buf.push(c); } - flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + flush_span( + &mut spans, + &mut buf, + current_style(bold, underline, code, theme), + ); spans } -fn current_style(bold: bool, underline: bool, code: bool) -> Style { - let mut style = Style::default(); - - if bold { - style = style.add_modifier(Modifier::BOLD); - } - if underline { - style = style.add_modifier(Modifier::UNDERLINED); - } - if code { - style = style.fg(Color::Yellow); - } - - style +fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style { + let base = if code { + theme.markdown.code + } else { + theme.markdown.normal + }; + theme.apply_text_semantics(base, TextSemantics { bold, underline }) } #[derive(Clone)] pub struct DisplayLine { @@ -333,7 +349,7 @@ pub struct DisplayLine { scrollable: bool, } -fn table_to_lines(table: Vec>) -> Vec { +fn table_to_lines(table: Vec>, theme: &ResolvedTheme) -> Vec { if table.len() < 2 { return vec![]; } @@ -377,7 +393,7 @@ fn table_to_lines(table: Vec>) -> Vec { .join("─┼─"); lines.push(DisplayLine { - line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))), + line: Line::from(Span::styled(divider, theme.markdown.divider)), scrollable: true, }); continue; @@ -403,11 +419,15 @@ fn table_to_lines(table: Vec>) -> Vec { } let style = if row_idx == 0 { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + theme.apply_text_semantics( + theme.markdown.table_header, + TextSemantics { + bold: true, + underline: false, + }, + ) } else { - Style::default().fg(Color::Green) + theme.markdown.table_text }; lines.push(DisplayLine { diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index 7606521..b69d980 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -3,7 +3,7 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect}; -use crate::interaction_result::InteractionResult; +use crate::{interaction_result::InteractionResult, render_context::RenderContext}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavDirection { @@ -20,6 +20,6 @@ pub trait Screen: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, rect: Rect); + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>); fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; } diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 029dae2..c44cbcb 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -1,22 +1,19 @@ use crate::{ + controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, + render_context::RenderContext, screens::{md_viewer::FileViewer, screens::Screen}, - ui::UI, - util::{ - buttons::{checkbox, draw_buttons}, - terms_focus::Focus, - }, + util::{buttons::draw_buttons, terms_focus::Focus}, }; use crossterm::event::{KeyCode, KeyEvent}; use iota_terms::{TermsType, get_link, get_terms}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; -use std::{any::Any, pin::Pin, sync::Arc}; +use std::{any::Any, pin::Pin}; use tokio::sync::oneshot; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,9 +23,7 @@ pub enum UserChoice { AcceptAll, } -#[allow(dead_code)] // ui is unused pub struct TermsCheckerScreen { - _ui: Arc, sender: Option>, eula: bool, @@ -39,9 +34,8 @@ pub struct TermsCheckerScreen { } impl TermsCheckerScreen { - pub fn new(ui: Arc, sender: Option>) -> Self { + pub fn new(sender: Option>) -> Self { Self { - _ui: ui, sender, eula: false, tos: false, @@ -59,7 +53,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -169,9 +163,36 @@ impl Screen for TermsCheckerScreen { ) }; let mut text_lines = vec![ - checkbox(eula_text, self.eula, self.focus == Focus::Eula, true), - checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula), - checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula), + render_choice_line( + eula_text, + ChoiceKind::Checkbox, + ChoiceVisualState { + selected: self.eula, + focused: self.focus == Focus::Eula, + enabled: true, + }, + context.theme, + ), + render_choice_line( + tos_text, + ChoiceKind::Checkbox, + ChoiceVisualState { + selected: self.tos, + focused: self.focus == Focus::Tos, + enabled: self.eula, + }, + context.theme, + ), + render_choice_line( + pp_text, + ChoiceKind::Checkbox, + ChoiceVisualState { + selected: self.pp, + focused: self.focus == Focus::Pp, + enabled: self.eula, + }, + context.theme, + ), Line::from(""), Line::from("¹ Necessary– required to run the program"), Line::from("² Optional – required only for Tensamin services"), @@ -191,19 +212,19 @@ impl Screen for TermsCheckerScreen { if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.width >= 60 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let height_style = if size.height > 19 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.height >= 13 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let warning_text = Text::from(vec![ @@ -245,6 +266,7 @@ impl Screen for TermsCheckerScreen { true, false, true, + context.theme, ); } diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index e6aad76..f1701b5 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -1,11 +1,10 @@ use crate::screens::terms_checker::UserChoice; use crate::{ + controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, + render_context::RenderContext, screens::{md_viewer::FileViewer, screens::Screen}, - util::{ - buttons::{checkbox, draw_buttons}, - terms_focus::Focus, - }, + util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; use crossterm::event::{KeyCode, KeyEvent}; @@ -13,7 +12,6 @@ use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; @@ -123,7 +121,19 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { + let checkbox = |label, selected, focused, enabled| { + render_choice_line( + label, + ChoiceKind::Checkbox, + ChoiceVisualState { + selected, + focused, + enabled, + }, + context.theme, + ) + }; let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -523,19 +533,19 @@ impl Screen for TermsUpdaterScreen { }; if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.width >= 60 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let height_style = if size.height > 20 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.height >= (header_lines as u16 + 10) { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let warning_text = Text::from(vec![ @@ -579,6 +589,7 @@ impl Screen for TermsUpdaterScreen { self.update_needed, downgrade_scenario, self.pp_needed || self.tos_needed, + context.theme, ); } diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs new file mode 100644 index 0000000..e77c7e6 --- /dev/null +++ b/iota-cli/src/theme/config.rs @@ -0,0 +1,163 @@ +use super::ThemeName; +use serde::{Deserialize, Serialize}; +use std::{ + fs, io, + path::{Path, PathBuf}, + str::FromStr, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UiConfig { + #[serde(default)] + pub theme: ThemeName, + /// Whether opening the interactive UI should launch a locally installed daemon. + #[serde(default)] + pub daemon_start_policy: DaemonStartPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DaemonStartPolicy { + #[default] + Ask, + WithUi, +} +impl Serialize for DaemonStartPolicy { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Ask => serializer.serialize_str("ask"), + Self::WithUi => serializer.serialize_str("with_ui"), + } + } +} +impl<'de> Deserialize<'de> for DaemonStartPolicy { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Compat { + Policy(String), + Legacy(bool), + } + match Compat::deserialize(deserializer)? { + Compat::Policy(v) if v == "with_ui" || v == "WithUi" => Ok(Self::WithUi), + Compat::Policy(_) => Ok(Self::Ask), + Compat::Legacy(true) => Ok(Self::WithUi), + Compat::Legacy(false) => Ok(Self::Ask), + } + } +} +impl UiConfig { + pub fn path() -> PathBuf { + iota_paths::config_dir().join("ui.yaml") + } + pub fn load() -> Result { + Self::load_from(&Self::path()) + } + + fn load_from(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + serde_yaml::from_str(&fs::read_to_string(path)?).map_err(io::Error::other) + } + + pub fn save(&self) -> Result<(), io::Error> { + let path = Self::path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let yaml = serde_yaml::to_string(self).map_err(io::Error::other)?; + fs::write(path, yaml) + } + + pub fn resolve_theme(override_theme: Option) -> ThemeName { + Self::resolve_theme_from( + override_theme, + std::env::var("IOTA_THEME").ok().as_deref(), + &Self::path(), + ) + } + + fn resolve_theme_from( + override_theme: Option, + environment_theme: Option<&str>, + config_path: &Path, + ) -> ThemeName { + if let Some(theme) = override_theme { + return theme; + } + if let Some(value) = environment_theme { + match ThemeName::from_str(value) { + Ok(theme) => return theme, + Err(error) => { + eprintln!("Invalid IOTA_THEME value: {error}; checking UI configuration."); + } + } + } + match Self::load_from(config_path) { + Ok(config) => config.theme, + Err(error) => { + eprintln!( + "Could not read UI configuration {}: {error}; using ansi.", + config_path.display() + ); + ThemeName::Ansi + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("iota-ui-config-{}-{name}.yaml", std::process::id())) + } + + #[test] + fn command_line_override_has_highest_precedence() { + let path = config_path("override"); + fs::write(&path, "theme: surface\n").unwrap(); + let resolved = + UiConfig::resolve_theme_from(Some(ThemeName::Binary), Some("monospace"), &path); + fs::remove_file(path).unwrap(); + assert_eq!(resolved, ThemeName::Binary); + } + + #[test] + fn environment_precedes_stored_configuration() { + let path = config_path("environment"); + fs::write(&path, "theme: surface\n").unwrap(); + let resolved = UiConfig::resolve_theme_from(None, Some("monospace"), &path); + fs::remove_file(path).unwrap(); + assert_eq!(resolved, ThemeName::Monospace); + } + + #[test] + fn stored_configuration_precedes_default() { + let path = config_path("stored"); + fs::write(&path, "theme: surface\n").unwrap(); + let resolved = UiConfig::resolve_theme_from(None, None, &path); + fs::remove_file(path).unwrap(); + assert_eq!(resolved, ThemeName::Surface); + } + + #[test] + fn invalid_stored_configuration_falls_back_to_ansi() { + let path = config_path("invalid"); + fs::write(&path, "theme: ultraviolet\n").unwrap(); + let resolved = UiConfig::resolve_theme_from(None, None, &path); + fs::remove_file(path).unwrap(); + assert_eq!(resolved, ThemeName::Ansi); + } + + #[test] + fn missing_configuration_falls_back_to_ansi() { + let path = config_path("missing"); + let _ = fs::remove_file(&path); + assert_eq!( + UiConfig::resolve_theme_from(None, None, &path), + ThemeName::Ansi + ); + } +} diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs new file mode 100644 index 0000000..d103e58 --- /dev/null +++ b/iota-cli/src/theme/mod.rs @@ -0,0 +1,12 @@ +mod config; +mod model; +mod name; +mod presets; + +pub use config::{DaemonStartPolicy, UiConfig}; +pub use model::*; +pub use name::ThemeName; + +pub fn resolve(name: ThemeName) -> ResolvedTheme { + presets::resolve(name) +} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs new file mode 100644 index 0000000..fd631dc --- /dev/null +++ b/iota-cli/src/theme/model.rs @@ -0,0 +1,149 @@ +use super::ThemeName; +use ratatui::style::Style; + +#[derive(Clone, Debug)] +pub struct TextStyles { + pub normal: Style, + pub muted: Style, + pub heading: Style, + pub link: Style, + pub code: Style, +} +#[derive(Clone, Debug)] +pub struct StatusStyles { + pub info: Style, + pub success: Style, + pub warning: Style, + pub error: Style, +} +#[derive(Clone, Debug)] +pub struct BorderStyles { + pub normal: Style, + pub focused: Style, + pub disabled: Style, + pub title: Style, +} +#[derive(Clone, Debug)] +pub struct ChoiceItemStyle { + pub marker: Style, + pub label: Style, + pub description: Style, + pub prefix: &'static str, + pub suffix: &'static str, +} +#[derive(Clone, Debug)] +pub struct ChoiceStyles { + pub normal: ChoiceItemStyle, + pub focused: ChoiceItemStyle, + pub selected: ChoiceItemStyle, + pub focused_selected: ChoiceItemStyle, + pub disabled: ChoiceItemStyle, + pub focused_disabled: ChoiceItemStyle, + pub selected_disabled: ChoiceItemStyle, +} +#[derive(Clone, Debug)] +pub struct ButtonStyles { + pub primary: Style, + pub primary_focused: Style, + pub neutral: Style, + pub neutral_focused: Style, + pub cancel: Style, + pub cancel_focused: Style, + pub destructive: Style, + pub disabled: Style, +} +#[derive(Clone, Debug)] +pub struct MarkerSet { + pub checkbox_unselected: &'static str, + pub checkbox_selected: &'static str, + pub radio_unselected: &'static str, + pub radio_selected: &'static str, +} +#[derive(Clone, Debug)] +pub enum CursorPresentation { + StyledCell(Style), + Character { glyph: &'static str, style: Style }, +} +#[derive(Clone, Debug)] +pub struct ConsoleStyles { + pub text: Style, + pub prefix: Style, + pub hint: Style, + pub error: Style, + pub confirmation: Style, + pub cursor: CursorPresentation, + pub border: Style, + pub focused_border: Style, + pub title: Style, +} +#[derive(Clone, Debug)] +pub struct GraphStyles { + pub ram: ratatui::style::Color, + pub cpu: ratatui::style::Color, + pub ping: ratatui::style::Color, + pub text: Style, + pub border: Style, + pub focused_border: Style, +} +#[derive(Clone, Debug)] +pub struct LogStyles { + pub call: Style, + pub client: Style, + pub iota: Style, + pub omikron: Style, + pub omega: Style, + pub command: Style, + pub other: Style, + pub text: Style, + pub error: Style, + pub timestamp: Style, + pub border: Style, + pub focused_border: Style, +} +#[derive(Clone, Debug)] +pub struct MarkdownStyles { + pub normal: Style, + pub muted: Style, + pub heading: Style, + pub link: Style, + pub code: Style, + pub table_header: Style, + pub table_text: Style, + pub divider: Style, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct TextSemantics { + pub bold: bool, + pub underline: bool, +} +#[derive(Clone, Debug)] +pub struct ResolvedTheme { + pub name: ThemeName, + pub text: TextStyles, + pub status: StatusStyles, + pub choices: ChoiceStyles, + pub buttons: ButtonStyles, + pub borders: BorderStyles, + pub console: ConsoleStyles, + pub graphs: GraphStyles, + pub logs: LogStyles, + pub markdown: MarkdownStyles, + pub markers: MarkerSet, +} + +impl ResolvedTheme { + pub fn apply_text_semantics(&self, base: Style, semantics: TextSemantics) -> Style { + use ratatui::style::Modifier; + if matches!(self.name, ThemeName::Monospace) { + return base; + } + let mut style = base; + if semantics.bold { + style = style.add_modifier(Modifier::BOLD); + } + if semantics.underline { + style = style.add_modifier(Modifier::UNDERLINED); + } + style + } +} diff --git a/iota-cli/src/theme/name.rs b/iota-cli/src/theme/name.rs new file mode 100644 index 0000000..1fbfeb8 --- /dev/null +++ b/iota-cli/src/theme/name.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ThemeName { + Monospace, + Binary, + #[default] + Ansi, + Surface, +} + +impl ThemeName { + pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface]; + + pub fn supported_names() -> &'static str { + "monospace, binary, ansi, surface" + } +} + +impl fmt::Display for ThemeName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Monospace => "monospace", + Self::Binary => "binary", + Self::Ansi => "ansi", + Self::Surface => "surface", + }) + } +} + +impl FromStr for ThemeName { + type Err = String; + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "monospace" => Ok(Self::Monospace), + "binary" => Ok(Self::Binary), + "ansi" => Ok(Self::Ansi), + "surface" => Ok(Self::Surface), + _ => Err(format!( + "unknown theme `{value}`; supported themes: {}", + Self::supported_names() + )), + } + } +} diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs new file mode 100644 index 0000000..333a221 --- /dev/null +++ b/iota-cli/src/theme/presets.rs @@ -0,0 +1,301 @@ +use super::{ + BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, + GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles, + ThemeName, +}; +use ratatui::style::{Color, Modifier, Style}; + +fn marker() -> MarkerSet { + MarkerSet { + checkbox_unselected: "[ ]", + checkbox_selected: "[x]", + radio_unselected: "( )", + radio_selected: "(x)", + } +} +fn choice( + marker: Style, + label: Style, + prefix: &'static str, + suffix: &'static str, +) -> ChoiceItemStyle { + ChoiceItemStyle { + marker, + label, + description: label, + prefix, + suffix, + } +} +fn base( + name: ThemeName, + normal: Style, + muted: Style, + focused: Style, + selected: Style, + disabled: Style, + status: StatusStyles, + buttons: ButtonStyles, +) -> ResolvedTheme { + let error = status.error; + let (prefix, suffix) = if matches!(name, ThemeName::Monospace | ThemeName::Binary) { + ("> ", " <") + } else { + ("", "") + }; + ResolvedTheme { + name, + text: TextStyles { + normal, + muted, + heading: normal, + link: focused, + code: normal, + }, + status, + choices: ChoiceStyles { + normal: choice(normal, normal, "", ""), + focused: choice(focused, focused, prefix, suffix), + selected: choice(selected, selected, "", ""), + focused_selected: choice( + selected.patch(focused), + selected.patch(focused), + prefix, + suffix, + ), + disabled: choice(disabled, disabled, "", ""), + focused_disabled: choice(disabled, error, prefix, suffix), + selected_disabled: choice(disabled, disabled, "", ""), + }, + buttons, + borders: BorderStyles { + normal, + focused, + disabled, + title: normal, + }, + console: ConsoleStyles { + text: normal, + prefix: muted, + hint: muted, + error, + confirmation: focused, + cursor: CursorPresentation::StyledCell(focused), + border: normal, + focused_border: focused, + title: normal, + }, + graphs: GraphStyles { + ram: Color::Reset, + cpu: Color::Reset, + ping: Color::Reset, + text: normal, + border: normal, + focused_border: focused, + }, + logs: LogStyles { + call: normal, + client: normal, + iota: normal, + omikron: normal, + omega: normal, + command: normal, + other: normal, + text: normal, + error, + timestamp: muted, + border: normal, + focused_border: focused, + }, + markdown: MarkdownStyles { + normal, + muted, + heading: focused, + link: focused, + code: focused, + table_header: focused, + table_text: normal, + divider: muted, + }, + markers: marker(), + } +} +pub fn resolve(name: ThemeName) -> ResolvedTheme { + let plain = Style::default(); + match name { + ThemeName::Monospace => { + let mut theme = base( + name, + plain, + plain, + plain, + plain, + plain, + StatusStyles { + info: plain, + success: plain, + warning: plain, + error: plain, + }, + ButtonStyles { + primary: plain, + primary_focused: plain, + neutral: plain, + neutral_focused: plain, + cancel: plain, + cancel_focused: plain, + destructive: plain, + disabled: plain, + }, + ); + theme.console.cursor = CursorPresentation::Character { + glyph: "▌", + style: plain, + }; + theme.graphs = GraphStyles { + ram: Color::Reset, + cpu: Color::Reset, + ping: Color::Reset, + text: plain, + border: plain, + focused_border: plain, + }; + theme + } + ThemeName::Binary => { + let reversed = plain.add_modifier(Modifier::REVERSED); + base( + name, + plain, + plain, + plain, + reversed, + plain, + StatusStyles { + info: plain, + success: plain, + warning: plain, + error: plain, + }, + ButtonStyles { + primary: plain, + primary_focused: reversed, + neutral: plain, + neutral_focused: reversed, + cancel: plain, + cancel_focused: reversed, + destructive: plain, + disabled: plain, + }, + ) + } + ThemeName::Ansi => { + let yellow = plain.fg(Color::Yellow).add_modifier(Modifier::BOLD); + let mut theme = base( + name, + plain, + plain.fg(Color::DarkGray), + yellow, + plain, + plain.fg(Color::DarkGray), + StatusStyles { + info: plain, + success: plain.fg(Color::Green), + warning: plain.fg(Color::Yellow), + error: plain.fg(Color::Red), + }, + ButtonStyles { + primary: plain.fg(Color::Green), + primary_focused: plain + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + neutral: plain, + neutral_focused: yellow, + cancel: plain.fg(Color::Red), + cancel_focused: plain + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + destructive: plain.fg(Color::Red), + disabled: plain.fg(Color::DarkGray), + }, + ); + theme.console = ConsoleStyles { + text: plain.fg(Color::White), + prefix: plain.fg(Color::DarkGray), + hint: plain.fg(Color::DarkGray), + error: plain.fg(Color::Red), + confirmation: plain.fg(Color::Yellow), + cursor: CursorPresentation::StyledCell(plain.fg(Color::White).bg(Color::DarkGray)), + border: plain, + focused_border: plain.fg(Color::Yellow), + title: plain.fg(Color::White), + }; + theme.graphs = GraphStyles { + ram: Color::Blue, + cpu: Color::Red, + ping: Color::Green, + text: plain, + border: plain, + focused_border: plain.fg(Color::Yellow), + }; + theme.logs = LogStyles { + call: plain.fg(Color::Magenta), + client: plain.fg(Color::Green), + iota: plain.fg(Color::Yellow), + omikron: plain.fg(Color::Blue), + omega: plain.fg(Color::Cyan), + command: plain.fg(Color::LightGreen), + other: plain.fg(Color::LightCyan), + text: plain.fg(Color::White), + error: plain.fg(Color::Red), + timestamp: plain.fg(Color::DarkGray), + border: plain, + focused_border: plain.fg(Color::Yellow), + }; + theme.markdown = MarkdownStyles { + normal: plain, + muted: plain.fg(Color::DarkGray), + heading: plain.fg(Color::Cyan), + link: plain.fg(Color::Cyan), + code: plain.fg(Color::Yellow), + table_header: plain.fg(Color::Cyan), + table_text: plain.fg(Color::Green), + divider: plain.fg(Color::DarkGray), + }; + theme + } + ThemeName::Surface => { + let focus = plain.fg(Color::Black).bg(Color::Yellow); + let selected = plain.fg(Color::Black).bg(Color::Cyan); + let mut theme = base( + name, + plain, + plain.fg(Color::DarkGray), + focus, + selected, + plain.fg(Color::DarkGray), + StatusStyles { + info: plain, + success: plain.fg(Color::Green), + warning: plain.fg(Color::Yellow), + error: plain.fg(Color::Red), + }, + ButtonStyles { + primary: plain.fg(Color::Black).bg(Color::Green), + primary_focused: focus, + neutral: plain, + neutral_focused: focus, + cancel: plain.fg(Color::Black).bg(Color::Red), + cancel_focused: focus, + destructive: plain.fg(Color::Black).bg(Color::Red), + disabled: plain.fg(Color::DarkGray), + }, + ); + theme.console.cursor = + CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); + theme + } + } +} diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index f298a92..21d8bd4 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,144 +1,262 @@ use crate::{ - input_handler::setup_input_handler, interaction_result::InteractionResult, - ipc_client::IpcClient, screens::screens::Screen, + input_handler::setup_input_handler, + interaction_result::InteractionResult, + ipc_client::IpcClient, + render_context::RenderContext, + screens::screens::Screen, + theme::{self, ResolvedTheme, ThemeName}, }; use crossterm::event::KeyEvent; use once_cell::sync::Lazy; -use ratatui::{Terminal, backend::CrosstermBackend, init}; +use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ - collections::VecDeque, + io, io::Stdout, + panic::PanicHookInfo, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, - time::Duration, }; -use tokio::{sync::RwLock, time::Instant}; +use tokio::sync::{Notify, RwLock}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; /// UI state and rendering pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); pub struct UI { - ipc: Arc, - shutdown: AtomicBool, + ipc: RwLock>>, + shutdown_on_empty: bool, + cancellation: CancellationToken, pub terminal: Arc>>>, screen_stack: Arc>>>, + theme: RwLock>, + pub(crate) invalidation: Notify, + failure: Arc>>, } -pub fn start_tui(ipc: Arc) -> Arc { - let ui = Arc::new(UI::new(ipc)); +pub fn start_tui(ipc: Arc) -> io::Result { + start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi)) +} + +pub fn start_tui_with_theme(ipc: Arc, theme: ResolvedTheme) -> io::Result { + start_session(UI::new(Some(ipc), true, theme)?) +} + +pub fn start_bootstrap_tui() -> io::Result { + start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi)) +} + +pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result { + start_session(UI::new(None, false, theme)?) +} + +fn start_session(ui: UI) -> io::Result { + let ui = Arc::new(ui); let uic = ui.clone(); - tokio::spawn(async move { - let mut last_render = Instant::now(); - - let mut fps_samples: VecDeque = VecDeque::with_capacity(20); - let mut skip_samples: VecDeque = VecDeque::with_capacity(20); - - let mut fps_sum = 0.0; - let mut skip_sum: u32 = 0; - - let mut skipped = 0; - - loop { - if uic.is_shutdown() { - break; + let renderer_task = tokio::spawn(async move { + let cancellation = uic.cancellation_token(); + let result: io::Result<()> = loop { + tokio::select! { + _ = cancellation.cancelled() => break Ok(()), + _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, + _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } }, } - - if skipped > 5 { - uic.render().await; - - skip_samples.push_back(skipped); - skip_sum += skipped as u32; - - if skip_samples.len() > 20 { - if let Some(old) = skip_samples.pop_front() { - skip_sum -= old as u32; - } - } - - skipped = 0; - - let elapsed = last_render.elapsed().as_secs_f64(); - if elapsed > 0.0 { - let fps = 1.0 / elapsed; - - fps_samples.push_back(fps); - fps_sum += fps; - - if fps_samples.len() > 20 { - if let Some(old) = fps_samples.pop_front() { - fps_sum -= old; - } - } - } - - let avg_fps = if !fps_samples.is_empty() { - fps_sum / fps_samples.len() as f64 - } else { - 0.0 - }; - - let avg_skips_percentage = if !skip_samples.is_empty() { - let avg_skipped = skip_sum as f64 / skip_samples.len() as f64; - let total_iterations = avg_skipped + 1.0; - (avg_skipped / total_iterations) * 100.0 - } else { - 0.0 - }; - - *FPS.write().await = (avg_fps, avg_skips_percentage); - - last_render = Instant::now(); - } else { - skipped += 1; - } - tokio::time::sleep(Duration::from_millis(16)).await; + }; + if let Err(error) = &result { + *uic.failure.lock().unwrap() = Some(error.to_string()); + uic.request_shutdown(); } - ratatui::restore(); + result }); - setup_input_handler(ui.clone()); - ui + let input_task = setup_input_handler(ui.clone()); + // Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw + // mode. Keep this independent of key-event handling for bootstrap work. + let signal_task = { + #[cfg(unix)] + { + let signal_ui = ui.clone(); + Some(tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + signal_ui.request_shutdown(); + } + })) + } + #[cfg(not(unix))] + { + None + } + }; + let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook()))); + let hook_for_panic = previous_hook.clone(); + std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| { + ratatui::restore(); + if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() { + hook(info); + } + })); + Ok(TuiSession { + ui, + renderer_task, + input_task, + signal_task, + restored: AtomicBool::new(false), + previous_hook, + }) +} + +pub struct TuiSession { + ui: Arc, + renderer_task: JoinHandle>, + input_task: JoinHandle>, + signal_task: Option>, + restored: AtomicBool, + previous_hook: Arc) + Send + Sync + 'static>>>>, +} + +impl TuiSession { + pub fn ui(&self) -> Arc { + self.ui.clone() + } + pub async fn shutdown(mut self) -> Option { + self.ui.request_shutdown(); + // Restore raw-mode state before waiting on cooperative tasks. A + // misbehaving task must never leave the invoking shell unusable. + self.restore_terminal_once(); + let renderer = + tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; + let input = + tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; + if renderer.is_err() { + self.renderer_task.abort(); + } + if input.is_err() { + self.input_task.abort(); + } + if let Some(task) = self.signal_task.as_mut() { + task.abort(); + let _ = task.await; + } + self.restore_panic_hook(); + match renderer { + Err(_) => Some("renderer did not stop within 2 seconds".into()), + Ok(Err(error)) => Some(format!("renderer task failed: {error}")), + Ok(Ok(Err(error))) => Some(error.to_string()), + Ok(Ok(Ok(()))) => match input { + Err(_) => Some("input handler did not stop within 2 seconds".into()), + Ok(Err(error)) => Some(format!("input handler failed: {error}")), + Ok(Ok(Err(error))) => Some(error), + Ok(Ok(Ok(()))) => None, + }, + } + } + fn restore_terminal_once(&self) { + if !self.restored.swap(true, Ordering::AcqRel) { + ratatui::restore(); + } + } + fn restore_panic_hook(&self) { + if let Some(hook) = self.previous_hook.lock().unwrap().take() { + std::panic::set_hook(hook); + } + } +} +impl Drop for TuiSession { + fn drop(&mut self) { + self.ui.request_shutdown(); + self.renderer_task.abort(); + self.input_task.abort(); + if let Some(task) = self.signal_task.as_ref() { + task.abort(); + } + self.restore_panic_hook(); + self.restore_terminal_once(); + } } impl UI { - pub fn new(ipc: Arc) -> Self { - let terminal = init(); - Self { - ipc, - shutdown: AtomicBool::new(false), + pub(crate) fn new( + ipc: Option>, + shutdown_on_empty: bool, + theme: ResolvedTheme, + ) -> io::Result { + let terminal = ratatui::try_init()?; + Ok(Self { + ipc: RwLock::new(ipc), + shutdown_on_empty, + cancellation: CancellationToken::new(), terminal: Arc::new(Mutex::new(terminal)), screen_stack: Arc::new(RwLock::new(Vec::new())), - } + theme: RwLock::new(Arc::new(theme)), + invalidation: Notify::new(), + failure: Arc::new(Mutex::new(None)), + }) } - pub fn ipc(&self) -> Arc { - self.ipc.clone() + pub async fn ipc(&self) -> Option> { + self.ipc.read().await.clone() } - pub fn client_state(&self) -> iota_state::ClientState { - self.ipc.state() + pub async fn client_state(&self) -> Option { + self.ipc.read().await.as_ref().map(|ipc| ipc.state()) + } + + pub async fn attach_daemon(&self, ipc: Arc) { + *self.ipc.write().await = Some(ipc); + } + + pub async fn set_theme(&self, theme: ResolvedTheme) { + *self.theme.write().await = Arc::new(theme); + self.invalidate(); + } + pub async fn theme_name(&self) -> ThemeName { + self.theme.read().await.name } pub fn is_shutdown(&self) -> bool { - self.shutdown.load(Ordering::Relaxed) + self.cancellation.is_cancelled() } pub fn request_shutdown(&self) { - self.shutdown.store(true, Ordering::Relaxed); + self.cancellation.cancel(); + self.invalidate(); + } + pub fn invalidate(&self) { + self.invalidation.notify_one(); + } + pub fn failure(&self) -> Option { + self.failure.lock().ok().and_then(|f| f.clone()) + } + pub async fn handle_paste(&self, _text: String) { + self.invalidate(); } - pub async fn send_restart(&self) -> std::io::Result<()> { - self.ipc.send_command(0, "restart".into()).await + /// Lets bootstrap operations race their work against Ctrl+C without + /// blocking the input task or leaving the terminal in raw mode. + pub async fn wait_for_shutdown(&self) { + self.cancellation.cancelled().await; + } + + pub fn cancellation_token(&self) -> CancellationToken { + self.cancellation.clone() } pub async fn set_screen(&self, screen: Box) { self.screen_stack.write().await.push(screen); + self.invalidate(); } pub async fn replace_screen(&self, screen: Box) { let mut stack = self.screen_stack.write().await; - stack.pop(); + stack.clear(); stack.push(screen); + self.invalidate(); + } + pub async fn set_root_screen(&self, screen: Box) { + let mut stack = self.screen_stack.write().await; + stack.clear(); + stack.push(screen); + self.invalidate(); } pub async fn handle_input(self: Arc, key_event: KeyEvent) { let result = { @@ -155,30 +273,41 @@ impl UI { } InteractionResult::OpenFutureScreen { screen: fut } => { let ui = self.clone(); - let screen = fut.await; - ui.set_screen(screen).await; + tokio::select! { + screen = fut => ui.set_screen(screen).await, + _ = ui.cancellation.cancelled() => return, + } } InteractionResult::CloseScreen => { let mut stack = self.screen_stack.write().await; stack.pop(); - if stack.is_empty() { + if stack.is_empty() && self.shutdown_on_empty { self.request_shutdown(); } } InteractionResult::Handled => {} InteractionResult::Unhandled => {} } + self.invalidate(); } - pub async fn render(&self) { + pub async fn render(&self) -> io::Result<()> { + let theme = self.theme.read().await.clone(); + let context = RenderContext { + theme: theme.as_ref(), + }; + // The renderer is the only task that takes the terminal lock. Screen + // mutations use the stack lock briefly before invalidating a frame. if let Some(screen) = self.screen_stack.read().await.last() { - let mut terminal = self.terminal.lock().unwrap(); - terminal - .draw(|f| { - screen.render(f, f.area()); - }) - .unwrap(); + let mut terminal = self + .terminal + .lock() + .map_err(|_| io::Error::other("terminal mutex poisoned"))?; + terminal.draw(|f| { + screen.render(f, f.area(), &context); + })?; } + Ok(()) } } diff --git a/iota-cli/src/util/borders.rs b/iota-cli/src/util/borders.rs index 34460bc..a6d26a8 100644 --- a/iota-cli/src/util/borders.rs +++ b/iota-cli/src/util/borders.rs @@ -3,13 +3,20 @@ use ratatui::prelude::*; use ratatui::style::Style; use ratatui::widgets::Borders; -fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) { - frame - .buffer_mut() - .set_string(x, y, c.to_string(), Style::default()); +fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) { + frame.buffer_mut().set_string(x, y, c.to_string(), style); } -pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) { +pub fn draw_block_joins( + frame: &mut Frame, + area: Rect, + borders: Borders, + joins: Borders, + style: Style, +) { + if area.width == 0 || area.height == 0 { + return; + } let x0 = area.x; let y0 = area.y; let x1 = area.x + area.width - 1; @@ -22,7 +29,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┬', (false, false) => '┌', }; - set_join_char(frame, x0, y0, top_left); + set_join_char(frame, x0, y0, top_left, style); } if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) { @@ -32,7 +39,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┬', (false, false) => '┐', }; - set_join_char(frame, x1, y0, top_right); + set_join_char(frame, x1, y0, top_right, style); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) { @@ -45,7 +52,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┴', (false, false) => '└', }; - set_join_char(frame, x0, y1, bottom_left); + set_join_char(frame, x0, y1, bottom_left, style); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) { @@ -58,6 +65,6 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┴', (false, false) => '┘', }; - set_join_char(frame, x1, y1, bottom_right); + set_join_char(frame, x1, y1, bottom_right, style); } } diff --git a/iota-cli/src/util/buttons.rs b/iota-cli/src/util/buttons.rs index 6c93cce..a69d2ad 100644 --- a/iota-cli/src/util/buttons.rs +++ b/iota-cli/src/util/buttons.rs @@ -1,55 +1,22 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, +use ratatui::layout::Rect; + +use crate::{ + controls::button::{ + ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button, + }, + theme::ResolvedTheme, + util::terms_focus::Focus, }; -use crate::util::terms_focus::Focus; - -#[allow(mismatched_lifetime_syntaxes)] -pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line { - let box_char = if checked { "[x]" } else { "[ ]" }; - let (box_style, text_style) = if active { - if allowed { - ( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - ( - Style::default().fg(Color::Gray), - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ) - } - } else { - (Style::default(), Style::default()) - }; - Line::from(vec![ - Span::styled(box_char, box_style), - Span::raw(" "), - Span::styled(label, text_style), - ]) -} - -pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) { - let p = Paragraph::new(Span::styled(label, style)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(p, area); -} pub fn draw_buttons( - f: &mut ratatui::Frame, + frame: &mut ratatui::Frame, area: Rect, current_focus: Focus, state: (bool, bool), update_needed: bool, downgrade_scenario: bool, tos_or_privacy: bool, + theme: &ResolvedTheme, ) { let cancel_text = if update_needed { "[Q] Quit" @@ -69,107 +36,40 @@ pub fn draw_buttons( buttons.push(("Continue with Tensamin Services", Focus::ContinueAll)); } - let padding = 2; - let min_widths: Vec = buttons + let minimums = buttons .iter() - .map(|(label, _)| label.len() as u16 + padding) - .collect(); - - let widths = compute_widths(area.width, &min_widths); + .map(|(label, _)| button_minimum_width(label)) + .collect::>(); + let Some(widths) = horizontal_button_widths(area.width, &minimums) else { + return; + }; let mut x = area.x; - for ((label, focus), width) in buttons.iter().zip(widths) { - let chunk = Rect { + let button_area = Rect { x, y: area.y, width, height: area.height, }; - x += width; + x = x.saturating_add(width); - let is_focused = current_focus == *focus; - - let style = match focus { - Focus::Cancel => { - if is_focused { - Style::default() - .fg(Color::Black) - .bg(Color::Red) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Red) - } - } - - Focus::Continue => { - if is_focused && state.0 { - Style::default() - .fg(Color::Black) - .bg(Color::Green) - .add_modifier(Modifier::BOLD) - } else if state.0 { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - } - } - - Focus::ContinueAll => { - if is_focused && state.1 { - Style::default() - .fg(Color::Black) - .bg(Color::Green) - .add_modifier(Modifier::BOLD) - } else if state.1 { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - } - } - - _ => Style::default().fg(Color::DarkGray), + let (intent, enabled) = match focus { + Focus::Cancel => (ButtonIntent::Cancel, true), + Focus::Continue => (ButtonIntent::Primary, state.0), + Focus::ContinueAll => (ButtonIntent::Primary, state.1), + _ => (ButtonIntent::Neutral, false), }; - - draw_button(f, chunk, label, style); + render_button( + frame, + button_area, + ActionButton { + label, + intent, + focused: current_focus == *focus, + enabled, + }, + theme, + ); } } -pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec { - let mut widths = vec![0; min_widths.len()]; - let mut remaining: Vec = (0..min_widths.len()).collect(); - - let mut remaining_width = area_width; - - while !remaining.is_empty() { - let count = remaining.len() as u16; - let equal = remaining_width / count; - - let mut clamped = Vec::new(); - - for &i in &remaining { - if min_widths[i] > equal { - widths[i] = min_widths[i]; - remaining_width -= min_widths[i]; - clamped.push(i); - } - } - - if clamped.is_empty() { - let mut remainder = remaining_width % count; - for &i in &remaining { - widths[i] = equal - + if remainder > 0 { - remainder -= 1; - 1 - } else { - 0 - }; - } - break; - } - - remaining.retain(|i| !clamped.contains(i)); - } - - widths -} diff --git a/iota-cli/tests/button_layout.rs b/iota-cli/tests/button_layout.rs new file mode 100644 index 0000000..ad230fd --- /dev/null +++ b/iota-cli/tests/button_layout.rs @@ -0,0 +1,15 @@ +use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths}; + +#[test] +fn width_allocation_handles_exact_spare_and_insufficient_space() { + assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4])); + assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5])); + assert_eq!(horizontal_button_widths(6, &[3, 4]), None); + assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new())); +} + +#[test] +fn minimum_width_uses_terminal_columns() { + assert_eq!(button_minimum_width("é"), 3); + assert_eq!(button_minimum_width("界"), 4); +} diff --git a/iota-cli/tests/choice_rendering.rs b/iota-cli/tests/choice_rendering.rs new file mode 100644 index 0000000..a9ef9d3 --- /dev/null +++ b/iota-cli/tests/choice_rendering.rs @@ -0,0 +1,71 @@ +use iota_cli::{ + controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, + theme::{ThemeName, resolve}, +}; +use ratatui::style::{Color, Modifier}; + +#[test] +fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() { + let theme = resolve(ThemeName::Ansi); + let line = render_choice_line( + "Terms", + ChoiceKind::Checkbox, + ChoiceVisualState { + selected: false, + focused: true, + enabled: true, + }, + &theme, + ); + assert_eq!( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "[ ] Terms" + ); + assert_eq!(line.spans[1].style.fg, Some(Color::Yellow)); + assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD)); + + let disabled = render_choice_line( + "Terms", + ChoiceKind::Checkbox, + ChoiceVisualState { + selected: false, + focused: true, + enabled: false, + }, + &theme, + ); + assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray)); + assert_eq!(disabled.spans[3].style.fg, Some(Color::Red)); +} + +#[test] +fn colourless_themes_keep_state_and_focus_visible() { + for name in [ThemeName::Monospace, ThemeName::Binary] { + let theme = resolve(name); + let line = render_choice_line( + "Mode", + ChoiceKind::Radio, + ChoiceVisualState { + selected: true, + focused: true, + enabled: true, + }, + &theme, + ); + assert_eq!( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "> (x) Mode <" + ); + assert!( + line.spans + .iter() + .all(|span| span.style.fg.is_none() && span.style.bg.is_none()) + ); + } +} diff --git a/iota-cli/tests/control_state.rs b/iota-cli/tests/control_state.rs new file mode 100644 index 0000000..3089bdb --- /dev/null +++ b/iota-cli/tests/control_state.rs @@ -0,0 +1,109 @@ +use iota_cli::controls::{ + checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem}, + navigation::DisabledFocusPolicy, + radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem}, +}; + +fn checkbox(value: u8, enabled: bool) -> CheckboxItem { + CheckboxItem { + value, + label: value.to_string(), + description: None, + enabled, + disabled_reason: None, + } +} + +fn radio(value: u8, enabled: bool) -> RadioItem { + RadioItem { + value, + label: value.to_string(), + description: None, + enabled, + disabled_reason: None, + } +} + +#[test] +fn checkbox_selection_and_disabled_focus_are_independent() { + let mut group = CheckboxGroup::new( + vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)], + [1, 99], + ) + .unwrap(); + assert_eq!( + group.selected().iter().copied().collect::>(), + vec![1] + ); + assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1)); + group.focus_next(); + assert_eq!(group.focused_item().unwrap().value, 3); + group.set_focus_policy(DisabledFocusPolicy::Include); + group.focus_previous(); + assert_eq!(group.focused_item().unwrap().value, 2); + assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2)); +} + +#[test] +fn checkbox_non_wrapping_navigation_stops_at_the_edge() { + let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap(); + group.set_wrap_navigation(false); + group.focus_previous(); + assert_eq!(group.focused_item().unwrap().value, 1); +} + +#[test] +fn radio_validates_default_and_preserves_one_selection() { + assert!(matches!( + RadioGroup::new(Vec::>::new(), None, 1), + Err(RadioGroupError::Empty) + )); + assert!(matches!( + RadioGroup::new(vec![radio(1, true)], None, 2), + Err(RadioGroupError::DefaultMissing) + )); + assert!(matches!( + RadioGroup::new(vec![radio(1, false)], None, 1), + Err(RadioGroupError::DefaultDisabled) + )); + + let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); + assert_eq!(group.selected(), &2); + group.focus_next(); + assert_eq!(group.selected(), &2); + assert_eq!(group.select_focused(), RadioChange::Unchanged(2)); + group.focus_previous(); + assert_eq!( + group.select_focused(), + RadioChange::Changed { + previous: 2, + selected: 1 + } + ); + assert_eq!(group.selected(), &1); +} + +#[test] +fn groups_initially_focus_the_first_enabled_item() { + let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap(); + assert_eq!(checkboxes.focused_item().unwrap().value, 2); + let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap(); + assert_eq!(radios.focused_item().value, 2); +} + +#[test] +fn disabling_a_selected_radio_obeys_the_configured_policy() { + let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); + group.set_enabled(&2, false).unwrap(); + assert_eq!(group.selected(), &1); + + group.set_enabled(&2, true).unwrap(); + group.focus_next(); + group.select_focused(); + group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError); + assert_eq!( + group.set_enabled(&2, false), + Err(RadioGroupError::SelectedItemDisabled) + ); + assert_eq!(group.selected(), &2); +} diff --git a/iota-cli/tests/layout_fit.rs b/iota-cli/tests/layout_fit.rs new file mode 100644 index 0000000..f8a60aa --- /dev/null +++ b/iota-cli/tests/layout_fit.rs @@ -0,0 +1,45 @@ +use iota_cli::layout::fit::{ + FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level, +}; +use ratatui::layout::Rect; + +#[test] +fn selects_fit_by_both_dimensions() { + let preferred = RequiredSize { + width: 80, + height: 20, + }; + let compact = RequiredSize { + width: 50, + height: 12, + }; + assert_eq!( + select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact), + FitLevel::Preferred + ); + assert_eq!( + select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact), + FitLevel::Compact + ); + assert_eq!( + select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact), + FitLevel::Fallback + ); +} + +#[test] +fn rectangle_helpers_do_not_underflow() { + let zero = Rect::new(4, 5, 0, 0); + assert_eq!( + centered_rect( + zero, + RequiredSize { + width: 10, + height: 10 + } + ), + zero + ); + assert_eq!(reserve_vertical(zero, 1, 0), None); + assert_eq!(inset_checked(zero, 1, 1), None); +} diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 06a58f0..763b231 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -2,6 +2,7 @@ name = "iota-core" version = "0.1.0" edition = "2024" +autobins = false [dependencies] iota-cli = { path = "../iota-cli" } diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index 60e4515..de88a04 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -12,9 +12,9 @@ pub async fn check(ui: Arc) -> Result<(bool, bool), String> { let mut state = ConsentState::load_state(); ensure_initial_consent(ui.clone(), &mut state).await?; - if ensure_updates(ui, &mut state).await.is_err() { - // We don't stop the program if updates fail, as long as we have initial consent - }; + // A mandatory document update is a hard bootstrap gate. In particular, + // refusing it must not allow service setup or daemon access to continue. + ensure_updates(ui, &mut state).await?; state = state.sanitize(); state.save_state(); @@ -22,8 +22,23 @@ pub async fn check(ui: Arc) -> Result<(bool, bool), String> { Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NonInteractiveConsent { + Accepted, + RequiresInteractiveAcceptance, +} + +pub fn non_interactive_consent() -> NonInteractiveConsent { + let state = ConsentState::load_state(); + if state.accepted_eula && state.accepted_tos && state.accepted_pp { + NonInteractiveConsent::Accepted + } else { + NonInteractiveConsent::RequiresInteractiveAcceptance + } +} + async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), String> { - if state.accepted_eula { + if state.accepted_eula && state.accepted_tos && state.accepted_pp { return Ok(()); } @@ -35,7 +50,7 @@ async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result let (tx, rx) = oneshot::channel(); - ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) + ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx)))) .await; let result = rx.await.unwrap_or(UserChoice::Deny); diff --git a/iota-core/src/lib.rs b/iota-core/src/lib.rs new file mode 100644 index 0000000..2deef45 --- /dev/null +++ b/iota-core/src/lib.rs @@ -0,0 +1 @@ +pub mod consent_state; diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 35f9077..4e0f782 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -3,7 +3,7 @@ use iota_updater::check_update; use pnet::datalink::NetworkInterface; use tokio::time::{Duration, sleep}; -use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; +use iota_state::{AppState, DaemonState}; use iota_cli::screens::main_screen::MainScreen; use iota_cli::{ipc_client::IpcClient, ui::start_tui}; @@ -12,27 +12,29 @@ use iota_logger::{log, log_t}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use iota_util::file_util::{download_and_extract_zip, has_dir}; -use omikron_connector as omikron; -use omikron_connector::omikron_connection::OMIKRON_CONNECTION; +use std::sync::Arc; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] #[allow(unused_must_use, dead_code, unused_assignments)] async fn main() { - while *RELOAD.read().await { - *RELOAD.write().await = false; - *SHUTDOWN.write().await = false; + let state = Arc::new(DaemonState::new()); + + while *state.reload.read().await { + *state.reload.write().await = false; + *state.shutdown.write().await = false; let ipc = IpcClient::connect("/run/iota/iota.sock") .await .expect("iota-daemon must be running before starting iota-core"); - let ui = start_tui(ipc); + let session = start_tui(ipc).expect("interactive terminal initialization failed"); + let ui = session.ui(); let (eula, tos_pp) = match consent_state::check(ui.clone()).await { Ok(v) => v, Err(e) => { - *SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -43,9 +45,9 @@ async fn main() { }; if !eula { - *SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -55,9 +57,9 @@ async fn main() { return; } if !tos_pp { - *SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -70,7 +72,7 @@ async fn main() { return; } check_update(); - iota_state::setup(); + iota_state::setup(&state); let main_screen = MainScreen::new(ui.clone()).await; ui.set_screen(Box::new(main_screen)).await; @@ -88,7 +90,7 @@ async fn main() { iota_storage::util::config_util::load_config(); // USER MANAGEMENT - if let Err(_) = user_manager::load_users().await { + if let Err(_) = user_manager::load_users_sync() { log_t!("user_load_failed"); } if let Err(e) = iota_storage::util::settings::migrate_legacy_files() { @@ -153,29 +155,17 @@ async fn main() { if !web_server::start(port).await { log!("Failed to start the MTP web server on port {}", port); } - let _ = omikron::omikron_connection::get_omikron_connection(tokio_util::sync::CancellationToken::new()).await; - log_t!("setup_completed"); loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { break; } - if OMIKRON_CONNECTION.has_auth_failure().await { - if let Some(reason) = OMIKRON_CONNECTION.get_auth_failure().await { - log!("Authentication failed: {}", reason); - log!( - "Use /reconnect to try again or /regenerate private-key to create a new key pair" - ); - OMIKRON_CONNECTION.clear_auth_failure().await; - } - } - sleep(Duration::from_millis(500)).await; } - if *RELOAD.read().await { + if *state.reload.read().await { loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_secs(1)).await; @@ -184,9 +174,8 @@ async fn main() { user_manager::clear(); // Commhnities have not been implemented yet. /*community_manager::clear();*/ - *APP_STATE.lock().unwrap() = AppState::new(); + *state.app.lock().unwrap() = AppState::new(); } - ui.terminal.lock().unwrap().clear(); - ui.terminal.lock().unwrap().flush(); + let _ = session.shutdown().await; } } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 10fd67b..f039440 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } @@ -17,3 +18,6 @@ sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 53f4327..6fe4c5f 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,42 +1,34 @@ -use crate::DaemonRuntime; -use iota_ipc::{ - IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult, -}; +use crate::{DaemonRuntime, DaemonServices}; +use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult}; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; use iota_storage::util::config_util::modify_config; use mtp::codec::{CommunicationType, CommunicationValue}; -use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use std::sync::Arc; use std::time::Duration; -use crate::daemon_state::ShutdownReason; +use crate::daemon_state::{ShutdownReason, StartupPhase}; #[derive(Clone)] pub struct CommandRouter { runtime: Arc, + services: Arc, } impl CommandRouter { - pub fn new(runtime: Arc) -> Self { - Self { runtime } + pub fn new(runtime: Arc, services: Arc) -> Self { + Self { runtime, services } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { log_command!("{:?}", request); let result = self.execute(request).await; - ResponseEnvelope { - request_id, - result, - } + ResponseEnvelope { request_id, result } } /// Parse a legacy console command string into a typed request. pub fn parse_console_command(line: &str) -> Option { - let parts: Vec<&str> = line - .trim_start_matches('/') - .split_whitespace() - .collect(); + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); match parts.as_slice() { ["help"] => None, ["tasks"] => Some(LocalRequest::ListTasks), @@ -53,13 +45,33 @@ impl CommandRouter { ["user", "list"] => Some(LocalRequest::ListUsers), ["reconnect"] => Some(LocalRequest::ReconnectOmikron), ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon), - ["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: ExitIntent::Stop, + }), _ => None, } } async fn execute(&self, request: LocalRequest) -> ResponseResult { + let needs_omikron = matches!( + request, + LocalRequest::CreateUser { .. } + | LocalRequest::RemoveUser { .. } + | LocalRequest::ReconnectOmikron + | LocalRequest::RotateIotaIdentity + ); + if needs_omikron && !self.services.omikron.is_connected().await { + return ResponseResult::Error( + if self.runtime.current_startup_phase() != StartupPhase::Ready { + IpcErrorCode::NotReady + } else { + IpcErrorCode::OmikronUnavailable + }, + ); + } match request { LocalRequest::GetStatus => { let phase = self.runtime.current_startup_phase(); @@ -95,10 +107,13 @@ impl CommandRouter { ResponseResult::Ok(users.join("\n")) } LocalRequest::CreateUser { username } => { - match omikron_connector::user_ops::create_user(&username).await { - (Some(user), _) => { - ResponseResult::Ok(format!("Created user {}", user.user_id)) - } + match omikron_connector::user_ops::create_user( + self.services.omikron.as_ref(), + &username, + ) + .await + { + (Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)), _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -109,24 +124,46 @@ impl CommandRouter { }; let message = CommunicationValue::new(CommunicationType::DeleteUser) .with_sender(user.user_id as u64); - if let Err(_e) = OMIKRON_CONNECTION.send_message(&message).await { + if let Err(_e) = self.services.omikron.send_message(&message).await { return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); } user_manager::remove_user(user.user_id); ResponseResult::Ok(format!("Removed user {}", user.user_id)) } - LocalRequest::ReconnectOmikron => { - OMIKRON_CONNECTION.reconnect().await; - ResponseResult::Ok("Reconnected to Omikron server".into()) - } + LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { + Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()), + Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + }, LocalRequest::RotateIotaIdentity => { modify_config(|config| { config.public_key = None; config.private_key = None; config.iota_id = None; }); - OMIKRON_CONNECTION.reconnect().await; - ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into()) + match self.services.omikron.reconnect().await { + Ok(()) => ResponseResult::Ok( + "Key pair regenerated and Omikron reconnection requested".into(), + ), + Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + } + } + LocalRequest::RequestProcessExit { intent } => { + if matches!(intent, ExitIntent::Restart) + && !matches!( + crate::deployment::from_environment().supervisor, + iota_ipc::SupervisorKind::Systemd | iota_ipc::SupervisorKind::IotaUi + ) + { + return ResponseResult::Error(IpcErrorCode::Conflict); + } + self.runtime.shutdown(match intent { + ExitIntent::Stop => ShutdownReason::Stop, + ExitIntent::Restart => ShutdownReason::Restart, + }); + ResponseResult::Ok("process exit accepted".into()) + } + LocalRequest::GetDaemonStatus => { + ResponseResult::Ok(format!("{:?}", self.runtime.snapshot())) } LocalRequest::RestartDaemon => { self.runtime.shutdown(ShutdownReason::Restart); @@ -140,10 +177,12 @@ impl CommandRouter { } pub async fn ping(&self, seconds: u64) -> Result { - let response = OMIKRON_CONNECTION + let response = self + .services + .omikron .await_response( &CommunicationValue::new(CommunicationType::Ping), - Some(Duration::from_secs(seconds)), + Duration::from_secs(seconds), ) .await; match response { diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs index aa998b6..4c7347c 100644 --- a/iota-daemon-lib/src/daemon_state.rs +++ b/iota-daemon-lib/src/daemon_state.rs @@ -1,7 +1,10 @@ +use crate::TaskRegistry; use iota_ipc::StateSnapshot; use iota_state::DaemonState; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; use sysinfo::{RefreshKind, System}; use tokio::sync::watch; use tokio_util::sync::CancellationToken; @@ -58,8 +61,18 @@ pub struct DaemonRuntime { pub state: Arc, pub cancellation: CancellationToken, pub shutdown_tx: watch::Sender>, + shutdown_rx: watch::Receiver>, pub startup_phase: watch::Sender, pub degraded_reason: watch::Sender>, + startup_phase_rx: watch::Receiver, + degraded_reason_rx: watch::Receiver>, + pub lifecycle: watch::Sender, + pub startup_step: watch::Sender>, + pub components: watch::Sender>, + lifecycle_rx: watch::Receiver, + startup_step_rx: watch::Receiver>, + components_rx: watch::Receiver>, + pub tasks: TaskRegistry, } impl Clone for DaemonRuntime { @@ -68,8 +81,18 @@ impl Clone for DaemonRuntime { state: self.state.clone(), cancellation: self.cancellation.clone(), shutdown_tx: self.shutdown_tx.clone(), + shutdown_rx: self.shutdown_rx.clone(), startup_phase: self.startup_phase.clone(), degraded_reason: self.degraded_reason.clone(), + startup_phase_rx: self.startup_phase_rx.clone(), + degraded_reason_rx: self.degraded_reason_rx.clone(), + lifecycle: self.lifecycle.clone(), + startup_step: self.startup_step.clone(), + components: self.components.clone(), + lifecycle_rx: self.lifecycle_rx.clone(), + startup_step_rx: self.startup_step_rx.clone(), + components_rx: self.components_rx.clone(), + tasks: self.tasks.clone(), } } } @@ -82,21 +105,36 @@ impl Default for DaemonRuntime { impl DaemonRuntime { pub fn new() -> Self { - let (shutdown_tx, _) = watch::channel(None); - let (startup_phase, _) = watch::channel(StartupPhase::Starting); - let (degraded_reason, _) = watch::channel(None); + let (shutdown_tx, shutdown_rx) = watch::channel(None); + let (startup_phase, startup_phase_rx) = watch::channel(StartupPhase::Starting); + let (degraded_reason, degraded_reason_rx) = watch::channel(None); + let (lifecycle, lifecycle_rx) = watch::channel(iota_ipc::LifecyclePhase::Starting); + let (startup_step, startup_step_rx) = watch::channel(Some("starting".to_string())); + let (components, components_rx) = watch::channel(BTreeMap::new()); Self { state: Arc::new(DaemonState::new()), cancellation: CancellationToken::new(), shutdown_tx, + shutdown_rx, startup_phase, degraded_reason, + startup_phase_rx, + degraded_reason_rx, + lifecycle, + startup_step, + components, + lifecycle_rx, + startup_step_rx, + components_rx, + tasks: TaskRegistry::default(), } } pub fn shutdown(&self, reason: ShutdownReason) { - self.cancellation.cancel(); - let _ = self.shutdown_tx.send(Some(reason)); + if self.shutdown_tx.borrow().is_none() { + let _ = self.shutdown_tx.send(Some(reason)); + self.cancellation.cancel(); + } } pub fn shutdown_reason(&self) -> Option { @@ -109,6 +147,27 @@ impl DaemonRuntime { pub fn set_startup_phase(&self, phase: StartupPhase) { let _ = self.startup_phase.send(phase); + let (lifecycle, step) = match phase { + StartupPhase::Ready => (iota_ipc::LifecyclePhase::Ready, None), + StartupPhase::Stopping => (iota_ipc::LifecyclePhase::Stopping, Some("stopping".into())), + StartupPhase::MigratingStorage => ( + iota_ipc::LifecyclePhase::Starting, + Some("migrating_storage".into()), + ), + StartupPhase::LoadingUsers => ( + iota_ipc::LifecyclePhase::Starting, + Some("loading_users".into()), + ), + StartupPhase::StartingServices => ( + iota_ipc::LifecyclePhase::Starting, + Some("starting_services".into()), + ), + StartupPhase::Starting | StartupPhase::Degraded => { + (iota_ipc::LifecyclePhase::Starting, Some("starting".into())) + } + }; + let _ = self.lifecycle.send(lifecycle); + let _ = self.startup_step.send(step); } pub fn current_startup_phase(&self) -> StartupPhase { @@ -117,7 +176,62 @@ impl DaemonRuntime { pub fn mark_degraded(&self, reason: String) { let _ = self.degraded_reason.send(Some(reason.clone())); - let _ = self.startup_phase.send(StartupPhase::Degraded); + self.set_component_degraded(iota_ipc::ComponentId::Omikron, reason); + } + + pub fn set_component_healthy(&self, component: iota_ipc::ComponentId, message: Option) { + self.update_component(component, iota_ipc::HealthStatus::Healthy, message); + } + + pub fn set_component_degraded(&self, component: iota_ipc::ComponentId, message: String) { + self.update_component(component, iota_ipc::HealthStatus::Degraded, Some(message)); + } + + pub fn set_component_failed(&self, component: iota_ipc::ComponentId, message: String) { + self.update_component(component, iota_ipc::HealthStatus::Failed, Some(message)); + } + + fn update_component( + &self, + component: iota_ipc::ComponentId, + status: iota_ipc::HealthStatus, + message: Option, + ) { + let mut components = self.components.borrow().clone(); + components.insert( + component, + iota_ipc::ComponentHealth { + status, + message, + changed_at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + }, + ); + let _ = self.components.send(components); + } + + pub fn overall_health(&self) -> iota_ipc::HealthStatus { + let components = self.components.borrow(); + if [iota_ipc::ComponentId::Ipc, iota_ipc::ComponentId::Storage] + .iter() + .any(|id| { + components + .get(id) + .is_some_and(|v| v.status == iota_ipc::HealthStatus::Failed) + }) + { + return iota_ipc::HealthStatus::Failed; + } + if components.values().any(|v| { + v.status == iota_ipc::HealthStatus::Degraded + || v.status == iota_ipc::HealthStatus::Failed + }) { + iota_ipc::HealthStatus::Degraded + } else { + iota_ipc::HealthStatus::Healthy + } } pub fn snapshot(&self) -> StateSnapshot { @@ -133,42 +247,51 @@ impl DaemonRuntime { net_up: state.net_up.clone(), net_down: state.net_down.clone(), sys_info: state.sys_info.clone(), + startup_phase: self.current_startup_phase().into(), + degraded_reason: self.degraded_reason.borrow().clone(), + lifecycle: *self.lifecycle.borrow(), + startup_step: self.startup_step.borrow().clone(), + overall_health: self.overall_health(), + components: self.components.borrow().clone(), } } - pub fn spawn_system_monitor(&self) { + pub async fn spawn_system_monitor(&self) { let runtime = self.clone(); - tokio::spawn(async move { - runtime.state.active_tasks.insert("System monitor".into()); - let mut system = System::new_with_specifics(RefreshKind::everything()); - let mut counter = 0.0; - loop { - if runtime.is_shutting_down() { - break; + self.tasks + .spawn_tracked("system-monitor", async move { + runtime.state.active_tasks.insert("System monitor".into()); + let mut system = System::new_with_specifics(RefreshKind::everything()); + let mut counter = 0.0; + loop { + if runtime.is_shutting_down() { + break; + } + system.refresh_cpu_all(); + system.refresh_memory(); + let cpu = system.global_cpu_usage() as f64; + let total_memory = system.total_memory(); + let ram = if total_memory == 0 { + 0.0 + } else { + system.used_memory() as f64 / total_memory as f64 * 100.0 + }; + { + let mut state = runtime + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_cpu((counter, cpu)); + state.push_ram((counter, ram)); + state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); + } + counter += 1.0; + tokio::time::sleep(Duration::from_millis(500)).await; } - system.refresh_cpu_all(); - system.refresh_memory(); - let cpu = system.global_cpu_usage() as f64; - let total_memory = system.total_memory(); - let ram = if total_memory == 0 { - 0.0 - } else { - system.used_memory() as f64 / total_memory as f64 * 100.0 - }; - { - let mut state = runtime - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); - state.push_cpu((counter, cpu)); - state.push_ram((counter, ram)); - state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); - } - counter += 1.0; - tokio::time::sleep(Duration::from_millis(500)).await; - } - runtime.state.active_tasks.remove("System monitor"); - }); + runtime.state.active_tasks.remove("System monitor"); + Ok(()) + }) + .await; } } diff --git a/iota-daemon-lib/src/deployment.rs b/iota-daemon-lib/src/deployment.rs new file mode 100644 index 0000000..c7a5cde --- /dev/null +++ b/iota-daemon-lib/src/deployment.rs @@ -0,0 +1,37 @@ +use iota_ipc::{DeploymentMode, SupervisorKind}; + +#[derive(Clone, Copy, Debug)] +pub struct DeploymentContext { + pub mode: DeploymentMode, + pub supervisor: SupervisorKind, +} + +impl Default for DeploymentContext { + fn default() -> Self { + Self { + mode: DeploymentMode::External, + supervisor: SupervisorKind::None, + } + } +} + +pub fn from_environment() -> DeploymentContext { + let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() { + Some("session_child") => DeploymentMode::SessionChild, + Some("ui_auto_start") => DeploymentMode::UiAutoStart, + Some("user_service") => DeploymentMode::UserService, + Some("system_socket_activated") => DeploymentMode::SystemSocketActivated, + Some("system_always_on") => DeploymentMode::SystemAlwaysOn, + _ => DeploymentMode::External, + }; + if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") { + mode = DeploymentMode::SystemSocketActivated; + } + let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() { + Some("iota_ui") => SupervisorKind::IotaUi, + Some("systemd") => SupervisorKind::Systemd, + Some("external") => SupervisorKind::External, + _ => SupervisorKind::None, + }; + DeploymentContext { mode, supervisor } +} diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index 2e3f6e9..bc99cbc 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,63 +1,113 @@ -use crate::{CommandRouter, DaemonRuntime}; +use crate::deployment::from_environment; +use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, write_msg, }; use iota_logger::log; use std::io::Result; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::{env, os::fd::FromRawFd}; +use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, mpsc, watch}; +use tokio::time::timeout; use uuid::Uuid; /// Per-client outbound queue capacity. const CLIENT_CHANNEL_SIZE: usize = 256; /// Maximum handshake retries before giving up. -const MAX_HANDSHAKE_RETRIES: u32 = 10; +const MAX_HANDSHAKE_RETRIES: u32 = 1; +const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); pub struct IpcServer { - path: PathBuf, + listener: UnixListener, runtime: Arc, + services: Arc, log_tx: broadcast::Sender, - state_rx: watch::Sender, + state_rx: watch::Receiver, + instance_id: String, + _instance_lock: File, } impl IpcServer { - pub fn new( + pub async fn bind( path: impl Into, runtime: Arc, + services: Arc, log_tx: broadcast::Sender, - state_rx: watch::Sender, - ) -> Self { - Self { - path: path.into(), - runtime, - log_tx, - state_rx, - } - } - - pub async fn run(self) -> Result<()> { - if let Some(parent) = self.path.parent() { - tokio::fs::create_dir_all(parent).await?; - } + state_rx: watch::Receiver, + ) -> Result { + let path = path.into(); let listener = match activated_listener()? { Some(listener) => listener, None => { - remove_stale_socket(&self.path).await?; - UnixListener::bind(&self.path)? + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let lock_path = path.with_extension("sock.lock"); + let lock = File::options() + .create(true) + .mode(0o600) + .read(true) + .write(true) + .open(lock_path)?; + let locked = unsafe { + libc::flock( + std::os::fd::AsRawFd::as_raw_fd(&lock), + libc::LOCK_EX | libc::LOCK_NB, + ) + } == 0; + if !locked { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "another daemon instance is already running", + )); + } + remove_stale_socket(&path).await?; + let listener = UnixListener::bind(&path)?; + let _ = tokio::fs::set_permissions( + &path, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .await; + return Ok(Self { + listener, + runtime, + services, + log_tx, + state_rx, + instance_id: Uuid::new_v4().to_string(), + _instance_lock: lock, + }); } }; + Ok(Self { + listener, + runtime, + services, + log_tx, + state_rx, + instance_id: Uuid::new_v4().to_string(), + _instance_lock: File::options().read(true).open("/dev/null")?, + }) + } + + pub async fn serve(self) -> Result<()> { loop { - let (stream, _addr) = listener.accept().await?; + let (stream, _addr) = self.listener.accept().await?; + eprintln!("IPC client accepted"); let runtime = self.runtime.clone(); + let services = self.services.clone(); let log_tx = self.log_tx.clone(); let state_rx = self.state_rx.clone(); + let instance_id = self.instance_id.clone(); tokio::spawn(async move { - if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await { + if let Err(error) = + handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await + { eprintln!("IPC client error: {error}"); } }); @@ -77,13 +127,67 @@ fn activated_listener() -> Result> { if listen_fds != Some(1) || listen_pid != Some(std::process::id()) { return Ok(None); } - let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) }; - UnixListener::from_std(listener).map(Some) + // SAFETY: systemd transfers ownership of the activated descriptor to us. + let listener = unsafe { StdUnixListener::from_raw_fd(3) }; + into_tokio_listener(listener).map(Some) +} + +fn into_tokio_listener(listener: StdUnixListener) -> Result { + listener.set_nonblocking(true)?; + UnixListener::from_std(listener) +} + +async fn write_client_message(writer: &mut W, message: &DaemonMessage) -> Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + timeout(CLIENT_IO_TIMEOUT, write_msg(writer, message)) + .await + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "IPC client write timed out") + })? } async fn remove_stale_socket(path: &Path) -> Result<()> { match tokio::fs::symlink_metadata(path).await { - Ok(_) => tokio::fs::remove_file(path).await, + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IPC path exists but is not an owned Unix socket", + )); + } + if metadata.uid() != unsafe { libc::geteuid() } as u32 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "existing IPC socket is not owned by the current user", + )); + } + match timeout( + std::time::Duration::from_millis(250), + UnixStream::connect(path), + ) + .await + { + Ok(Ok(_)) => Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "an IPC daemon is already listening", + )), + Ok(Err(error)) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + tokio::fs::remove_file(path).await + } + Ok(Err(error)) => Err(error), + Err(_) => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "could not determine whether the existing IPC socket is active", + )), + } + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error), } @@ -93,10 +197,10 @@ async fn remove_stale_socket(path: &Path) -> Result<()> { struct PeerIdentity { pid: i32, uid: u32, - gid: u32, + _gid: u32, } -fn peer_credentials(stream: &UnixStream) -> PeerIdentity { +fn peer_credentials(stream: &UnixStream) -> Result { #[cfg(target_os = "linux")] { use std::os::unix::io::AsRawFd; @@ -104,70 +208,109 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity { let mut cred: libc::ucred = std::mem::zeroed(); let mut len = std::mem::size_of::() as libc::socklen_t; let fd = stream.as_raw_fd(); - libc::getsockopt( + if libc::getsockopt( fd, libc::SOL_SOCKET, libc::SO_PEERCRED, &mut cred as *mut _ as *mut libc::c_void, &mut len, - ); - PeerIdentity { + ) != 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(PeerIdentity { pid: cred.pid, uid: cred.uid, - gid: cred.gid, - } + _gid: cred.gid, + }) } } #[cfg(not(target_os = "linux"))] { - PeerIdentity { + Ok(PeerIdentity { pid: 0, uid: 0, - gid: 0, - } + _gid: 0, + }) } } async fn handle_client( stream: UnixStream, runtime: Arc, + services: Arc, log_tx: broadcast::Sender, - _state_rx: watch::Sender, + mut state_rx: watch::Receiver, + instance_id: String, ) -> Result<()> { - let peer = peer_credentials(&stream); + let peer = peer_credentials(&stream)?; + // Access control belongs to the Unix socket. The systemd socket grants + // iota-operators group access (0660); rejecting every UID other than the + // service account here would make that authorization ineffective. Manual + // sockets remain owner-only (0600) at bind time. let (mut reader, mut writer) = stream.into_split(); + // A failed writer must stop the reader and any subsequent command work + // for this client; otherwise the reader can remain parked forever. + let session_cancellation = runtime.cancellation.child_token(); let (directed_tx, directed_rx) = mpsc::channel::(CLIENT_CHANNEL_SIZE); + eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid); // --- Handshake --- let mut negotiated_version: Option = None; for _ in 0..MAX_HANDSHAKE_RETRIES { - match read_msg::<_, ClientMessage>(&mut reader).await { - Ok(ClientMessage::Hello { supported_versions }) => { - let version = supported_versions - .iter() - .copied() - .find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) - .unwrap_or(PROTOCOL_VERSION); - negotiated_version = Some(version); - let instance_id = Uuid::new_v4().to_string(); - let ack = DaemonMessage::HelloAck(HelloAck { - protocol_version: version, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), - instance_id, - startup_phase: runtime.current_startup_phase().into(), - capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], - }); - write_msg(&mut writer, &ack).await?; - break; - } - Ok(_) => { - // Unexpected first message — send error and close. + match timeout( + std::time::Duration::from_secs(15), + read_msg::<_, ClientMessage>(&mut reader), + ) + .await + { + Err(_) => { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Expected Hello as first message", + std::io::ErrorKind::TimedOut, + "IPC Hello timed out", )); } - Err(e) => return Err(e), + Ok(result) => match result { + Ok(ClientMessage::Hello { supported_versions }) => { + let version = supported_versions + .iter() + .copied() + .filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) + .max() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "No compatible IPC protocol version", + ) + })?; + negotiated_version = Some(version); + let ack = DaemonMessage::HelloAck(HelloAck { + protocol_version: version, + daemon_version: env!("CARGO_PKG_VERSION").to_string(), + instance_id: instance_id.clone(), + startup_phase: runtime.current_startup_phase().into(), + capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], + lifecycle: *runtime.lifecycle.borrow(), + health: runtime.overall_health(), + deployment_mode: from_environment().mode, + supervisor: from_environment().supervisor, + }); + write_client_message(&mut writer, &ack).await?; + eprintln!( + "IPC handshake acknowledged (pid={}, uid={})", + peer.pid, peer.uid + ); + break; + } + Ok(_) => { + // Unexpected first message — send error and close. + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Expected Hello as first message", + )); + } + Err(e) => return Err(e), + }, } } let _version = negotiated_version.ok_or_else(|| { @@ -182,9 +325,9 @@ async fn handle_client( // --- Writer task: merge directed responses + shared log events --- let mut log_rx = log_tx.subscribe(); - let directed_for_writer = directed_tx.clone(); let writer_task = { let runtime = runtime.clone(); + let session_cancellation = session_cancellation.clone(); tokio::spawn(async move { let mut directed_rx = directed_rx; loop { @@ -193,7 +336,9 @@ async fn handle_client( msg = directed_rx.recv() => { match msg { Some(message) => { - if write_msg(&mut writer, &message).await.is_err() { + if let Err(error) = write_client_message(&mut writer, &message).await { + eprintln!("IPC client writer stopped while sending directed message: {error}"); + session_cancellation.cancel(); break; } } @@ -204,36 +349,91 @@ async fn handle_client( result = log_rx.recv() => { match result { Ok(message) => { - if write_msg(&mut writer, &message).await.is_err() { + if let Err(error) = write_client_message(&mut writer, &message).await { + eprintln!("IPC client writer stopped while sending log message: {error}"); + session_cancellation.cancel(); break; } } Err(broadcast::error::RecvError::Lagged(skipped)) => { - let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await; - // Then send current snapshot for resync - let _ = directed_for_writer.send( - DaemonMessage::StateUpdate(runtime.snapshot()) - ).await; + if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err() + || write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err() + { + session_cancellation.cancel(); + break; + } } Err(broadcast::error::RecvError::Closed) => break, } } + changed = state_rx.changed() => { + if changed.is_err() { + break; + } + let snapshot = state_rx.borrow().clone(); + if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await { + eprintln!("IPC client writer stopped while sending state update: {error}"); + session_cancellation.cancel(); + break; + } + } } } }) }; // --- Reader loop --- - let router = CommandRouter::new(runtime.clone()); + let router = CommandRouter::new(runtime.clone(), services); loop { - match read_msg::<_, ClientMessage>(&mut reader).await { + let message = tokio::select! { + _ = session_cancellation.cancelled() => break, + result = read_msg::<_, ClientMessage>(&mut reader) => result, + }; + match message { Ok(ClientMessage::Request(envelope)) => { - let response = router.route(envelope.request_id, envelope.request).await; + let shutdown_reason = match &envelope.request { + iota_ipc::LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Restart, + } + | iota_ipc::LocalRequest::RestartDaemon => Some("restart requested"), + iota_ipc::LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + } => Some("shutdown requested"), + iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"), + _ => None, + }; + let response = if envelope.protocol_version < MIN_PROTOCOL_VERSION + || envelope.protocol_version > PROTOCOL_VERSION + { + iota_ipc::ResponseEnvelope { + request_id: envelope.request_id, + result: iota_ipc::ResponseResult::Error( + iota_ipc::IpcErrorCode::UnsupportedVersion, + ), + } + } else { + router.route(envelope.request_id, envelope.request).await + }; let _ = directed_tx.send(DaemonMessage::Response(response)).await; + if let Some(reason) = shutdown_reason { + let _ = directed_tx + .send(DaemonMessage::LifecycleEvent( + iota_ipc::LifecycleEvent::Shutdown { + reason: reason.into(), + }, + )) + .await; + // The request itself initiates daemon cancellation. Give + // the dedicated writer a chance to flush the response + // and lifecycle event before this session is torn down. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + break; + } } Ok(ClientMessage::Subscribe { .. }) => { let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); let _ = directed_tx.send(snapshot).await; + let _ = directed_tx.send(DaemonMessage::Subscribed).await; } Ok(ClientMessage::Ping { seq }) => { let _ = directed_tx.send(DaemonMessage::Pong { seq }).await; @@ -250,6 +450,7 @@ async fn handle_client( } } } + session_cancellation.cancel(); writer_task.abort(); log!( "IPC client disconnected (pid={}, uid={})", @@ -258,3 +459,25 @@ async fn handle_client( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test(flavor = "current_thread")] + async fn converted_listener_does_not_block_the_runtime() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ipc.sock"); + let listener = match StdUnixListener::bind(path) { + Ok(listener) => into_tokio_listener(listener).unwrap(), + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return, + Err(error) => panic!("could not create test socket: {error}"), + }; + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } +} diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 48f8c68..5a40295 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -1,8 +1,13 @@ pub mod command_router; pub mod daemon_state; +pub mod deployment; pub mod ipc_server; pub mod log_broadcaster; +pub mod services; +pub mod task_registry; pub use command_router::CommandRouter; pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase}; pub use ipc_server::IpcServer; +pub use services::DaemonServices; +pub use task_registry::TaskRegistry; diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs new file mode 100644 index 0000000..a04002c --- /dev/null +++ b/iota-daemon-lib/src/services.rs @@ -0,0 +1,23 @@ +use omikron_connector::{OmikronClient, OmikronConnection}; +use std::sync::Arc; + +#[derive(Default)] +pub struct UserService; +#[derive(Default)] +pub struct ConfigService; + +pub struct DaemonServices { + pub omikron: Arc, + pub users: Arc, + pub config: Arc, +} + +impl DaemonServices { + pub fn new(omikron: Arc) -> Arc { + Arc::new(Self { + omikron, + users: Arc::new(UserService), + config: Arc::new(ConfigService), + }) + } +} diff --git a/iota-daemon-lib/src/task_registry.rs b/iota-daemon-lib/src/task_registry.rs new file mode 100644 index 0000000..89769db --- /dev/null +++ b/iota-daemon-lib/src/task_registry.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::task::JoinSet; + +#[derive(Clone, Default)] +pub struct TaskRegistry { + tasks: Arc)>>>, +} + +impl TaskRegistry { + pub async fn spawn_tracked(&self, name: impl Into, future: F) + where + F: std::future::Future> + Send + 'static, + { + let name = name.into(); + self.tasks + .lock() + .await + .spawn(async move { (name, future.await) }); + } + + pub async fn join_with_timeout(&self, timeout: Duration) -> Vec { + let mut tasks = self.tasks.lock().await; + let mut failures = Vec::new(); + let deadline = tokio::time::Instant::now() + timeout; + while !tasks.is_empty() { + match tokio::time::timeout_at(deadline, tasks.join_next()).await { + Ok(Some(Ok((name, Err(error))))) => failures.push(format!("{name}: {error}")), + Ok(Some(Ok((_, Ok(()))))) | Ok(Some(Err(_))) => {} + Ok(None) => break, + Err(_) => { + tasks.abort_all(); + break; + } + } + } + failures + } +} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs new file mode 100644 index 0000000..525bca1 --- /dev/null +++ b/iota-daemon-lib/tests/command_router.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; +use iota_ipc::{LocalRequest, ResponseResult}; +use mtp::codec::CommunicationValue; +use omikron_connector::{OmikronClient, OmikronError}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::Duration; + +struct FakeOmikron { + reconnects: AtomicUsize, +} +#[async_trait] +impl OmikronClient for FakeOmikron { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + Ok(()) + } + async fn await_response( + &self, + _: &CommunicationValue, + _: Duration, + ) -> Result { + Err(OmikronError::Disconnected("fake".into())) + } + async fn reconnect(&self) -> Result<(), OmikronError> { + self.reconnects.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn is_connected(&self) -> bool { + true + } +} + +#[tokio::test] +async fn reconnect_uses_the_injected_client() { + let fake = Arc::new(FakeOmikron { + reconnects: AtomicUsize::new(0), + }); + let services = Arc::new(DaemonServices { + omikron: fake.clone(), + users: Default::default(), + config: Default::default(), + }); + let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services); + assert!(matches!( + router.route(1, LocalRequest::ReconnectOmikron).await.result, + ResponseResult::Ok(_) + )); + assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); +} diff --git a/iota-daemon-lib/tests/daemon_health.rs b/iota-daemon-lib/tests/daemon_health.rs new file mode 100644 index 0000000..a24f277 --- /dev/null +++ b/iota-daemon-lib/tests/daemon_health.rs @@ -0,0 +1,29 @@ +use iota_daemon_lib::{DaemonRuntime, StartupPhase}; +use iota_ipc::{ComponentId, HealthStatus, LifecyclePhase}; + +#[test] +fn component_failures_are_independent_and_recovery_is_scoped() { + let runtime = DaemonRuntime::new(); + runtime.set_component_degraded(ComponentId::Omikron, "offline".into()); + runtime.set_component_failed(ComponentId::Web, "bind failed".into()); + runtime.set_startup_phase(StartupPhase::Ready); + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.lifecycle, LifecyclePhase::Ready); + assert_eq!(snapshot.overall_health, HealthStatus::Degraded); + assert_eq!( + snapshot.components[&ComponentId::Omikron].status, + HealthStatus::Degraded + ); + runtime.set_component_healthy(ComponentId::Web, None); + assert_eq!( + runtime.snapshot().components[&ComponentId::Omikron].status, + HealthStatus::Degraded + ); +} + +#[test] +fn critical_failure_is_failed_but_optional_degradation_is_not() { + let runtime = DaemonRuntime::new(); + runtime.set_component_failed(ComponentId::Storage, "database unavailable".into()); + assert_eq!(runtime.snapshot().overall_health, HealthStatus::Failed); +} diff --git a/iota-daemon-lib/tests/shutdown.rs b/iota-daemon-lib/tests/shutdown.rs new file mode 100644 index 0000000..d0c74d6 --- /dev/null +++ b/iota-daemon-lib/tests/shutdown.rs @@ -0,0 +1,40 @@ +use iota_daemon_lib::{DaemonRuntime, ShutdownReason}; +use std::time::Duration; + +#[tokio::test] +async fn shutdown_reason_is_first_write_wins_and_tasks_join() { + let runtime = DaemonRuntime::new(); + runtime.shutdown(ShutdownReason::Fatal("first".into())); + runtime.shutdown(ShutdownReason::Restart); + assert_eq!( + runtime.shutdown_reason(), + Some(ShutdownReason::Fatal("first".into())) + ); + runtime.tasks.spawn_tracked("quick", async { Ok(()) }).await; + assert!( + runtime + .tasks + .join_with_timeout(Duration::from_millis(100)) + .await + .is_empty() + ); +} + +#[tokio::test] +async fn long_task_is_aborted_at_join_timeout() { + let runtime = DaemonRuntime::new(); + runtime + .tasks + .spawn_tracked("slow", async { + tokio::time::sleep(Duration::from_secs(10)).await; + Ok(()) + }) + .await; + assert!( + runtime + .tasks + .join_with_timeout(Duration::from_millis(10)) + .await + .is_empty() + ); +} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 620b413..2c5e976 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -8,6 +8,7 @@ iota-daemon-lib = { path = "../iota-daemon-lib" } iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } +iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index b9404f1..5239baf 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,94 +1,198 @@ -use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster}; -use iota_logger::{self as logger, log, log_t}; +use iota_daemon_lib::{ + DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster, +}; +use iota_logger::{self as logger, log}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; -use std::path::PathBuf; +use std::process::ExitCode; use std::sync::Arc; use std::time::Duration; use tokio::sync::{broadcast, watch}; -fn socket_path() -> PathBuf { - std::env::var_os("IOTA_SOCKET") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock")) -} - #[tokio::main(flavor = "multi_thread")] -async fn main() { +async fn main() -> ExitCode { logger::startup(); iota_storage::util::config_util::load_config(); let runtime = Arc::new(DaemonRuntime::new()); - runtime.set_startup_phase(StartupPhase::LoadingUsers); - - if user_manager::load_users().await.is_err() { - log_t!("user_load_failed"); - } - // --- IPC infrastructure --- let (log_tx, _) = broadcast::channel(512); log_broadcaster::spawn(log_tx.clone()); - let (state_tx, _state_rx) = watch::channel(iota_ipc::StateSnapshot::default()); + let (state_tx, state_rx) = watch::channel(runtime.snapshot()); - // --- Start IPC server early (before services) so clients can see startup phases --- - runtime.set_startup_phase(StartupPhase::StartingServices); - let ipc_server = IpcServer::new( - socket_path(), - runtime.clone(), - log_tx.clone(), - state_tx.clone(), - ); - tokio::spawn(async move { - if let Err(error) = ipc_server.run().await { - eprintln!("iota-daemon IPC server failed: {error}"); + runtime.set_startup_phase(StartupPhase::LoadingUsers); + if tokio::task::spawn_blocking(user_manager::load_users_sync) + .await + .ok() + .and_then(Result::ok) + .is_none() + { + runtime.set_component_failed( + iota_ipc::ComponentId::Storage, + "user storage failed to load".into(), + ); + } else { + runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None); + } + + // Bind before migration and service startup: a successful bind is the + // readiness boundary visible to clients and socket activation. + let socket = iota_paths::socket_path(iota_paths::SocketScope::User); + let omikron = match omikron_connector::omikron_connection::connect_initial( + runtime.cancellation.clone(), + runtime.state.active_tasks.clone(), + runtime.state.app.clone(), + ) + .await + { + Ok(connection) => connection, + Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => { + runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + "Omikron connection unavailable; retrying".into(), + ); + connection } - }); - log!("iota-daemon IPC server started"); + Err(omikron_connector::OmikronStartupError::Authentication) => { + runtime.set_component_failed( + iota_ipc::ComponentId::Omikron, + "Omikron authentication failed".into(), + ); + return ExitCode::FAILURE; + } + Err(omikron_connector::OmikronStartupError::Construction(error)) => { + eprintln!("Cannot construct Omikron connection: {error}"); + return ExitCode::FAILURE; + } + }; + let services = DaemonServices::new(omikron); + let ipc_server = + match IpcServer::bind(socket, runtime.clone(), services, log_tx.clone(), state_rx).await { + Ok(server) => server, + Err(error) => { + eprintln!("Cannot bind daemon IPC socket: {error}"); + return ExitCode::FAILURE; + } + }; + eprintln!( + "iota-daemon IPC listener ready at {}", + iota_paths::socket_path(iota_paths::SocketScope::User).display() + ); + runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None); + let listener_runtime = runtime.clone(); + runtime + .tasks + .spawn_tracked("ipc-server", async move { + if let Err(error) = ipc_server.serve().await { + eprintln!("iota-daemon IPC server failed: {error}"); + listener_runtime.shutdown(ShutdownReason::Fatal(format!( + "IPC listener stopped: {error}" + ))); + } + Ok(()) + }) + .await; + log!("iota-daemon IPC server ready"); + + runtime.set_startup_phase(StartupPhase::StartingServices); // --- System monitor --- - runtime.spawn_system_monitor(); + runtime.spawn_system_monitor().await; // --- State update publisher (watch-based, no full broadcast per tick) --- let state_publisher = runtime.clone(); - tokio::spawn(async move { - loop { - if state_publisher.is_shutting_down() { - break; + runtime + .tasks + .spawn_tracked("state-publisher", async move { + loop { + if state_publisher.is_shutting_down() { + break; + } + let snapshot = state_publisher.snapshot(); + let _ = state_tx.send(snapshot); + tokio::time::sleep(Duration::from_millis(500)).await; } - let snapshot = state_publisher.snapshot(); - let _ = state_tx.send(snapshot); - tokio::time::sleep(Duration::from_millis(500)).await; - } - }); + Ok(()) + }) + .await; // --- Web server --- - let port = CONFIG.load().port; - if !web_server::start(port, runtime.cancellation.clone()).await { - log!("Failed to start the MTP web server on port {}", port); - runtime.mark_degraded("MTP web server failed to start".into()); - } - - // --- Omikron connection --- - let omikron_result = - omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone()) - .await; - if omikron_result.is_none() { - runtime.mark_degraded("Omikron connection unavailable".into()); + let web = CONFIG.load().web.clone(); + let web_config = web_server::WebConfig { + mode: match web.mode { + iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled, + iota_storage::util::config_util::WebMode::Loopback => web_server::WebMode::Loopback, + iota_storage::util::config_util::WebMode::Network => web_server::WebMode::Network, + }, + bind: web + .bind + .parse() + .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + port: web.port, + asset_dir: std::path::PathBuf::from(web.asset_dir), + tls: web + .certificate + .zip(web.key) + .map(|(certificate, key)| web_server::TlsConfig { + certificate: certificate.into(), + key: key.into(), + }), + required: web.required, + }; + match web_server::start(web_config, runtime.cancellation.clone()).await { + Ok(None) => { + runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into())) + } + Ok(Some(handle)) => { + runtime.set_component_healthy(iota_ipc::ComponentId::Web, None); + runtime + .tasks + .spawn_tracked("web-server", async move { + handle.join().await; + Ok(()) + }) + .await; + } + Err(error) if web.required => { + runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string()); + } + Err(error) => { + runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string()); + } } runtime.set_startup_phase(StartupPhase::Ready); log!("iota-daemon started (phase: Ready)"); // --- Main lifecycle loop --- - runtime.cancellation.cancelled().await; + let signal = async { + #[cfg(unix)] + { + let mut term = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler"); + tokio::select! { _ = tokio::signal::ctrl_c() => ShutdownReason::Stop, _ = term.recv() => ShutdownReason::Stop } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + ShutdownReason::Stop + } + }; + tokio::select! { + _ = runtime.cancellation.cancelled() => {}, + reason = signal => runtime.shutdown(reason), + } let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop); log!("iota-daemon shutting down (reason: {:?})", reason); runtime.set_startup_phase(StartupPhase::Stopping); - // Wait a moment for in-flight operations to complete - tokio::time::sleep(Duration::from_millis(500)).await; + let _ = runtime + .tasks + .join_with_timeout(Duration::from_secs(5)) + .await; let exit_code = reason.exit_code(); log!("iota-daemon exited (code: {})", exit_code); - std::process::exit(exit_code); + ExitCode::from(exit_code as u8) } diff --git a/iota-installer/Cargo.toml b/iota-installer/Cargo.toml new file mode 100644 index 0000000..755c8f7 --- /dev/null +++ b/iota-installer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "iota-installer" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +tempfile = "3" +zip = "6" +serde_json = "1" +iota-paths = { path = "../iota-paths" } diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs new file mode 100644 index 0000000..f290cf0 --- /dev/null +++ b/iota-installer/src/lib.rs @@ -0,0 +1,187 @@ +use anyhow::{Context, Result, bail}; +use std::{fs, io, path::Path, process::Command}; +use tempfile::tempdir; +use zip::ZipArchive; + +const REQUIRED: &[&str] = &[ + "bin/iota", + "bin/iota-daemon", + "bin/iota-updater", + "systemd/iota-daemon.service", + "systemd/iota-daemon.socket", + "systemd/sysusers.d/iota.conf", + "systemd/iota-update.service", + "systemd/iota-update.timer", + "manifest.json", +]; + +pub fn install_linux_bundle(bundle: &Path) -> Result<()> { + install_linux_bundle_with_operator(bundle, None) +} + +pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> { + if std::env::consts::OS != "linux" { + bail!("Linux systemd bundles are not supported on this platform"); + } + let staging = tempdir().context("create installer staging directory")?; + let file = fs::File::open(bundle).context("open release bundle")?; + let mut archive = ZipArchive::new(file).context("read release bundle")?; + for name in REQUIRED { + let mut entry = archive + .by_name(name) + .with_context(|| format!("bundle is missing {name}"))?; + let output = staging.path().join(name); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut out = fs::File::create(&output)?; + io::copy(&mut entry, &mut out)?; + } + + install( + &staging.path().join("bin/iota"), + &format!( + "{}/versions/{}/bin/iota", + iota_paths::install_root().display(), + product_version(staging.path()) + ), + "0755", + )?; + install( + &staging.path().join("bin/iota-daemon"), + &format!( + "{}/versions/{}/bin/iota-daemon", + iota_paths::install_root().display(), + product_version(staging.path()) + ), + "0755", + )?; + let version_dir = format!( + "{}/versions/{}", + iota_paths::install_root().display(), + product_version(staging.path()) + ); + if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() { + bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon"); + } + install( + &staging.path().join("bin/iota-updater"), + &format!( + "{}/versions/{}/bin/iota-updater", + iota_paths::install_root().display(), + product_version(staging.path()) + ), + "0755", + )?; + for unit in [ + "iota-daemon.service", + "iota-daemon.socket", + "iota-update.service", + "iota-update.timer", + ] { + install( + &staging.path().join("systemd").join(unit), + &format!("/etc/systemd/system/{unit}"), + "0644", + )?; + } + install( + &staging.path().join("systemd/sysusers.d/iota.conf"), + "/etc/sysusers.d/iota.conf", + "0644", + )?; + run( + "ln", + &[ + "-sfn", + &version_dir, + &iota_paths::current_version_link().to_string_lossy(), + ], + )?; + run( + "ln", + &[ + "-sfn", + &format!("{}/current/bin/iota", iota_paths::install_root().display()), + "/usr/local/bin/iota", + ], + )?; + run( + "ln", + &[ + "-sfn", + &format!( + "{}/current/bin/iota-daemon", + iota_paths::install_root().display() + ), + "/usr/local/lib/iota/iota-daemon", + ], + )?; + run("systemd-sysusers", &[])?; + if let Some(operator) = operator { + run("usermod", &["-aG", "iota-operators", operator])?; + } else { + eprintln!("To grant socket access, run: usermod -aG iota-operators USER"); + eprintln!( + "A new login session is required before supplementary group membership is visible." + ); + } + run("systemctl", &["daemon-reload"])?; + run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; + run("systemctl", &["is-active", "iota-daemon.socket"])?; + run("systemctl", &["is-enabled", "iota-daemon.socket"])?; + if !Path::new("/run/iota/iota.sock").exists() { + bail!("systemd socket is active but /run/iota/iota.sock was not created"); + } + Ok(()) +} + +fn product_version(staging: &Path) -> String { + fs::read_to_string(staging.join("manifest.json")) + .ok() + .and_then(|value| serde_json::from_str::(&value).ok()) + .and_then(|value| { + value + .get("product_version") + .and_then(|v| v.as_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unversioned".into()) +} + +fn install(source: &Path, destination: &str, mode: &str) -> Result<()> { + run( + "install", + &["-D", "-m", mode, &source.to_string_lossy(), destination], + ) +} + +fn run(program: &str, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("run {program}"))?; + if status.success() { + Ok(()) + } else { + bail!("{program} failed; run the installer as root") + } +} + +#[cfg(test)] +mod tests { + #[test] + fn service_uses_installed_daemon_and_declared_identities() { + let service = include_str!("../../systemd/iota-daemon.service"); + let socket = include_str!("../../systemd/iota-daemon.socket"); + let sysusers = include_str!("../../systemd/sysusers.d/iota.conf"); + assert!(service.contains("ExecStart=/usr/local/lib/iota/iota-daemon")); + assert!(service.contains("User=iota")); + assert!(service.contains("Group=iota")); + assert!(socket.contains("SocketUser=iota")); + assert!(socket.contains("SocketGroup=iota-operators")); + assert!(socket.contains("NonBlocking=true")); + assert!(sysusers.contains("u iota ")); + assert!(sysusers.contains("g iota-operators")); + } +} diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 11105c2..1118fc2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -2,9 +2,10 @@ pub mod protocol; pub mod transport; pub use protocol::{ - ClientMessage, DaemonMessage, HelloAck, LogEntry, StateSnapshot, MetricSample, - RequestEnvelope, ResponseEnvelope, ResponseResult, LocalRequest, IpcErrorCode, - ConnectionStatus, StartupPhase, LifecycleEvent, + ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode, + ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, + LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase, + StateSnapshot, SupervisorKind, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 35a93d6..7a95a3d 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -7,10 +7,17 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ClientMessage { - Hello { supported_versions: Vec }, - Subscribe { log_classes: Vec, metric_interval_ms: Option }, + Hello { + supported_versions: Vec, + }, + Subscribe { + log_classes: Vec, + metric_interval_ms: Option, + }, Request(RequestEnvelope), - Ping { seq: u64 }, + Ping { + seq: u64, + }, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -26,14 +33,31 @@ pub enum LocalRequest { GetStatus, ListTasks, ListUsers, - CreateUser { username: String }, - RemoveUser { user_id: i64 }, + CreateUser { + username: String, + }, + RemoveUser { + user_id: i64, + }, ReconnectOmikron, RotateIotaIdentity, + RequestProcessExit { + intent: ExitIntent, + }, + GetDaemonStatus, + #[serde(skip)] RestartDaemon, + #[serde(skip)] StopDaemon, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExitIntent { + Stop, + Restart, +} + // --------------------------------------------------------------------------- // Daemon → Client // --------------------------------------------------------------------------- @@ -42,13 +66,19 @@ pub enum LocalRequest { #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum DaemonMessage { HelloAck(HelloAck), + /// Confirms that the server has installed this connection's subscription. + Subscribed, LogEntry(LogEntry), StateUpdate(StateSnapshot), MetricSample(MetricSample), Response(ResponseEnvelope), - Pong { seq: u64 }, + Pong { + seq: u64, + }, LifecycleEvent(LifecycleEvent), - Gap { skipped: u64 }, + Gap { + skipped: u64, + }, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -58,6 +88,14 @@ pub struct HelloAck { pub instance_id: String, pub startup_phase: StartupPhase, pub capabilities: Vec, + #[serde(default)] + pub lifecycle: LifecyclePhase, + #[serde(default)] + pub health: HealthStatus, + #[serde(default)] + pub deployment_mode: DeploymentMode, + #[serde(default)] + pub supervisor: SupervisorKind, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -84,6 +122,10 @@ pub enum IpcErrorCode { UnsupportedVersion, NotReady, Disconnected, + Timeout, + Cancelled, + Unauthorized, + InternalFailure, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -114,6 +156,69 @@ pub enum StartupPhase { Stopping, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecyclePhase { + #[default] + Starting, + Ready, + Stopping, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + #[default] + Healthy, + Degraded, + Failed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentId { + Storage, + Ipc, + Omikron, + Web, + Updater, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentMode { + SessionChild, + UiAutoStart, + UserService, + SystemSocketActivated, + SystemAlwaysOn, + #[default] + External, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SupervisorKind { + #[default] + None, + IotaUi, + Systemd, + External, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ComponentHealth { + pub status: HealthStatus, + pub message: Option, + pub changed_at_ms: u128, +} + +impl Default for StartupPhase { + fn default() -> Self { + Self::Starting + } +} + // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- @@ -134,6 +239,18 @@ pub struct StateSnapshot { pub net_up: Vec<(f64, f64)>, pub net_down: Vec<(f64, f64)>, pub sys_info: String, + #[serde(default)] + pub startup_phase: StartupPhase, + #[serde(default)] + pub degraded_reason: Option, + #[serde(default)] + pub lifecycle: LifecyclePhase, + #[serde(default)] + pub startup_step: Option, + #[serde(default)] + pub overall_health: HealthStatus, + #[serde(default)] + pub components: std::collections::BTreeMap, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] diff --git a/iota-logger/src/language_manager.rs b/iota-logger/src/language_manager.rs index 8cced10..2d3bda3 100644 --- a/iota-logger/src/language_manager.rs +++ b/iota-logger/src/language_manager.rs @@ -59,42 +59,52 @@ impl LanguagePack { } pub fn load_language(&mut self, language: &str) { - let path = format!("languages/{}/", language); + let files = [ + "frontend.json", + "omikron.json", + "buttons.json", + "debug.json", + "general.json", + ]; - let frontend_messages = file_util::load_file(&path, "frontend.json"); - let frontend_messages = parse(&frontend_messages).unwrap(); - for (key, value) in frontend_messages.entries() { - self.language - .insert(key.to_string(), value.as_str().unwrap().to_string()); + // The daemon can initialize the logger before iota-core has created + // the generated language files. Create the built-in pack on demand. + if language == "en_INT" + && files + .iter() + .any(|file| !file_util::has_file(&format!("languages/{language}/"), file)) + { + let _ = crate::language_creator::create_languages(); } - let omikron_messages = file_util::load_file(&path, "omikron.json"); - let omikron_messages = parse(&omikron_messages).unwrap(); - for (key, value) in omikron_messages.entries() { - self.language - .insert(key.to_string(), value.as_str().unwrap().to_string()); - } + let loaded = files + .iter() + .all(|file| self.load_file(&format!("languages/{language}/"), file)); - let button_texts = file_util::load_file(&path, "buttons.json"); - let button_texts = parse(&button_texts).unwrap(); - for (key, value) in button_texts.entries() { - self.language - .insert(key.to_string(), value.as_str().unwrap().to_string()); + // A process may have been interrupted while an older version was + // writing a language file. Regenerate the default pack once in that + // case, and still leave custom language packs non-fatal. + if !loaded && language == "en_INT" { + self.language.clear(); + let _ = crate::language_creator::create_languages(); + for file in files { + let _ = self.load_file(&format!("languages/{language}/"), file); + } } + } - let debug_messages = file_util::load_file(&path, "debug.json"); - let debug_messages = parse(&debug_messages).unwrap(); - for (key, value) in debug_messages.entries() { - self.language - .insert(key.to_string(), value.as_str().unwrap().to_string()); - } + fn load_file(&mut self, path: &str, file: &str) -> bool { + let contents = file_util::load_file(path, file); + let Ok(messages) = parse(&contents) else { + return false; + }; - let general_messages = file_util::load_file(&path, "general.json"); - let general_messages = parse(&general_messages).unwrap(); - for (key, value) in general_messages.entries() { - self.language - .insert(key.to_string(), value.as_str().unwrap().to_string()); + for (key, value) in messages.entries() { + if let Some(value) = value.as_str() { + self.language.insert(key.to_string(), value.to_string()); + } } + true } pub fn get_translation(&self, key: &str) -> String { match self.language.get(key) { diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 486a04c..8dd7f11 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -97,15 +97,17 @@ pub fn startup() { format!("{} ", msg.prefix) }; - let _ = writeln!( - file, + let line1 = format!( "{} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), prefix, resolved_message ); + let line2 = format!(" {}", timestamp); - let _ = writeln!(file, " {}", timestamp); + let _ = writeln!(file, "{}\n{}", line1, line2); + + let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); let entry = UiLogEntry { timestamp_ms: msg.timestamp_ms, diff --git a/iota-paths/Cargo.toml b/iota-paths/Cargo.toml new file mode 100644 index 0000000..00a1b19 --- /dev/null +++ b/iota-paths/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "iota-paths" +version = "0.1.0" +edition = "2024" diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs new file mode 100644 index 0000000..4461e1e --- /dev/null +++ b/iota-paths/src/lib.rs @@ -0,0 +1,156 @@ +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SocketScope { + User, + System, +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +pub fn data_dir() -> PathBuf { + if let Some(path) = std::env::var_os("IOTA_DATA_DIR") { + return PathBuf::from(path); + } + + #[cfg(target_os = "linux")] + { + return std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir().join(".local/state")) + .join("iota"); + } + #[cfg(target_os = "macos")] + { + return home_dir().join("Library/Application Support/Iota"); + } + #[cfg(target_os = "windows")] + { + return std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(home_dir) + .join("Tensamin/Iota"); + } + #[allow(unreachable_code)] + home_dir().join(".iota") +} +pub fn config_dir() -> PathBuf { + if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") { + return PathBuf::from(path); + } + #[cfg(target_os = "linux")] + { + return std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir().join(".config")) + .join("iota"); + } + #[cfg(target_os = "macos")] + { + return home_dir().join("Library/Application Support/Iota"); + } + #[cfg(target_os = "windows")] + { + return std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(home_dir) + .join("Tensamin/Iota"); + } + #[allow(unreachable_code)] + home_dir().join(".iota") +} + +pub fn socket_override() -> Option { + std::env::var_os("IOTA_SOCKET").map(PathBuf::from) +} + +pub fn socket_path(scope: SocketScope) -> PathBuf { + if let Some(path) = socket_override() { + return path; + } + match scope { + SocketScope::User => data_dir().join("iota.sock"), + SocketScope::System => PathBuf::from("/run/iota/iota.sock"), + } +} + +pub fn socket_lock_path(scope: SocketScope) -> PathBuf { + let socket = socket_path(scope); + PathBuf::from(format!("{}.lock", socket.display())) +} + +pub fn daemon_executable() -> PathBuf { + if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") { + return PathBuf::from(path); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(path) = exe.parent().map(|p| p.join("iota-daemon")) { + if path.is_file() { + return path; + } + } + } + #[cfg(target_os = "linux")] + { + let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon"); + if installed.is_file() { + return installed; + } + } + PathBuf::from("iota-daemon") +} + +pub fn updater_executable() -> PathBuf { + std::env::var_os("IOTA_UPDATER_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("iota-updater")) +} +pub fn install_root() -> PathBuf { + std::env::var_os("IOTA_INSTALL_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota")) +} +pub fn versions_dir() -> PathBuf { + install_root().join("versions") +} +pub fn current_version_link() -> PathBuf { + install_root().join("current") +} +pub fn updater_lock_path() -> PathBuf { + data_dir().join("update.lock") +} +pub fn updater_status_path() -> PathBuf { + data_dir().join("update-status.json") +} +pub fn updater_staging_dir() -> PathBuf { + data_dir().join("update-staging") +} +pub fn web_asset_dir() -> PathBuf { + std::env::var_os("IOTA_WEB_ASSET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| data_dir().join("web")) +} + +pub fn daemon_endpoints() -> Vec { + if let Some(path) = socket_override() { + return vec![path]; + } + vec![ + socket_path(SocketScope::User), + socket_path(SocketScope::System), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_data_directory_wins() { + assert!(!data_dir().as_os_str().is_empty()); + } +} diff --git a/iota-process-manager/Cargo.toml b/iota-process-manager/Cargo.toml new file mode 100644 index 0000000..c159ef8 --- /dev/null +++ b/iota-process-manager/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "iota-process-manager" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] } diff --git a/iota-process-manager/src/lib.rs b/iota-process-manager/src/lib.rs new file mode 100644 index 0000000..e3a03d5 --- /dev/null +++ b/iota-process-manager/src/lib.rs @@ -0,0 +1,459 @@ +use async_trait::async_trait; +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +pub const PROCESS_MANAGER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct UnitStatus { + pub active: bool, + pub enabled: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartupMode { + AlwaysOn, + SocketActivated, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessAction { + Start, + Stop, + Restart, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DetectedStartupMode { + AlwaysOn, + SocketActivated, + Disabled, + Conflicting, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DaemonStartupStatus { + pub service: UnitStatus, + pub socket: UnitStatus, + pub detected: DetectedStartupMode, +} + +impl DaemonStartupStatus { + pub fn classify(service: UnitStatus, socket: UnitStatus) -> Self { + let detected = match (service.enabled, socket.enabled) { + (true, false) => DetectedStartupMode::AlwaysOn, + (false, true) => DetectedStartupMode::SocketActivated, + (false, false) => DetectedStartupMode::Disabled, + (true, true) => DetectedStartupMode::Conflicting, + }; + Self { + service, + socket, + detected, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessManagerErrorKind { + CommandUnavailable, + PermissionDenied, + UnitMissing, + CommandFailed, + ParseFailed, + VerificationFailed, + TimedOut, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessManagerError { + pub kind: ProcessManagerErrorKind, + message: String, +} +impl ProcessManagerError { + pub fn new(kind: ProcessManagerErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + pub fn kind(&self) -> ProcessManagerErrorKind { + self.kind + } +} +impl Display for ProcessManagerError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for ProcessManagerError {} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandOutput { + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +#[async_trait] +pub trait CommandExecutor: Send + Sync { + async fn output( + &self, + program: &str, + args: &[&str], + ) -> Result; +} + +#[async_trait] +pub trait ProcessManager: Send + Sync { + fn name(&self) -> &'static str; + async fn unit_status(&self, unit: &str) -> Result; + async fn set_iota_startup_mode( + &self, + mode: StartupMode, + ) -> Result; + async fn iota_startup_status(&self) -> Result { + Ok(DaemonStartupStatus::classify( + self.unit_status("iota-daemon.service").await?, + self.unit_status("iota-daemon.socket").await?, + )) + } + async fn enable_startup( + &self, + mode: StartupMode, + ) -> Result { + self.set_iota_startup_mode(mode).await + } + async fn disable_startup(&self) -> Result { + self.set_iota_startup_mode(StartupMode::SocketActivated) + .await + } + async fn process_action( + &self, + action: ProcessAction, + ) -> Result { + let unit = "iota-daemon.service"; + match action { + ProcessAction::Start => self.unit_action(&["start", unit]).await?, + ProcessAction::Stop => self.unit_action(&["stop", unit]).await?, + ProcessAction::Restart => self.unit_action(&["restart", unit]).await?, + } + self.iota_startup_status().await + } + async fn unit_action(&self, _action: &[&str]) -> Result<(), ProcessManagerError> { + Err(ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + "process actions are unsupported", + )) + } +} + +pub async fn detect() -> Option> { + #[cfg(target_os = "linux")] + { + systemd::SystemdManager::detect() + .await + .map(|m| Arc::new(m) as Arc) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(target_os = "linux")] +mod systemd { + use super::*; + use std::{path::Path, process::Stdio}; + use tokio::{process::Command, time::timeout}; + + const SERVICE: &str = "iota-daemon.service"; + const SOCKET: &str = "iota-daemon.socket"; + const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"]; + + pub struct RealExecutor; + #[async_trait] + impl CommandExecutor for RealExecutor { + async fn output( + &self, + program: &str, + args: &[&str], + ) -> Result { + let child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(false) + .spawn() + .map_err(|e| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandUnavailable, + format!("Could not run {program}: {e}"), + ) + })?; + let output = timeout(PROCESS_MANAGER_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| { + ProcessManagerError::new( + ProcessManagerErrorKind::TimedOut, + format!( + "{program} timed out after {} seconds", + PROCESS_MANAGER_TIMEOUT.as_secs() + ), + ) + })? + .map_err(|e| { + ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string()) + })?; + Ok(CommandOutput { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + pub struct SystemdManager { + executor: Arc, + } + impl SystemdManager { + pub async fn detect() -> Option { + if !Path::new("/run/systemd/system").is_dir() { + return None; + } + let executor: Arc = Arc::new(RealExecutor); + executor + .output("systemctl", &["--version", &COMMON[0], &COMMON[1]]) + .await + .ok() + .filter(|r| r.success) + .map(|_| Self { executor }) + } + #[cfg(test)] + pub fn with_executor(executor: Arc) -> Self { + Self { executor } + } + async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> { + let mut args = COMMON.to_vec(); + args.extend_from_slice(action); + let output = self.executor.output("systemctl", &args).await?; + if output.success { + return Ok(()); + } + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + let kind = if detail.to_ascii_lowercase().contains("access denied") + || detail.to_ascii_lowercase().contains("permission denied") + { + ProcessManagerErrorKind::PermissionDenied + } else { + ProcessManagerErrorKind::CommandFailed + }; + Err(ProcessManagerError::new( + kind, + if detail.is_empty() { + format!("systemctl {} failed", action.join(" ")) + } else { + detail.to_owned() + }, + )) + } + async fn status(&self, unit: &str) -> Result { + let mut args = COMMON.to_vec(); + args.extend_from_slice(&[ + "show", + "--property=LoadState", + "--property=ActiveState", + "--property=UnitFileState", + "--value", + unit, + ]); + let output = self.executor.output("systemctl", &args).await?; + if !output.success { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + let kind = if detail.to_ascii_lowercase().contains("denied") { + ProcessManagerErrorKind::PermissionDenied + } else { + ProcessManagerErrorKind::CommandFailed + }; + return Err(ProcessManagerError::new( + kind, + format!("systemctl could not inspect {unit}: {detail}"), + )); + } + let values: Vec<_> = output.stdout.lines().map(str::trim).collect(); + if values.len() < 3 { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::ParseFailed, + format!("systemctl returned incomplete state for {unit}"), + )); + } + if values[0] == "not-found" { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::UnitMissing, + format!("systemd unit {unit} was not found"), + )); + } + if values[0] != "loaded" { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::ParseFailed, + format!("unsupported LoadState `{}` for {unit}", values[0]), + )); + } + let active = match values[1] { + "active" => true, + "inactive" | "failed" | "activating" | "deactivating" | "reloading" => false, + v => { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::ParseFailed, + format!("unsupported ActiveState `{v}` for {unit}"), + )); + } + }; + let enabled = match values[2] { + "enabled" | "enabled-runtime" => true, + "disabled" | "static" | "indirect" | "masked" | "generated" | "transient" => false, + v => { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::ParseFailed, + format!("unsupported UnitFileState `{v}` for {unit}"), + )); + } + }; + Ok(UnitStatus { active, enabled }) + } + async fn verify( + &self, + expected: DetectedStartupMode, + ) -> Result { + let status = self.iota_startup_status().await?; + if status.detected == expected { + Ok(status) + } else { + Err(ProcessManagerError::new( + ProcessManagerErrorKind::VerificationFailed, + format!( + "systemd reported {:?} after applying {:?}", + status.detected, expected + ), + )) + } + } + } + #[async_trait] + impl ProcessManager for SystemdManager { + fn name(&self) -> &'static str { + "systemd" + } + async fn unit_status(&self, unit: &str) -> Result { + self.status(unit).await + } + async fn set_iota_startup_mode( + &self, + mode: StartupMode, + ) -> Result { + match mode { + StartupMode::AlwaysOn => { + self.run(&["disable", SOCKET]).await?; + self.run(&["enable", "--now", SERVICE]).await?; + self.verify(DetectedStartupMode::AlwaysOn).await + } + StartupMode::SocketActivated => { + self.run(&["disable", "--now", SERVICE]).await?; + self.run(&["enable", "--now", SOCKET]).await?; + self.verify(DetectedStartupMode::SocketActivated).await + } + } + } + async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> { + self.run(action).await + } + async fn disable_startup(&self) -> Result { + self.run(&["disable", "--now", SERVICE]).await?; + self.run(&["disable", "--now", SOCKET]).await?; + self.verify(DetectedStartupMode::Disabled).await + } + } + + #[cfg(test)] + mod tests { + use super::*; + use std::sync::Mutex; + + struct Fake { + calls: Mutex>>, + results: Mutex>, + } + #[async_trait] + impl CommandExecutor for Fake { + async fn output( + &self, + _: &str, + args: &[&str], + ) -> Result { + self.calls + .lock() + .unwrap() + .push(args.iter().map(|arg| (*arg).to_owned()).collect()); + Ok(self.results.lock().unwrap().remove(0)) + } + } + fn ok(stdout: &str) -> CommandOutput { + CommandOutput { + success: true, + stdout: stdout.into(), + stderr: String::new(), + } + } + + #[tokio::test] + async fn every_systemctl_operation_disables_interactive_features() { + let fake = Arc::new(Fake { + calls: Mutex::new(Vec::new()), + results: Mutex::new(vec![ok("loaded\nactive\nenabled\n")]), + }); + let manager = SystemdManager::with_executor(fake.clone()); + manager.unit_status(SERVICE).await.unwrap(); + let call = &fake.calls.lock().unwrap()[0]; + assert!(call.contains(&"--no-pager".into())); + assert!(call.contains(&"--no-ask-password".into())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn modes_distinct() { + assert_ne!(StartupMode::AlwaysOn, StartupMode::SocketActivated); + } + + struct BlockingExecutor; + #[async_trait::async_trait] + impl CommandExecutor for BlockingExecutor { + async fn output(&self, _: &str, _: &[&str]) -> Result { + std::future::pending().await + } + } + + #[tokio::test] + async fn executor_future_can_be_cancelled_without_blocking_runtime() { + let result = tokio::time::timeout( + std::time::Duration::from_millis(20), + BlockingExecutor.output("systemctl", &["show"]), + ) + .await; + assert!(result.is_err()); + } +} diff --git a/iota-state/src/lib.rs b/iota-state/src/lib.rs index 3b29d08..8f668ea 100644 --- a/iota-state/src/lib.rs +++ b/iota-state/src/lib.rs @@ -12,7 +12,7 @@ use std::thread; use std::time::Duration; #[cfg(feature = "legacy-globals")] use sysinfo::{RefreshKind, System}; -use tokio::sync::RwLock; +use tokio::sync::{Mutex as TokioMutex, RwLock}; /* Process-owned daemon state and TUI-local state must be separate because IPC, * rather than shared memory, is the boundary between the two binaries. */ @@ -45,13 +45,13 @@ impl Default for DaemonState { * with the daemon and is populated from daemon IPC messages. */ #[derive(Clone)] pub struct ClientState { - pub app: Arc>, + pub app: Arc>, } impl ClientState { pub fn new() -> Self { Self { - app: Arc::new(Mutex::new(AppState::new())), + app: Arc::new(TokioMutex::new(AppState::new())), } } } @@ -94,6 +94,7 @@ pub struct AppState { pub net_up: Vec<(f64, f64)>, pub net_down: Vec<(f64, f64)>, pub sys_info: String, + next_sample_id: u64, } impl AppState { @@ -106,6 +107,7 @@ impl AppState { net_up: Vec::new(), net_down: Vec::new(), sys_info: String::from("Loading..."), + next_sample_id: 0, } } @@ -121,40 +123,51 @@ impl AppState { } pub fn push_cpu(&mut self, pt: (f64, f64)) { - self.cpu.push(pt); + let x = self.next_sample(); + self.cpu.push((x, pt.1)); if self.cpu.len() > MAX_POINTS { self.cpu.remove(0); } } pub fn push_ram(&mut self, pt: (f64, f64)) { - self.ram.push(pt); + let x = self.next_sample(); + self.ram.push((x, pt.1)); if self.ram.len() > MAX_POINTS { self.ram.remove(0); } } pub fn push_ping_val(&mut self, pt: f64) { - self.ping.push((self.ping.len() as f64, pt)); + let x = self.next_sample(); + self.ping.push((x, pt)); if self.ping.len() > MAX_POINTS { self.ping.remove(0); } } pub fn push_net_up(&mut self, pt: (f64, f64)) { - self.net_up.push(pt); + let x = self.next_sample(); + self.net_up.push((x, pt.1)); if self.net_up.len() > MAX_POINTS { self.net_up.remove(0); } } pub fn push_net_down(&mut self, pt: (f64, f64)) { - self.net_down.push(pt); + let x = self.next_sample(); + self.net_down.push((x, pt.1)); if self.net_down.len() > MAX_POINTS { self.net_down.remove(0); } } + fn next_sample(&mut self) -> f64 { + let value = self.next_sample_id as f64; + self.next_sample_id = self.next_sample_id.saturating_add(1); + value + } + pub fn to_json(&self) -> JsonValue { object! { "cpu" => self.cpu.iter().map(|(_, y)| *y).collect::>(), @@ -218,15 +231,16 @@ pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); #[cfg(feature = "legacy-globals")] -pub fn setup() { - ACTIVE_TASKS.insert("System info loader".to_string()); +pub fn setup(state: &DaemonState) { + state.active_tasks.insert("System info loader".to_string()); + let state = state.clone(); tokio::spawn(async move { let mut sys = System::new_with_specifics(RefreshKind::everything()); let mut last_total_received = 0u64; let mut last_total_transmitted = 0u64; let mut counter = 0.0; loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { break; } sys.refresh_all(); @@ -258,7 +272,7 @@ pub fn setup() { let net_up = delta_transmitted as f64; { - let mut st = APP_STATE.lock().unwrap(); + let mut st = state.app.lock().unwrap(); st.push_cpu((counter, tcpu as f64)); st.push_ram((counter, ram)); st.push_net_down((counter, net_down)); @@ -274,6 +288,21 @@ pub fn setup() { thread::sleep(Duration::from_millis(5)); } } - ACTIVE_TASKS.remove("System info loader"); + state.active_tasks.remove("System info loader"); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metric_coordinates_remain_monotonic_after_history_rollover() { + let mut state = AppState::new(); + for value in 0..(MAX_POINTS + 25) { + state.push_ping_val(value as f64); + } + assert_eq!(state.ping.len(), MAX_POINTS); + assert!(state.ping.windows(2).all(|pair| pair[0].0 < pair[1].0)); + } +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 67c4e6a..d6a0b47 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -242,7 +242,7 @@ pub fn save_users() { // No-op: users are auto-saved via SQLite. } -pub async fn load_users() -> std::io::Result<()> { +pub fn load_users_sync() -> std::io::Result<()> { // Users are loaded from SQLite on demand. This function is kept for API compat. // If we need to migrate from a legacy users.json file, we can do so here. let content = load_file("", "users.json"); diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 82fbbaa..5685208 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -13,6 +13,8 @@ pub struct IotaConfig { pub iota_id: Option, #[serde(default = "default_port")] pub port: u16, + #[serde(default)] + pub web: WebSettings, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_host: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -27,6 +29,54 @@ pub struct IotaConfig { pub read_receipts_enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebMode { + Disabled, + Loopback, + Network, +} +impl Default for WebMode { + fn default() -> Self { + Self::Disabled + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebSettings { + #[serde(default)] + pub mode: WebMode, + #[serde(default = "default_web_bind")] + pub bind: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_web_asset_dir")] + pub asset_dir: String, + pub certificate: Option, + pub key: Option, + #[serde(default)] + pub required: bool, +} +fn default_web_bind() -> String { + "127.0.0.1".into() +} +fn default_web_asset_dir() -> String { + "web".into() +} +impl Default for WebSettings { + fn default() -> Self { + Self { + mode: WebMode::default(), + bind: default_web_bind(), + port: default_port(), + asset_dir: default_web_asset_dir(), + certificate: None, + key: None, + required: false, + } + } +} + const fn default_port() -> u16 { 1984 } @@ -40,6 +90,7 @@ impl Default for IotaConfig { Self { iota_id: None, port: default_port(), + web: WebSettings::default(), omikron_host: None, omikron_port: None, keyring: None, diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 4d1ef48..d483f7c 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -29,4 +29,5 @@ serde = "1.0.228" tempfile = "3.27.0" anyhow = "1.0.102" semver = "1.0.28" -self-replace = "1.5.0" +ed25519-dalek = "2.2.0" +serde_json = "1.0" diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index c93e9d3..ec5d061 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -1,163 +1,13 @@ -/* This file is used for the auto update function for the Iota. - * It connects to the git server from methanium and checks if - * the version has updated inside the cargo.toml file. - * It is made by Yolokit and pasted in by AlexEmmet */ +pub mod manifest; +pub mod transaction; -use anyhow::{Context, Result, anyhow}; -use iota_logger::log; -use self_replace::self_replace; -use semver::Version; -use std::fs::File; -use tempfile::NamedTempFile; - -const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); - -const API_BASE: &str = "https://git.methanium.net/api/v1"; -const OWNER: &str = "Tensamin"; -const REPO: &str = "Iota"; - -#[derive(Debug)] -struct Release { - tag_name: String, - assets: Vec, -} - -#[derive(Debug)] -struct Asset { - name: String, - browser_download_url: String, -} - -async fn latest_release() -> Result { - let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest"); - - let response = reqwest::get(&url) - .await - .context("failed to query latest release.")?; - - if !response.status().is_success() { - return Err(anyhow!("release API returned {}", response.status())); - } - - let text = response - .text() - .await - .context("failed to read response text")?; - - let parsed = json::parse(&text).map_err(|e| anyhow!("failed to parse JSON: {}", e))?; - - let tag_name = parsed["tag_name"] - .as_str() - .ok_or_else(|| anyhow!("missing tag_name"))? - .to_string(); - - let assets_json = parsed["assets"].members().collect::>(); - - let mut assets = Vec::new(); - - for asset in assets_json { - let name = asset["name"] - .as_str() - .ok_or_else(|| anyhow!("missing asset name"))? - .to_string(); - - let browser_download_url = asset["browser_download_url"] - .as_str() - .ok_or_else(|| anyhow!("missing download url"))? - .to_string(); - - assets.push(Asset { - name, - browser_download_url, - }); - } - - Ok(Release { tag_name, assets }) -} - -async fn parse_tag_version(tag: &str) -> Result { - let normalized = tag.strip_prefix('v').unwrap_or(tag); - Ok(Version::parse(normalized)?) -} - -async fn current_version() -> Result { - Ok(Version::parse(CURRENT_VERSION)?) -} - -async fn asset_name_for_current_platform() -> String { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - match (os, arch) { - ("linux", "x86_64") => "iota-linux-x86_64".to_string(), - ("linux", "aarch64") => "iota-linux-aarch64".to_string(), - ("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(), - ("macos", "x86_64") => "iota-macos-x86_64".to_string(), - ("macos", "aarch64") => "iota-macos-aarch64".to_string(), - _ => panic!("unsupported platform: {os}/{arch}"), - } -} - -async fn download_asset(url: &str) -> Result { - let response = reqwest::get(url) - .await - .context("failed to download asset")?; - - if !response.status().is_success() { - return Err(anyhow!("asset download returned {}", response.status())); - } - - let tmp = NamedTempFile::new().context("failed to create temp file")?; - let _out = File::create(tmp.path()).context("failed to open temp file")?; - - let bytes = response - .bytes() - .await - .context("failed to read response bytes")?; - - std::fs::write(tmp.path(), &bytes).context("failed to write file")?; - - Ok(tmp) -} - -async fn check_for_update() -> Result> { - let current = current_version().await?; - let release = latest_release().await?; - let latest = parse_tag_version(&release.tag_name).await?; - - if latest > current { - Ok(Some(release)) - } else { - Ok(None) - } -} - -async fn perform_update() -> Result { - let Some(release) = check_for_update().await? else { - return Ok(false); - }; - - let wanted_asset = asset_name_for_current_platform().await; - - let asset = release - .assets - .iter() - .find(|a| a.name == wanted_asset) - .ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?; - - log!("Downloading update: {}", asset.name); - - let downloaded = download_asset(&asset.browser_download_url).await?; - - self_replace(downloaded.path()).context("failed to replace current executable")?; - - Ok(true) -} +use anyhow::Result; +/// Compatibility entry point used by the UI. Updates are now manifest-driven; +/// this function only checks and never replaces the invoking executable. pub async fn check_update() -> Result { - if perform_update().await? { - return Ok(true); - } else { + if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() { return Ok(false); } + Ok(false) } diff --git a/iota-updater/src/main.rs b/iota-updater/src/main.rs new file mode 100644 index 0000000..d566dc9 --- /dev/null +++ b/iota-updater/src/main.rs @@ -0,0 +1,21 @@ +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); + match command.as_str() { + "check" => println!("update check is manifest-driven"), + "status" => println!("updater ready"), + "apply" | "rollback" => { + return Err(anyhow::anyhow!( + "explicit signed transaction input is required" + )); + } + _ => { + return Err(anyhow::anyhow!( + "usage: iota-updater check|status|apply|rollback" + )); + } + } + Ok(()) +} diff --git a/iota-updater/src/manifest.rs b/iota-updater/src/manifest.rs new file mode 100644 index 0000000..f2bae0a --- /dev/null +++ b/iota-updater/src/manifest.rs @@ -0,0 +1,83 @@ +use anyhow::{Context, Result, bail}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ReleaseManifest { + pub product_version: String, + pub channel: String, + pub published_at: String, + pub minimum_data_schema: u64, + pub supported_ipc_min: u16, + pub supported_ipc_max: u16, + pub artifacts: Vec, + pub release_signing_key_id: String, + pub rollback_compatible: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Artifact { + pub role: String, + pub os: String, + pub architecture: String, + pub path: String, + pub url: String, + pub sha256: String, + pub size: u64, +} + +pub fn canonical_bytes(manifest: &ReleaseManifest) -> Result> { + Ok(serde_json::to_vec(manifest)?) +} +pub fn verify_signature( + manifest: &ReleaseManifest, + signature: &[u8], + public_key: &[u8; 32], +) -> Result<()> { + let key = VerifyingKey::from_bytes(public_key).context("invalid release public key")?; + let signature = Signature::from_slice(signature).context("invalid release signature")?; + key.verify(&canonical_bytes(manifest)?, &signature) + .context("release manifest signature verification failed") +} +pub fn verify_artifact(path: &std::path::Path, artifact: &Artifact) -> Result<()> { + let metadata = std::fs::metadata(path)?; + if metadata.len() != artifact.size { + bail!("artifact size mismatch for {}", artifact.path); + } + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = std::io::Read::read(&mut file, &mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let actual = hex::encode(hasher.finalize()); + if actual != artifact.sha256.to_ascii_lowercase() { + bail!("artifact hash mismatch for {}", artifact.path); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_size_or_hash_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("iota-daemon"); + std::fs::write(&path, b"daemon").unwrap(); + let artifact = Artifact { + role: "daemon".into(), + os: "linux".into(), + architecture: "x86_64".into(), + path: "bin/iota-daemon".into(), + url: "https://example.invalid".into(), + sha256: "00".repeat(32), + size: 6, + }; + assert!(verify_artifact(&path, &artifact).is_err()); + } +} diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs new file mode 100644 index 0000000..9dfcd4e --- /dev/null +++ b/iota-updater/src/transaction.rs @@ -0,0 +1,79 @@ +use crate::manifest::{Artifact, verify_artifact}; +use anyhow::{Context, Result}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug)] +pub struct UpdateTransaction { + pub root: PathBuf, + pub staging: PathBuf, +} +impl UpdateTransaction { + pub fn new(root: impl Into) -> Self { + let root = root.into(); + Self { + staging: root.join(".staging"), + root, + } + } + pub fn acquire(&self) -> Result { + fs::create_dir_all(&self.root)?; + let path = self.root.join("update.lock"); + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .context("update already in progress")?; + Ok(file) + } + pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result { + fs::create_dir_all(&self.staging)?; + let target = self.staging.join(&artifact.path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, &target)?; + verify_artifact(&target, artifact)?; + Ok(target) + } + pub fn activate(&self, version: &str) -> Result<()> { + let version_dir = self.root.join("versions").join(version); + fs::create_dir_all(version_dir.parent().unwrap())?; + fs::rename(&self.staging, &version_dir).context("activate staged release")?; + let current_tmp = self.root.join("current.new"); + let _ = fs::remove_file(¤t_tmp); + std::os::unix::fs::symlink(&version_dir, ¤t_tmp)?; + fs::rename(current_tmp, self.root.join("current"))?; + Ok(()) + } + pub fn rollback(&self, previous: &str) -> Result<()> { + let current = self.root.join("current"); + let tmp = self.root.join("current.rollback"); + let _ = fs::remove_file(&tmp); + std::os::unix::fs::symlink(self.root.join("versions").join(previous), &tmp)?; + fs::rename(tmp, current)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lock_is_exclusive_and_activation_switches_current() { + let dir = tempfile::tempdir().unwrap(); + let tx = UpdateTransaction::new(dir.path()); + let lock = tx.acquire().unwrap(); + assert!(tx.acquire().is_err()); + drop(lock); + std::fs::create_dir_all(&tx.staging).unwrap(); + std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap(); + tx.activate("1.0.0").unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(), + "ok" + ); + } +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 23ed0b7..d61786e 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-paths = { path = "../iota-paths" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "crypto" ] } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 286c2f2..fe4a8ba 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -162,10 +162,7 @@ pub fn get_children(path: &str) -> Vec { } pub fn get_directory() -> String { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .to_string_lossy() - .to_string() + iota_paths::data_dir().to_string_lossy().to_string() } // Helper to download the zip file content to a file on disk diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 18c53b0..64de533 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -5,5 +5,10 @@ edition = "2024" [dependencies] iota-cli = { path = "../iota-cli" } +iota-ipc = { path = "../iota-ipc" } +iota-installer = { path = "../iota-installer" } +iota-core = { path = "../iota-core" } +iota-process-manager = { path = "../iota-process-manager" } +iota-paths = { path = "../iota-paths" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs new file mode 100644 index 0000000..cedefb0 --- /dev/null +++ b/iota/src/cli_args.rs @@ -0,0 +1,154 @@ +use iota_cli::theme::ThemeName; + +#[derive(Debug)] +pub struct CliInvocation { + pub theme_override: Option, + pub command: Command, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + Dashboard, + Help, + Install { + bundle: String, + operator: Option, + }, + Status, + Tasks, + UsersList, + DaemonRestart { + confirmed: bool, + }, + DaemonStop { + confirmed: bool, + }, + DaemonStopProcess, + DaemonEnable { + mode: String, + }, + DaemonDisableStartup, + DaemonDaemonStatus, +} +impl CliInvocation { + pub fn parse(args: impl IntoIterator) -> Result { + let mut theme_override = None; + let mut command = Vec::new(); + let mut args = args.into_iter(); + while let Some(argument) = args.next() { + if argument == "--theme" { + let value = args.next().ok_or_else(|| { + format!( + "--theme requires a value ({})", + ThemeName::supported_names() + ) + })?; + theme_override = Some(value.parse()?); + } else if let Some(value) = argument.strip_prefix("--theme=") { + theme_override = Some(value.parse()?); + } else { + command.push(argument); + } + } + let command = match command.as_slice() { + [] => Command::Dashboard, + [help] if help == "help" || help == "--help" => Command::Help, + [status] if status == "status" => Command::Status, + [tasks] if tasks == "tasks" => Command::Tasks, + [noun, verb] if noun == "users" && verb == "list" => Command::UsersList, + [noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart { + confirmed: flag == "--yes", + }, + [noun, verb] if noun == "daemon" && verb == "restart" => { + Command::DaemonRestart { confirmed: false } + } + [noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop { + confirmed: flag == "--yes", + }, + [noun, verb] if noun == "daemon" && verb == "stop" => { + Command::DaemonStop { confirmed: false } + } + [noun, verb] if noun == "daemon" && verb == "stop-process" => { + Command::DaemonStopProcess + } + [noun, verb] if noun == "daemon" && verb == "disable-startup" => { + Command::DaemonDisableStartup + } + [noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus, + [noun, verb, flag, mode] + if noun == "daemon" && verb == "enable" && flag == "--mode" => + { + Command::DaemonEnable { mode: mode.clone() } + } + [noun, verb, bundle_flag, bundle] + if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" => + { + Command::Install { + bundle: bundle.clone(), + operator: None, + } + } + [noun, verb, bundle_flag, bundle, operator_flag, operator] + if noun == "daemon" + && verb == "install" + && bundle_flag == "--bundle" + && operator_flag == "--operator" => + { + Command::Install { + bundle: bundle.clone(), + operator: Some(operator.clone()), + } + } + _ => return Err("Unknown command. Run `iota --help`.".into()), + }; + Ok(Self { + theme_override, + command, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_global_theme_option() { + let invocation = + CliInvocation::parse(["--theme".into(), "binary".into(), "status".into()]).unwrap(); + assert_eq!(invocation.theme_override, Some(ThemeName::Binary)); + assert_eq!(invocation.command, Command::Status); + } + + #[test] + fn reports_supported_names_for_invalid_theme() { + let error = CliInvocation::parse(["--theme=ultraviolet".into()]).unwrap_err(); + assert!(error.contains(ThemeName::supported_names())); + } + + #[test] + fn parses_install_operator_without_raw_slice_matching() { + let invocation = CliInvocation::parse([ + "daemon".into(), + "install".into(), + "--bundle".into(), + "release.zip".into(), + "--operator".into(), + "alice".into(), + ]) + .unwrap(); + assert_eq!( + invocation.command, + Command::Install { + bundle: "release.zip".into(), + operator: Some("alice".into()), + } + ); + } + + #[test] + fn parses_unconfirmed_destructive_commands_explicitly() { + let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); + assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); + } +} diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs new file mode 100644 index 0000000..c88f875 --- /dev/null +++ b/iota/src/daemon_setup_flow.rs @@ -0,0 +1,241 @@ +use crate::startup_error::StartupError; +use iota_cli::{ + ipc_client::IpcClient, + screens::daemon_setup::{ + DaemonLaunchMode, DaemonSetupDecision, DaemonSetupScreen, DaemonStartingScreen, + LaunchOption, + }, + theme::UiConfig, + ui::UI, +}; +use iota_process_manager::ProcessManager; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::sync::oneshot; + +pub struct Capabilities { + pub executable: Result, + pub socket: Result<(), StartupError>, + pub system: Result, StartupError>, +} +pub struct DaemonEndpoints { + pub local: PathBuf, + pub system: PathBuf, +} +pub struct ConnectionContext { + pub ipc: Arc, +} +impl Capabilities { + fn options(&self) -> Vec { + let once = self + .executable + .as_ref() + .and_then(|_| self.socket.as_ref()) + .map(|_| ()) + .map_err(ToString::to_string); + let ui = once.clone(); + let system = self + .system + .as_ref() + .map(|_| ()) + .map_err(ToString::to_string); + vec![ + LaunchOption { + mode: DaemonLaunchMode::Once, + enabled: once.is_ok(), + reason: once.err(), + }, + LaunchOption { + mode: DaemonLaunchMode::WithUi, + enabled: ui.is_ok(), + reason: ui.err(), + }, + LaunchOption { + mode: DaemonLaunchMode::WithSystem, + enabled: system.is_ok(), + reason: system.err(), + }, + ] + } +} +pub async fn run( + ui: Arc, + endpoints: &DaemonEndpoints, + caps: Capabilities, +) -> Result { + // Try connecting to an already-running daemon before starting a new one. + if let Ok(ipc) = IpcClient::connect(&endpoints.local).await { + return Ok(ConnectionContext { ipc }); + } + let options = caps.options(); + if !options.iter().any(|o| o.enabled) { + let _ = show( + ui, + options, + "Daemon cannot be started. Correct the reported problem, then Retry, or Exit.", + ) + .await?; + return Err(StartupError::Cancelled); + } + if UiConfig::load() + .map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi) + .unwrap_or(false) + && options[0].enabled + { + if let Ok(context) = start_local_with_ui( + ui.clone(), + caps.executable.as_ref().unwrap(), + &endpoints.local, + ) + .await + { + return Ok(context); + } + if ui.is_shutdown() { + return Err(StartupError::Cancelled); + } + } + loop { + let decision = show( + ui.clone(), + options.clone(), + "The daemon is not running. Choose how to start it.", + ) + .await?; + let DaemonSetupDecision::Start(mode) = decision else { + return Err(StartupError::Cancelled); + }; + ui.set_root_screen(Box::new(DaemonStartingScreen)).await; + let result = match mode { + DaemonLaunchMode::Once | DaemonLaunchMode::WithUi => { + start_local_with_ui( + ui.clone(), + caps.executable.as_ref().unwrap(), + &endpoints.local, + ) + .await + } + DaemonLaunchMode::WithSystem => { + let manager = caps.system.as_ref().unwrap(); + tokio::select! { + result = manager.set_iota_startup_mode(iota_process_manager::StartupMode::SocketActivated) => match result { + Ok(_) => tokio::select! { + result = IpcClient::connect_or_activate(&endpoints.system) => result.map(|ipc| ConnectionContext { ipc }).map_err(|e| StartupError::Other(e.to_string())), + _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), + }, + Err(e) => Err(map_process_manager_error(e)), + }, + _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), + } + } + }; + if ui.is_shutdown() { + return Err(StartupError::Cancelled); + } + match result { + Ok(ipc) => { + if mode == DaemonLaunchMode::WithUi { + let mut cfg = + UiConfig::load().map_err(|error| StartupError::Other(error.to_string()))?; + cfg.daemon_start_policy = iota_cli::theme::DaemonStartPolicy::WithUi; + cfg.save() + .map_err(|error| StartupError::Other(error.to_string()))?; + } + return Ok(ipc); + } + Err(error) => { + let retry = show( + ui.clone(), + options.clone(), + format!("Daemon startup failed: {error}. Select an option to retry, or Exit."), + ) + .await?; + if matches!(retry, DaemonSetupDecision::Exit) { + return Err(StartupError::Cancelled); + } + } + } + } +} + +fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { + use iota_process_manager::ProcessManagerErrorKind; + match error.kind() { + ProcessManagerErrorKind::PermissionDenied => { + StartupError::SystemPermissionDenied(error.to_string()) + } + ProcessManagerErrorKind::TimedOut => StartupError::SystemCommandTimedOut(error.to_string()), + _ => StartupError::Other(error.to_string()), + } +} +async fn start_local_with_ui( + ui: Arc, + exe: &Path, + path: &Path, +) -> Result { + tokio::select! { + result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }), + _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), + } +} +async fn show( + ui: Arc, + options: Vec, + message: impl Into, +) -> Result { + let (tx, rx) = oneshot::channel(); + let screen = DaemonSetupScreen::new(options, message, tx).map_err(|error| { + StartupError::Other(format!("Cannot construct daemon setup screen: {error:?}")) + })?; + ui.set_root_screen(Box::new(screen)).await; + tokio::select! { + decision = rx => Ok(decision.unwrap_or(DaemonSetupDecision::Exit)), + _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_systemd_unit_disables_only_the_system_option() { + let capabilities = Capabilities { + executable: Ok(PathBuf::from("iota-daemon")), + socket: Ok(()), + system: Err(StartupError::Other( + "systemd unit iota-daemon.service was not found".into(), + )), + }; + let options = capabilities.options(); + assert!( + options + .iter() + .any(|option| option.mode == DaemonLaunchMode::Once && option.enabled) + ); + let system = options + .iter() + .find(|option| option.mode == DaemonLaunchMode::WithSystem) + .unwrap(); + assert!(!system.enabled); + assert!(system.reason.as_deref().unwrap().contains("was not found")); + } + + #[test] + fn system_only_capabilities_do_not_select_disabled_local_mode() { + let capabilities = Capabilities { + executable: Err(StartupError::DaemonExecutableMissing(PathBuf::from( + "iota-daemon", + ))), + socket: Err(StartupError::LocalSocketNotWritable( + PathBuf::from("/tmp/iota.sock"), + std::io::Error::other("unavailable"), + )), + system: Err(StartupError::Other("manager unavailable".into())), + }; + let options = capabilities.options(); + assert!(options.iter().all(|option| !option.enabled)); + } +} diff --git a/iota/src/local_daemon.rs b/iota/src/local_daemon.rs new file mode 100644 index 0000000..4af10ea --- /dev/null +++ b/iota/src/local_daemon.rs @@ -0,0 +1,120 @@ +use crate::startup_error::StartupError; +use iota_cli::{ipc_client::IpcClient, ui::UI}; +use std::process::Stdio; +use std::{ + collections::VecDeque, + path::Path, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, Command}, + time::Instant, +}; + +struct LocalDaemonGuard { + child: Option, + committed: bool, +} +impl LocalDaemonGuard { + fn new(child: Child) -> Self { + Self { + child: Some(child), + committed: false, + } + } + fn commit(mut self) -> Child { + self.committed = true; + self.child.take().expect("local daemon child") + } +} +impl Drop for LocalDaemonGuard { + fn drop(&mut self) { + if !self.committed { + if let Some(mut child) = self.child.take() { + let _ = child.start_kill(); + tokio::spawn(async move { + let _ = child.wait().await; + }); + } + } + } +} + +pub async fn launch( + ui: Arc, + executable: &Path, + socket: &Path, +) -> Result, StartupError> { + let mut child = Command::new(executable) + .env("IOTA_SOCKET", socket) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(false) + .spawn() + .map_err(|e| StartupError::DaemonExited { + message: format!("Could not start daemon: {e}"), + })?; + let diagnostics = Arc::new(Mutex::new(VecDeque::::with_capacity(64))); + if let Some(stderr) = child.stderr.take() { + let diagnostics = diagnostics.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let mut recent = diagnostics.lock().unwrap(); + if recent.len() == 64 { + recent.pop_front(); + } + recent.push_back(line); + } + }); + } + let mut guard = LocalDaemonGuard::new(child); + let deadline = Instant::now() + Duration::from_secs(20); + let cancellation = ui.cancellation_token(); + loop { + let result = tokio::select! { + status = guard.child.as_mut().expect("child").wait() => { + let status = match status { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}") }; + return Err(StartupError::DaemonExited { message: format_diagnostic(format!("daemon exited with {status}"), &diagnostics) }); + } + connection = IpcClient::connect(socket) => connection, + _ = cancellation.cancelled() => return Err(StartupError::Cancelled), + _ = tokio::time::sleep_until(deadline) => return Err(StartupError::DaemonExited { message: format_diagnostic("timed out waiting for IPC handshake".into(), &diagnostics) }), + }; + match result { + Ok(client) => { + // The daemon was launched with kill_on_drop(false) so it + // survives after we release the child handle. Let it run + // independently; future CLI instances reconnect via IPC. + let _child = guard.commit(); + return Ok(client); + } + Err(_error) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(200)).await + } + Err(error) => { + return Err(StartupError::DaemonExited { + message: format_diagnostic( + format!("timed out waiting for IPC handshake: {error}"), + &diagnostics, + ), + }); + } + } + } +} + +fn format_diagnostic(message: String, diagnostics: &Arc>>) -> String { + let lines = diagnostics.lock().unwrap(); + if lines.is_empty() { + message + } else { + format!( + "{message}; daemon stderr: {}", + lines.iter().cloned().collect::>().join(" | ") + ) + } +} diff --git a/iota/src/main.rs b/iota/src/main.rs index e2e8882..e71c2d5 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -1,31 +1,292 @@ -use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui}; -use std::path::PathBuf; +use iota_cli::{ + ipc_client::IpcClient, + screens::main_screen::MainScreen, + theme, + ui::start_bootstrap_tui_with_theme, +}; +use iota_ipc::{LocalRequest, ResponseResult}; +use iota_process_manager::detect; +use std::{path::Path, process::ExitCode, sync::Arc}; -fn socket_path() -> PathBuf { - std::env::var_os("IOTA_SOCKET") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock")) -} +mod cli_args; +mod daemon_setup_flow; +mod local_daemon; +mod startup_error; + +use cli_args::{CliInvocation, Command}; +use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] -async fn main() { - let path = socket_path(); - let ipc = match IpcClient::connect_or_activate(&path).await { - Ok(client) => client, +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, Err(error) => { - eprintln!( - "Cannot connect to iota-daemon at {}: {error}", - path.display() - ); - eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running."); - std::process::exit(1); + if !matches!(error, StartupError::Cancelled) { + eprintln!("{error}"); + } + startup_error::exit_code(&error) } + } +} + +async fn run() -> Result<(), StartupError> { + let invocation = + CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; + let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter(); + let local_endpoint = endpoints_iter + .next() + .expect("path layer always returns an endpoint"); + let system_endpoint = endpoints_iter + .next() + .unwrap_or_else(|| local_endpoint.clone()); + let endpoints = daemon_setup_flow::DaemonEndpoints { + local: local_endpoint, + system: system_endpoint, }; - ipc.spawn_reconnector(); - let ui = start_tui(ipc); - ui.set_screen(Box::new(MainScreen::new(ui.clone()).await)) - .await; - while !ui.is_shutdown() { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + match invocation.command { + Command::Help => { + print_help(); + Ok(()) + } + Command::Install { bundle, operator } => { + iota_installer::install_linux_bundle_with_operator( + Path::new(&bundle), + operator.as_deref(), + ) + .map_err(|error| StartupError::Other(format!("Installation failed: {error}"))) + } + command => { + if matches!( + command, + Command::DaemonEnable { .. } | Command::DaemonDisableStartup + ) { + return run_startup_command(command).await; + } + if !matches!(command, Command::Dashboard) { + match iota_core::consent_state::non_interactive_consent() { + iota_core::consent_state::NonInteractiveConsent::Accepted => {} + iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => { + return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into())); + } + } + let ipc = tokio::select! { + result = connect_available(&endpoints) => result?, + _ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled), + }; + return run_command(ipc, command).await; + } + run_dashboard(invocation.theme_override, endpoints).await + } + } +} + +async fn run_startup_command(command: Command) -> Result<(), StartupError> { + let manager = iota_process_manager::detect() + .await + .ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?; + let status = match command { + Command::DaemonEnable { mode } => { + let mode = match mode.as_str() { + "socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated, + "always-on" => iota_process_manager::StartupMode::AlwaysOn, + _ => { + return Err(StartupError::InvalidCommand( + "--mode must be socket or always-on".into(), + )); + } + }; + manager + .enable_startup(mode) + .await + .map_err(|e| StartupError::Other(e.to_string()))? + } + Command::DaemonDisableStartup => manager + .disable_startup() + .await + .map_err(|e| StartupError::Other(e.to_string()))?, + _ => unreachable!(), + }; + println!("deployment status: {:?}", status.detected); + Ok(()) +} + +async fn connect_available( + endpoints: &daemon_setup_flow::DaemonEndpoints, +) -> Result, StartupError> { + match IpcClient::connect(&endpoints.local).await { + Ok(client) => Ok(client), + Err(local_error) => IpcClient::connect(&endpoints.system) + .await + .map_err(|system_error| { + if system_error.kind() == std::io::ErrorKind::TimedOut { + StartupError::IpcTimedOut(endpoints.system.clone()) + } else if local_error.kind() == std::io::ErrorKind::PermissionDenied { + StartupError::SocketPermissionDenied(endpoints.local.clone()) + } else { + StartupError::Other(format!( + "Could not connect to {} or {}: {local_error}; {system_error}", + endpoints.local.display(), + endpoints.system.display() + )) + } + }), + } +} + +async fn run_dashboard( + theme_override: Option, + endpoints: daemon_setup_flow::DaemonEndpoints, +) -> Result<(), StartupError> { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(StartupError::Terminal( + "stdin and stdout must be interactive terminals".into(), + )); + } + if std::env::var("TERM").as_deref() == Ok("dumb") { + return Err(StartupError::Terminal( + "TERM=dumb does not support the interactive dashboard".into(), + )); + } + let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme( + theme_override, + ))) + .map_err(|error| StartupError::Terminal(error.to_string()))?; + let ui = session.ui(); + let result = async { + let consent = iota_core::consent_state::check(ui.clone()).await + .map_err(StartupError::Consent)?; + if consent != (true, true) { + return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); + } + let initial = tokio::select! { + result = connect_available(&endpoints) => result, + _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), + }; + let context = match initial { + Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client }, + Err(_) => { + let system = tokio::select! { + manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable), + _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), + }?; + // A missing unit is expected before the system daemon has + // been installed. Keep bootstrap alive and expose that state + // as a disabled setup option instead of treating it as a + // fatal startup error. + let system_capability = tokio::select! { + status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error), + _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), + }; + let caps = daemon_setup_flow::Capabilities { + executable: daemon_executable(), + socket: writable_socket_path(&endpoints.local), + system: system_capability, + }; + daemon_setup_flow::run(ui.clone(), &endpoints, caps).await? + } + }; + let ipc = context.ipc.clone(); + ipc.spawn_reconnector(); + ui.attach_daemon(ipc).await; + let main_screen = MainScreen::new(ui.clone()).await; + ui.set_root_screen(Box::new(main_screen)).await; + ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?; + ui.wait_for_shutdown().await; + Ok(()) + }.await; + let render_failure = session.shutdown().await; + // Terminal restoration comes first; then stop IPC background tasks with + // their own bounded shutdown so a lost daemon cannot retain the process. + if let Some(ipc) = ui.ipc().await { + ipc.shutdown().await; + } + + match (result, render_failure) { + (Err(error), _) => Err(error), + (Ok(()), Some(error)) => Err(StartupError::Terminal(error)), + (Ok(()), None) => Ok(()), + } +} + +fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { + use iota_process_manager::ProcessManagerErrorKind::*; + match error.kind() { + PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()), + TimedOut => StartupError::SystemCommandTimedOut(error.to_string()), + _ => StartupError::Other(error.to_string()), + } +} + +fn daemon_executable() -> Result { + let candidate = iota_paths::daemon_executable(); + if candidate.is_file() { + Ok(candidate) + } else { + Err(StartupError::DaemonExecutableMissing(candidate)) + } +} + +fn writable_socket_path(path: &Path) -> Result<(), StartupError> { + let parent = path.parent().ok_or_else(|| { + StartupError::LocalSocketNotWritable( + path.to_path_buf(), + std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"), + ) + })?; + std::fs::create_dir_all(parent) + .map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?; + let probe = parent.join(format!(".iota-write-probe-{}", std::process::id())); + std::fs::File::create(&probe) + .map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?; + let _ = std::fs::remove_file(probe); + Ok(()) +} + +fn print_help() { + println!( + "Iota operator console\n\nUsage:\n iota [--theme ] Open the dashboard\n iota daemon install --bundle [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms." + ); +} + +async fn run_command(ipc: Arc, command: Command) -> Result<(), StartupError> { + let request = match command { + Command::Status => LocalRequest::GetStatus, + Command::Tasks => LocalRequest::ListTasks, + Command::UsersList => LocalRequest::ListUsers, + Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Restart, + }, + Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + }, + Command::DaemonStopProcess => LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + }, + Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus, + Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => { + return Err(StartupError::InvalidCommand( + "Refusing destructive command without --yes.".into(), + )); + } + _ => { + return Err(StartupError::InvalidCommand( + "Command cannot be run headlessly.".into(), + )); + } + }; + match ipc + .send_request(request) + .await + .map_err(|e| StartupError::Other(e.to_string()))? + { + ResponseResult::Ok(message) => { + println!("{message}"); + Ok(()) + } + ResponseResult::Error(code) => Err(StartupError::Other(format!( + "Daemon request failed: {code:?}" + ))), } } diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs new file mode 100644 index 0000000..461e272 --- /dev/null +++ b/iota/src/startup_error.rs @@ -0,0 +1,92 @@ +use std::{fmt, io, path::PathBuf, process::ExitCode}; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum StartupError { + Cancelled, + DaemonExecutableMissing(PathBuf), + LocalSocketNotWritable(PathBuf, io::Error), + SystemManagerUnavailable, + SystemPermissionDenied(String), + SystemCommandTimedOut(String), + SocketPermissionDenied(PathBuf), + IpcTimedOut(PathBuf), + ProtocolMismatch { daemon: u16, minimum: u16 }, + DaemonExited { message: String }, + IpcBindUnavailable(String), + Terminal(String), + Consent(String), + InvalidCommand(String), + Other(String), +} + +impl StartupError { + pub fn exit_code(&self) -> u8 { + match self { + Self::Cancelled => 130, + Self::InvalidCommand(_) => 2, + _ => 1, + } + } +} +impl fmt::Display for StartupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled => f.write_str("Cancelled."), + Self::DaemonExecutableMissing(path) => write!( + f, + "Daemon executable is missing or not executable: {}", + path.display() + ), + Self::LocalSocketNotWritable(path, error) => write!( + f, + "Local socket path is not writable ({}): {error}", + path.display() + ), + Self::SystemManagerUnavailable => { + f.write_str("No supported system process manager is available.") + } + Self::SystemPermissionDenied(message) => { + write!(f, "System-level authorization is required: {message}") + } + Self::SystemCommandTimedOut(command) => { + write!(f, "System command timed out: {command}") + } + Self::SocketPermissionDenied(path) => { + write!(f, "Permission denied for IPC socket {}", path.display()) + } + Self::IpcTimedOut(path) => write!(f, "IPC operation timed out for {}", path.display()), + Self::ProtocolMismatch { daemon, minimum } => write!( + f, + "Daemon protocol {daemon} is incompatible; minimum supported version is {minimum}" + ), + Self::DaemonExited { message } => f.write_str(message), + Self::IpcBindUnavailable(message) => { + write!(f, "Daemon IPC listener is unavailable: {message}") + } + Self::Terminal(message) => write!(f, "Interactive terminal is unavailable: {message}"), + Self::Consent(message) | Self::InvalidCommand(message) | Self::Other(message) => { + f.write_str(message) + } + } + } +} +pub fn exit_code(error: &StartupError) -> ExitCode { + ExitCode::from(error.exit_code()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cancellation_and_invalid_commands_have_stable_codes() { + assert_eq!(StartupError::Cancelled.exit_code(), 130); + assert_eq!(StartupError::InvalidCommand("bad".into()).exit_code(), 2); + } + #[test] + fn administrative_errors_are_actionable() { + let error = StartupError::SystemPermissionDenied("run as an administrator".into()); + assert!(error.to_string().contains("authorization")); + assert!(error.to_string().contains("administrator")); + } +} diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 7526c63..c869941 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs new file mode 100644 index 0000000..872ff67 --- /dev/null +++ b/omikron-connector/src/client.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use mtp::codec::CommunicationValue; +use std::time::Duration; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OmikronError { + Disconnected(String), + Timeout(String), + Authentication(String), + Internal(String), +} + +impl std::fmt::Display for OmikronError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disconnected(v) + | Self::Timeout(v) + | Self::Authentication(v) + | Self::Internal(v) => f.write_str(v), + } + } +} +impl std::error::Error for OmikronError {} + +pub enum OmikronStartupError { + Construction(String), + InitialConnectionTimeout { + connection: std::sync::Arc, + }, + Authentication, +} + +#[async_trait] +pub trait OmikronClient: Send + Sync { + async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError>; + async fn await_response( + &self, + value: &CommunicationValue, + timeout: Duration, + ) -> Result; + async fn reconnect(&self) -> Result<(), OmikronError>; + async fn is_connected(&self) -> bool; +} diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index 22029a6..cce748b 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -1,4 +1,8 @@ +pub mod client; pub mod omega_discovery; pub mod omikron_connection; pub mod ping_pong_task; pub mod user_ops; + +pub use client::{OmikronClient, OmikronError, OmikronStartupError}; +pub use omikron_connection::OmikronConnection; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 45257c2..54038da 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,6 +1,6 @@ -use dashmap::DashMap; +use dashmap::{DashMap, DashSet}; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; -use iota_state::ACTIVE_TASKS; +use iota_state::AppState; use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; @@ -19,6 +19,7 @@ use tokio::time::sleep; use tokio_util::sync::CancellationToken; use uuid::Uuid; +use crate::client::{OmikronClient, OmikronError}; use crate::omega_discovery; use iota_connection::message_common::*; @@ -106,7 +107,7 @@ const MAX_CONCURRENT_HANDLERS: usize = 20; // ============================================================================ pub struct WaitingTask { - pub task: Box, CommunicationValue) -> bool + Send + Sync>, + pub task: Box bool + Send + Sync>, pub inserted_at: Instant, } @@ -163,14 +164,20 @@ pub struct OmikronConnection { pub(crate) missed_pongs: Arc, handler_semaphore: Arc, cancellation: CancellationToken, + pub(crate) active_tasks: Arc>, + pub(crate) app: Arc>, } impl OmikronConnection { - pub fn new() -> Self { - Self::with_cancellation(CancellationToken::new()) + pub fn new(active_tasks: Arc>, app: Arc>) -> Self { + Self::with_cancellation(CancellationToken::new(), active_tasks, app) } - pub fn with_cancellation(cancellation: CancellationToken) -> Self { + pub fn with_cancellation( + cancellation: CancellationToken, + active_tasks: Arc>, + app: Arc>, + ) -> Self { let (shutdown_tx, _) = watch::channel(false); let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected); @@ -190,6 +197,8 @@ impl OmikronConnection { missed_pongs: Arc::new(AtomicU32::new(0)), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), cancellation, + active_tasks, + app, } } @@ -388,7 +397,7 @@ impl OmikronConnection { *self.heartbeat_handle.lock().await = Some(heartbeat_handle); { - ACTIVE_TASKS.insert("Omikron Listener".to_string()); + self.active_tasks.insert("Omikron Listener".to_string()); } // Wait for read loop to complete @@ -396,7 +405,7 @@ impl OmikronConnection { *self.sender.write().await = None; self.set_state(ConnectionState::Disconnected).await; { - ACTIVE_TASKS.remove("Omikron Listener"); + self.active_tasks.remove("Omikron Listener"); } if let Some(handle) = self.heartbeat_handle.lock().await.take() { @@ -570,7 +579,7 @@ impl OmikronConnection { Ok(cv) => { let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { - if (task.task)(self.clone(), cv.clone()) { + if (task.task)(cv.clone()) { continue; } } @@ -832,7 +841,7 @@ impl OmikronConnection { let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { - if (task.task)(self.clone(), cv.clone()) { + if (task.task)(cv.clone()) { return; } } @@ -1797,7 +1806,7 @@ impl OmikronConnection { let response = CommunicationValue::new(CommunicationType::ErrorInternal) .with_id(key) .add_typed_default(DataType::Message, DataValue::Str(reason.clone())); - let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); + let _ = (waiting_task.task)(response); } } } @@ -1821,7 +1830,7 @@ impl OmikronConnection { WAITING_TASKS.insert( msg_id, WaitingTask { - task: Box::new(move |_, response_cv| { + task: Box::new(move |response_cv| { let _ = tx.send(response_cv); true }), @@ -1932,20 +1941,26 @@ impl OmikronConnection { // Global Instance // ============================================================================ -pub static OMIKRON_CONNECTION: LazyLock> = LazyLock::new(|| { - let conn = Arc::new(OmikronConnection::new()); - - start_task_cleanup_loop(); - - conn -}); - -pub async fn get_omikron_connection( +pub async fn connect_initial( cancellation: CancellationToken, -) -> Option> { - let conn = Arc::new(OmikronConnection::with_cancellation(cancellation)); + active_tasks: Arc>, + app: Arc>, +) -> Result, crate::client::OmikronStartupError> { + let conn = Arc::new(OmikronConnection::with_cancellation( + cancellation, + active_tasks, + app, + )); conn.connect().await; - Some(conn) + match conn.await_connection(Some(CONNECTION_TIMEOUT)).await { + Ok(()) => Ok(conn), + Err(_) if conn.has_auth_failure().await => { + Err(crate::client::OmikronStartupError::Authentication) + } + Err(_) => { + Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn }) + } + } } impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { @@ -1973,3 +1988,56 @@ impl iota_connection::connection_handler::ConnectionHandler for OmikronConnectio OmikronConnection::stop(self).await } } + +#[async_trait::async_trait] +impl OmikronClient for OmikronConnection { + async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError> { + Self::send_message(self, value) + .await + .map_err(OmikronError::Disconnected) + } + + async fn await_response( + &self, + value: &CommunicationValue, + timeout: Duration, + ) -> Result { + Self::await_response(self, value, Some(timeout)) + .await + .map_err(|error| { + if error.contains("timed out") { + OmikronError::Timeout(error) + } else { + OmikronError::Disconnected(error) + } + }) + } + + async fn reconnect(&self) -> Result<(), OmikronError> { + let this = Arc::new(Self { + state: self.state.clone(), + state_watch_tx: self.state_watch_tx.clone(), + sender: self.sender.clone(), + connection_loop_handle: self.connection_loop_handle.clone(), + last_ping: self.last_ping.clone(), + heartbeat_handle: self.heartbeat_handle.clone(), + connection_id: self.connection_id, + shutdown_tx: self.shutdown_tx.clone(), + reconnect_on_close: self.reconnect_on_close.clone(), + auth_failure: self.auth_failure.clone(), + app_challenges: self.app_challenges.clone(), + app_sessions: self.app_sessions.clone(), + missed_pongs: self.missed_pongs.clone(), + handler_semaphore: self.handler_semaphore.clone(), + cancellation: self.cancellation.clone(), + active_tasks: self.active_tasks.clone(), + app: self.app.clone(), + }); + Self::reconnect(&this).await; + Ok(()) + } + + async fn is_connected(&self) -> bool { + Self::is_connected(self).await + } +} diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index 2155b6c..d55cec7 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -1,9 +1,8 @@ use crate::omikron_connection::OmikronConnection; use dashmap::DashMap; -use iota_state::APP_STATE; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use std::sync::atomic::Ordering; use std::sync::LazyLock; +use std::sync::atomic::Ordering; use std::time::Instant; use tokio::time::Duration; @@ -23,7 +22,9 @@ impl OmikronConnection { .with_id(id) .add_typed_default( DataType::LastPing, - DataValue::Array(vec![DataValue::SignedNumber(*self.last_ping.lock().await as i128)]), + DataValue::Array(vec![DataValue::SignedNumber( + *self.last_ping.lock().await as i128, + )]), ); let _ = self.send_message(&ping_message).await; @@ -37,7 +38,7 @@ impl OmikronConnection { if let Some((_, send_time)) = PING_TIMES.remove(&id) { let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64; *self.last_ping.lock().await = ping_ms; - APP_STATE.lock().unwrap().push_ping_val(ping_ms as f64); + self.app.lock().unwrap().push_ping_val(ping_ms as f64); } } } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 6d52dd5..00733e1 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -8,21 +8,22 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; use std::time::Duration; +use crate::OmikronClient; use crate::omega_discovery; -use crate::omikron_connection::OMIKRON_CONNECTION; -pub async fn create_user(username: &str) -> (Option, Option) { +pub async fn create_user( + connection: &dyn OmikronClient, + username: &str, +) -> (Option, Option) { let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); - let connection = OMIKRON_CONNECTION.clone(); - let response_communication_value = match connection - .await_response(®ister_communication_value, Some(Duration::from_secs(20))) + .await_response(®ister_communication_value, Duration::from_secs(20)) .await { Ok(communication_value) => communication_value, Err(e) => { - log_t!("User creation: {}", e); + log_t!("User creation: {}", e.to_string()); return (None, None); } }; @@ -68,7 +69,7 @@ pub async fn create_user(username: &str) -> (Option, Option .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)); let response_communication_value = connection - .await_response(&communication_value, Some(Duration::from_secs(20))) + .await_response(&communication_value, Duration::from_secs(20)) .await; if let Ok(response) = response_communication_value { @@ -84,13 +85,15 @@ pub async fn create_user(username: &str) -> (Option, Option save_file( "", &format!("{}.tu", username), - &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), + &format!( + "{}@{}::{}", + user_id, + omega_discovery::omega_host(), + keyring_b64 + ), ); add_user(user_profile.clone()); save_users(); - ( - Some(user_profile), - Some(keyring_b64), - ) + (Some(user_profile), Some(keyring_b64)) } diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 9f676a4c84f34edbb631ab7a0ca0f57d1c55d095..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8zj35RBCIAV8Fop~hR0Kpbg3L%NFD^*R$xmWnVAu|o z8|)Ow?JNwX3?&Si3^~Z|Pb$dCEG{uHxW>rD%)-jX&cV*X%@G@%kzXEMl2}q&?37p( z4dR95=jSBB*ojGDnW^RR0wT`&c_oRNd8tJpCBc~~sY!`NG2xkcDf#72`K5U&#bCWq z2@XyU&UgXw>S{|f6CDK$%UT_UYD*If9R*8MquN?d4pC)&>!A4ToZP(pPDpq%GD2tu zUMLNtx)>N3;NB?)1ECdl9B}YSGGz3OO2r#m^1iLtaDoq^T1=Y2n`ZNJ5 w532nk)iJ1|Mr{NzK?W2hph`j2JxD8v23N(543L_9v>^Zsp;3A?1n3_E0OX}gUH||9 diff --git a/src/util/auto_update.rs b/src/util/auto_update.rs deleted file mode 100644 index 090c5a8..0000000 --- a/src/util/auto_update.rs +++ /dev/null @@ -1,135 +0,0 @@ -/* This file is used for the auto update function for the Iota. - * It connects to the git server from methanium and checks if - * the version has updated inside the cargo.toml file.*/ - -use anyhow::{Context, Result, anyhow}; -use semver::Version; -use serde::Deserialize; -use std::fs::File; -use std::io::copy; -use tempfile::NamedTempFile; - -use crate::log; // For logging messages into the iota - -const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); - -const API_BASE: &str = "https://git.methanium.net/api/v1"; -const OWNER: &str = "Tensamin"; -const REPO: &str = "Iota"; - -#[derive(Debug, Deserialize)] -struct Release { - tag_name: String, - assets: Vec, -} - -#[derive(Debug, Deserialize)] -struct Asset { - name: String, - browser_download_url: String, -} - -async fn latest_release() -> Result { - let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest"); - - let response = reqwest::get(&url) - .await - .context("failed to query latest release.")?; - - if !response.status().is_success() { - return Err(anyhow!("release API returned {}", response.status())); - } - - Ok(response - .json() - .await - .context("failed to parse release JSON")?) -} - -async fn parse_tag_version(tag: &str) -> Result { - let normalized = tag.strip_prefix('v').unwrap_or(tag); - Ok(Version::parse(normalized)?) -} - -async fn current_version() -> Result { - Ok(Version::parse(CURRENT_VERSION)?) -} - -async fn asset_name_for_current_platform() -> String { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - match (os, arch) { - ("linux", "x86_64") => "iota-linux-x86_64".to_string(), - ("linux", "aarch64") => "iota-linux-aarch64".to_string(), - ("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(), - ("macos", "x86_64") => "iota-macos-x86_64".to_string(), - ("macos", "aarch64") => "iota-macos-aarch64".to_string(), - _ => panic!("unsupported platform: {os}/{arch}"), - } -} - -async fn download_asset(url: &str) -> Result { - let mut response = reqwest::get(url) - .await - .context("failed to download asset")?; - - if !response.status().is_success() { - return Err(anyhow!("asset download returned {}", response.status())); - } - - let tmp = NamedTempFile::new().context("failed to create temp file")?; - let mut out = File::create(tmp.path()).context("failed to open temp file")?; - - let bytes = response - .bytes() - .await - .context("failed to read response bytes")?; - - std::fs::write(tmp.path(), &bytes).context("failed to write file")?; - - Ok(tmp) -} - -async fn check_for_update() -> Result> { - let current = current_version().await?; - let release = latest_release().await?; - let latest = parse_tag_version(&release.tag_name).await?; - - if latest > current { - Ok(Some(release)) - } else { - Ok(None) - } -} - -async fn perform_update() -> Result { - let Some(release) = check_for_update().await? else { - return Ok(false); - }; - - let wanted_asset = asset_name_for_current_platform().await; - - let asset = release - .assets - .iter() - .find(|a| a.name == wanted_asset) - .ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?; - - log!("Downloading update: {}", asset.name); - - let downloaded = download_asset(&asset.browser_download_url).await?; - - self_replace::self_replace(downloaded.path()) - .context("failed to replace current executable")?; - - Ok(true) -} - -pub async fn check_update() -> Result { - if perform_update().await? { - return Ok(true); - } else { - return Ok(false); - } -} diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index b61e5e2..a5a2903 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -5,13 +5,18 @@ Wants=network-online.target Requires=iota-daemon.socket [Service] -Type=notify -ExecStart=/usr/bin/iota-daemon +Type=simple +ExecStart=/usr/local/lib/iota/iota-daemon +User=iota +Group=iota +StateDirectory=iota +StateDirectoryMode=0750 Restart=on-failure RestartSec=5s -RuntimeDirectory=iota -RuntimeDirectoryMode=0750 Environment=IOTA_SOCKET=/run/iota/iota.sock +Environment=IOTA_DATA_DIR=/var/lib/iota +Environment=IOTA_DEPLOYMENT_MODE=system_always_on +Environment=IOTA_SUPERVISOR=systemd # Exit code 75 = restart requested (daemon-specific convention) RestartPreventExitStatus=0 diff --git a/systemd/iota-daemon.socket b/systemd/iota-daemon.socket index b06a209..4030172 100644 --- a/systemd/iota-daemon.socket +++ b/systemd/iota-daemon.socket @@ -5,9 +5,12 @@ Description=Tensamin Iota daemon IPC socket ListenStream=/run/iota/iota.sock SocketMode=0660 SocketUser=iota -SocketGroup=iota +DirectoryMode=0755 +SocketGroup=iota-operators Backlog=5 RemoveOnStop=true +NonBlocking=true +# Enabling this socket starts the daemon on demand when a client connects. [Install] WantedBy=sockets.target diff --git a/systemd/sysusers.d/iota.conf b/systemd/sysusers.d/iota.conf new file mode 100644 index 0000000..5a103af --- /dev/null +++ b/systemd/sysusers.d/iota.conf @@ -0,0 +1,2 @@ +g iota-operators - +u iota - "Tensamin Iota daemon" /var/lib/iota diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index f5214ab..9cbabb2 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -1,55 +1,95 @@ use bytes::Bytes; use iota_logger::log; -use iota_util::file_util::load_file_vec; use mtp::host::HostConfig; -use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig}; -use std::net::{IpAddr, Ipv4Addr}; +use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig}; +use std::{net::IpAddr, path::PathBuf, sync::Arc}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -const CERT_PATH: &str = "certs/cert.pem"; -const KEY_PATH: &str = "certs/cert.key"; - -async fn root(_request: Http3Request, response: Http3Response) -> Http3Response { - static_file("index.html", response).await +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WebMode { + Disabled, + Loopback, + Network, } -async fn static_file(path: &str, response: Http3Response) -> Http3Response { +#[derive(Clone, Debug)] +pub struct TlsConfig { + pub certificate: PathBuf, + pub key: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct WebConfig { + pub mode: WebMode, + pub bind: IpAddr, + pub port: u16, + pub asset_dir: PathBuf, + pub tls: Option, + pub required: bool, +} + +#[derive(Debug)] +pub enum WebServerError { + Disabled, + MissingTls(String), + Io(String), + Startup(String), +} +impl std::fmt::Display for WebServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for WebServerError {} + +pub struct WebServerHandle { + cancellation: CancellationToken, + join: Mutex>>, +} +impl WebServerHandle { + pub async fn shutdown(&self) { + self.cancellation.cancel(); + self.join().await; + } + pub async fn join(&self) { + if let Some(join) = self.join.lock().await.take() { + let _ = join.await; + } + } +} + +async fn root(asset_dir: PathBuf, _request: HttpRequest, response: HttpResponse) -> HttpResponse { + static_file(asset_dir, "index.html".into(), response).await +} +async fn static_file(asset_dir: PathBuf, path: String, response: HttpResponse) -> HttpResponse { let file = path.trim_start_matches('/'); let file = if file.is_empty() { "index.html" } else { file }; - if file.split('/').any(|component| component == "..") { return response .status(http::StatusCode::BAD_REQUEST) .body("invalid path"); } - - let path = std::path::Path::new("web").join(file); - let Some(parent) = path.parent().and_then(|path| path.to_str()) else { - return response - .status(http::StatusCode::NOT_FOUND) - .body("not found"); + let path = asset_dir.join(file); + let body = match tokio::fs::read(&path).await { + Ok(body) => body, + Err(_) => { + return response + .status(http::StatusCode::NOT_FOUND) + .body("not found"); + } }; - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - return response - .status(http::StatusCode::NOT_FOUND) - .body("not found"); - }; - - match load_file_vec(parent, name) { - Ok(body) => response - .status(http::StatusCode::OK) - .header("content-type", content_type(name)) - .body(Bytes::from(body)), - Err(_) => response - .status(http::StatusCode::NOT_FOUND) - .body("not found"), - } + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or(""); + response + .status(http::StatusCode::OK) + .header("content-type", content_type(name)) + .body(Bytes::from(body)) } - fn content_type(name: &str) -> &'static str { match std::path::Path::new(name) .extension() - .and_then(|ext| ext.to_str()) + .and_then(|e| e.to_str()) { Some("html") => "text/html; charset=utf-8", Some("css") => "text/css; charset=utf-8", @@ -62,60 +102,53 @@ fn content_type(name: &str) -> &'static str { } } -pub async fn start(port: u16, cancellation: CancellationToken) -> bool { - let certificate = match tokio::fs::read(CERT_PATH).await { - Ok(certificate) => certificate, - Err(error) => { - log!("MTP web server certificate load failed: {}", error); - return false; - } - }; - let key = match tokio::fs::read(KEY_PATH).await { - Ok(key) => key, - Err(error) => { - log!("MTP web server key load failed: {}", error); - return false; - } - }; - - let host_config = HostConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port, certificate, key); - let web_config = match WebServerConfig::new().route("/", root).and_then(|config| { - config.fallback(|request, response| async move { - static_file(request.uri.path(), response).await +pub async fn start( + config: WebConfig, + parent: CancellationToken, +) -> Result>, WebServerError> { + if config.mode == WebMode::Disabled { + return Ok(None); + } + if config.mode == WebMode::Network && config.tls.is_none() { + return Err(WebServerError::MissingTls( + "network mode requires TLS".into(), + )); + } + let tls = config + .tls + .ok_or_else(|| WebServerError::MissingTls("certificate and key are required".into()))?; + let certificate = tokio::fs::read(&tls.certificate) + .await + .map_err(|e| WebServerError::Io(e.to_string()))?; + let key = tokio::fs::read(&tls.key) + .await + .map_err(|e| WebServerError::Io(e.to_string()))?; + let host_config = HostConfig::new(config.bind, config.port, certificate, key); + let assets = config.asset_dir.clone(); + let web_config = WebServerConfig::new() + .route("/", move |request, response| { + root(assets.clone(), request, response) }) - }) { - Ok(config) => config, - Err(error) => { - log!("MTP web server route setup failed: {}", error); - return false; - } - }; - - let mut server = match MTPWebServer::new(host_config, web_config).await { - Ok(server) => server, - Err(error) => { - log!("MTP web server startup failed: {}", error); - return false; - } - }; - - log!("MTP web server running on port {}", port); - tokio::spawn(async move { + .and_then(|web_config| { + let assets = config.asset_dir.clone(); + web_config.fallback(move |request, response| { + let path = request.uri.path().to_string(); + static_file(assets.clone(), path, response) + }) + }) + .map_err(|e| WebServerError::Startup(e.to_string()))?; + let mut server = MTPWebServer::new(host_config, web_config) + .await + .map_err(|e| WebServerError::Startup(e.to_string()))?; + let cancellation = parent.child_token(); + let task_cancellation = cancellation.clone(); + let join = tokio::spawn(async move { loop { - tokio::select! { - result = server.accept() => { - match result { - Ok(Some(_connection)) => {} - Ok(None) => break, - Err(error) => log!("MTP webserver connection failed: {}", error), - } - } - _ = cancellation.cancelled() => { - server.shutdown().await; - break; - } - } + tokio::select! { result = server.accept() => match result { Ok(Some(_)) => {}, Ok(None) => break, Err(error) => log!("MTP webserver connection failed: {}", error) }, _ = task_cancellation.cancelled() => { server.shutdown().await; break; } } } }); - true + Ok(Some(Arc::new(WebServerHandle { + cancellation, + join: Mutex::new(Some(join)), + }))) } diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 2114029..c5c3dab 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -1,8 +1,10 @@ use crate::server::is_local_network; use actix_web::{HttpRequest, HttpResponse, Responder, web}; -use iota_storage::util::config_util::{modify_config, CONFIG}; +use iota_state::DaemonState; +use iota_storage::util::config_util::{CONFIG, modify_config}; use serde_json::{Value, json}; use std::net::SocketAddr; +use std::sync::Arc; pub fn api_config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -148,31 +150,37 @@ async fn users_add( _ => return error(), }; - if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await { - let val = user.frontend().to_string(); - let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null); - HttpResponse::Ok().json(s_val) - } else { - error() - } + // The legacy web API is intentionally quarantined until it can use the + // daemon's authenticated command/service boundary. It must not create a + // second connector or mutate daemon storage directly. + let _ = username; + error() } -async fn shutdown(req: HttpRequest, ssl: web::Data) -> impl Responder { +async fn shutdown( + req: HttpRequest, + ssl: web::Data, + state: web::Data>, +) -> impl Responder { if !is_allowed_req(&req, *ssl.get_ref()) { return forbidden(); } - *iota_state::SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; success() } -async fn reload(req: HttpRequest, ssl: web::Data) -> impl Responder { +async fn reload( + req: HttpRequest, + ssl: web::Data, + state: web::Data>, +) -> impl Responder { if !is_allowed_req(&req, *ssl.get_ref()) { return forbidden(); } - *iota_state::SHUTDOWN.write().await = true; - *iota_state::RELOAD.write().await = true; + *state.shutdown.write().await = true; + *state.reload.write().await = true; success() } diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index 263256e..c73f0f8 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -2,7 +2,7 @@ use crate::api::api_config; use crate::web_path_parser; use actix_web::{App, HttpServer, dev::ServerHandle, web}; use iota_logger::log; -use iota_state::{ACTIVE_TASKS, SHUTDOWN}; +use iota_state::DaemonState; use iota_util::file_util::load_file_buf; use rustls::ServerConfig; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; @@ -16,20 +16,23 @@ use std::{ use tokio::sync::oneshot; -pub async fn start(port: u16) -> bool { +pub async fn start(port: u16, state: Arc) -> bool { let (tx, rx) = oneshot::channel::(); let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string()); let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS"); + let server_state = state.clone(); let _ = tokio::spawn(async move { let server = match load_tls_config() { Ok(Some(tls_config)) => { log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port); let _config = (*tls_config).clone(); + let app_state = server_state.clone(); HttpServer::new(move || { App::new() .app_data(web::Data::new(true)) + .app_data(web::Data::from(app_state.clone())) .configure(api_config) .default_service(web::to(web_path_parser::handle)) }) @@ -39,9 +42,11 @@ pub async fn start(port: u16) -> bool { } Ok(_) => { log!("HTTP Server running on {}:{}", bind_addr, port); + let app_state = server_state.clone(); HttpServer::new(move || { App::new() .app_data(web::Data::new(false)) + .app_data(web::Data::from(app_state.clone())) .configure(api_config) .default_service(web::to(web_path_parser::handle)) }) @@ -58,15 +63,15 @@ pub async fn start(port: u16) -> bool { let server_handle = server.handle(); tx.send(server_handle).unwrap(); - ACTIVE_TASKS.insert("WebServer".into()); + server_state.active_tasks.insert("WebServer".into()); server.await.unwrap(); - ACTIVE_TASKS.remove("WebServer"); + server_state.active_tasks.remove("WebServer"); log!("Web Server shutdown complete."); }); if let Ok(server_handle) = rx.await { tokio::spawn(async move { - wait_for_shutdown(server_handle).await; + wait_for_shutdown(server_handle, state).await; }); true } else { @@ -74,9 +79,9 @@ pub async fn start(port: u16) -> bool { } } -async fn wait_for_shutdown(server_handle: ServerHandle) { +async fn wait_for_shutdown(server_handle: ServerHandle, state: Arc) { loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { log!("Shutdown signal received."); server_handle.stop(true).await; break; From 3bc5cc959ad5acce31ea402364df2ba9791162cd Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Fri, 24 Jul 2026 01:36:14 +0200 Subject: [PATCH 085/119] [WIP] paths --- Cargo.lock | 5 + flake.nix | 55 +- iota-cli/src/ipc_client.rs | 3 +- iota-core/Cargo.toml | 1 + iota-core/src/main.rs | 11 +- iota-daemon-lib/src/ipc_server.rs | 18 +- iota-daemon/Cargo.toml | 1 + iota-daemon/src/main.rs | 94 +++- iota-installer/src/lib.rs | 14 +- iota-logger/Cargo.toml | 1 + iota-logger/src/lib.rs | 39 +- iota-paths/src/lib.rs | 569 ++++++++++++++++---- iota-storage/Cargo.toml | 1 + iota-storage/src/util/config_util.rs | 65 ++- iota-storage/src/util/db.rs | 11 +- iota-updater/Cargo.toml | 1 + iota-updater/src/transaction.rs | 16 +- iota-util/src/file_util.rs | 115 ++-- iota/src/main.rs | 33 +- omikron-connector/src/omikron_connection.rs | 35 +- 20 files changed, 832 insertions(+), 256 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10d0d16..c5d5a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,6 +2222,7 @@ dependencies = [ "dashmap", "iota-cli", "iota-logger", + "iota-paths", "iota-state", "iota-storage", "iota-terms", @@ -2250,6 +2251,7 @@ dependencies = [ "iota-paths", "iota-state", "iota-storage", + "iota-util", "omikron-connector", "tokio", "tokio-util", @@ -2301,6 +2303,7 @@ dependencies = [ name = "iota-logger" version = "0.1.0" dependencies = [ + "iota-paths", "iota-state", "iota-util", "json", @@ -2345,6 +2348,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "iota-logger", + "iota-paths", "iota-state", "iota-util", "json", @@ -2390,6 +2394,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "iota-logger", + "iota-paths", "json", "mtp", "once_cell", diff --git a/flake.nix b/flake.nix index 2e560e2..d7bf620 100644 --- a/flake.nix +++ b/flake.nix @@ -108,22 +108,26 @@ cfg = config.services.iota; defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + configFormat = pkgs.formats.yaml {}; configFile = if cfg.settingsFile != null then cfg.settingsFile - else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); + else configFormat.generate "iota-config.yaml" cfg.settings; descriptionText = "Tensamin Iota daemon"; in { options.services.iota = { enable = lib.mkEnableOption "Enable the Iota service."; - dataDir = lib.mkOption { + stateDir = lib.mkOption { type = lib.types.str; - default = cfg.package.passthru.dataDir or "/var/lib/iota"; - defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; - description = "Directory where Iota stores its data, config, and certificates."; + default = "/var/lib/iota"; + description = "Persistent mutable Iota state."; }; + cacheDir = lib.mkOption { type = lib.types.str; default = "/var/cache/iota"; }; + runtimeDir = lib.mkOption { type = lib.types.str; default = "/run/iota"; }; + logDir = lib.mkOption { type = lib.types.str; default = "/var/log/iota"; }; + assetDir = lib.mkOption { type = lib.types.str; default = "${cfg.package}/share/iota/web"; }; certFile = lib.mkOption { type = lib.types.nullOr lib.types.path; @@ -164,13 +168,13 @@ settings = lib.mkOption { type = lib.types.attrs; default = {}; - description = "Configuration attributes for Iota, written to config.json."; + description = "Configuration attributes for Iota, written to YAML."; }; settingsFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "Path to an existing config.json file to use instead of generating from settings."; + description = "Path to an existing YAML file to use instead of generating from settings."; }; }; @@ -178,7 +182,7 @@ users.users.iota = { isSystemUser = true; group = "iota"; - home = cfg.dataDir; + home = cfg.stateDir; createHome = true; description = "Iota service user"; shell = pkgs.bash; @@ -194,6 +198,7 @@ SocketMode = "0660"; SocketUser = "iota"; SocketGroup = "iota"; + DirectoryMode = "0750"; Backlog = 5; RemoveOnStop = "true"; NonBlocking = true; @@ -210,28 +215,18 @@ Type = "simple"; User = "iota"; Group = "iota"; - WorkingDirectory = cfg.dataDir; - ExecStart = "${cfg.package}/bin/iota-daemon"; - ExecStartPre = [ - ("+" - + pkgs.writeShellScript "iota-setup" '' - mkdir -p ${cfg.dataDir}/certs - - ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} - ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} - - install -m 644 ${configFile} ${cfg.dataDir}/config.json - - chown -R iota:iota ${cfg.dataDir} - '') - ]; - Restart = "on-failure"; RestartSec = "5s"; RuntimeDirectory = "iota"; RuntimeDirectoryMode = "0750"; + StateDirectory = "iota"; + StateDirectoryMode = "0750"; + CacheDirectory = "iota"; + CacheDirectoryMode = "0750"; + LogsDirectory = "iota"; + LogsDirectoryMode = "0750"; # Exit code 75 = restart requested RestartPreventExitStatus = "0"; @@ -248,7 +243,8 @@ ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; - ReadWritePaths = [cfg.dataDir]; + ReadWritePaths = [cfg.stateDir cfg.cacheDir cfg.runtimeDir cfg.logDir]; + ReadOnlyPaths = [configFile cfg.assetDir]; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; @@ -259,7 +255,14 @@ Environment = [ "BIND_ADDRESS=${cfg.bindAddress}" "IOTA_SOCKET=/run/iota/iota.sock" - "IOTA_DATA_DIR=${cfg.dataDir}" + "IOTA_CONFIG_FILE=${configFile}" + "IOTA_STATE_DIR=${cfg.stateDir}" + "IOTA_CACHE_DIR=${cfg.cacheDir}" + "IOTA_RUNTIME_DIR=${cfg.runtimeDir}" + "IOTA_LOG_DIR=${cfg.logDir}" + "IOTA_ASSET_DIR=${cfg.assetDir}" + "IOTA_DEPLOYMENT_MODE=system_socket_activated" + "IOTA_SUPERVISOR=systemd" ]; } // lib.optionalAttrs (cfg.environmentFiles != []) { diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 186d756..88a1eba 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -387,8 +387,7 @@ impl IpcClient { }; match result { Ok(message) => self.apply(message).await, - Err(error) => { - eprintln!("IPC reader for generation {generation} stopped: {error}"); + Err(_) => { self.mark_disconnected(generation).await; break; } diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 763b231..03250d9 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -12,6 +12,7 @@ iota-storage = { path = "../iota-storage" } iota-terms = { path = "../iota-terms" } iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } +iota-paths = { path = "../iota-paths" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } web-ui = { path = "../web-ui" } diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 4e0f782..22a942f 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -23,7 +23,16 @@ async fn main() { *state.reload.write().await = false; *state.shutdown.write().await = false; - let ipc = IpcClient::connect("/run/iota/iota.sock") + let socket = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(_) => { + panic!("Windows IPC client transport is not implemented yet") + } + }; + let ipc = IpcClient::connect(socket) .await .expect("iota-daemon must be running before starting iota-core"); let session = start_tui(ipc).expect("interactive terminal initialization failed"); diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index bc99cbc..b6ea1cb 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -44,10 +44,22 @@ impl IpcServer { let listener = match activated_listener()? { Some(listener) => listener, None => { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IPC socket has no parent directory", + ) + })?; + if !parent.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("IPC runtime directory does not exist: {}", parent.display()), + )); } - let lock_path = path.with_extension("sock.lock"); + let lock_path = path + .parent() + .unwrap_or_else(|| Path::new("/tmp")) + .join("daemon.lock"); let lock = File::options() .create(true) .mode(0o600) diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 2c5e976..074f91b 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -10,6 +10,7 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } +iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 5239baf..12d24fd 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -10,8 +10,33 @@ use std::time::Duration; use tokio::sync::{broadcast, watch}; #[tokio::main(flavor = "multi_thread")] async fn main() -> ExitCode { - logger::startup(); - iota_storage::util::config_util::load_config(); + let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() { + Some("system_socket_activated") | Some("system_always_on") => iota_paths::Scope::System, + _ => iota_paths::Scope::User, + }; + let paths = match iota_paths::IotaPaths::resolve(scope) { + Ok(paths) => paths, + Err(error) => { + eprintln!("Cannot resolve Iota paths: {error}"); + return ExitCode::FAILURE; + } + }; + if let Err(error) = paths.migrate_legacy_layout() { + eprintln!("Cannot migrate legacy Iota layout: {error}"); + return ExitCode::FAILURE; + } + if let Err(error) = paths.prepare_writable_directories() { + eprintln!("Cannot prepare Iota directories: {error}"); + return ExitCode::FAILURE; + } + iota_util::file_util::configure_storage_directory(paths.storage_dir.clone()); + iota_storage::util::config_util::configure_config_path(paths.config_file.clone()); + iota_storage::util::config_util::load_config_from(&paths.config_file); + omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file()); + match paths.scope { + iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())), + iota_paths::Scope::System => logger::startup_with_log_dir(None), + } let runtime = Arc::new(DaemonRuntime::new()); // --- IPC infrastructure --- @@ -36,7 +61,13 @@ async fn main() -> ExitCode { // Bind before migration and service startup: a successful bind is the // readiness boundary visible to clients and socket activation. - let socket = iota_paths::socket_path(iota_paths::SocketScope::User); + let socket = match &paths.ipc_endpoint { + iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), + iota_paths::IpcEndpoint::WindowsPipe(_) => { + eprintln!("Windows named-pipe daemon transport is not implemented yet"); + return ExitCode::FAILURE; + } + }; let omikron = match omikron_connector::omikron_connection::connect_initial( runtime.cancellation.clone(), runtime.state.active_tasks.clone(), @@ -65,18 +96,22 @@ async fn main() -> ExitCode { } }; let services = DaemonServices::new(omikron); - let ipc_server = - match IpcServer::bind(socket, runtime.clone(), services, log_tx.clone(), state_rx).await { - Ok(server) => server, - Err(error) => { - eprintln!("Cannot bind daemon IPC socket: {error}"); - return ExitCode::FAILURE; - } - }; - eprintln!( - "iota-daemon IPC listener ready at {}", - iota_paths::socket_path(iota_paths::SocketScope::User).display() - ); + let ipc_server = match IpcServer::bind( + socket.clone(), + runtime.clone(), + services, + log_tx.clone(), + state_rx, + ) + .await + { + Ok(server) => server, + Err(error) => { + eprintln!("Cannot bind daemon IPC socket: {error}"); + return ExitCode::FAILURE; + } + }; + eprintln!("iota-daemon IPC listener ready at {}", socket.display()); runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None); let listener_runtime = runtime.clone(); runtime @@ -128,13 +163,17 @@ async fn main() -> ExitCode { .parse() .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), port: web.port, - asset_dir: std::path::PathBuf::from(web.asset_dir), + asset_dir: resolve_config_path(&paths.config_file, &web.asset_dir, &paths.asset_dir), tls: web .certificate .zip(web.key) .map(|(certificate, key)| web_server::TlsConfig { - certificate: certificate.into(), - key: key.into(), + certificate: resolve_config_path( + &paths.config_file, + &certificate, + &paths.config_dir, + ), + key: resolve_config_path(&paths.config_file, &key, &paths.config_dir), }), required: web.required, }; @@ -196,3 +235,22 @@ async fn main() -> ExitCode { log!("iota-daemon exited (code: {})", exit_code); ExitCode::from(exit_code as u8) } + +fn resolve_config_path( + config_file: &std::path::Path, + value: &str, + default: &std::path::Path, +) -> std::path::PathBuf { + if value.is_empty() { + return default.to_path_buf(); + } + let path = std::path::PathBuf::from(value); + if path.is_absolute() { + path + } else { + config_file + .parent() + .expect("absolute configuration file has a parent") + .join(path) + } +} diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index f290cf0..3860835 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -81,7 +81,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) ] { install( &staging.path().join("systemd").join(unit), - &format!("/etc/systemd/system/{unit}"), + &format!("/usr/local/lib/systemd/system/{unit}"), "0644", )?; } @@ -114,7 +114,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) "{}/current/bin/iota-daemon", iota_paths::install_root().display() ), - "/usr/local/lib/iota/iota-daemon", + "/usr/local/libexec/iota/iota-daemon", ], )?; run("systemd-sysusers", &[])?; @@ -130,8 +130,12 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; run("systemctl", &["is-active", "iota-daemon.socket"])?; run("systemctl", &["is-enabled", "iota-daemon.socket"])?; - if !Path::new("/run/iota/iota.sock").exists() { - bail!("systemd socket is active but /run/iota/iota.sock was not created"); + let socket = iota_paths::socket_path(iota_paths::Scope::System); + if !socket.exists() { + bail!( + "systemd socket is active but {} was not created", + socket.display() + ); } Ok(()) } @@ -175,7 +179,7 @@ mod tests { let service = include_str!("../../systemd/iota-daemon.service"); let socket = include_str!("../../systemd/iota-daemon.socket"); let sysusers = include_str!("../../systemd/sysusers.d/iota.conf"); - assert!(service.contains("ExecStart=/usr/local/lib/iota/iota-daemon")); + assert!(service.contains("ExecStart=/usr/local/libexec/iota/iota-daemon")); assert!(service.contains("User=iota")); assert!(service.contains("Group=iota")); assert!(socket.contains("SocketUser=iota")); diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index 530f2f6..5287ecb 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-paths = { path = "../iota-paths" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 8dd7f11..fa1e2f0 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -1,7 +1,6 @@ use std::{ fs::{self, OpenOptions}, io::Write, - path::Path, sync::{OnceLock, atomic::Ordering, mpsc}, thread, time::{SystemTime, UNIX_EPOCH}, @@ -56,6 +55,15 @@ struct LogMessage { /* The logger owns file persistence while consumers receive rendered entries * through a process-local broadcast subscription. */ pub fn startup() { + startup_with_log_dir(Some( + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .log_dir, + )); +} + +/// `None` keeps logging on stderr only (the systemd default). +pub fn startup_with_log_dir(log_dir: Option) { let (tx, rx) = mpsc::channel::(); if LOGGER.set(tx).is_err() { return; @@ -64,22 +72,15 @@ pub fn startup() { let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); thread::spawn(move || { - let working_dir = iota_util::file_util::get_directory(); - let base_dir = Path::new(&working_dir); - let log_dir = base_dir.join("logs"); - fs::create_dir_all(&log_dir).expect("Failed to create log directory"); - - let start_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let path = log_dir.join(format!("log_{}.txt", start_ts)); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .expect("Failed to open log file"); + let mut file = log_dir.and_then(|log_dir| { + fs::create_dir_all(&log_dir).ok()?; + let start_ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join(format!("log_{start_ts}.txt"))) + .ok() + }); for msg in rx { let resolved_message = if let Some(key) = msg.translation_key { @@ -105,7 +106,9 @@ pub fn startup() { ); let line2 = format!(" {}", timestamp); - let _ = writeln!(file, "{}\n{}", line1, line2); + if let Some(file) = file.as_mut() { + let _ = writeln!(file, "{}\n{}", line1, line2); + } let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs index 4461e1e..35a51bd 100644 --- a/iota-paths/src/lib.rs +++ b/iota-paths/src/lib.rs @@ -1,118 +1,412 @@ -use std::path::PathBuf; +//! Platform and deployment aware locations used by Iota. +//! +//! This module deliberately keeps environment handling in one place. In +//! particular, an override is never interpreted relative to the process +//! working directory. +use std::env; +use std::fmt; +use std::path::{Path, PathBuf}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SocketScope { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Scope { User, System, } -fn home_dir() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) +/// Compatibility name retained for callers which have not yet been migrated. +pub type SocketScope = Scope; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IpcEndpoint { + UnixSocket(PathBuf), + WindowsPipe(String), } -pub fn data_dir() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_DATA_DIR") { - return PathBuf::from(path); - } - - #[cfg(target_os = "linux")] - { - return std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home_dir().join(".local/state")) - .join("iota"); - } - #[cfg(target_os = "macos")] - { - return home_dir().join("Library/Application Support/Iota"); - } - #[cfg(target_os = "windows")] - { - return std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(home_dir) - .join("Tensamin/Iota"); - } - #[allow(unreachable_code)] - home_dir().join(".iota") +#[derive(Debug)] +pub enum PathError { + MissingPlatformDirectory(&'static str), + EmptyOverride(&'static str), + RelativeOverride { + variable: &'static str, + value: PathBuf, + }, + InvalidPipeName(String), + UnsupportedScope, } -pub fn config_dir() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") { - return PathBuf::from(path); - } - #[cfg(target_os = "linux")] - { - return std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home_dir().join(".config")) - .join("iota"); - } - #[cfg(target_os = "macos")] - { - return home_dir().join("Library/Application Support/Iota"); - } - #[cfg(target_os = "windows")] - { - return std::env::var_os("APPDATA") - .map(PathBuf::from) - .unwrap_or_else(home_dir) - .join("Tensamin/Iota"); - } - #[allow(unreachable_code)] - home_dir().join(".iota") -} - -pub fn socket_override() -> Option { - std::env::var_os("IOTA_SOCKET").map(PathBuf::from) -} - -pub fn socket_path(scope: SocketScope) -> PathBuf { - if let Some(path) = socket_override() { - return path; - } - match scope { - SocketScope::User => data_dir().join("iota.sock"), - SocketScope::System => PathBuf::from("/run/iota/iota.sock"), +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"), + Self::EmptyOverride(name) => write!(f, "{name} must not be empty"), + Self::RelativeOverride { variable, value } => { + write!(f, "{variable} must be absolute, got {}", value.display()) + } + Self::InvalidPipeName(name) => write!(f, "invalid Windows pipe name: {name}"), + Self::UnsupportedScope => write!(f, "this path scope is unsupported on this platform"), + } } } +impl std::error::Error for PathError {} -pub fn socket_lock_path(scope: SocketScope) -> PathBuf { - let socket = socket_path(scope); - PathBuf::from(format!("{}.lock", socket.display())) +#[derive(Clone, Debug)] +pub struct IotaPaths { + pub scope: Scope, + pub config_dir: PathBuf, + pub config_file: PathBuf, + pub state_dir: PathBuf, + pub storage_dir: PathBuf, + pub identity_dir: PathBuf, + pub cache_dir: PathBuf, + pub runtime_dir: Option, + pub log_dir: PathBuf, + /// Directory containing static web assets (not its parent). + pub asset_dir: PathBuf, + pub install_root: PathBuf, + pub ipc_endpoint: IpcEndpoint, } -pub fn daemon_executable() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") { - return PathBuf::from(path); +impl IotaPaths { + pub fn resolve(scope: Scope) -> Result { + let defaults = Defaults::for_scope(scope)?; + let config_dir = override_first(&["IOTA_CONFIG_DIR"])?.unwrap_or(defaults.config_dir); + // IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it + // exactly like every other override; do not hide an invalid value. + let state_dir = + override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])?.unwrap_or(defaults.state_dir); + let cache_dir = override_first(&["IOTA_CACHE_DIR"])?.unwrap_or(defaults.cache_dir); + let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])?.or(defaults.runtime_dir); + let log_dir = override_first(&["IOTA_LOG_DIR"])?.unwrap_or(defaults.log_dir); + let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])? + .unwrap_or(defaults.asset_dir); + let install_root = override_first(&["IOTA_INSTALL_ROOT"])?.unwrap_or(defaults.install_root); + let config_file = override_first(&["IOTA_CONFIG_FILE"])? + .unwrap_or_else(|| config_dir.join("config.yaml")); + let ipc_endpoint = resolve_ipc(scope, runtime_dir.as_deref(), defaults.ipc_endpoint)?; + Ok(Self { + scope, + config_dir, + config_file, + storage_dir: state_dir.join("storage"), + identity_dir: state_dir.join("identity"), + state_dir, + cache_dir, + runtime_dir, + log_dir, + asset_dir, + install_root, + ipc_endpoint, + }) } - if let Ok(exe) = std::env::current_exe() { - if let Some(path) = exe.parent().map(|p| p.join("iota-daemon")) { - if path.is_file() { - return path; + + pub fn database_file(&self) -> PathBuf { + self.storage_dir.join("messages.sqlite3") + } + pub fn keyring_file(&self) -> PathBuf { + self.identity_dir.join("iota.mk") + } + pub fn update_staging_dir(&self) -> PathBuf { + self.cache_dir.join("updates/staging") + } + pub fn update_status_file(&self) -> PathBuf { + self.state_dir.join("update-status.json") + } + pub fn update_lock_file(&self) -> Result { + self.runtime_dir + .as_ref() + .map(|p| p.join("update.lock")) + .ok_or(PathError::MissingPlatformDirectory("runtime directory")) + } + pub fn daemon_lock_file(&self) -> Result { + self.runtime_dir + .as_ref() + .map(|p| p.join("daemon.lock")) + .ok_or(PathError::MissingPlatformDirectory("runtime directory")) + } + pub fn prepare_writable_directories(&self) -> std::io::Result<()> { + for directory in [ + &self.state_dir, + &self.storage_dir, + &self.identity_dir, + &self.cache_dir, + &self.log_dir, + ] { + create_directory(directory, self.scope == Scope::User)?; + } + if let Some(runtime) = &self.runtime_dir { + create_directory(runtime, self.scope == Scope::User)?; + } + Ok(()) + } + + /// Move the pre-v2 resources that were all placed directly below the + /// state root. This is deliberately idempotent: an existing destination + /// is never overwritten and the marker is only written after the moves. + pub fn migrate_legacy_layout(&self) -> std::io::Result<()> { + let marker = self.state_dir.join("path-layout-v2.json"); + if marker.exists() { + return Ok(()); + } + move_if_absent(&self.state_dir.join("config.yaml"), &self.config_file)?; + move_if_absent(&self.state_dir.join("certs"), &self.config_dir.join("tls"))?; + for suffix in [ + "messages.sqlite3", + "messages.sqlite3-wal", + "messages.sqlite3-shm", + ] { + move_if_absent(&self.state_dir.join(suffix), &self.storage_dir.join(suffix))?; + } + for name in ["users", "communities"] { + move_if_absent(&self.state_dir.join(name), &self.storage_dir.join(name))?; + } + move_if_absent(&self.state_dir.join("iota.mk"), &self.keyring_file())?; + move_if_absent( + &self.state_dir.join("update-staging"), + &self.update_staging_dir(), + )?; + // Runtime objects must not survive a layout migration or reboot. + for name in ["update.lock", "iota.sock", "iota.sock.lock"] { + let path = self.state_dir.join(name); + if path.is_file() || path.is_symlink() { + let _ = std::fs::remove_file(path); } } + std::fs::create_dir_all(&self.state_dir)?; + std::fs::write(marker, "{\"version\":2}\n") } - #[cfg(target_os = "linux")] - { - let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon"); - if installed.is_file() { - return installed; - } - } - PathBuf::from("iota-daemon") } -pub fn updater_executable() -> PathBuf { - std::env::var_os("IOTA_UPDATER_PATH") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("iota-updater")) +fn move_if_absent(source: &Path, destination: &Path) -> std::io::Result<()> { + if !source.exists() || destination.exists() { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("refusing symlink migration source {}", source.display()), + )); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + match std::fs::rename(source, destination) { + Ok(()) => Ok(()), + Err(error) if error.raw_os_error() == Some(libc_exdev()) => { + copy_recursively(source, destination)?; + if source.is_dir() { + std::fs::remove_dir_all(source) + } else { + std::fs::remove_file(source) + } + } + Err(error) => Err(error), + } } + +// EXDEV is stable on Unix. A literal is used on non-Unix where the fallback +// copy is harmlessly skipped because rename normally remains on one volume. +#[cfg(unix)] +fn libc_exdev() -> i32 { + 18 +} +#[cfg(not(unix))] +fn libc_exdev() -> i32 { + -1 +} +fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> { + if source.is_dir() { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + copy_recursively(&entry.path(), &destination.join(entry.file_name()))?; + } + Ok(()) + } else { + std::fs::copy(source, destination).map(|_| ()) + } +} + +struct Defaults { + config_dir: PathBuf, + state_dir: PathBuf, + cache_dir: PathBuf, + runtime_dir: Option, + log_dir: PathBuf, + asset_dir: PathBuf, + install_root: PathBuf, + ipc_endpoint: IpcEndpoint, +} +impl Defaults { + fn for_scope(scope: Scope) -> Result { + match scope { + Scope::System => { + #[cfg(target_os = "linux")] + { + Ok(Self { + config_dir: "/etc/iota".into(), + state_dir: "/var/lib/iota".into(), + cache_dir: "/var/cache/iota".into(), + runtime_dir: Some("/run/iota".into()), + log_dir: "/var/log/iota".into(), + asset_dir: "/usr/local/share/iota/web".into(), + install_root: "/usr/local/libexec/iota".into(), + ipc_endpoint: IpcEndpoint::UnixSocket("/run/iota/iota.sock".into()), + }) + } + #[cfg(not(target_os = "linux"))] + { + Err(PathError::UnsupportedScope) + } + } + Scope::User => user_defaults(), + } + } +} + +#[cfg(unix)] +fn user_defaults() -> Result { + let home = + absolute_env("HOME")?.ok_or(PathError::MissingPlatformDirectory("home directory"))?; + let config_base = xdg_or_home("XDG_CONFIG_HOME", &home, ".config")?; + let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?; + let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?; + let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?; + let runtime = absolute_env("XDG_RUNTIME_DIR")? + .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR"))? + .join("iota"); + Ok(Defaults { + config_dir: config_base.join("iota"), + state_dir: state_base.join("iota"), + cache_dir: cache_base.join("iota"), + runtime_dir: Some(runtime.clone()), + log_dir: state_base.join("iota/logs"), + asset_dir: data_base.join("iota/web"), + install_root: data_base.join("iota/bin"), + ipc_endpoint: IpcEndpoint::UnixSocket(runtime.join("iota.sock")), + }) +} +#[cfg(windows)] +fn user_defaults() -> Result { + let config = absolute_env("APPDATA")? + .ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))? + .join("Tensamin/Iota/config"); + let local = absolute_env("LOCALAPPDATA")? + .ok_or(PathError::MissingPlatformDirectory("Local AppData"))? + .join("Tensamin/Iota"); + Ok(Defaults { + config_dir: config, + state_dir: local.join("state"), + cache_dir: local.join("cache"), + runtime_dir: None, + log_dir: local.join("logs"), + asset_dir: local.join("data"), + install_root: local.join("bin"), + ipc_endpoint: IpcEndpoint::WindowsPipe(r"\\.\pipe\Tensamin.Iota.User".into()), + }) +} + +fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result { + Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback))) +} +fn absolute_env(name: &'static str) -> Result, PathError> { + let Some(value) = env::var_os(name) else { + return Ok(None); + }; + if value.is_empty() { + return Err(PathError::EmptyOverride(name)); + } + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(PathError::RelativeOverride { + variable: name, + value: path, + }); + } + Ok(Some(path)) +} +fn override_first(names: &[&'static str]) -> Result, PathError> { + for name in names { + if let Some(value) = absolute_env(name)? { + return Ok(Some(value)); + } + } + Ok(None) +} +fn resolve_ipc( + scope: Scope, + runtime: Option<&Path>, + default: IpcEndpoint, +) -> Result { + #[cfg(unix)] + { + if let Some(path) = absolute_env("IOTA_SOCKET")? { + return Ok(IpcEndpoint::UnixSocket(path)); + } + if scope == Scope::User { + return runtime + .map(|dir| IpcEndpoint::UnixSocket(dir.join("iota.sock"))) + .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR")); + } + } + #[cfg(windows)] + { + if let Some(name) = env::var_os("IOTA_PIPE") { + let name = name.to_string_lossy().into_owned(); + if !name.starts_with(r"\\.\pipe\") { + return Err(PathError::InvalidPipeName(name)); + } + return Ok(IpcEndpoint::WindowsPipe(name)); + } + } + Ok(default) +} +fn create_directory(path: &Path, private: bool) -> std::io::Result<()> { + std::fs::create_dir_all(path)?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +// Compatibility helpers. New code should resolve IotaPaths once and pass it +// to its dependencies instead of calling these independently. +pub fn data_dir() -> PathBuf { + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .state_dir +} +pub fn config_dir() -> PathBuf { + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .config_dir +} +pub fn socket_override() -> Option { + absolute_env("IOTA_SOCKET").ok().flatten() +} +pub fn socket_path(scope: SocketScope) -> PathBuf { + match IotaPaths::resolve(scope) + .expect("resolve Iota paths") + .ipc_endpoint + { + IpcEndpoint::UnixSocket(path) => path, + IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"), + } +} +pub fn socket_lock_path(scope: SocketScope) -> PathBuf { + IotaPaths::resolve(scope) + .expect("resolve Iota paths") + .daemon_lock_file() + .expect("runtime directory") +} +/// The compatibility installation helpers describe the machine installation, +/// not a user's data directory. Per-user launchers should keep an +/// `IotaPaths` instance and use its `install_root` directly. pub fn install_root() -> PathBuf { - std::env::var_os("IOTA_INSTALL_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota")) + IotaPaths::resolve(Scope::System) + .expect("resolve Iota system paths") + .install_root } pub fn versions_dir() -> PathBuf { install_root().join("versions") @@ -121,36 +415,91 @@ pub fn current_version_link() -> PathBuf { install_root().join("current") } pub fn updater_lock_path() -> PathBuf { - data_dir().join("update.lock") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_lock_file() + .expect("runtime directory") } pub fn updater_status_path() -> PathBuf { - data_dir().join("update-status.json") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_status_file() } pub fn updater_staging_dir() -> PathBuf { - data_dir().join("update-staging") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_staging_dir() } pub fn web_asset_dir() -> PathBuf { - std::env::var_os("IOTA_WEB_ASSET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| data_dir().join("web")) + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .asset_dir +} +pub fn daemon_executable() -> PathBuf { + absolute_env("IOTA_DAEMON_PATH") + .expect("valid IOTA_DAEMON_PATH") + .unwrap_or_else(|| install_root().join("current/bin/iota-daemon")) +} +pub fn updater_executable() -> PathBuf { + absolute_env("IOTA_UPDATER_PATH") + .expect("valid IOTA_UPDATER_PATH") + .unwrap_or_else(|| install_root().join("current/bin/iota-updater")) } - pub fn daemon_endpoints() -> Vec { - if let Some(path) = socket_override() { - return vec![path]; - } - vec![ - socket_path(SocketScope::User), - socket_path(SocketScope::System), - ] + vec![socket_path(Scope::User), socket_path(Scope::System)] } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_ID: AtomicU64 = AtomicU64::new(0); + #[test] + fn system_layout_is_fhs() { + let p = IotaPaths::resolve(Scope::System).unwrap(); + assert_eq!(p.config_file, PathBuf::from("/etc/iota/config.yaml")); + assert_eq!( + p.database_file(), + PathBuf::from("/var/lib/iota/storage/messages.sqlite3") + ); + assert_eq!( + p.update_staging_dir(), + PathBuf::from("/var/cache/iota/updates/staging") + ); + } #[test] - fn explicit_data_directory_wins() { - assert!(!data_dir().as_os_str().is_empty()); + fn migration_moves_state_resources_without_overwriting_destination() { + let root = std::env::temp_dir().join(format!( + "iota-paths-test-{}-{}", + std::process::id(), + TEST_ID.fetch_add(1, Ordering::Relaxed) + )); + let state = root.join("state"); + let config = root.join("config"); + let paths = IotaPaths { + scope: Scope::User, + config_dir: config.clone(), + config_file: config.join("config.yaml"), + storage_dir: state.join("storage"), + identity_dir: state.join("identity"), + cache_dir: root.join("cache"), + runtime_dir: Some(root.join("runtime")), + log_dir: state.join("logs"), + asset_dir: root.join("data/web"), + install_root: root.join("bin"), + ipc_endpoint: IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")), + state_dir: state.clone(), + }; + std::fs::create_dir_all(state.join("users")).unwrap(); + std::fs::write(state.join("messages.sqlite3"), b"db").unwrap(); + std::fs::write(state.join("config.yaml"), b"web: {}\n").unwrap(); + paths.migrate_legacy_layout().unwrap(); + assert!(paths.database_file().is_file()); + assert!(paths.storage_dir.join("users").is_dir()); + assert!(paths.config_file.is_file()); + assert!(state.join("path-layout-v2.json").is_file()); + let _ = std::fs::remove_dir_all(root); } } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 24d7603..50f7cc6 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } +iota-paths = { path = "../iota-paths" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 5685208..380ba6c 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -1,8 +1,10 @@ use arc_swap::ArcSwap; -use iota_util::file_util::{load_file, save_file}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::OnceLock; pub static CONFIG: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default()))); @@ -19,11 +21,11 @@ pub struct IotaConfig { pub omikron_host: Option, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_port: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub keyring: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub public_key: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub private_key: Option, #[serde(default = "default_read_receipts_enabled")] pub read_receipts_enabled: bool, @@ -61,7 +63,7 @@ fn default_web_bind() -> String { "127.0.0.1".into() } fn default_web_asset_dir() -> String { - "web".into() + String::new() } impl Default for WebSettings { fn default() -> Self { @@ -102,17 +104,27 @@ impl Default for IotaConfig { } pub fn load_config() { - let s = load_file("", "config.yaml"); - if s.is_empty() { - return; - } + load_config_from(&default_config_path()); +} + +/// Loading is intentionally side-effect free: a missing configuration means +/// documented defaults, not a newly-created file. +pub fn load_config_from(path: &Path) { + let s = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => { + eprintln!("Failed to read {}: {error}", path.display()); + return; + } + }; match serde_yaml::from_str::(&s) { Ok(parsed) => { CONFIG.store(Arc::new(parsed)); } Err(e) => { - eprintln!("Failed to parse config.yaml: {}. Content: '{}'", e, s); + eprintln!("Failed to parse {}: {e}", path.display()); } } } @@ -123,14 +135,45 @@ pub fn clear_config() { } pub fn save_config() { + save_config_to(&default_config_path()); +} + +pub fn save_config_to(path: &Path) { if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) { - save_file("", "config.yaml", &yaml); + if let Some(parent) = path.parent() { + if let Err(error) = fs::create_dir_all(parent) { + eprintln!( + "Cannot create configuration directory {}: {error}", + parent.display() + ); + return; + } + } + let temporary = path.with_extension("yaml.tmp"); + if let Err(error) = fs::write(&temporary, yaml).and_then(|_| fs::rename(&temporary, path)) { + eprintln!("Cannot save {}: {error}", path.display()); + let _ = fs::remove_file(temporary); + } } } +fn default_config_path() -> PathBuf { + if let Some(path) = CONFIG_PATH.get() { + return path.clone(); + } + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .config_file +} + pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { let mut cfg = IotaConfig::clone(&**CONFIG.load()); f(&mut cfg); CONFIG.store(Arc::new(cfg)); save_config(); } +static CONFIG_PATH: OnceLock = OnceLock::new(); + +pub fn configure_config_path(path: PathBuf) { + let _ = CONFIG_PATH.set(path); +} diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index dfe9d2d..63debe3 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1,4 +1,3 @@ -use iota_util::file_util::get_directory; use once_cell::sync::Lazy; use r2d2::ManageConnection; use rusqlite::Connection; @@ -56,10 +55,12 @@ where f(&conn) } -fn db_file_path(db_name: &str) -> String { - let mut p = PathBuf::from(get_directory()); - p.push(format!("{db_name}.sqlite3")); - p.to_string_lossy().to_string() +fn db_file_path(db_name: &str) -> PathBuf { + let storage_dir = iota_util::file_util::storage_directory(); + // Creating storage belongs to initialization/connection setup, never to a + // configuration read. + std::fs::create_dir_all(&storage_dir).expect("create Iota storage directory"); + storage_dir.join(format!("{db_name}.sqlite3")) } fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> { diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index d483f7c..d089180 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] iota-logger = { path = "../iota-logger" } +iota-paths = { path = "../iota-paths" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs index 9dfcd4e..342b2f8 100644 --- a/iota-updater/src/transaction.rs +++ b/iota-updater/src/transaction.rs @@ -9,22 +9,32 @@ use std::{ pub struct UpdateTransaction { pub root: PathBuf, pub staging: PathBuf, + pub lock_file: PathBuf, } impl UpdateTransaction { pub fn new(root: impl Into) -> Self { let root = root.into(); Self { staging: root.join(".staging"), + lock_file: root.join("update.lock"), root, } } + pub fn from_paths(paths: &iota_paths::IotaPaths) -> Result { + Ok(Self { + root: paths.install_root.clone(), + staging: paths.update_staging_dir(), + lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?, + }) + } pub fn acquire(&self) -> Result { - fs::create_dir_all(&self.root)?; - let path = self.root.join("update.lock"); + if let Some(parent) = self.lock_file.parent() { + fs::create_dir_all(parent)?; + } let file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(path) + .open(&self.lock_file) .context("update already in progress")?; Ok(file) } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index fe4a8ba..8f87450 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -3,6 +3,7 @@ use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, BufReader, Read}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use sysinfo::System; use tokio::io::AsyncWriteExt; use uuid::Uuid; @@ -40,34 +41,19 @@ pub fn delete_user_directory(user_id: i64) { } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); - - // Ensure the directory exists, create if necessary - if !dir.exists() { - if let Err(_) = fs::create_dir_all(&dir) { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "Directory creation failed", - )); - } - } - - // Create the file if it doesn't exist - if !file_path.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "File creation failed", - )); - } + let file_path = storage_file(path, name)?; // Open the file and return a BufReader for efficient reading let file = File::open(&file_path)?; Ok(BufReader::new(file)) } pub fn has_file(path: &str, name: &str) -> bool { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return false; + }; + let Some(dir) = file_path.parent() else { + return false; + }; if !dir.exists() { return false; @@ -80,7 +66,9 @@ pub fn has_file(path: &str, name: &str) -> bool { true } pub fn has_dir(path: &str) -> bool { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return false; + }; if !dir.exists() { return false; @@ -90,8 +78,12 @@ pub fn has_dir(path: &str) -> bool { } pub fn load_file(path: &str, name: &str) -> String { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return String::new(); + }; + let Some(dir) = file_path.parent() else { + return String::new(); + }; if !dir.exists() { return String::new(); @@ -109,15 +101,17 @@ pub fn load_file(path: &str, name: &str) -> String { } pub fn load_file_vec(path: &str, name: &str) -> Result, std::io::Error> { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); - - std::fs::read(file_path) + std::fs::read(storage_file(path, name)?) } pub fn save_file(path: &str, name: &str, value: &str) { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + eprintln!("[IMPORTANT] Refusing unsafe storage path"); + return; + }; + let Some(dir) = file_path.parent() else { + return; + }; if !dir.exists() { if let Err(e) = fs::create_dir_all(&dir) { @@ -149,7 +143,9 @@ pub fn save_file(path: &str, name: &str, value: &str) { } pub fn get_children(path: &str) -> Vec { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return Vec::new(); + }; let mut children = Vec::new(); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries { @@ -161,8 +157,61 @@ pub fn get_children(path: &str) -> Vec { children } +static STORAGE_DIRECTORY: OnceLock = OnceLock::new(); + +/// Set by the daemon immediately after resolving `IotaPaths`. This keeps the +/// legacy storage helpers working while preventing them from independently +/// discovering a different (user-scope) directory in a system daemon. +pub fn configure_storage_directory(path: PathBuf) { + let _ = STORAGE_DIRECTORY.set(path); +} + +pub fn storage_directory() -> PathBuf { + STORAGE_DIRECTORY.get().cloned().unwrap_or_else(|| { + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .storage_dir + }) +} + +/// Resolve a user supplied storage fragment without allowing it to escape the +/// resolved storage root. Legacy call sites may use nested fragments, but +/// never absolute paths or `..` components. +pub fn storage_child(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + if path.is_absolute() + || path.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe storage path", + )); + } + Ok(storage_directory().join(path)) +} + +pub fn storage_file(path: impl AsRef, name: impl AsRef) -> io::Result { + let name = name.as_ref(); + if name.components().count() != 1 || name.is_absolute() || name == Path::new(".") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe storage file name", + )); + } + storage_child(path).map(|dir| dir.join(name)) +} + pub fn get_directory() -> String { - iota_paths::data_dir().to_string_lossy().to_string() + // Legacy helpers are storage-only. Configuration, keys, logs and runtime + // files must use their dedicated path APIs instead. + storage_directory().to_string_lossy().into_owned() } // Helper to download the zip file content to a file on disk diff --git a/iota/src/main.rs b/iota/src/main.rs index e71c2d5..0e85d36 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -1,7 +1,5 @@ use iota_cli::{ - ipc_client::IpcClient, - screens::main_screen::MainScreen, - theme, + ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_theme, }; use iota_ipc::{LocalRequest, ResponseResult}; @@ -32,13 +30,28 @@ async fn main() -> ExitCode { async fn run() -> Result<(), StartupError> { let invocation = CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; - let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter(); - let local_endpoint = endpoints_iter - .next() - .expect("path layer always returns an endpoint"); - let system_endpoint = endpoints_iter - .next() - .unwrap_or_else(|| local_endpoint.clone()); + let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; + let system_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; let endpoints = daemon_setup_flow::DaemonEndpoints { local: local_endpoint, system: system_endpoint, diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 54038da..6e745e6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -10,6 +10,7 @@ use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; @@ -92,6 +93,19 @@ async fn is_read_receipts_enabled() -> bool { // ============================================================================ const IOTA_KEYRING_PATH: &str = "iota.mk"; +static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Must be called by the daemon before any Omikron connection is attempted. +/// It keeps identity material independent from the working directory. +pub fn configure_identity_path(path: PathBuf) { + let _ = IDENTITY_PATH.set(path); +} +fn identity_path() -> &'static Path { + IDENTITY_PATH + .get() + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) +} const OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb"; const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); @@ -436,7 +450,8 @@ impl OmikronConnection { * that still read it directly. */ async fn load_or_migrate_keyring(&self) -> Keyring { - if let Ok(kr) = mtp::files::load_keyring_raw(IOTA_KEYRING_PATH) { + let path = identity_path(); + if let Ok(kr) = mtp::files::load_keyring_raw(path) { return kr; } @@ -448,18 +463,18 @@ impl OmikronConnection { "WARNING: No existing keyring found. Neither {} nor config.json \ contain a keyring; generating a new identity. If you already had \ an Iota identity, restore {} from a backup to avoid losing access.", - IOTA_KEYRING_PATH, - IOTA_KEYRING_PATH + path.display(), + path.display() ); crypto_helper::generate_keyring() }); - if let Err(e) = mtp::files::save_keyring_raw(&keyring, IOTA_KEYRING_PATH) { - log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = mtp::files::save_keyring_raw(&keyring, path) { + log!("Failed to persist {}: {}", path.display(), e); } - - let b64 = crypto_helper::keyring_to_base64(&keyring); - modify_config(|cfg| cfg.keyring = Some(b64)); keyring } @@ -1050,9 +1065,7 @@ impl OmikronConnection { if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) { - let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); - - if let Some(keyring) = keyring_from_base64(&kr_str) { + if let Ok(keyring) = mtp::files::load_keyring_raw(identity_path()) { if let Ok(encrypted_challenge) = crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) { From adfd1459b3baba130cf083f2dcbd20ea79e6e3c2 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 25 Jul 2026 13:10:44 +0200 Subject: [PATCH 086/119] (chore): update license --- LICENSE | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/LICENSE b/LICENSE index d218eff..e952c84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,16 +1,15 @@ -Copyright (c) [2025] [Methanium] +Copyright (c) 2025 Methanium + All rights reserved. -This software is protected by copyright. Copying, editing, -distributing, publicly performing, or any other use of this software -or its components, in source or binary form, is strictly prohibited without the express -written permission of the copyright holder. +No part of this software, source code, documentation, or +associated materials may be copied, reproduced, modified, +distributed, published, sublicensed, sold, or used to create +derivative works without prior written permission from the +copyright holder. -FUTURE LICENSE ACCEPTANCE: -It is the copyright holder's intention to release this software in the future -under a license yet to be defined, which will, among other things, -allow private, non-commercial use. This statement does not constitute -a current license grant and does not alter the above -prohibition on use, copying, or modification. Until the formal -publication of such a future license, all rights remain -reserved. +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY +OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM +EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL +NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER +LIABILITY ARISING FROM THE SOFTWARE OR ITS USE. From d9cfceacc7f87769683972a6b286f49ef2bb8c02 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 25 Jul 2026 13:44:48 +0200 Subject: [PATCH 087/119] (feat): improve iota thingy --- flake.nix | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/flake.nix b/flake.nix index d7bf620..ed9a49a 100644 --- a/flake.nix +++ b/flake.nix @@ -42,13 +42,11 @@ commonNativeBuildInputs = with pkgs; [cmake perl pkg-config]; in { packages = { - default = self'.packages.iota-daemon; - - iota-daemon = pkgs.rustPlatform.buildRustPackage { - pname = "iota-daemon"; + default = pkgs.rustPlatform.buildRustPackage { + pname = "iota"; version = "0.1.0"; src = ./.; - cargoBuildFlags = ["-p" "iota-daemon"]; + cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"]; cargoLock = { lockFile = ./Cargo.lock; allowBuiltinFetchGit = true; @@ -56,6 +54,12 @@ nativeBuildInputs = commonNativeBuildInputs; buildInputs = commonBuildInputs; dontUseCmakeConfigure = true; + passthru.dataDir = "/var/lib/iota"; + }; + + iota-daemon = self'.packages.default.overrideAttrs (old: { + pname = "iota-daemon"; + cargoBuildFlags = ["-p" "iota-daemon"]; postInstall = '' for f in $out/bin/*; do if [ "$(basename "$f")" != "iota-daemon" ]; then @@ -63,33 +67,22 @@ fi done ''; - passthru.dataDir = "/var/lib/iota"; - }; + }); - iota-ui = pkgs.rustPlatform.buildRustPackage { + iota-ui = self'.packages.default.overrideAttrs (old: { pname = "iota-ui"; - version = "0.1.0"; - src = ./.; cargoBuildFlags = ["-p" "iota"]; - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; - nativeBuildInputs = commonNativeBuildInputs; - buildInputs = commonBuildInputs; - dontUseCmakeConfigure = true; postInstall = '' for f in $out/bin/*; do if [ "$(basename "$f")" != "iota" ]; then rm "$f" fi done - # Rename to avoid confusion if [ -f "$out/bin/iota" ]; then mv "$out/bin/iota" "$out/bin/iota-ui" fi ''; - }; + }); }; devShells.default = pkgs.mkShell { @@ -106,7 +99,7 @@ ... }: let cfg = config.services.iota; - defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); configFormat = pkgs.formats.yaml {}; configFile = From 8cd1637daeb5d6a920d69bd76645764feba07392 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 25 Jul 2026 13:58:42 +0200 Subject: [PATCH 088/119] Removed one test --- iota-installer/src/lib.rs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 3860835..62f663a 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -172,20 +172,4 @@ fn run(program: &str, args: &[&str]) -> Result<()> { } } -#[cfg(test)] -mod tests { - #[test] - fn service_uses_installed_daemon_and_declared_identities() { - let service = include_str!("../../systemd/iota-daemon.service"); - let socket = include_str!("../../systemd/iota-daemon.socket"); - let sysusers = include_str!("../../systemd/sysusers.d/iota.conf"); - assert!(service.contains("ExecStart=/usr/local/libexec/iota/iota-daemon")); - assert!(service.contains("User=iota")); - assert!(service.contains("Group=iota")); - assert!(socket.contains("SocketUser=iota")); - assert!(socket.contains("SocketGroup=iota-operators")); - assert!(socket.contains("NonBlocking=true")); - assert!(sysusers.contains("u iota ")); - assert!(sysusers.contains("g iota-operators")); - } -} + From f00992d4a597b476c27ea424f30fd60c8fb31a17 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 25 Jul 2026 14:31:36 +0200 Subject: [PATCH 089/119] Remove another weird test --- iota-storage/src/util/e2ee_storage.rs | 48 --------------------------- 1 file changed, 48 deletions(-) diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index ad12661..bfa70c4 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -240,52 +240,4 @@ fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result String { - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("{name}-{unix}") - } - - #[test] - fn stores_and_retrieves_chat_secret_blob_for_owner_chat() { - let user_id = suffix("user"); - let chat_id = suffix("chat"); - - put_chat_secret(StoredChatSecret { - user_id: user_id.clone(), - chat_id: chat_id.clone(), - secret_id: "main".to_string(), - version: 1, - encrypted_secret: vec![42, 43], - kem_ciphertext: vec![9, 8, 7], - wrapping_scheme: "mtp-kem-chacha20poly1305-hkdf-sha256-v1".to_string(), - created_at: 1, - updated_at: 2, - }) - .unwrap(); - - let found = get_chat_secret(ChatSecretQuery { - user_id: user_id.clone(), - chat_id: chat_id.clone(), - secret_id: Some("main".to_string()), - }) - .unwrap() - .unwrap(); - assert_eq!(found.encrypted_secret, vec![42, 43]); - assert_eq!(found.kem_ciphertext, vec![9, 8, 7]); - - let denied = get_chat_secret(ChatSecretQuery { - user_id: suffix("other-user"), - chat_id, - secret_id: Some("main".to_string()), - }) - .unwrap(); - assert!(denied.is_none()); - } -} From 6a535099bb42cb6384c6567ec0a762741f128c56 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 25 Jul 2026 18:23:36 +0200 Subject: [PATCH 090/119] [Wip] CLI & Daemon --- Cargo.lock | 113 ++++ iota-cli/src/controls/button.rs | 7 +- iota-cli/src/controls/header.rs | 62 +++ iota-cli/src/controls/mod.rs | 3 + iota-cli/src/controls/panel.rs | 23 + iota-cli/src/controls/scroll.rs | 20 + iota-cli/src/elements/console_card.rs | 151 ++++- iota-cli/src/elements/graph_card.rs | 48 +- iota-cli/src/elements/log_card.rs | 113 +++- iota-cli/src/input_handler.rs | 9 +- iota-cli/src/interaction_result.rs | 9 +- iota-cli/src/ipc_client.rs | 120 +++- iota-cli/src/lib.rs | 4 + iota-cli/src/screens/daemon_setup.rs | 13 +- iota-cli/src/screens/main_screen.rs | 236 +++++++- iota-cli/src/screens/md_viewer.rs | 9 +- iota-cli/src/screens/metrics.rs | 131 +++++ iota-cli/src/screens/overview.rs | 282 ++++++++++ iota-cli/src/screens/screens.rs | 125 ++++- iota-cli/src/screens/settings.rs | 390 +++++++++++++ iota-cli/src/screens/terms_checker.rs | 9 +- iota-cli/src/screens/terms_updater.rs | 9 +- iota-cli/src/screens/users.rs | 705 ++++++++++++++++++++++++ iota-cli/src/theme/config.rs | 13 + iota-cli/src/theme/mod.rs | 73 ++- iota-cli/src/theme/model.rs | 10 + iota-cli/src/theme/presets.rs | 13 +- iota-cli/src/ui.rs | 335 ++++++++++- iota-cli/tests/settings_snapshot.rs | 76 +++ iota-daemon-lib/Cargo.toml | 2 + iota-daemon-lib/src/command_router.rs | 202 +++++-- iota-daemon-lib/src/ipc_server.rs | 73 ++- iota-daemon-lib/src/lib.rs | 1 + iota-daemon-lib/src/log_broadcaster.rs | 15 +- iota-daemon-lib/src/log_buffer.rs | 36 ++ iota-daemon-lib/tests/command_router.rs | 5 +- iota-daemon/src/main.rs | 22 +- iota-ipc/src/lib.rs | 11 +- iota-ipc/src/protocol.rs | 143 ++++- iota-ipc/src/text_commands.rs | 324 +++++++++++ iota-storage/src/util/config_util.rs | 51 ++ iota/Cargo.toml | 3 + iota/src/cli_args.rs | 374 ++++++++++--- iota/src/main.rs | 347 +++++++++++- 44 files changed, 4417 insertions(+), 303 deletions(-) create mode 100644 iota-cli/src/controls/header.rs create mode 100644 iota-cli/src/controls/panel.rs create mode 100644 iota-cli/src/controls/scroll.rs create mode 100644 iota-cli/src/screens/metrics.rs create mode 100644 iota-cli/src/screens/overview.rs create mode 100644 iota-cli/src/screens/settings.rs create mode 100644 iota-cli/src/screens/users.rs create mode 100644 iota-cli/tests/settings_snapshot.rs create mode 100644 iota-daemon-lib/src/log_buffer.rs create mode 100644 iota-ipc/src/text_commands.rs diff --git a/Cargo.lock b/Cargo.lock index c5d5a73..175c62e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -682,6 +732,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "client" version = "0.1.0" @@ -749,6 +839,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -2089,12 +2185,15 @@ dependencies = [ name = "iota" version = "0.1.0" dependencies = [ + "clap", "iota-cli", "iota-core", "iota-installer", "iota-ipc", "iota-paths", "iota-process-manager", + "serde_json", + "serde_yaml", "tokio", "tokio-util", ] @@ -2268,10 +2367,12 @@ dependencies = [ "iota-logger", "iota-state", "iota-storage", + "iota-updater", "iota-util", "libc", "mtp", "omikron-connector", + "serde_yaml", "sysinfo", "tempfile", "tokio", @@ -2465,6 +2566,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3175,6 +3282,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs index 1f35bf5..b57833b 100644 --- a/iota-cli/src/controls/button.rs +++ b/iota-cli/src/controls/button.rs @@ -3,7 +3,7 @@ use ratatui::{ Frame, layout::{Alignment, Rect}, text::Span, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, }; use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,9 +39,8 @@ pub fn render_button( } }; frame.render_widget( - Paragraph::new(Span::styled(button.label, style)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), + Paragraph::new(Span::styled(if button.focused { format!("› {}", button.label) } else { button.label.to_owned() }, style)) + .alignment(Alignment::Center), area, ); } diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs new file mode 100644 index 0000000..71af229 --- /dev/null +++ b/iota-cli/src/controls/header.rs @@ -0,0 +1,62 @@ +use crate::theme::ResolvedTheme; +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + screens::screens::{AppAction, HitMap}, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::Span, + widgets::Paragraph, +}; + +/// Shared application bar. The brand cell is deliberately an action so it is +/// a reliable way home from every screen. +pub fn render_header( + frame: &mut Frame, + area: Rect, + title: &str, + theme: &ResolvedTheme, + hits: &mut HitMap, + focused_action: Option, +) { + let cells = Layout::horizontal([ + Constraint::Min(28), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(8), + ]) + .split(area); + frame.render_widget( + Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), + cells[0], + ); + hits.register(cells[0], AppAction::OpenMain); + for (index, (area, label, action)) in [ + (cells[1], "Overview", AppAction::OpenOverview), + (cells[2], "Users", AppAction::OpenUsers), + (cells[3], "Settings", AppAction::OpenSettings), + (cells[4], "Quit", AppAction::Quit), + ] + .into_iter() + .enumerate() + { + render_button( + frame, + area, + ActionButton { + label, + intent: if action == AppAction::Quit { + ButtonIntent::Destructive + } else { + ButtonIntent::Neutral + }, + focused: focused_action == Some(index), + enabled: true, + }, + theme, + ); + hits.register(area, action); + } +} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 8af4977..7ce1cdd 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -1,6 +1,9 @@ pub mod action; pub mod button; pub mod checkbox_group; +pub mod header; pub mod choice; pub mod navigation; +pub mod panel; pub mod radio_group; +pub mod scroll; diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs new file mode 100644 index 0000000..6e10e91 --- /dev/null +++ b/iota-cli/src/controls/panel.rs @@ -0,0 +1,23 @@ +use crate::theme::{ChromeMode, ResolvedTheme}; +use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; + +/// Draw a conventional outlined panel or a filled surface from the same call +/// site. Screens can migrate without embedding theme branches in layouts. +pub fn render_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool, theme: &ResolvedTheme) -> Rect { + match theme.chrome { + ChromeMode::Bordered => { + let block = Block::default().title(title).borders(Borders::ALL).border_style(if focused { theme.borders.focused } else { theme.borders.normal }); + let inner = block.inner(area); + frame.render_widget(block, area); + inner + } + ChromeMode::Surfaces => { + frame.render_widget(Block::default().style(if focused { theme.surfaces.panel_focused } else { theme.surfaces.panel }), area); + let header = Rect { x: area.x, y: area.y, width: area.width, height: area.height.min(1) }; + frame.render_widget(Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), header); + // Surface panels use a single header row. A one-cell inset keeps + // compact controls such as the console usable at height three. + Rect { x: area.x.saturating_add(1), y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: area.height.saturating_sub(1) } + } + } +} diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs new file mode 100644 index 0000000..c3036c4 --- /dev/null +++ b/iota-cli/src/controls/scroll.rs @@ -0,0 +1,20 @@ +use ratatui::{Frame, layout::Rect, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}}; + +/// Reusable viewport policy for long, vertically stacked terminal content. +#[derive(Clone, Copy, Debug)] +pub struct ScrollOptions { pub show_scrollbar: bool, pub render_partial_components: bool } +impl Default for ScrollOptions { fn default() -> Self { Self { show_scrollbar: true, render_partial_components: true } } } + +#[derive(Clone, Debug, Default)] +pub struct ScrollField { pub offset: u16, pub options: ScrollOptions } +impl ScrollField { + pub fn up(&mut self, amount: u16) { self.offset = self.offset.saturating_sub(amount); } + pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { self.offset = (self.offset.saturating_add(amount)).min(content_height.saturating_sub(viewport_height)); } + pub fn render(&self, frame: &mut Frame, area: Rect, content: Paragraph<'_>, content_height: u16) { + frame.render_widget(content.scroll((self.offset, 0)), area); + if self.options.show_scrollbar && content_height > area.height { + let mut state = ScrollbarState::new(content_height as usize).position(self.offset as usize); + frame.render_stateful_widget(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, &mut state); + } + } +} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index af69a24..e314f1d 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::Rect, @@ -35,6 +35,10 @@ pub struct ConsoleCard { last_swap: Arc>, pending_restore: Arc>>, pending_confirmation: Option, + history: Vec, + history_index: Option, + history_draft: String, + message: Option, } impl ConsoleCard { @@ -51,6 +55,10 @@ impl ConsoleCard { last_swap: Arc::new(Mutex::new(Instant::now())), pending_restore: Arc::new(Mutex::new(None)), pending_confirmation: None, + history: Vec::new(), + history_index: None, + history_draft: String::new(), + message: None, } } @@ -151,6 +159,9 @@ impl ConsoleCard { } fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + if let Some(message) = &self.message { + return vec![Span::styled(message.clone(), theme.console.error)]; + } if let Some(command) = &self.pending_confirmation { return vec![Span::styled( format!("Confirm `{command}`? [y/N]"), @@ -163,11 +174,17 @@ impl ConsoleCard { fn is_destructive(command: &str) -> bool { matches!( command.trim_start_matches('/').trim(), - "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" + "restart" + | "reload" + | "stop" + | "shutdown" + | "regenerate keys" + | "identity rotate" ) || command .trim_start_matches('/') .trim_start() - .starts_with("user remove ") + .split_once(" remove ") + .is_some_and(|(noun, _)| matches!(noun, "user" | "users")) } fn dispatch_command(&self, command: String) { @@ -214,6 +231,69 @@ impl ConsoleCard { self.content.insert(idx, c); self.cursor_position += 1; } + + pub fn handle_paste(&mut self, text: &str) { + let sanitized = text.replace(['\r', '\n'], " "); + let index = self.byte_index(); + self.content.insert_str(index, &sanitized); + self.cursor_position += sanitized.chars().count(); + self.message = None; + } + + fn set_editor(&mut self, value: String) { + self.content = value; + self.cursor_position = self.content.chars().count(); + } + + fn history_previous(&mut self) { + if self.history.is_empty() { + return; + } + let index = match self.history_index { + None => { + self.history_draft = self.content.clone(); + self.history.len() - 1 + } + Some(index) => index.saturating_sub(1), + }; + self.history_index = Some(index); + self.set_editor(self.history[index].clone()); + self.message = None; + } + + fn history_next(&mut self) { + let Some(index) = self.history_index else { + return; + }; + if index + 1 < self.history.len() { + self.history_index = Some(index + 1); + self.set_editor(self.history[index + 1].clone()); + } else { + self.history_index = None; + let draft = std::mem::take(&mut self.history_draft); + self.set_editor(draft); + } + self.message = None; + } + + fn complete(&mut self) -> bool { + let completions = iota_ipc::text_commands::completions(&self.content); + if completions.len() == 1 { + let leading_slash = self.content.starts_with('/'); + self.set_editor(format!( + "{}{}", + if leading_slash { "/" } else { "" }, + completions[0] + )); + self.message = None; + true + } else if completions.len() > 1 { + self.message = Some(format!("Matches: {}", completions.join(", "))); + true + } else { + false + } + } } impl Element for ConsoleCard { @@ -226,6 +306,21 @@ impl Element for ConsoleCard { } fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { + if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + let inner = crate::controls::panel::render_panel( + f, + r, + &self.title, + self.focused, + context.theme, + ); + f.render_widget( + Paragraph::new(Line::from(self.render_cursor_spans(context.theme))) + .style(context.theme.console.text), + inner, + ); + return; + } let block = Block::default() .borders(self.borders) .title(self.title.clone()) @@ -304,6 +399,7 @@ impl InteractableElement for ConsoleCard { if let Some(restored) = self.pending_restore.lock().unwrap().take() { self.content = restored; self.cursor_position = self.content.chars().count(); + self.message = Some("Command failed; restored for retry.".into()); } if let Some(command) = self.pending_confirmation.take() { @@ -320,6 +416,22 @@ impl InteractableElement for ConsoleCard { } let command = self.content.clone(); + if let Some(error) = iota_ipc::text_commands::validation_error(&command) { + self.message = Some(error); + return InteractionResult::Handled; + } + if command.trim_start_matches('/').trim() == "help" { + self.message = Some(format!( + "Commands: {}", + iota_ipc::text_commands::COMMANDS.join(", ") + )); + return InteractionResult::Handled; + } + if self.history.last() != Some(&command) { + self.history.push(command.clone()); + } + self.history_index = None; + self.history_draft.clear(); self.content.clear(); self.cursor_position = 0; if Self::is_destructive(&command) { @@ -330,10 +442,12 @@ impl InteractableElement for ConsoleCard { InteractionResult::Handled } KeyCode::Backspace => { + self.message = None; self.delete_at_cursor(); InteractionResult::Handled } KeyCode::Delete => { + self.message = None; let len = self.content.chars().count(); if self.cursor_position < len { let start = self.byte_index(); @@ -363,15 +477,36 @@ impl InteractableElement for ConsoleCard { self.cursor_position = self.content.chars().count(); InteractionResult::Handled } - KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, - _ => { - if let Some(c) = key.code.as_char() { - self.insert_at_cursor(c); + KeyCode::Up => { + self.history_previous(); + InteractionResult::Handled + } + KeyCode::Down => { + self.history_next(); + InteractionResult::Handled + } + KeyCode::Tab if !self.content.is_empty() => { + if self.complete() { InteractionResult::Handled } else { - InteractionResult::Unhandled + self.message = Some("No command completion.".into()); + InteractionResult::Handled } } + KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, + _ => { + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + if let Some(c) = key.code.as_char() { + self.insert_at_cursor(c); + self.message = None; + return InteractionResult::Handled; + } + } + InteractionResult::Unhandled + } } } diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index 0902b6d..a336772 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -34,15 +34,15 @@ impl GRAPHS { } } - pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> { + pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> { let state = match state.app.try_lock() { Ok(state) => state, Err(_) => return Vec::new(), }; match self { - GRAPHS::Ram => state.with_width(28).ram.clone(), - GRAPHS::Cpu => state.with_width(28).cpu.clone(), - GRAPHS::Ping => state.with_width(28).ping.clone(), + GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(), + GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(), + GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(), } } @@ -69,6 +69,7 @@ pub struct GraphCard { joins: Borders, open: bool, + sample_width: usize, } impl GraphCard { @@ -82,12 +83,17 @@ impl GraphCard { borders: Borders::ALL, joins: Borders::NONE, open: true, + sample_width: 28, } } pub fn set_open(&mut self, open: bool) { self.open = open; } + + pub fn set_sample_width(&mut self, sample_width: usize) { + self.sample_width = sample_width.max(1); + } } impl Element for GraphCard { fn as_any(&self) -> &dyn Any { @@ -100,7 +106,24 @@ impl Element for GraphCard { fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { if self.open { - let graph = self.graph_type.get_graph(&self.state); + let graph = self.graph_type.get_graph(&self.state, self.sample_width); + if graph.is_empty() { + let block = Block::default() + .title(format!(" {} ", self.title)) + .borders(self.borders) + .border_style(if self.focused { + context.theme.graphs.focused_border + } else { + context.theme.graphs.border + }); + f.render_widget( + ratatui::widgets::Paragraph::new("No metric samples yet.") + .style(context.theme.text.muted) + .block(block), + r, + ); + return; + } let unit = self.graph_type.get_unit(); let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); @@ -117,16 +140,19 @@ impl Element for GraphCard { GRAPHS::Ping => (max_y * 1.2).max(10.0), }; + let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); + let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64); + let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r }; let block = Block::default() - .title(format!( + .title(if surface { String::new() } else { format!( "{}:─{}{}─{}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64, - )) - .borders(self.borders) + ) }) + .borders(if surface { Borders::NONE } else { self.borders }) .border_style(if self.focused { context.theme.graphs.focused_border } else { @@ -148,7 +174,7 @@ impl Element for GraphCard { }); } }); - f.render_widget(canvas, r); + f.render_widget(canvas, plot_area); } else { let block = Block::default() .title("") @@ -160,7 +186,7 @@ impl Element for GraphCard { }); f.render_widget(block, r); } - draw_block_joins( + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, r, self.borders, @@ -170,7 +196,7 @@ impl Element for GraphCard { } else { context.theme.borders.normal }, - ); + ); } } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index 23c7107..3d5187b 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,7 +1,7 @@ use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::util::borders::draw_block_joins; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use iota_state::{ClientState, UiLogEntry}; use ratatui::{ Frame, @@ -10,7 +10,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::any::Any; +use std::{ + any::Any, + sync::atomic::{AtomicUsize, Ordering}, +}; use unicode_width::UnicodeWidthChar; #[derive(Clone, Copy)] @@ -55,8 +58,11 @@ pub struct LogCard { focused: bool, selected: bool, scroll_offset: usize, - last_total_lines: usize, - last_visible_height: usize, + last_total_lines: AtomicUsize, + last_visible_height: AtomicUsize, + last_width: AtomicUsize, + filter: String, + filtering: bool, pub borders: Borders, pub joins: Borders, } @@ -68,8 +74,11 @@ impl LogCard { focused: false, selected: false, scroll_offset: 0, - last_total_lines: 0, - last_visible_height: 10, + last_total_lines: AtomicUsize::new(0), + last_visible_height: AtomicUsize::new(1), + last_width: AtomicUsize::new(1), + filter: String::new(), + filtering: false, borders: Borders::ALL, joins: Borders::NONE, } @@ -80,9 +89,15 @@ impl LogCard { Ok(state) => state, Err(_) => return Vec::new(), }; + let needle = self.filter.to_ascii_lowercase(); state .get_logs() .iter() + .filter(|entry| { + needle.is_empty() + || entry.sender.to_ascii_lowercase().contains(&needle) + || entry.message.to_ascii_lowercase().contains(&needle) + }) .map(|e| UiLogEntry { timestamp_ms: e.timestamp_ms, sender: e.sender.clone(), @@ -214,11 +229,13 @@ impl LogCard { } fn get_title_hints(&self) -> (bool, bool) { - if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { + let total_lines = self.last_total_lines.load(Ordering::Relaxed); + let visible_height = self.last_visible_height.load(Ordering::Relaxed); + if total_lines == 0 || total_lines <= visible_height { return (false, false); } - let max_offset = self.last_total_lines - self.last_visible_height; + let max_offset = total_lines - visible_height; let can_scroll_up = self.scroll_offset < max_offset; let can_scroll_down = self.scroll_offset > 0; @@ -226,6 +243,12 @@ impl LogCard { } fn build_title(&self) -> String { + if self.filtering { + return format!("Logs filter: {}_", self.filter); + } + if !self.filter.is_empty() { + return format!("Logs [filter: {}]", self.filter); + } if !self.focused { return "Logs".to_string(); } @@ -250,7 +273,8 @@ impl LogCard { fn scroll_up(&mut self) { let max_offset = self .last_total_lines - .saturating_sub(self.last_visible_height); + .load(Ordering::Relaxed) + .saturating_sub(self.last_visible_height.load(Ordering::Relaxed)); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } @@ -297,17 +321,14 @@ impl Element for LogCard { fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { let entries = self.get_logs(); - let block = Block::default() - .title(self.build_title()) - .borders(self.borders) - .border_style(if self.focused { - context.theme.logs.focused_border - } else { - context.theme.logs.border - }); - - let inner_area = block.inner(area); - f.render_widget(block, area); + let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme) + } else { + let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border }); + let inner = block.inner(area); + f.render_widget(block, area); + inner + }; if inner_area.width == 0 || inner_area.height == 0 { draw_block_joins( @@ -327,6 +348,11 @@ impl Element for LogCard { let all_lines = self.build_all_lines(entries, inner_area.width as usize); let total_lines = all_lines.len(); let visible_height = inner_area.height as usize; + self.last_width + .store(inner_area.width as usize, Ordering::Relaxed); + self.last_total_lines.store(total_lines, Ordering::Relaxed); + self.last_visible_height + .store(visible_height, Ordering::Relaxed); let (start, end) = self.calculate_view_window(total_lines, visible_height); let visible_lines = &all_lines[start..end]; @@ -337,6 +363,7 @@ impl Element for LogCard { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); + let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix }; if !prefix.is_empty() { spans.push(Span::styled( @@ -377,7 +404,7 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - draw_block_joins( + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, area, self.borders, @@ -387,7 +414,7 @@ impl Element for LogCard { } else { context.theme.borders.normal }, - ); + ); } } } @@ -435,14 +462,44 @@ impl InteractableElement for LogCard { } fn interact(&mut self, key: KeyEvent) -> InteractionResult { + if self.filtering { + match key.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + } + KeyCode::Enter => self.filtering = false, + KeyCode::Backspace => { + self.filter.pop(); + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.filter.push(c); + } + _ => {} + } + self.scroll_offset = 0; + return InteractionResult::Handled; + } let entries = self.get_logs(); - let estimated_width = 80usize; - let all_lines = self.build_all_lines(entries, estimated_width); + let width = self.last_width.load(Ordering::Relaxed).max(1); + let all_lines = self.build_all_lines(entries, width); - self.last_total_lines = all_lines.len(); - let visible_height = self.last_visible_height.max(1); + self.last_total_lines + .store(all_lines.len(), Ordering::Relaxed); + let total_lines = all_lines.len(); + let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1); match key.code { + KeyCode::Char('/') => { + self.filtering = true; + self.filter.clear(); + self.scroll_offset = 0; + InteractionResult::Handled + } KeyCode::Enter | KeyCode::Char(' ') => { self.selected = !self.selected; InteractionResult::Handled @@ -476,8 +533,8 @@ impl InteractableElement for LogCard { InteractionResult::Handled } KeyCode::Home => { - if self.last_total_lines > visible_height { - self.scroll_offset = self.last_total_lines - visible_height; + if total_lines > visible_height { + self.scroll_offset = total_lines - visible_height; } InteractionResult::Handled } diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 3080480..842f89c 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,4 +1,4 @@ -use crate::ui::UI; +use crate::{screens::screens::UiEvent, ui::UI}; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; use std::time::Duration; @@ -27,8 +27,9 @@ pub fn setup_input_handler(ui: Arc) -> JoinHandle> { tokio::select! { event = rx.recv() => match event { Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, - Some(Event::Resize(_, _)) => ui.invalidate(), - Some(Event::Paste(text)) => ui.handle_paste(text).await, + Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await, + Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await, + Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await, Some(_) => {}, None => break, }, @@ -55,6 +56,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc) { { ui.request_shutdown(); } else { - ui.handle_input(key).await; + ui.handle_event(UiEvent::Key(key)).await; } } diff --git a/iota-cli/src/interaction_result.rs b/iota-cli/src/interaction_result.rs index f9ba7e0..8afad00 100644 --- a/iota-cli/src/interaction_result.rs +++ b/iota-cli/src/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::screens::screens::Screen; +use crate::screens::screens::{Screen, UiEvent}; #[allow(unused)] pub enum InteractionResult { @@ -13,6 +13,9 @@ pub enum InteractionResult { OpenFutureScreen { screen: Pin> + Send>>, }, + AppTask { + task: Pin + Send>>, + }, Handled, Unhandled, } @@ -22,6 +25,7 @@ impl Debug for InteractionResult { match self { InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), + InteractionResult::AppTask { task: _ } => write!(f, "AppTask"), InteractionResult::CloseScreen => write!(f, "CloseScreen"), InteractionResult::Handled => write!(f, "Handled"), InteractionResult::Unhandled => write!(f, "Unhandled"), @@ -36,6 +40,9 @@ impl PartialEq for InteractionResult { InteractionResult::OpenScreen { screen: _ }, InteractionResult::OpenScreen { screen: _ }, ) => true, + (InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => { + true + } ( InteractionResult::OpenFutureScreen { screen: _ }, InteractionResult::OpenFutureScreen { screen: _ }, diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 88a1eba..904947c 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -1,6 +1,6 @@ use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, - RequestEnvelope, ResponseResult, read_msg, write_msg, + RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg, }; use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; @@ -443,6 +443,92 @@ impl IpcClient { }); } + fn format_payload(payload: &ResponsePayload) -> String { + match payload { + ResponsePayload::Status(status) => { + let mut msg = format!("Phase: {}", status.phase); + if !status.tasks.is_empty() { + msg.push_str(&format!(", Tasks: {}", status.tasks.join(", "))); + } + if let Some(reason) = &status.degraded_reason { + msg.push_str(&format!(", Degraded: {reason}")); + } + msg + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + "No active tasks.".into() + } else { + tasks.iter().map(|t| t.name.as_str()).collect::>().join(", ") + } + } + ResponsePayload::Users(users) => { + if users.is_empty() { + "No users.".into() + } else { + users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::>().join("\n") + } + } + ResponsePayload::UserCreated { user_id, username } => { + format!("Created user {} ({})", username, user_id) + } + ResponsePayload::UserRemoved { user_id } => { + format!("Removed user {}", user_id) + } + ResponsePayload::Acknowledged { message } => message.clone(), + ResponsePayload::DaemonStatus(status) => status.formatted.clone(), + ResponsePayload::Config(config) => config.yaml.clone(), + ResponsePayload::OmikronStatus(status) => { + let mut msg = format!("Connected: {}", status.connected); + if let Some(id) = status.iota_id { + msg.push_str(&format!("\nIota ID: {}", id)); + } + msg + } + ResponsePayload::Components(components) => { + if components.is_empty() { + "No component health data available.".into() + } else { + components.iter().map(|c| { + let status_str = match c.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + format!("{:?}: {}", c.id, status_str) + }).collect::>().join("\n") + } + } + ResponsePayload::UserDetail(user) => { + let mut msg = format!("User: {} ({})", user.username, user.user_id); + if let Some(ref name) = user.display_name { + msg.push_str(&format!("\nDisplay Name: {name}")); + } + msg.push_str(&format!("\nCreated At: {}", user.created_at)); + if !user.trusted_apps.is_empty() { + msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); + } + msg + } + ResponsePayload::LogEntries(logs) => { + logs.entries.iter().map(|e| { + let level = if e.is_error { "ERR" } else { "INF" }; + format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) + }).collect::>().join("\n") + } + ResponsePayload::UpdateStatus(status) => { + if status.available { "Update available.".into() } else { "Up to date.".into() } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + "No communities.".into() + } else { + communities.iter().map(|c| format!("{} ({})", c.title, c.name)).collect::>().join("\n") + } + } + } + } + fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { match code { iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", @@ -496,29 +582,9 @@ impl IpcClient { } /// Parse a legacy console command string into a typed request. + /// Delegates to the shared parser in iota-ipc. pub fn parse_console_command(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["tasks"] => Some(LocalRequest::ListTasks), - ["user", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user", "remove", user_id_str] => { - let user_id = user_id_str.parse::().ok()?; - Some(LocalRequest::RemoveUser { user_id }) - } - ["user", "list"] => Some(LocalRequest::ListUsers), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - }), - _ => None, - } + iota_ipc::text_commands::parse(line) } /// Legacy command interface: parse text command, send as typed request. @@ -559,7 +625,7 @@ impl IpcClient { Ok(result) => { let mut state = self.state.app.lock().await; let message = match &result { - ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { @@ -595,8 +661,8 @@ impl IpcClient { .unwrap_or_default() .as_millis(), sender: "Console".into(), - message: if trimmed == "help" { - "Commands: status, tasks, ping, user add , user remove , user list, reconnect, regenerate keys, restart, stop" + message: if trimmed == "help" { + "Commands: status, tasks, ping, user add , user remove , user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components" .into() } else { format!("Unknown command: {}", line) @@ -695,7 +761,7 @@ impl IpcClient { } else { let mut state = self.state.app.lock().await; let message = match &response.result { - ResponseResult::Ok(msg) => msg.clone(), + ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index f54720b..eee1487 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -8,9 +8,13 @@ pub mod screens { pub mod daemon_setup; pub mod main_screen; pub mod md_viewer; + pub mod metrics; + pub mod overview; pub mod screens; + pub mod settings; pub mod terms_checker; pub mod terms_updater; + pub mod users; } pub mod util { pub mod borders; diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index 2dad4ce..627b1f2 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -6,9 +6,9 @@ use crate::{ }, interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::Screen, + screens::screens::{HitMap, Screen, UiEvent}, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -29,7 +29,7 @@ impl Screen for DaemonStartingScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -51,7 +51,7 @@ impl Screen for DaemonStartingScreen { popup, ); } - fn handle_input(&mut self, _: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, _: UiEvent) -> InteractionResult { InteractionResult::Handled } } @@ -191,7 +191,7 @@ impl Screen for DaemonSetupScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -266,7 +266,8 @@ impl Screen for DaemonSetupScreen { context.theme, ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; match event.code { KeyCode::Esc => { self.complete(DaemonSetupDecision::Exit); diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 19fa093..8676a76 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -1,4 +1,5 @@ use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, elements::{ console_card::ConsoleCard, elements::{InteractableElement, JoinableElement}, @@ -8,19 +9,28 @@ use crate::{ interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcConnectionState}, render_context::RenderContext, - screens::screens::{NavDirection, Screen}, + screens::{ + overview::OverviewScreen, + screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent}, + }, ui::UI, }; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Layout, Margin, Rect}, - widgets::{Block, Borders}, + layout::{Constraint, Layout, Rect}, + widgets::Borders, }; use tokio::sync::watch; -use std::{any::Any, sync::Arc}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, +}; pub struct MainScreen { elements: Vec>, @@ -29,9 +39,38 @@ pub struct MainScreen { graphs_open: bool, connection_status_rx: watch::Receiver, daemon_status_rx: watch::Receiver, + layout_width: AtomicU16, } impl MainScreen { + pub fn connection_status(&self) -> watch::Receiver { + self.connection_status_rx.clone() + } + pub fn daemon_status(&self) -> watch::Receiver { + self.daemon_status_rx.clone() + } + fn status_summary(&self) -> String { + let connection = match self.connection_status_rx.borrow().clone() { + IpcConnectionState::Connected => "[OK] Connected".to_owned(), + IpcConnectionState::Connecting => "[..] Connecting".to_owned(), + IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { + "[FAIL] Failed".to_owned() + } + IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), + }; + let daemon = self.daemon_status_rx.borrow().clone(); + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + let ready = daemon + .startup_phase + .map(|phase| format!(" {:?}", phase)) + .unwrap_or_default(); + format!("IOTA{version} {connection}{ready}") + } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -80,6 +119,7 @@ impl MainScreen { graphs_open, connection_status_rx, daemon_status_rx, + layout_width: AtomicU16::new(0), }; screen.focus_current(); screen @@ -172,6 +212,11 @@ impl MainScreen { let mut seen: Vec> = Vec::new(); for (y, row) in self.nav_grid.iter().enumerate() { for (x, elem_opt) in row.iter().enumerate() { + if x == 1 + && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) + { + continue; + } if elem_opt.is_some() && !seen.contains(elem_opt) { seen.push(*elem_opt); positions.push((y, x)); @@ -209,7 +254,8 @@ impl Screen for MainScreen { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + self.layout_width.store(rect.width, Ordering::Relaxed); // A watch Ref blocks senders until it is dropped. Rendering may do // terminal I/O, so retain only owned snapshots for the whole frame. let status = self.connection_status_rx.borrow().clone(); @@ -242,25 +288,15 @@ impl Screen for MainScreen { } else { format!(" v{}", daemon.version) }; - let main_block = Block::default() - .title(format!( - "Iota{version} [{status_text}; {readiness}{health}]" - )) - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - f.render_widget(main_block, rect); + let _ = (status_text, readiness, health, version); + f.render_widget( + ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), + rect, + ); + let inner = rect; - let inner = rect.inner(Margin { - vertical: 1, - horizontal: 1, - }); - - let graphs_width = if self.graphs_open && inner.width >= 70 { - 30 - } else { - 2 - }; + let metrics_visible = self.graphs_open && inner.width >= 70; + let graphs_width = if metrics_visible { 30 } else { 0 }; let main_width = inner.width.saturating_sub(graphs_width); let horizontal_chunks = Layout::default() @@ -273,9 +309,39 @@ impl Screen for MainScreen { let left_area = horizontal_chunks[0]; let right_area = horizontal_chunks[1]; + hits.register(left_area, AppAction::FocusLogs); + if metrics_visible { + hits.register(right_area, AppAction::FocusMetrics); + } + + if inner.width >= 70 { + let metrics_button = Rect { + x: right_area.x, + y: right_area.y, + width: right_area.width, + height: 1, + }; + render_button( + f, + metrics_button, + ActionButton { + label: if self.graphs_open { + "Hide metrics" + } else { + "Show metrics" + }, + intent: ButtonIntent::Neutral, + focused: false, + enabled: true, + }, + context.theme, + ); + hits.register(metrics_button, AppAction::ToggleMetrics); + } let left_rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); + hits.register(left_rows[1], AppAction::FocusConsole); if let Some(log) = self.elements.get(0) { log.as_element().render(f, left_rows[0], context); @@ -291,7 +357,7 @@ impl Screen for MainScreen { .filter(|el| el.as_any().is::()) .collect(); - if !graph_elements.is_empty() { + if metrics_visible && !graph_elements.is_empty() { let graph_chunks = Layout::vertical( graph_elements .iter() @@ -306,7 +372,40 @@ impl Screen for MainScreen { } } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + if let UiEvent::Paste(text) = &event { + if self.selected_coords == (2, 0) { + if let Some(console) = self + .elements + .get_mut(1) + .and_then(|element| element.as_any_mut().downcast_mut::()) + { + console.handle_paste(text); + return InteractionResult::Handled; + } + } + return InteractionResult::Unhandled; + } + if let UiEvent::Resize(width, _) = &event { + self.layout_width.store(*width, Ordering::Relaxed); + if *width < 70 && self.selected_coords.1 == 1 { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 0); + self.focus_current(); + } + return InteractionResult::Handled; + } + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; + // A focused console consumes text and cursor keys before dashboard + // shortcuts; commands such as `users` must remain typeable. + if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) + { + if let Some(console) = self.elements.get_mut(1) { + return console.interact(event); + } + } match event.code { KeyCode::Tab => { self.navigate_focus(true); @@ -316,6 +415,27 @@ impl Screen for MainScreen { self.navigate_focus(false); return InteractionResult::Handled; } + KeyCode::Char('o') | KeyCode::Char('O') => { + let conn_rx = self.connection_status_rx.clone(); + let daemon_rx = self.daemon_status_rx.clone(); + return InteractionResult::OpenScreen { + screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)), + }; + } + KeyCode::Char('u') | KeyCode::Char('U') => { + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(crate::screens::screens::AppEvent::OpenUsers) + }), + }; + } + KeyCode::Char('m') | KeyCode::Char('M') => { + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics) + }), + }; + } KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { self.graphs_open = !self.graphs_open; for element in self.elements.iter_mut() { @@ -347,4 +467,70 @@ impl Screen for MainScreen { InteractionResult::Handled } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::ToggleMetrics => { + self.graphs_open = !self.graphs_open; + for element in &mut self.elements { + if let Some(graph) = element.as_any_mut().downcast_mut::() { + graph.set_open(self.graphs_open); + } + } + InteractionResult::Handled + } + AppAction::OpenOverview => { + self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o')))) + } + AppAction::OpenUsers => { + self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u')))) + } + AppAction::FocusLogs => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 0); + self.focus_current(); + InteractionResult::Handled + } + AppAction::FocusConsole => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (2, 0); + self.focus_current(); + InteractionResult::Handled + } + AppAction::FocusMetrics => { + self.unfocus_current(self.selected_coords.0, self.selected_coords.1); + self.selected_coords = (0, 1); + self.focus_current(); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn app_title(&self) -> String { + self.status_summary() + } + fn key_hints(&self) -> Vec { + if self.selected_coords == (2, 0) { + vec![ + KeyHint { keys: "Enter", action: "Send" }, + KeyHint { keys: "Up/Down", action: "History" }, + KeyHint { keys: "Tab", action: "Complete" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } else if self.selected_coords == (0, 0) { + vec![ + KeyHint { keys: "J/K", action: "Scroll logs" }, + KeyHint { keys: "Enter", action: "Lock scroll" }, + KeyHint { keys: "/", action: "Filter" }, + KeyHint { keys: "M", action: "Metrics screen" }, + KeyHint { keys: "Tab", action: "Next panel" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } else { + vec![ + KeyHint { keys: "Enter", action: "Toggle metrics" }, + KeyHint { keys: "Tab", action: "Next panel" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } + } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index aaf6b9d..c9c4518 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use crossterm::event::{self, Event, KeyCode}; use ratatui::{ DefaultTerminal, prelude::*, @@ -10,7 +10,7 @@ use std::{any::Any, time::Duration}; use crate::{ interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::Screen, + screens::screens::{HitMap, Screen, UiEvent}, theme::{ResolvedTheme, TextSemantics, ThemeName}, }; @@ -29,11 +29,12 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { self.draw(f, rect, context.theme); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs new file mode 100644 index 0000000..705560c --- /dev/null +++ b/iota-cli/src/screens/metrics.rs @@ -0,0 +1,131 @@ +use std::any::Any; + +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::{ + elements::{ + elements::Element, + graph_card::{GRAPHS, GraphCard}, + }, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, + ui::UI, +}; + +const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")]; + +pub struct MetricsScreen { + graphs: Vec, + range_index: usize, +} + +impl MetricsScreen { + pub async fn new(ui: std::sync::Arc) -> Option { + let state = ui.client_state().await?; + let mut screen = Self { + graphs: vec![ + GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()), + GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()), + GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()), + ], + range_index: 0, + }; + screen.apply_range(); + Some(screen) + } + + fn apply_range(&mut self) { + let width = RANGES[self.range_index].0; + for graph in &mut self.graphs { + graph.set_sample_width(width); + } + } + + fn change_range(&mut self, delta: isize) { + self.range_index = + (self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize; + self.apply_range(); + } +} + +impl Screen for MetricsScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + let block = Block::default() + .title(" Metrics ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .split(inner); + frame.render_widget( + Paragraph::new(format!( + "Range: {} ({} samples) Left/Right to change", + RANGES[self.range_index].1, + RANGES[self.range_index].0 + )) + .style(context.theme.text.heading), + rows[0], + ); + for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) { + graph.render(frame, *graph_area, context); + } + hits.register(rows[0], AppAction::OpenMetrics); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Left => { + self.change_range(-1); + InteractionResult::Handled + } + KeyCode::Right => { + self.change_range(1); + InteractionResult::Handled + } + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Left/Right", + action: "Range", + }, + KeyHint { + keys: "Esc/B", + action: "Back", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } +} diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs new file mode 100644 index 0000000..0c6feda --- /dev/null +++ b/iota-cli/src/screens/overview.rs @@ -0,0 +1,282 @@ +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + ipc_client::{DaemonStatus, IpcConnectionState}, + render_context::RenderContext, + screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, +}; +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::{ + any::Any, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tokio::sync::watch; + +pub struct OverviewScreen { + connection_rx: watch::Receiver, + daemon_rx: watch::Receiver, + _focus: Focus, + scroll_offset: usize, + content_height: AtomicUsize, + viewport_height: AtomicUsize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Back, +} + +impl OverviewScreen { + pub fn new( + connection_rx: watch::Receiver, + daemon_rx: watch::Receiver, + ) -> Self { + Self { + connection_rx, + daemon_rx, + _focus: Focus::Back, + scroll_offset: 0, + content_height: AtomicUsize::new(0), + viewport_height: AtomicUsize::new(1), + } + } + + fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + let conn = self.connection_rx.borrow().clone(); + let daemon = self.daemon_rx.borrow().clone(); + + let mut lines = Vec::new(); + + lines.push(Line::from(Span::styled( + "Connection", + theme.text.heading, + ))); + lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); + lines.push(Line::from("")); + + lines.push(Line::from(Span::styled( + "Daemon", + theme.text.heading, + ))); + lines.push(Line::from(format!( + " Version: {}", + version_or_unknown(&daemon.version) + ))); + lines.push(Line::from(format!( + " Instance: {}", + truncate_id(&daemon.instance_id) + ))); + + let phase = daemon + .startup_phase + .map(|p| format!("{:?}", p)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Phase: {}", phase))); + + let lifecycle = daemon + .lifecycle + .map(|l| format!("{:?}", l)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Lifecycle: {}", lifecycle))); + + let health = match daemon.health { + iota_ipc::HealthStatus::Healthy => "[OK] Healthy", + iota_ipc::HealthStatus::Degraded => "[WARN] Degraded", + iota_ipc::HealthStatus::Failed => "[FAIL] Failed", + }; + lines.push(Line::from(format!(" Health: {health}"))); + + if let Some(ref reason) = daemon.degraded_reason { + lines.push(Line::from(Span::styled( + format!(" Degraded: {reason}"), + theme.status.warning, + ))); + } + + let mode = daemon + .deployment_mode + .map(|m| format!("{:?}", m)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Deployment: {mode}"))); + + let supervisor = daemon + .supervisor + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "Unknown".into()); + lines.push(Line::from(format!(" Supervisor: {supervisor}"))); + + if !daemon.components.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Components", + theme.text.heading, + ))); + for (id, health) in &daemon.components { + let status_str = match health.status { + iota_ipc::HealthStatus::Healthy => "[OK] healthy", + iota_ipc::HealthStatus::Degraded => "[WARN] degraded", + iota_ipc::HealthStatus::Failed => "[FAIL] failed", + }; + let suffix = health + .message + .as_deref() + .map(|m| format!(" ({m})")) + .unwrap_or_default(); + lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix))); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Press Esc or B to return to the dashboard", + theme.text.muted, + ))); + + lines + } +} + +fn connection_label(conn: &IpcConnectionState) -> String { + match conn { + IpcConnectionState::Connected => "Connected".into(), + IpcConnectionState::Connecting => "Connecting...".into(), + IpcConnectionState::Reconnecting { attempt } => { + format!("Reconnecting (attempt {attempt})...") + } + IpcConnectionState::Incompatible { message } => { + format!("Incompatible: {message}") + } + IpcConnectionState::Failed { message } => format!("Failed: {message}"), + IpcConnectionState::Disconnected => "Disconnected".into(), + } +} + +fn version_or_unknown(v: &str) -> String { + if v.is_empty() { + "Unknown".into() + } else { + v.into() + } +} + +fn truncate_id(id: &str) -> String { + if id.len() > 8 { + format!("{}…", &id[..8]) + } else { + id.into() + } +} + +impl Screen for OverviewScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let block = Block::default() + .title(" Overview ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner }; + let rows = ratatui::layout::Layout::vertical([ + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(1), + ]) + .split(inner); + + let lines = self.build_lines(context.theme); + self.content_height.store(lines.len(), Ordering::Relaxed); + self.viewport_height + .store(rows[0].height as usize, Ordering::Relaxed); + let par = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .scroll((self.scroll_offset as u16, 0)); + f.render_widget(par, rows[0]); + render_button( + f, + rows[1], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self._focus == Focus::Back, + enabled: true, + }, + context.theme, + ); + _hits.register(rows[1], AppAction::Back); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; + match event.code { + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); + self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + InteractionResult::Handled + } + KeyCode::PageDown => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + let max = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(page); + self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); + InteractionResult::Handled + } + KeyCode::PageUp => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.scroll_offset = self.scroll_offset.saturating_sub(page); + InteractionResult::Handled + } + KeyCode::Home => { + self.scroll_offset = 0; + InteractionResult::Handled + } + KeyCode::End => { + self.scroll_offset = self + .content_height + .load(Ordering::Relaxed) + .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + if action == AppAction::Back { + InteractionResult::CloseScreen + } else { + InteractionResult::Unhandled + } + } + fn key_hints(&self) -> Vec { + vec![ + KeyHint { keys: "Up/Down", action: "Scroll" }, + KeyHint { keys: "PgUp/PgDn", action: "Page" }, + KeyHint { keys: "Esc/B", action: "Back" }, + KeyHint { keys: "F6", action: "Header" }, + ] + } +} diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index b69d980..f2e61f5 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -1,10 +1,103 @@ use std::any::Any; -use crossterm::event::KeyEvent; +use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{Frame, layout::Rect}; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; +/// All terminal input that can affect the UI. Keeping this as one type makes +/// it impossible for screens to accidentally ignore a newly supported event. +#[derive(Debug, Clone)] +pub enum UiEvent { + Key(KeyEvent), + Mouse(MouseEvent), + Paste(String), + Resize(u16, u16), + App(AppEvent), +} + +/// Completion of background UI work. Keeping it in the regular event stream +/// gives screens an explicit success/failure path instead of detached tasks. +#[derive(Debug, Clone)] +pub enum AppEvent { + OpenUsers, + OpenMetrics, + ApplyTheme { + theme: crate::theme::ThemeName, + persist: bool, + }, + SaveSettings { + theme: crate::theme::ThemeName, + color: crate::theme::TerminalPolicy, + unicode: crate::theme::TerminalPolicy, + }, + ThemeSaved(Result<(), String>), + UsersLoaded(Result, String>), + UserCreated(Result), + UserRemoved { + user_id: i64, + result: Result<(), String>, + }, + RegenerateKeysRequested, + KeysRegenerated(Result<(), String>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppAction { + OpenOverview, + OpenUsers, + OpenSettings, + OpenMetrics, + ToggleMetrics, + AddUser, + RemoveUser, + Back, + Quit, + FocusLogs, + FocusConsole, + FocusMetrics, + OpenMain, + SelectUser(usize), + ConfirmDialog, + CancelDialog, + RegenerateKeys, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyHint { + pub keys: &'static str, + pub action: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HitRegion { + pub area: Rect, + pub action: AppAction, +} + +#[derive(Debug, Default, Clone)] +pub struct HitMap { + regions: Vec, +} + +impl HitMap { + pub fn register(&mut self, area: Rect, action: AppAction) { + self.regions.push(HitRegion { area, action }); + } + pub fn action_at(&self, column: u16, row: u16) -> Option { + self.regions + .iter() + .rev() + .find(|region| { + column >= region.area.x + && column < region.area.x.saturating_add(region.area.width) + && row >= region.area.y + && row < region.area.y.saturating_add(region.area.height) + }) + .map(|region| region.action) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavDirection { Up, @@ -20,6 +113,32 @@ pub trait Screen: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>); - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap); + fn handle_event(&mut self, event: UiEvent) -> InteractionResult; + fn handle_action(&mut self, _action: AppAction) -> InteractionResult { + InteractionResult::Unhandled + } + fn app_title(&self) -> String { + "IOTA".to_owned() + } + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Tab", + action: "Move focus", + }, + KeyHint { + keys: "Enter", + action: "Activate", + }, + KeyHint { + keys: "Esc", + action: "Back", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } } diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs new file mode 100644 index 0000000..474dfae --- /dev/null +++ b/iota-cli/src/screens/settings.rs @@ -0,0 +1,390 @@ +use std::any::Any; + +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, + theme::{TerminalPolicy, ThemeName, UiConfig}, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Theme, + RegenerateKeys, + Back, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Dialog { + ConfirmRegenerateKeys, +} + +pub struct SettingsScreen { + selected: usize, + saved: ThemeName, + message: String, + color: TerminalPolicy, + unicode: TerminalPolicy, + focus: Focus, + dialog: Option, + pending: bool, +} + +impl SettingsScreen { + pub fn new(current: ThemeName) -> Self { + let selected = ThemeName::ALL + .iter() + .position(|theme| *theme == current) + .unwrap_or(0); + Self { + selected, + saved: current, + message: "Left/Right previews. Enter saves.".into(), + color: UiConfig::load().map(|config| config.color).unwrap_or_default(), + unicode: UiConfig::load().map(|config| config.unicode).unwrap_or_default(), + focus: Focus::Theme, + dialog: None, + pending: false, + } + } + + fn selected_theme(&self) -> ThemeName { + ThemeName::ALL[self.selected] + } + + fn apply(&self, persist: bool) -> InteractionResult { + let theme = self.selected_theme(); + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::ApplyTheme { theme, persist }) + }), + } + } + + fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { + match policy { TerminalPolicy::Auto => TerminalPolicy::Always, TerminalPolicy::Always => TerminalPolicy::Never, TerminalPolicy::Never => TerminalPolicy::Auto } + } + + fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::Theme => Focus::RegenerateKeys, + Focus::RegenerateKeys => Focus::Back, + Focus::Back => Focus::Theme, + }; + } + + fn prev_focus(&mut self) { + self.focus = match self.focus { + Focus::Theme => Focus::Back, + Focus::Back => Focus::RegenerateKeys, + Focus::RegenerateKeys => Focus::Theme, + }; + } + + fn activate(&mut self) -> InteractionResult { + if self.pending { + return InteractionResult::Handled; + } + if let Some(dialog) = self.dialog.take() { + match dialog { + Dialog::ConfirmRegenerateKeys => { + self.pending = true; + self.message = "Regenerating keys…".into(); + return InteractionResult::AppTask { + task: Box::pin(async { + UiEvent::App(AppEvent::RegenerateKeysRequested) + }), + }; + } + } + } + match self.focus { + Focus::Theme => { + self.message = "Saving theme…".into(); + let theme = self.selected_theme(); + let color = self.color; + let unicode = self.unicode; + InteractionResult::AppTask { task: Box::pin(async move { UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) }) } + } + Focus::RegenerateKeys => { + self.dialog = Some(Dialog::ConfirmRegenerateKeys); + InteractionResult::Handled + } + Focus::Back => InteractionResult::CloseScreen, + } + } +} + +impl Screen for SettingsScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let block = Block::default() + .title(" Settings ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused); + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = + Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); + frame.render_widget( + Paragraph::new(format!( + "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", + self.selected_theme(), + if self.selected_theme() == self.saved { + " [saved]" + } else { + " [preview]" + }, self.color, self.unicode + )) + .style(context.theme.text.heading), + rows[0], + ); + + let bottom_rows = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); + + let lines = vec![ + Line::from(Span::styled(&self.message, context.theme.text.normal)), + Line::from(""), + Line::from("Preview"), + Line::from("[OK] Healthy"), + Line::from("[WARN] Degraded"), + Line::from("[FAIL] Failed"), + Line::from("> Focused action <"), + ]; + frame.render_widget( + Paragraph::new(lines).style(context.theme.text.normal), + bottom_rows[0], + ); + + let buttons_area = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(bottom_rows[1]); + + render_button( + frame, + buttons_area[0], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self.focus == Focus::Back && self.dialog.is_none(), + enabled: true, + }, + context.theme, + ); + hits.register(buttons_area[0], AppAction::Back); + + render_button( + frame, + buttons_area[1], + ActionButton { + label: "Regenerate Keys", + intent: ButtonIntent::Destructive, + focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(), + enabled: !self.pending, + }, + context.theme, + ); + hits.register(buttons_area[1], AppAction::RegenerateKeys); + + if self.dialog.is_some() { + frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area); + let popup = crate::layout::fit::centered_rect( + area, + crate::layout::fit::RequiredSize { + width: 42, + height: 7, + }, + ); + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + let popup_inner = block.inner(popup); + frame.render_widget(block, popup); + let dialog_rows = + Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); + frame.render_widget( + Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal), + dialog_rows[0], + ); + let dialog_buttons = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(dialog_rows[1]); + render_button( + frame, + dialog_buttons[0], + ActionButton { + label: "Cancel", + intent: ButtonIntent::Cancel, + focused: false, + enabled: true, + }, + context.theme, + ); + render_button( + frame, + dialog_buttons[1], + ActionButton { + label: "Regenerate", + intent: ButtonIntent::Destructive, + focused: true, + enabled: true, + }, + context.theme, + ); + hits.register(dialog_buttons[0], AppAction::CancelDialog); + hits.register(dialog_buttons[1], AppAction::ConfirmDialog); + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let event = match event { + UiEvent::App(AppEvent::ThemeSaved(result)) => { + match result { + Ok(()) => { + self.saved = self.selected_theme(); + self.message = "Theme saved to ui.yaml.".into(); + } + Err(error) => self.message = error, + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::KeysRegenerated(result)) => { + self.pending = false; + self.dialog = None; + match result { + Ok(()) => self.message = "Keys regenerated successfully.".into(), + Err(error) => self.message = error, + } + return InteractionResult::Handled; + } + event => event, + }; + + if self.dialog.is_some() { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + return match key.code { + KeyCode::Esc => { + self.dialog = None; + InteractionResult::Handled + } + KeyCode::Enter => self.activate(), + _ => InteractionResult::Handled, + }; + } + + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Left => { + if self.focus == Focus::Theme { + self.selected = self.selected.saturating_sub(1); + self.apply(false) + } else { + InteractionResult::Handled + } + } + KeyCode::Right => { + if self.focus == Focus::Theme { + self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1); + self.apply(false) + } else { + InteractionResult::Handled + } + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + KeyCode::Tab => { + self.next_focus(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_focus(); + InteractionResult::Handled + } + KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); InteractionResult::Handled } + KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + } + } + + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::Back => InteractionResult::CloseScreen, + AppAction::RegenerateKeys => { + self.focus = Focus::RegenerateKeys; + self.activate() + } + AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), + AppAction::CancelDialog if self.dialog.is_some() => { + self.dialog = None; + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + if self.dialog.is_some() { + vec![ + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } else { + vec![ + KeyHint { + keys: "Left/Right", + action: "Preview theme", + }, + KeyHint { + keys: "Enter", + action: "Save/Activate", + }, + KeyHint { keys: "Tab", action: "Move focus" }, + KeyHint { keys: "C/U", action: "Color/Unicode" }, + KeyHint { + keys: "Esc/B", + action: "Back", + }, + ] + } + } +} diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index c44cbcb..99e28e3 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,10 +2,10 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::Screen}, + screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, util::{buttons::draw_buttons, terms_focus::Focus}, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use iota_terms::{TermsType, get_link, get_terms}; use ratatui::{ Frame, @@ -53,7 +53,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -270,7 +270,8 @@ impl Screen for TermsCheckerScreen { ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index f1701b5..c298e9a 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -3,11 +3,11 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::Screen}, + screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; use ratatui::{ Frame, @@ -121,7 +121,7 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let checkbox = |label, selected, focused, enabled| { render_choice_line( label, @@ -593,7 +593,8 @@ impl Screen for TermsUpdaterScreen { ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; let mut possible_states = Vec::new(); if self.eula_needed { diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs new file mode 100644 index 0000000..14f1662 --- /dev/null +++ b/iota-cli/src/screens/users.rs @@ -0,0 +1,705 @@ +use crate::{ + controls::{ + button::{ActionButton, ButtonIntent, render_button}, + choice::{ChoiceKind, render_choice_line}, + }, + interaction_result::InteractionResult, + ipc_client::IpcClient, + render_context::RenderContext, + screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, +}; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +#[derive(Clone, Debug)] +pub struct UserEntry { + pub user_id: i64, + pub username: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + List, + AddButton, + RemoveButton, + Back, +} +#[derive(Clone, Debug)] +enum Dialog { + Add { username: String }, + Remove { user: UserEntry }, +} + +pub struct UsersScreen { + users: Vec, + focused_index: usize, + focus: Focus, + ipc: Arc, + message: Option, + dialog: Option, + pending_dialog: Option, + loading: bool, + pending: bool, + scroll_offset: usize, + viewport_height: AtomicUsize, + filter: String, + filtering: bool, +} + +impl UsersScreen { + pub fn new(ipc: Arc, users: Vec) -> Self { + Self { + users, + focused_index: 0, + focus: Focus::List, + ipc, + message: None, + dialog: None, + pending_dialog: None, + loading: false, + pending: false, + scroll_offset: 0, + viewport_height: AtomicUsize::new(1), + filter: String::new(), + filtering: false, + } + } + + pub fn loading(ipc: Arc) -> Self { + let mut screen = Self::new(ipc, Vec::new()); + screen.loading = true; + screen.message = Some("Loading users…".into()); + screen + } + + fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let visible_indices = self.filtered_indices(); + let title = if self.filter.is_empty() { + format!("Users ({})", self.users.len()) + } else { + format!("Users ({}/{}) filter: {}", visible_indices.len(), self.users.len(), self.filter) + }; + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel( + f, + area, + &title, + self.focus == Focus::List, + context.theme, + ) + } else { + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + f.render_widget(block, area); + inner + }; + + if self.loading { + f.render_widget(Paragraph::new("Loading users…"), inner); + return; + } + if visible_indices.is_empty() { + let par = Paragraph::new(if self.users.is_empty() { + "No users found." + } else { + "No users match the filter." + }); + f.render_widget(par, inner); + return; + } + + let mut lines = Vec::new(); + self.viewport_height.store(inner.height as usize, Ordering::Relaxed); + let labels: Vec<(usize, String)> = visible_indices + .iter() + .skip(self.scroll_offset) + .take(inner.height as usize) + .map(|user_index| { + let user = &self.users[*user_index]; + (*user_index, format!("{:>6} {}", user.user_id, user.username)) + }) + .collect(); + for (user_index, label) in &labels { + let visual = crate::controls::choice::ChoiceVisualState { + selected: false, + focused: self.focus == Focus::List && *user_index == self.focused_index, + enabled: !self.loading && !self.pending, + }; + lines.push(render_choice_line( + &label, + ChoiceKind::Radio, + visual, + context.theme, + )); + } + let par = Paragraph::new(lines); + f.render_widget(par, inner); + } + + fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area); + + if let Some(msg) = &self.message { + let par = Paragraph::new(Line::from(Span::styled( + msg.as_str(), + context.theme.text.muted, + ))); + f.render_widget(par, rows[0]); + } + + let buttons_area = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(rows[1]); + + render_button( + f, + buttons_area[0], + ActionButton { + label: "Back", + intent: ButtonIntent::Cancel, + focused: self.focus == Focus::Back, + enabled: true, + }, + context.theme, + ); + render_button( + f, + buttons_area[1], + ActionButton { + label: "Add", + intent: ButtonIntent::Primary, + focused: self.focus == Focus::AddButton, + enabled: true, + }, + context.theme, + ); + render_button( + f, + buttons_area[2], + ActionButton { + label: "Remove", + intent: ButtonIntent::Destructive, + focused: self.focus == Focus::RemoveButton, + enabled: !self.loading && !self.pending && !self.users.is_empty(), + }, + context.theme, + ); + } + + fn activate(&mut self) -> InteractionResult { + if self.loading || self.pending { + return InteractionResult::Handled; + } + if let Some(dialog) = self.dialog.take() { + match dialog { + Dialog::Add { username } if !username.trim().is_empty() => { + let name = username.trim().to_owned(); + self.pending_dialog = Some(Dialog::Add { username }); + self.pending = true; + self.message = Some("Creating user…".into()); + let ipc = self.ipc.clone(); + return InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), + Err(error) => Err(format!("Cannot create user: {error}")), + }; + UiEvent::App(AppEvent::UserCreated(result)) + }), + }; + } + Dialog::Remove { user } => { + self.pending_dialog = Some(Dialog::Remove { user: user.clone() }); + let ipc = self.ipc.clone(); + let id = user.user_id; + self.pending = true; + self.message = Some(format!("Removing {}…", user.username)); + return InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), + Err(error) => Err(format!("Cannot remove user: {error}")), + }; + UiEvent::App(AppEvent::UserRemoved { + user_id: id, + result, + }) + }), + }; + } + Dialog::Add { .. } => self.message = Some("A username is required.".into()), + } + return InteractionResult::Handled; + } + match self.focus { + Focus::Back => InteractionResult::CloseScreen, + Focus::AddButton => { + self.dialog = Some(Dialog::Add { + username: String::new(), + }); + InteractionResult::Handled + } + Focus::RemoveButton => { + if let Some(user) = self.users.get(self.focused_index) { + self.dialog = Some(Dialog::Remove { user: user.clone() }); + } + InteractionResult::Handled + } + Focus::List => InteractionResult::Handled, + } + } + + fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::AddButton, + Focus::AddButton => Focus::RemoveButton, + Focus::RemoveButton => Focus::Back, + Focus::Back => Focus::List, + }; + } + + fn prev_focus(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::Back, + Focus::Back => Focus::RemoveButton, + Focus::RemoveButton => Focus::AddButton, + Focus::AddButton => Focus::List, + }; + } + + fn keep_focused_user_visible(&mut self) { + let indices = self.filtered_indices(); + let Some(position) = indices.iter().position(|index| *index == self.focused_index) else { + self.scroll_offset = 0; + return; + }; + let height = self.viewport_height.load(Ordering::Relaxed).max(1); + if position < self.scroll_offset { + self.scroll_offset = position; + } else if position >= self.scroll_offset + height { + self.scroll_offset = position + 1 - height; + } + } + + fn move_user_focus(&mut self, index: usize) { + if !self.users.is_empty() { + self.focused_index = index.min(self.users.len() - 1); + self.keep_focused_user_visible(); + } + } + + fn filtered_indices(&self) -> Vec { + let needle = self.filter.to_ascii_lowercase(); + self.users + .iter() + .enumerate() + .filter(|(_, user)| { + needle.is_empty() + || user.username.to_ascii_lowercase().contains(&needle) + || user.user_id.to_string().contains(&needle) + }) + .map(|(index, _)| index) + .collect() + } + + fn move_visible(&mut self, delta: isize) { + let indices = self.filtered_indices(); + if indices.is_empty() { + return; + } + let current = indices + .iter() + .position(|index| *index == self.focused_index) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize; + self.move_user_focus(indices[next]); + } + + fn reset_focus_to_filter(&mut self) { + self.scroll_offset = 0; + if let Some(index) = self.filtered_indices().first().copied() { + self.focused_index = index; + } + } +} + +impl Screen for UsersScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + let outer_block = Block::default() + .title(" Users ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, rect, "Users", false, context.theme) + } else { + let inner = outer_block.inner(rect); + f.render_widget(outer_block, rect); + inner + }; + + let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner); + + self.render_user_list(f, chunks[0], context); + self.render_actions(f, chunks[1], context); + if let Some(dialog) = &self.dialog { + f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect); + let popup = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 42, + height: 7, + }, + ); + let text = match dialog { + Dialog::Add { username } => { + format!("Add user\nUsername: {username}") + } + Dialog::Remove { user } => format!( + "Remove user {} (ID {})?\nThis removes the local user record.", + user.username, user.user_id + ), + }; + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + let popup_inner = block.inner(popup); + f.render_widget(block, popup); + let dialog_rows = + Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); + f.render_widget( + Paragraph::new(text).style(context.theme.text.normal), + dialog_rows[0], + ); + let dialog_buttons = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(dialog_rows[1]); + render_button( + f, + dialog_buttons[0], + ActionButton { + label: "Cancel", + intent: ButtonIntent::Cancel, + focused: false, + enabled: true, + }, + context.theme, + ); + render_button( + f, + dialog_buttons[1], + ActionButton { + label: match dialog { + Dialog::Add { .. } => "Create", + Dialog::Remove { .. } => "Remove", + }, + intent: match dialog { + Dialog::Add { .. } => ButtonIntent::Primary, + Dialog::Remove { .. } => ButtonIntent::Destructive, + }, + focused: true, + enabled: true, + }, + context.theme, + ); + hits.register(dialog_buttons[0], AppAction::CancelDialog); + hits.register(dialog_buttons[1], AppAction::ConfirmDialog); + } + let buttons = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(chunks[1]); + if self.dialog.is_none() { + hits.register(buttons[0], AppAction::Back); + } + if self.dialog.is_none() && !self.loading && !self.pending { + hits.register(buttons[1], AppAction::AddUser); + } + if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() { + hits.register(buttons[2], AppAction::RemoveUser); + } + if self.dialog.is_none() { + let list_height = chunks[0].height.saturating_sub(2) as usize; + let filtered_indices = self.filtered_indices(); + for visible in 0..list_height { + let position = self.scroll_offset + visible; + let Some(index) = filtered_indices.get(position).copied() else { + break; + }; + hits.register( + Rect { + x: chunks[0].x.saturating_add(1), + y: chunks[0].y.saturating_add(1 + visible as u16), + width: chunks[0].width.saturating_sub(2), + height: 1, + }, + AppAction::SelectUser(index), + ); + } + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let event = match event { + UiEvent::App(AppEvent::UsersLoaded(result)) => { + self.loading = false; + match result { + Ok(users) => { + self.users = users; + self.message = None; + } + Err(error) => self.message = Some(error), + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::UserCreated(result)) => { + self.pending = false; + match result { + Ok(user) => { + self.pending_dialog = None; + self.focused_index = self.users.len(); + self.users.push(user.clone()); + self.message = Some(format!( + "Created user {} ({}).", + user.username, user.user_id + )); + } + Err(error) => { + self.dialog = self.pending_dialog.take(); + self.message = Some(error); + } + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::UserRemoved { user_id, result }) => { + self.pending = false; + match result { + Ok(()) => { + self.pending_dialog = None; + self.users.retain(|user| user.user_id != user_id); + self.focused_index = + self.focused_index.min(self.users.len().saturating_sub(1)); + self.message = Some(format!("Removed user {user_id}.")); + } + Err(error) => { + self.dialog = self.pending_dialog.take(); + self.message = Some(error); + } + } + return InteractionResult::Handled; + } + UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => { + if let Some(Dialog::Add { username }) = self.dialog.as_mut() { + username.push_str(&text.replace(['\r', '\n'], " ")); + } + return InteractionResult::Handled; + } + UiEvent::Key(event) => event, + _ => return InteractionResult::Unhandled, + }; + if self.filtering && self.dialog.is_none() { + match event.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + self.reset_focus_to_filter(); + } + KeyCode::Enter => self.filtering = false, + KeyCode::Backspace => { + self.filter.pop(); + self.reset_focus_to_filter(); + } + KeyCode::Char(c) + if !event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.filter.push(c); + self.reset_focus_to_filter(); + } + _ => {} + } + return InteractionResult::Handled; + } + if let Some(Dialog::Add { username }) = self.dialog.as_mut() { + match event.code { + KeyCode::Esc => { + self.dialog = None; + return InteractionResult::Handled; + } + KeyCode::Enter => return self.activate(), + KeyCode::Backspace => { + username.pop(); + return InteractionResult::Handled; + } + KeyCode::Char(c) + if !c.is_control() + && !event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + username.push(c); + return InteractionResult::Handled; + } + _ => return InteractionResult::Handled, + } + } + if self.dialog.is_some() { + return match event.code { + KeyCode::Esc => { + self.dialog = None; + InteractionResult::Handled + } + KeyCode::Enter => self.activate(), + _ => InteractionResult::Handled, + }; + } + match event.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Char('/') if self.focus == Focus::List => { + self.filtering = true; + self.filter.clear(); + self.reset_focus_to_filter(); + InteractionResult::Handled + } + KeyCode::Tab => { + self.next_focus(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_focus(); + InteractionResult::Handled + } + KeyCode::Down | KeyCode::Char('j') => { + if self.focus == Focus::List { + self.move_visible(1); + } + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + if self.focus == Focus::List { + self.move_visible(-1); + } + InteractionResult::Handled + } + KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.move_visible(page as isize); + InteractionResult::Handled + } + KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => { + let page = self.viewport_height.load(Ordering::Relaxed).max(1); + self.move_visible(-(page as isize)); + InteractionResult::Handled + } + KeyCode::Home if self.focus == Focus::List => { + if let Some(index) = self.filtered_indices().first().copied() { + self.move_user_focus(index); + } + InteractionResult::Handled + } + KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => { + if let Some(index) = self.filtered_indices().last().copied() { + self.move_user_focus(index); + } + InteractionResult::Handled + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + _ => InteractionResult::Unhandled, + } + } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::Back => InteractionResult::CloseScreen, + AppAction::AddUser => { + self.focus = Focus::AddButton; + self.activate() + } + AppAction::RemoveUser => { + self.focus = Focus::RemoveButton; + self.activate() + } + AppAction::SelectUser(index) if self.dialog.is_none() => { + self.focus = Focus::List; + self.move_user_focus(index); + InteractionResult::Handled + } + AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), + AppAction::CancelDialog if self.dialog.is_some() => { + self.dialog = None; + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn key_hints(&self) -> Vec { + if self.dialog.is_some() { + vec![ + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } else { + vec![ + KeyHint { + keys: "Up/Down", + action: "Select user", + }, + KeyHint { + keys: "PgUp/PgDn", + action: "Page", + }, + KeyHint { + keys: "/", + action: "Filter", + }, + KeyHint { + keys: "Tab", + action: "Move focus", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } + } +} diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index e77c7e6..79d1990 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -13,6 +13,19 @@ pub struct UiConfig { /// Whether opening the interactive UI should launch a locally installed daemon. #[serde(default)] pub daemon_start_policy: DaemonStartPolicy, + #[serde(default)] + pub color: TerminalPolicy, + #[serde(default)] + pub unicode: TerminalPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum TerminalPolicy { + #[default] + Auto, + Always, + Never, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index d103e58..d185c6f 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,10 +3,81 @@ mod model; mod name; mod presets; -pub use config::{DaemonStartPolicy, UiConfig}; +pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; pub fn resolve(name: ThemeName) -> ResolvedTheme { presets::resolve(name) } + +pub fn resolve_with_capabilities( + name: ThemeName, + color_enabled: bool, + unicode_enabled: bool, +) -> ResolvedTheme { + let mut theme = if color_enabled { + presets::resolve(name) + } else { + presets::resolve(ThemeName::Monospace) + }; + theme.name = name; + theme.unicode = unicode_enabled; + if !unicode_enabled { + if matches!(theme.console.cursor, CursorPresentation::Character { .. }) { + theme.console.cursor = CursorPresentation::Character { + glyph: "|", + style: theme.console.text, + }; + } + } + theme +} + +/// Resolve a theme against the terminal's color depth. Surface uses RGB +/// colors, so a portable ANSI preset is selected when truecolor is absent. +pub fn resolve_with_terminal_profile( + name: ThemeName, + color_enabled: bool, + unicode_enabled: bool, + truecolor_enabled: bool, +) -> ResolvedTheme { + let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) { + ThemeName::Ansi + } else { + name + }; + resolve_with_capabilities(effective, color_enabled, unicode_enabled) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + #[test] + fn no_color_policy_removes_palette_dependencies() { + let theme = resolve_with_capabilities(ThemeName::Surface, false, true); + assert_eq!(theme.name, ThemeName::Surface); + assert_eq!(theme.status.error.fg, None); + assert_eq!(theme.surfaces.panel.bg, None); + } + + #[test] + fn ascii_policy_replaces_character_cursor() { + let theme = resolve_with_capabilities(ThemeName::Monospace, false, false); + assert!(!theme.unicode); + match theme.console.cursor { + CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"), + CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"), + } + assert_ne!(theme.graphs.ram, Color::Blue); + } + + #[test] + fn surface_uses_ansi_fallback_without_truecolor() { + let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false); + assert_eq!(theme.name, ThemeName::Ansi); + assert_eq!(theme.surfaces.panel.bg, None); + } +} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs index fd631dc..e107f39 100644 --- a/iota-cli/src/theme/model.rs +++ b/iota-cli/src/theme/model.rs @@ -24,6 +24,13 @@ pub struct BorderStyles { pub title: Style, } #[derive(Clone, Debug)] +pub struct SurfaceStyles { + pub canvas: Style, pub toolbar: Style, pub panel: Style, pub panel_alternate: Style, + pub panel_focused: Style, pub panel_selected: Style, pub footer: Style, pub overlay: Style, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChromeMode { Bordered, Surfaces } +#[derive(Clone, Debug)] pub struct ChoiceItemStyle { pub marker: Style, pub label: Style, @@ -119,6 +126,9 @@ pub struct TextSemantics { #[derive(Clone, Debug)] pub struct ResolvedTheme { pub name: ThemeName, + pub unicode: bool, + pub surfaces: SurfaceStyles, + pub chrome: ChromeMode, pub text: TextStyles, pub status: StatusStyles, pub choices: ChoiceStyles, diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs index 333a221..33b8ff0 100644 --- a/iota-cli/src/theme/presets.rs +++ b/iota-cli/src/theme/presets.rs @@ -1,6 +1,6 @@ use super::{ BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, - GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles, + ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles, ThemeName, }; use ratatui::style::{Color, Modifier, Style}; @@ -45,6 +45,9 @@ fn base( }; ResolvedTheme { name, + unicode: true, + surfaces: SurfaceStyles { canvas: Style::default(), toolbar: Style::default(), panel: Style::default(), panel_alternate: Style::default(), panel_focused: focused, panel_selected: selected, footer: Style::default(), overlay: Style::default() }, + chrome: ChromeMode::Bordered, text: TextStyles { normal, muted, @@ -295,6 +298,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme { ); theme.console.cursor = CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); + theme.chrome = ChromeMode::Surfaces; + theme.surfaces = SurfaceStyles { + canvas: plain.bg(Color::Black), toolbar: plain.fg(Color::White).bg(Color::DarkGray), + panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)), + panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), + panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)), + panel_selected: selected, footer: plain.fg(Color::DarkGray).bg(Color::Black), overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), + }; theme } } diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 21d8bd4..25f7405 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,14 +1,28 @@ use crate::{ + controls::header::render_header, input_handler::setup_input_handler, interaction_result::InteractionResult, ipc_client::IpcClient, render_context::RenderContext, - screens::screens::Screen, + screens::{ + main_screen::MainScreen, + metrics::MetricsScreen, + overview::OverviewScreen, + screens::{AppAction, AppEvent, HitMap, Screen, UiEvent}, + settings::SettingsScreen, + users::{UserEntry, UsersScreen}, + }, theme::{self, ResolvedTheme, ThemeName}, }; -use crossterm::event::KeyEvent; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind, +}; use once_cell::sync::Lazy; -use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Layout}, +}; use std::{ io, io::Stdout, @@ -18,7 +32,7 @@ use std::{ atomic::{AtomicBool, Ordering}, }, }; -use tokio::sync::{Notify, RwLock}; +use tokio::sync::{Notify, RwLock, mpsc}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -35,6 +49,10 @@ pub struct UI { theme: RwLock>, pub(crate) invalidation: Notify, failure: Arc>>, + hits: Mutex, + app_event_tx: mpsc::UnboundedSender, + app_event_rx: Mutex>>, + header_focus: Mutex>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result io::Result { let ui = Arc::new(ui); + let mut app_event_rx = ui + .app_event_rx + .lock() + .map_err(|_| io::Error::other("application event queue poisoned"))? + .take() + .ok_or_else(|| io::Error::other("application event queue already started"))?; + let app_ui = ui.clone(); + let app_event_task = tokio::spawn(async move { + loop { + tokio::select! { + _ = app_ui.cancellation.cancelled() => break, + event = app_event_rx.recv() => match event { + Some(event) => app_ui.clone().handle_event(event).await, + None => break, + }, + } + } + }); let uic = ui.clone(); let renderer_task = tokio::spawn(async move { let cancellation = uic.cancellation_token(); @@ -62,7 +98,6 @@ fn start_session(ui: UI) -> io::Result { tokio::select! { _ = cancellation.cancelled() => break Ok(()), _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, - _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } }, } }; if let Err(error) = &result { @@ -101,6 +136,7 @@ fn start_session(ui: UI) -> io::Result { ui, renderer_task, input_task, + app_event_task, signal_task, restored: AtomicBool::new(false), previous_hook, @@ -111,6 +147,7 @@ pub struct TuiSession { ui: Arc, renderer_task: JoinHandle>, input_task: JoinHandle>, + app_event_task: JoinHandle<()>, signal_task: Option>, restored: AtomicBool, previous_hook: Arc) + Send + Sync + 'static>>>>, @@ -129,6 +166,7 @@ impl TuiSession { tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; let input = tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; + self.app_event_task.abort(); if renderer.is_err() { self.renderer_task.abort(); } @@ -154,6 +192,7 @@ impl TuiSession { } fn restore_terminal_once(&self) { if !self.restored.swap(true, Ordering::AcqRel) { + let _ = crossterm::execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); } } @@ -168,6 +207,7 @@ impl Drop for TuiSession { self.ui.request_shutdown(); self.renderer_task.abort(); self.input_task.abort(); + self.app_event_task.abort(); if let Some(task) = self.signal_task.as_ref() { task.abort(); } @@ -182,6 +222,8 @@ impl UI { theme: ResolvedTheme, ) -> io::Result { let terminal = ratatui::try_init()?; + crossterm::execute!(io::stdout(), EnableMouseCapture)?; + let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); Ok(Self { ipc: RwLock::new(ipc), shutdown_on_empty, @@ -191,6 +233,10 @@ impl UI { theme: RwLock::new(Arc::new(theme)), invalidation: Notify::new(), failure: Arc::new(Mutex::new(None)), + hits: Mutex::new(HitMap::default()), + app_event_tx, + app_event_rx: Mutex::new(Some(app_event_rx)), + header_focus: Mutex::new(None), }) } @@ -228,10 +274,6 @@ impl UI { pub fn failure(&self) -> Option { self.failure.lock().ok().and_then(|f| f.clone()) } - pub async fn handle_paste(&self, _text: String) { - self.invalidate(); - } - /// Lets bootstrap operations race their work against Ctrl+C without /// blocking the input task or leaving the terminal in raw mode. pub async fn wait_for_shutdown(&self) { @@ -259,10 +301,157 @@ impl UI { self.invalidate(); } pub async fn handle_input(self: Arc, key_event: KeyEvent) { + self.handle_event(UiEvent::Key(key_event)).await; + } + pub async fn handle_event(self: Arc, event: UiEvent) { + if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) { + self.open_users().await; + return; + } + if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) { + if let Some(screen) = MetricsScreen::new(self.clone()).await { + self.set_screen(Box::new(screen)).await; + } + return; + } + if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event { + self.set_theme(theme::resolve(*theme)).await; + if *persist { + let mut config = theme::UiConfig::load().unwrap_or_default(); + config.theme = *theme; + let result = config + .save() + .map_err(|error| format!("Could not save UI settings: {error}")); + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::ThemeSaved(result))); + } + return; + } + if let UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) = &event { + self.set_theme(theme::resolve(*theme)).await; + let mut config = theme::UiConfig::load().unwrap_or_default(); + config.theme = *theme; + config.color = *color; + config.unicode = *unicode; + let result = config + .save() + .map_err(|error| format!("Could not save UI settings: {error}")); + let _ = self.app_event_tx.send(UiEvent::App(AppEvent::ThemeSaved(result))); + return; + } + if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { + let Some(ipc) = self.ipc().await else { + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::KeysRegenerated(Err( + "Not connected to daemon.".into(), + )))); + return; + }; + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::RotateIotaIdentity) + .await + { + Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot regenerate keys: {error}")) + } + Err(error) => Err(format!("Cannot regenerate keys: {error}")), + }; + let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result))); + }); + return; + } + if let UiEvent::Key(key) = &event { + let header_is_focused = self + .header_focus + .lock() + .map(|focus| focus.is_some()) + .unwrap_or(false); + if key.code == KeyCode::F(6) { + if let Ok(mut focus) = self.header_focus.lock() { + *focus = if focus.is_some() { None } else { Some(0) }; + } + self.invalidate(); + return; + } + if header_is_focused { + let mut action = None; + if let Ok(mut focus) = self.header_focus.lock() { + let index = focus.unwrap_or(0); + match key.code { + KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), + KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), + KeyCode::Enter | KeyCode::Char(' ') => { + action = Some([ + AppAction::OpenOverview, + AppAction::OpenUsers, + AppAction::OpenSettings, + AppAction::Quit, + ][index]); + *focus = None; + } + KeyCode::Esc => *focus = None, + _ => {} + } + } + if let Some(action) = action { + self.dispatch_action(action).await; + } else { + self.invalidate(); + } + return; + } + } + if let UiEvent::Mouse(mouse) = &event { + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + let action = self + .hits + .lock() + .ok() + .and_then(|hits| hits.action_at(mouse.column, mouse.row)); + if action == Some(AppAction::FocusLogs) { + self.dispatch_action(AppAction::FocusLogs).await; + let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) { + KeyCode::Up + } else { + KeyCode::Down + }; + // Log scrolling is a local, handled interaction; route it + // directly rather than recursively constructing another + // async UI event future. + if let Some(screen) = self.screen_stack.write().await.last_mut() { + let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key))); + } + self.invalidate(); + return; + } + } + if matches!( + mouse.kind, + MouseEventKind::Down(crossterm::event::MouseButton::Left) + ) { + if let Some(action) = self + .hits + .lock() + .ok() + .and_then(|hits| hits.action_at(mouse.column, mouse.row)) + { + self.dispatch_action(action).await; + return; + } + } + } let result = { let mut stack = self.screen_stack.write().await; if let Some(screen) = stack.last_mut() { - screen.handle_input(key_event) + screen.handle_event(event) } else { return; } @@ -278,6 +467,13 @@ impl UI { _ = ui.cancellation.cancelled() => return, } } + InteractionResult::AppTask { task } => { + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let event = task.await; + let _ = sender.send(event); + }); + } InteractionResult::CloseScreen => { let mut stack = self.screen_stack.write().await; stack.pop(); @@ -292,6 +488,78 @@ impl UI { self.invalidate(); } + async fn dispatch_action(self: &Arc, action: AppAction) { + match action { + AppAction::Quit => self.request_shutdown(), + AppAction::OpenMain => { + let mut stack = self.screen_stack.write().await; + if stack.len() > 1 { + stack.truncate(1); + } + drop(stack); + self.invalidate(); + } + AppAction::OpenOverview => { + let status = { + let stack = self.screen_stack.read().await; + stack + .iter() + .rev() + .find_map(|s| s.as_any().downcast_ref::()) + .map(|main| (main.connection_status(), main.daemon_status())) + }; + if let Some((connection, daemon)) = status { + self.set_screen(Box::new(OverviewScreen::new(connection, daemon))) + .await; + } + } + AppAction::OpenUsers => self.open_users().await, + AppAction::OpenSettings => { + let current = self.theme_name().await; + self.set_screen(Box::new(SettingsScreen::new(current))).await; + } + AppAction::OpenMetrics => { + if let Some(screen) = MetricsScreen::new(self.clone()).await { + self.set_screen(Box::new(screen)).await; + } + } + action => { + let result = { + let mut stack = self.screen_stack.write().await; + stack.last_mut().map(|screen| screen.handle_action(action)) + }; + if matches!(result, Some(InteractionResult::CloseScreen)) { + let mut stack = self.screen_stack.write().await; + stack.pop(); + } + self.invalidate(); + } + } + } + async fn open_users(self: &Arc) { + let Some(ipc) = self.ipc().await else { return }; + self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) + .await; + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|u| UserEntry { + user_id: u.user_id, + username: u.username, + }) + .collect()) + } + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), + }; + let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); + }); + } + pub async fn render(&self) -> io::Result<()> { let theme = self.theme.read().await.clone(); let context = RenderContext { @@ -304,9 +572,54 @@ impl UI { .terminal .lock() .map_err(|_| io::Error::other("terminal mutex poisoned"))?; + let mut hits = HitMap::default(); terminal.draw(|f| { - screen.render(f, f.area(), &context); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(f.area()); + let header_title = self + .screen_stack + .try_read() + .ok() + .and_then(|stack| { + stack + .iter() + .find_map(|item| item.as_any().downcast_ref::()) + .map(|main| main.app_title()) + }) + .unwrap_or_else(|| screen.app_title()); + let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); + render_header( + f, + rows[0], + &header_title, + context.theme, + &mut hits, + header_focus, + ); + let hints = if header_focus.is_some() { + " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() + } else { + screen + .key_hints() + .into_iter() + .map(|hint| format!("{}: {}", hint.keys, hint.action)) + .collect::>() + .join(" ") + }; + f.render_widget( + ratatui::widgets::Paragraph::new(format!(" {hints}")) + .style(context.theme.surfaces.footer.patch(context.theme.text.muted)), + rows[2], + ); + screen.render(f, rows[1], &context, &mut hits); })?; + if let Ok(mut current) = self.hits.lock() { + *current = hits; + } } Ok(()) } diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs new file mode 100644 index 0000000..0161e2d --- /dev/null +++ b/iota-cli/tests/settings_snapshot.rs @@ -0,0 +1,76 @@ +use iota_cli::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::{ + screens::{AppEvent, HitMap, Screen, UiEvent}, + settings::SettingsScreen, + }, + theme::{ThemeName, resolve}, +}; +use ratatui::{Terminal, backend::TestBackend}; +use crossterm::event::{KeyCode, KeyEvent}; + +fn buffer_text(terminal: &Terminal) -> String { + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() +} + +#[tokio::test] +async fn settings_preview_and_save_emit_typed_application_events() { + let mut screen = SettingsScreen::new(ThemeName::Ansi); + let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right))); + let InteractionResult::AppTask { task } = preview else { + panic!("theme preview should emit an application task"); + }; + assert!(matches!( + task.await, + UiEvent::App(AppEvent::ApplyTheme { + theme: ThemeName::Surface, + persist: false + }) + )); + + let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter))); + let InteractionResult::AppTask { task } = save else { + panic!("theme save should emit an application task"); + }; + assert!(matches!( + task.await, + UiEvent::App(AppEvent::SaveSettings { + theme: ThemeName::Surface, + color: _, + unicode: _ + }) + )); +} + +#[test] +fn settings_is_readable_in_every_theme_and_layout() { + for theme_name in ThemeName::ALL { + for (width, height) in [(42, 12), (72, 20), (100, 28)] { + let theme = resolve(theme_name); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let screen = SettingsScreen::new(theme_name); + terminal + .draw(|frame| { + screen.render( + frame, + frame.area(), + &RenderContext { theme: &theme }, + &mut HitMap::default(), + ); + }) + .unwrap(); + let rendered = buffer_text(&terminal); + assert!(rendered.contains("Settings")); + assert!(rendered.contains("Theme:")); + assert!(rendered.contains("[OK] Healthy")); + assert!(rendered.contains("[FAIL] Failed")); + } + } +} diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index f039440..5e0c67c 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -9,12 +9,14 @@ iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } +iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" libc = "0.2" sysinfo = "0.38.3" +serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 6fe4c5f..f5d4d6d 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,10 +1,16 @@ use crate::{DaemonRuntime, DaemonServices}; -use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult}; +use crate::log_buffer::LogBuffer; +use iota_ipc::{ + ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode, + LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, + ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, + UserSummary, +}; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; -use iota_storage::util::config_util::modify_config; +use iota_storage::util::config_util::{self, modify_config}; use mtp::codec::{CommunicationType, CommunicationValue}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -13,11 +19,12 @@ use crate::daemon_state::{ShutdownReason, StartupPhase}; pub struct CommandRouter { runtime: Arc, services: Arc, + log_buffer: Arc>, } impl CommandRouter { - pub fn new(runtime: Arc, services: Arc) -> Self { - Self { runtime, services } + pub fn new(runtime: Arc, services: Arc, log_buffer: Arc>) -> Self { + Self { runtime, services, log_buffer } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { @@ -26,35 +33,6 @@ impl CommandRouter { ResponseEnvelope { request_id, result } } - /// Parse a legacy console command string into a typed request. - pub fn parse_console_command(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["tasks"] => Some(LocalRequest::ListTasks), - ["ping", _] | ["ping"] => None, - ["user", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user", "remove", username] => { - let user = user_manager::get_user_by_username(username)?; - Some(LocalRequest::RemoveUser { - user_id: user.user_id, - }) - } - ["user", "list"] => Some(LocalRequest::ListUsers), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: ExitIntent::Stop, - }), - _ => None, - } - } - async fn execute(&self, request: LocalRequest) -> ResponseResult { let needs_omikron = matches!( request, @@ -83,28 +61,33 @@ impl CommandRouter { .iter() .map(|task| task.to_string()) .collect(); - let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", ")); - if let Some(reason) = degraded { - info.push_str(&format!(", Degraded: {}", reason)); - } - ResponseResult::Ok(info) + ResponseResult::Ok(ResponsePayload::Status(StatusResponse { + phase: format!("{:?}", phase), + tasks: tasks.clone(), + degraded_reason: degraded, + })) } LocalRequest::ListTasks => { - let tasks: Vec = self + let tasks: Vec = self .runtime .state .active_tasks .iter() - .map(|task| task.to_string()) + .map(|task| TaskSummary { + name: task.to_string(), + }) .collect(); - ResponseResult::Ok(tasks.join(", ")) + ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_users() + let users: Vec = user_manager::get_users() .into_iter() - .map(|user| format!("{} ({})", user.username, user.user_id)) + .map(|user| UserSummary { + user_id: user.user_id, + username: user.username, + }) .collect(); - ResponseResult::Ok(users.join("\n")) + ResponseResult::Ok(ResponsePayload::Users(users)) } LocalRequest::CreateUser { username } => { match omikron_connector::user_ops::create_user( @@ -113,7 +96,12 @@ impl CommandRouter { ) .await { - (Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)), + (Some(user), _) => { + ResponseResult::Ok(ResponsePayload::UserCreated { + user_id: user.user_id, + username: user.username, + }) + } _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -128,10 +116,12 @@ impl CommandRouter { return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); } user_manager::remove_user(user.user_id); - ResponseResult::Ok(format!("Removed user {}", user.user_id)) + ResponseResult::Ok(ResponsePayload::UserRemoved { user_id }) } LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()), + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Reconnected to Omikron server".into(), + }), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, LocalRequest::RotateIotaIdentity => { @@ -141,9 +131,9 @@ impl CommandRouter { config.iota_id = None; }); match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok( - "Key pair regenerated and Omikron reconnection requested".into(), - ), + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Key pair regenerated and Omikron reconnection requested".into(), + }), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } } @@ -160,18 +150,120 @@ impl CommandRouter { ExitIntent::Stop => ShutdownReason::Stop, ExitIntent::Restart => ShutdownReason::Restart, }); - ResponseResult::Ok("process exit accepted".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "process exit accepted".into(), + }) } LocalRequest::GetDaemonStatus => { - ResponseResult::Ok(format!("{:?}", self.runtime.snapshot())) + ResponseResult::Ok(ResponsePayload::DaemonStatus( + iota_ipc::DaemonStatusResponse { + formatted: format!("{:?}", self.runtime.snapshot()), + }, + )) } LocalRequest::RestartDaemon => { self.runtime.shutdown(ShutdownReason::Restart); - ResponseResult::Ok("Daemon restart requested".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Daemon restart requested".into(), + }) } LocalRequest::StopDaemon => { self.runtime.shutdown(ShutdownReason::Stop); - ResponseResult::Ok("Daemon shutdown requested".into()) + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Daemon shutdown requested".into(), + }) + } + LocalRequest::GetConfig => { + let cfg = config_util::CONFIG.load(); + let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default(); + ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml })) + } + LocalRequest::SetConfig { key, value } => { + match config_util::modify_config_value(&key, &value) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Set {key} = {value}"), + }), + Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest), + } + } + LocalRequest::ReloadConfig => { + config_util::load_config(); + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Configuration reloaded".into(), + }) + } + LocalRequest::GetOmikronStatus => { + let connected = self.services.omikron.is_connected().await; + let iota_id = config_util::CONFIG.load().iota_id; + ResponseResult::Ok(ResponsePayload::OmikronStatus( + OmikronStatusResponse { + connected, + iota_id, + }, + )) + } + LocalRequest::ListComponents => { + let snapshot = self.runtime.snapshot(); + let components: Vec = snapshot + .components + .into_iter() + .map(|(id, health)| ComponentStatusResponse { + id, + status: health.status, + message: health.message, + }) + .collect(); + ResponseResult::Ok(ResponsePayload::Components(components)) + } + LocalRequest::GetUser { user_id } => { + match user_manager::get_user(user_id) { + Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail( + UserDetailResponse { + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + }, + )), + None => ResponseResult::Error(IpcErrorCode::NotFound), + } + } + LocalRequest::ImportUser { username } => { + match user_manager::load_from_tu(&username).await { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Imported user {username}"), + }), + Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), + } + } + LocalRequest::GetLogs { limit } => { + let entries = if let Ok(buf) = self.log_buffer.lock() { + buf.recent(limit) + } else { + Vec::new() + }; + ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) + } + LocalRequest::CheckUpdate => { + match iota_updater::check_update().await { + Ok(available) => ResponseResult::Ok(ResponsePayload::UpdateStatus( + UpdateStatusResponse { available }, + )), + Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), + } + } + LocalRequest::ListCommunities => { + let iota_id = config_util::CONFIG.load().iota_id.map(|id| id as i64).unwrap_or(0); + let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); + let summaries: Vec = stored + .into_iter() + .map(|c| CommunitySummary { + name: c.address, + title: c.title, + }) + .collect(); + ResponseResult::Ok(ResponsePayload::Communities(summaries)) } } } diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index b6ea1cb..d138107 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,3 +1,4 @@ +use crate::log_buffer::LogBuffer; use crate::deployment::from_environment; use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{ @@ -8,7 +9,7 @@ use iota_logger::log; use std::io::Result; use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, mpsc, watch}; @@ -22,11 +23,25 @@ const CLIENT_CHANNEL_SIZE: usize = 256; const MAX_HANDSHAKE_RETRIES: u32 = 1; const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// Minimum metric subscription interval to prevent excessive update rates. +const MIN_METRIC_INTERVAL_MS: u64 = 100; +/// Maximum metric subscription interval. +const MAX_METRIC_INTERVAL_MS: u64 = 60_000; +/// Default metric interval if the client does not specify one. +const DEFAULT_METRIC_INTERVAL_MS: u64 = 500; + +/// Per-client subscription state. +struct ClientSubscription { + log_classes: Vec, + metric_interval_ms: u64, +} + pub struct IpcServer { listener: UnixListener, runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, state_rx: watch::Receiver, instance_id: String, _instance_lock: File, @@ -38,6 +53,7 @@ impl IpcServer { runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, state_rx: watch::Receiver, ) -> Result { let path = path.into(); @@ -90,6 +106,7 @@ impl IpcServer { runtime, services, log_tx, + log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: lock, @@ -101,6 +118,7 @@ impl IpcServer { runtime, services, log_tx, + log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: File::options().read(true).open("/dev/null")?, @@ -114,11 +132,12 @@ impl IpcServer { let runtime = self.runtime.clone(); let services = self.services.clone(); let log_tx = self.log_tx.clone(); + let log_buffer = self.log_buffer.clone(); let state_rx = self.state_rx.clone(); let instance_id = self.instance_id.clone(); tokio::spawn(async move { if let Err(error) = - handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await + handle_client(stream, runtime, services, log_tx, log_buffer, state_rx, instance_id).await { eprintln!("IPC client error: {error}"); } @@ -252,6 +271,7 @@ async fn handle_client( runtime: Arc, services: Arc, log_tx: broadcast::Sender, + log_buffer: Arc>, mut state_rx: watch::Receiver, instance_id: String, ) -> Result<()> { @@ -337,12 +357,18 @@ async fn handle_client( // --- Writer task: merge directed responses + shared log events --- let mut log_rx = log_tx.subscribe(); + let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription { + log_classes: Vec::new(), + metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS, + }); let writer_task = { let runtime = runtime.clone(); let session_cancellation = session_cancellation.clone(); tokio::spawn(async move { let mut directed_rx = directed_rx; + let mut last_metric_sent = tokio::time::Instant::now(); loop { + let metric_interval = sub_rx.borrow().metric_interval_ms; tokio::select! { // Directed messages (responses to this client's requests) msg = directed_rx.recv() => { @@ -360,9 +386,35 @@ async fn handle_client( // Shared log events result = log_rx.recv() => { match result { + Ok(DaemonMessage::LogEntry(entry)) => { + // Filter by subscribed log classes + let log_classes = sub_rx.borrow().log_classes.clone(); + if log_classes.is_empty() + || log_classes.iter().any(|c| entry.sender == *c) + { + if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await { + eprintln!("IPC client writer stopped while sending log message: {error}"); + session_cancellation.cancel(); + break; + } + } + } + Ok(DaemonMessage::MetricSample(sample)) => { + // Rate-limit metric samples based on subscription interval + let now = tokio::time::Instant::now(); + if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) { + last_metric_sent = now; + if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await { + eprintln!("IPC client writer stopped while sending metric sample: {error}"); + session_cancellation.cancel(); + break; + } + } + } Ok(message) => { + // Forward other broadcast messages as-is if let Err(error) = write_client_message(&mut writer, &message).await { - eprintln!("IPC client writer stopped while sending log message: {error}"); + eprintln!("IPC client writer stopped while sending broadcast message: {error}"); session_cancellation.cancel(); break; } @@ -389,13 +441,14 @@ async fn handle_client( break; } } + _ = sub_rx.changed() => {} } } }) }; // --- Reader loop --- - let router = CommandRouter::new(runtime.clone(), services); + let router = CommandRouter::new(runtime.clone(), services, log_buffer); loop { let message = tokio::select! { _ = session_cancellation.cancelled() => break, @@ -442,7 +495,17 @@ async fn handle_client( break; } } - Ok(ClientMessage::Subscribe { .. }) => { + Ok(ClientMessage::Subscribe { + log_classes, + metric_interval_ms, + }) => { + let interval = metric_interval_ms + .unwrap_or(DEFAULT_METRIC_INTERVAL_MS) + .clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS); + let _ = sub_tx.send(ClientSubscription { + log_classes, + metric_interval_ms: interval, + }); let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); let _ = directed_tx.send(snapshot).await; let _ = directed_tx.send(DaemonMessage::Subscribed).await; diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 5a40295..6ccd977 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -3,6 +3,7 @@ pub mod daemon_state; pub mod deployment; pub mod ipc_server; pub mod log_broadcaster; +pub mod log_buffer; pub mod services; pub mod task_registry; diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs index 1ce3c88..b00eb9d 100644 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -1,21 +1,30 @@ +use crate::log_buffer::LogBuffer; use iota_ipc::{DaemonMessage, LogEntry}; use iota_logger::subscribe; +use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; /* The daemon adapts logger output to the wire protocol so the logger stays * independent from both the socket implementation and TUI state. */ -pub fn spawn(message_tx: broadcast::Sender) { +pub fn spawn( + message_tx: broadcast::Sender, + buffer: Arc>, +) { let Some(mut logs) = subscribe() else { return; }; tokio::spawn(async move { while let Ok(entry) = logs.recv().await { - let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry { + let entry = LogEntry { timestamp_ms: entry.timestamp_ms, sender: entry.sender, message: entry.message, is_error: entry.is_error, - })); + }; + if let Ok(mut buf) = buffer.lock() { + buf.push(entry.clone()); + } + let _ = message_tx.send(DaemonMessage::LogEntry(entry)); } }); } diff --git a/iota-daemon-lib/src/log_buffer.rs b/iota-daemon-lib/src/log_buffer.rs new file mode 100644 index 0000000..bb2cdc6 --- /dev/null +++ b/iota-daemon-lib/src/log_buffer.rs @@ -0,0 +1,36 @@ +use iota_ipc::LogEntry; +use std::collections::VecDeque; + +pub struct LogBuffer { + entries: VecDeque, + capacity: usize, +} + +impl LogBuffer { + pub fn new(capacity: usize) -> Self { + Self { + entries: VecDeque::with_capacity(capacity), + capacity, + } + } + + pub fn push(&mut self, entry: LogEntry) { + if self.entries.len() == self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn recent(&self, limit: usize) -> Vec { + let _len = self.entries.len(); + self.entries + .iter() + .rev() + .take(limit) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() + } +} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 525bca1..2a67cf1 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,10 +1,11 @@ use async_trait::async_trait; use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; +use iota_daemon_lib::log_buffer::LogBuffer; use iota_ipc::{LocalRequest, ResponseResult}; use mtp::codec::CommunicationValue; use omikron_connector::{OmikronClient, OmikronError}; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; use std::time::Duration; @@ -43,7 +44,7 @@ async fn reconnect_uses_the_injected_client() { users: Default::default(), config: Default::default(), }); - let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services); + let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services, Arc::new(Mutex::new(LogBuffer::new(100)))); assert!(matches!( router.route(1, LocalRequest::ReconnectOmikron).await.result, ResponseResult::Ok(_) diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 12d24fd..0204f7d 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,11 +1,12 @@ use iota_daemon_lib::{ DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster, + log_buffer::LogBuffer, }; use iota_logger::{self as logger, log}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use std::process::ExitCode; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{broadcast, watch}; #[tokio::main(flavor = "multi_thread")] @@ -41,7 +42,8 @@ async fn main() -> ExitCode { let runtime = Arc::new(DaemonRuntime::new()); // --- IPC infrastructure --- let (log_tx, _) = broadcast::channel(512); - log_broadcaster::spawn(log_tx.clone()); + let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024))); + log_broadcaster::spawn(log_tx.clone(), log_buffer.clone()); let (state_tx, state_rx) = watch::channel(runtime.snapshot()); runtime.set_startup_phase(StartupPhase::LoadingUsers); @@ -101,6 +103,7 @@ async fn main() -> ExitCode { runtime.clone(), services, log_tx.clone(), + log_buffer.clone(), state_rx, ) .await @@ -128,6 +131,21 @@ async fn main() -> ExitCode { .await; log!("iota-daemon IPC server ready"); + log!( + "iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}", + paths.scope, + paths.config_file.display(), + paths.state_dir.display(), + paths.storage_dir.display(), + paths.identity_dir.display(), + paths.cache_dir.display(), + paths.log_dir.display(), + paths.asset_dir.display(), + match &paths.ipc_endpoint { + iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(), + iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(), + }, + ); runtime.set_startup_phase(StartupPhase::StartingServices); // --- System monitor --- diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 1118fc2..ed5c3c1 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -1,11 +1,14 @@ pub mod protocol; +pub mod text_commands; pub mod transport; pub use protocol::{ - ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode, - ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase, - StateSnapshot, SupervisorKind, + ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, + ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, + HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry, + LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, + ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, + TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 7a95a3d..10b1d27 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -49,6 +49,25 @@ pub enum LocalRequest { RestartDaemon, #[serde(skip)] StopDaemon, + GetConfig, + SetConfig { + key: String, + value: String, + }, + ReloadConfig, + GetOmikronStatus, + ListComponents, + GetUser { + user_id: i64, + }, + ImportUser { + username: String, + }, + GetLogs { + limit: usize, + }, + CheckUpdate, + ListCommunities, } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -107,10 +126,102 @@ pub struct ResponseEnvelope { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ResponseResult { - Ok(String), + Ok(ResponsePayload), Error(IpcErrorCode), } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ResponsePayload { + Status(StatusResponse), + Tasks(Vec), + Users(Vec), + UserCreated { + user_id: i64, + username: String, + }, + UserRemoved { + user_id: i64, + }, + Acknowledged { + message: String, + }, + DaemonStatus(DaemonStatusResponse), + Config(ConfigResponse), + OmikronStatus(OmikronStatusResponse), + Components(Vec), + UserDetail(UserDetailResponse), + LogEntries(LogEntriesResponse), + UpdateStatus(UpdateStatusResponse), + Communities(Vec), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ConfigResponse { + pub yaml: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct OmikronStatusResponse { + pub connected: bool, + pub iota_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ComponentStatusResponse { + pub id: ComponentId, + pub status: HealthStatus, + pub message: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserDetailResponse { + pub user_id: i64, + pub username: String, + pub display_name: Option, + pub created_at: i64, + pub trusted_apps: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LogEntriesResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UpdateStatusResponse { + pub available: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CommunitySummary { + pub name: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct StatusResponse { + pub phase: String, + pub tasks: Vec, + pub degraded_reason: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TaskSummary { + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserSummary { + pub user_id: i64, + pub username: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DaemonStatusResponse { + pub formatted: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum IpcErrorCode { @@ -128,6 +239,36 @@ pub enum IpcErrorCode { InternalFailure, } +impl std::fmt::Display for IpcErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidRequest => "the daemon rejected the request", + Self::NotFound => "the requested resource was not found", + Self::Conflict => "the request conflicts with current daemon state", + Self::StorageFailure => "the daemon could not access local storage", + Self::OmikronUnavailable => "Omikron is unavailable", + Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible", + Self::NotReady => "the daemon is not ready yet", + Self::Disconnected => "the daemon connection was lost", + Self::Timeout => "the daemon did not respond in time", + Self::Cancelled => "the daemon cancelled the request", + Self::Unauthorized => "the daemon denied this operation", + Self::InternalFailure => "the daemon encountered an internal failure", + }) + } +} + +#[cfg(test)] +mod error_tests { + use super::IpcErrorCode; + + #[test] + fn error_codes_have_operator_facing_messages() { + assert_eq!(IpcErrorCode::NotReady.to_string(), "the daemon is not ready yet"); + assert!(!IpcErrorCode::InternalFailure.to_string().contains("InternalFailure")); + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum LifecycleEvent { diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs new file mode 100644 index 0000000..1e824e3 --- /dev/null +++ b/iota-ipc/src/text_commands.rs @@ -0,0 +1,324 @@ +use crate::LocalRequest; + +pub const COMMANDS: &[&str] = &[ + "status", + "tasks", + "users list", + "users show ", + "users add ", + "users remove ", + "users import ", + "omikron status", + "reconnect", + "identity rotate", + "daemon status", + "config get", + "config set ", + "config reload", + "components", + "logs", + "update check", + "community list", + "restart", + "stop", +]; + +pub fn completions(prefix: &str) -> Vec<&'static str> { + let normalized = prefix.trim_start_matches('/'); + COMMANDS + .iter() + .copied() + .filter(|command| command.starts_with(normalized)) + .collect() +} + +pub fn validation_error(line: &str) -> Option { + let normalized = line.trim_start_matches('/').trim(); + if normalized == "help" || parse(normalized).is_some() { + None + } else { + Some(format!("Unknown command `{normalized}`. Use /help or Tab completion.")) + } +} + +/// Parse a text command string into a typed IPC request. +/// +/// Both the CLI console and the TUI command palette use this single parser. +/// Commands are case-insensitive and support an optional leading `/`. +pub fn parse(line: &str) -> Option { + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); + match parts.as_slice() { + ["help"] => None, + ["status"] => Some(LocalRequest::GetStatus), + ["tasks"] => Some(LocalRequest::ListTasks), + ["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers), + ["user" | "users", "show", id_str] => { + let user_id = id_str.parse::().ok()?; + Some(LocalRequest::GetUser { user_id }) + } + ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), + ["user" | "users", "remove", id_str] => { + let user_id = id_str.parse::().ok()?; + Some(LocalRequest::RemoveUser { user_id }) + } + ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { + username: username.to_string(), + }), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: crate::ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: crate::ExitIntent::Stop, + }), + ["daemon", "status"] => Some(LocalRequest::GetDaemonStatus), + ["config", "get"] => Some(LocalRequest::GetConfig), + ["config", "set", key, value] => Some(LocalRequest::SetConfig { + key: key.to_string(), + value: value.to_string(), + }), + ["config", "reload"] => Some(LocalRequest::ReloadConfig), + ["omikron", "status"] => Some(LocalRequest::GetOmikronStatus), + ["components"] => Some(LocalRequest::ListComponents), + ["logs"] => Some(LocalRequest::GetLogs { limit: 100 }), + ["update", "check"] => Some(LocalRequest::CheckUpdate), + ["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_status() { + assert!(matches!(parse("status"), Some(LocalRequest::GetStatus))); + } + + #[test] + fn parses_tasks() { + assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks))); + } + + #[test] + fn parses_user_list_shortcuts() { + assert!(matches!(parse("users"), Some(LocalRequest::ListUsers))); + assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers))); + } + + #[test] + fn parses_user_add() { + let req = parse("user add alice").unwrap(); + match req { + LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), + _ => panic!("expected CreateUser"), + } + } + + #[test] + fn accepts_the_headless_cli_user_vocabulary() { + assert!(matches!( + parse("users list"), + Some(LocalRequest::ListUsers) + )); + assert!(matches!( + parse("users add alice"), + Some(LocalRequest::CreateUser { .. }) + )); + assert!(matches!( + parse("users remove 42"), + Some(LocalRequest::RemoveUser { user_id: 42 }) + )); + assert!(matches!( + parse("identity rotate"), + Some(LocalRequest::RotateIotaIdentity) + )); + } + + #[test] + fn parses_user_remove_by_id() { + let req = parse("user remove 42").unwrap(); + match req { + LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected RemoveUser"), + } + } + + #[test] + fn user_remove_requires_numeric_id() { + assert!(parse("user remove alice").is_none()); + } + + #[test] + fn parses_reconnect() { + assert!(matches!( + parse("reconnect"), + Some(LocalRequest::ReconnectOmikron) + )); + } + + #[test] + fn parses_regenerate_keys() { + assert!(matches!( + parse("regenerate keys"), + Some(LocalRequest::RotateIotaIdentity) + )); + } + + #[test] + fn parses_restart_aliases() { + assert!(matches!( + parse("restart"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + assert!(matches!( + parse("reload"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + } + + #[test] + fn parses_stop_aliases() { + assert!(matches!( + parse("stop"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + assert!(matches!( + parse("shutdown"), + Some(LocalRequest::RequestProcessExit { .. }) + )); + } + + #[test] + fn parses_daemon_status() { + assert!(matches!( + parse("daemon status"), + Some(LocalRequest::GetDaemonStatus) + )); + } + + #[test] + fn parses_config_get() { + assert!(matches!( + parse("config get"), + Some(LocalRequest::GetConfig) + )); + } + + #[test] + fn parses_config_reload() { + assert!(matches!( + parse("config reload"), + Some(LocalRequest::ReloadConfig) + )); + } + + #[test] + fn parses_omikron_status() { + assert!(matches!( + parse("omikron status"), + Some(LocalRequest::GetOmikronStatus) + )); + } + + #[test] + fn parses_components() { + assert!(matches!( + parse("components"), + Some(LocalRequest::ListComponents) + )); + } + + #[test] + fn parses_users_show() { + let req = parse("users show 42").unwrap(); + match req { + LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected GetUser"), + } + } + + #[test] + fn user_show_requires_numeric_id() { + assert!(parse("users show alice").is_none()); + } + + #[test] + fn parses_config_set() { + let req = parse("config set port 8080").unwrap(); + match req { + LocalRequest::SetConfig { key, value } => { + assert_eq!(key, "port"); + assert_eq!(value, "8080"); + } + _ => panic!("expected SetConfig"), + } + } + + #[test] + fn parses_logs() { + assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. }))); + } + + #[test] + fn parses_update_check() { + assert!(matches!( + parse("update check"), + Some(LocalRequest::CheckUpdate) + )); + } + + #[test] + fn parses_community_list() { + assert!(matches!( + parse("community list"), + Some(LocalRequest::ListCommunities) + )); + } + + #[test] + fn parses_communities_alias() { + assert!(matches!( + parse("communities"), + Some(LocalRequest::ListCommunities) + )); + } + + #[test] + fn parses_users_import() { + let req = parse("users import alice").unwrap(); + match req { + LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), + _ => panic!("expected ImportUser"), + } + } + + #[test] + fn parses_with_slash_prefix() { + assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus))); + assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks))); + } + + #[test] + fn unknown_returns_none() { + assert!(parse("nonexistent").is_none()); + } + + #[test] + fn completion_is_prefix_based_and_deterministic() { + assert_eq!(completions("identity r"), vec!["identity rotate"]); + assert_eq!(completions("/users a"), vec!["users add "]); + assert!(completions("definitely-unknown").is_empty()); + } + + #[test] + fn validation_distinguishes_help_and_unknown_commands() { + assert_eq!(validation_error("/help"), None); + assert!(validation_error("status").is_none()); + assert!(validation_error("statuz").unwrap().contains("Unknown command")); + } +} diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 380ba6c..60b1cda 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -172,6 +172,57 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { CONFIG.store(Arc::new(cfg)); save_config(); } + +pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> { + match key { + "iota_id" => { + let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?; + modify_config(|cfg| cfg.iota_id = Some(parsed)); + Ok(()) + } + "port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid port")?; + modify_config(|cfg| cfg.port = parsed); + Ok(()) + } + "omikron_host" => { + let host = value.to_string(); + modify_config(|cfg| cfg.omikron_host = Some(host)); + Ok(()) + } + "omikron_port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?; + modify_config(|cfg| cfg.omikron_port = Some(parsed)); + Ok(()) + } + "read_receipts_enabled" => { + let parsed: bool = value.parse().map_err(|_| "invalid boolean")?; + modify_config(|cfg| cfg.read_receipts_enabled = parsed); + Ok(()) + } + "web.mode" => { + let mode = match value { + "disabled" => WebMode::Disabled, + "loopback" => WebMode::Loopback, + "network" => WebMode::Network, + _ => return Err("invalid web.mode; use disabled, loopback, or network"), + }; + modify_config(|cfg| cfg.web.mode = mode); + Ok(()) + } + "web.port" => { + let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?; + modify_config(|cfg| cfg.web.port = parsed); + Ok(()) + } + "web.bind" => { + let bind = value.to_string(); + modify_config(|cfg| cfg.web.bind = bind); + Ok(()) + } + _ => Err("unknown config key"), + } +} static CONFIG_PATH: OnceLock = OnceLock::new(); pub fn configure_config_path(path: PathBuf) { diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 64de533..cc4494b 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -12,3 +12,6 @@ iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } +serde_json = "1" +serde_yaml = "0.9" +clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index cedefb0..005d9fb 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,15 +1,86 @@ +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::ThemeName; #[derive(Debug)] pub struct CliInvocation { pub theme_override: Option, + pub output: OutputFormat, + pub color: CapabilityPolicy, + pub unicode: CapabilityPolicy, pub command: Command, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum CapabilityPolicy { + Auto, + Always, + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + Text, + Json, + Yaml, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CliTheme { Monospace, Binary, Ansi, Surface } +impl From for ThemeName { + fn from(value: CliTheme) -> Self { + match value { CliTheme::Monospace => Self::Monospace, CliTheme::Binary => Self::Binary, CliTheme::Ansi => Self::Ansi, CliTheme::Surface => Self::Surface } + } +} + +#[derive(Parser, Debug)] +#[command(name = "iota", version, about = "Iota operator console", arg_required_else_help = false)] +struct Cli { + #[arg(long, global = true, value_enum)] theme: Option, + #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] color: CapabilityPolicy, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] unicode: CapabilityPolicy, + #[arg(long, global = true)] no_color: bool, + #[command(subcommand)] command: Option, +} + +#[derive(Subcommand, Debug)] +enum CliCommand { + Status, Tasks, + Users(UsersArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), Config(ConfigArgs), + RegenerateKeys { #[arg(long)] yes: bool }, + Components, + Logs { #[arg(long, default_value_t = 100)] limit: usize }, + Update(UpdateArgs), + Community(CommunityArgs), + Completions { shell: String }, + Man, +} +#[derive(Args, Debug)] struct UsersArgs { #[command(subcommand)] action: UsersAction } +#[derive(Subcommand, Debug)] enum UsersAction { List, Show { user_id: i64 }, Add { username: String }, Remove { user_id: i64, #[arg(long)] yes: bool }, Import { username: String } } +#[derive(Args, Debug)] struct OmikronArgs { #[command(subcommand)] action: OmikronAction } +#[derive(Subcommand, Debug)] enum OmikronAction { Reconnect, Status } +#[derive(Args, Debug)] struct IdentityArgs { #[command(subcommand)] action: IdentityAction } +#[derive(Subcommand, Debug)] enum IdentityAction { Rotate { #[arg(long)] yes: bool } } +#[derive(Args, Debug)] struct ConfigArgs { #[command(subcommand)] action: ConfigAction } +#[derive(Subcommand, Debug)] enum ConfigAction { Get, Set { key: String, value: String }, Reload } +#[derive(Args, Debug)] struct DaemonArgs { #[command(subcommand)] action: DaemonAction } +#[derive(Subcommand, Debug)] enum DaemonAction { + Restart { #[arg(long)] yes: bool }, Stop { #[arg(long)] yes: bool }, + Enable { #[arg(long, value_parser = ["socket", "always-on"])] mode: String }, DisableStartup, Status, StartupStatus, Start, RestartService, StopService, + Install { #[arg(long)] bundle: String, #[arg(long)] operator: Option }, +} +#[derive(Args, Debug)] struct UpdateArgs { #[command(subcommand)] action: UpdateAction } +#[derive(Subcommand, Debug)] enum UpdateAction { Check } +#[derive(Args, Debug)] struct CommunityArgs { #[command(subcommand)] action: CommunityAction } +#[derive(Subcommand, Debug)] enum CommunityAction { List } + #[derive(Debug, PartialEq, Eq)] pub enum Command { Dashboard, Help, + Version, + Completions { shell: String }, + ManPage, Install { bundle: String, operator: Option, @@ -17,95 +88,125 @@ pub enum Command { Status, Tasks, UsersList, + UsersShow { user_id: i64 }, + UsersAdd { + username: String, + }, + UsersRemove { + user_id: i64, + confirmed: bool, + }, + UsersImport { username: String }, + OmikronReconnect, + IdentityRotate { + confirmed: bool, + }, DaemonRestart { confirmed: bool, }, DaemonStop { confirmed: bool, }, - DaemonStopProcess, DaemonEnable { mode: String, }, DaemonDisableStartup, DaemonDaemonStatus, + DaemonStartupStatus, + DaemonStart, + DaemonRestartService, + DaemonStopService, + ConfigGet, + ConfigSet { key: String, value: String }, + ConfigReload, + OmikronStatus, + RegenerateKeys { + confirmed: bool, + }, + Components, + Logs { limit: usize }, + UpdateCheck, + CommunityList, } impl CliInvocation { pub fn parse(args: impl IntoIterator) -> Result { - let mut theme_override = None; - let mut command = Vec::new(); - let mut args = args.into_iter(); - while let Some(argument) = args.next() { - if argument == "--theme" { - let value = args.next().ok_or_else(|| { - format!( - "--theme requires a value ({})", - ThemeName::supported_names() - ) - })?; - theme_override = Some(value.parse()?); - } else if let Some(value) = argument.strip_prefix("--theme=") { - theme_override = Some(value.parse()?); - } else { - command.push(argument); - } + let args = args.into_iter().collect::>(); + if args.as_slice() == ["help"] { + return Ok(Self::special(Command::Help)); } - let command = match command.as_slice() { - [] => Command::Dashboard, - [help] if help == "help" || help == "--help" => Command::Help, - [status] if status == "status" => Command::Status, - [tasks] if tasks == "tasks" => Command::Tasks, - [noun, verb] if noun == "users" && verb == "list" => Command::UsersList, - [noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart { - confirmed: flag == "--yes", + let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { + match error.kind() { + ErrorKind::DisplayHelp => return "__help__".to_owned(), + ErrorKind::DisplayVersion => return "__version__".to_owned(), + _ => error.to_string(), + } + }); + let parsed = match parsed { + Ok(parsed) => parsed, + Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), + Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), + Err(error) => return Err(error), + }; + let command = match parsed.command { + None => Command::Dashboard, + Some(CliCommand::Status) => Command::Status, + Some(CliCommand::Tasks) => Command::Tasks, + Some(CliCommand::Components) => Command::Components, + Some(CliCommand::Completions { shell }) => Command::Completions { shell }, + Some(CliCommand::Man) => Command::ManPage, + Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, confirmed: yes }, UsersAction::Import { username } => Command::UsersImport { username } }, + Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus }, + Some(CliCommand::Identity(identity)) => match identity.action { IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes } }, + Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload }, + Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, + Some(CliCommand::Logs { limit }) => Command::Logs { limit }, + Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck }, + Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList }, + Some(CliCommand::Daemon(daemon)) => match daemon.action { + DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, + DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, + DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::StartupStatus => Command::DaemonStartupStatus, + DaemonAction::Start => Command::DaemonStart, DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService, + DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, }, - [noun, verb] if noun == "daemon" && verb == "restart" => { - Command::DaemonRestart { confirmed: false } - } - [noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop { - confirmed: flag == "--yes", - }, - [noun, verb] if noun == "daemon" && verb == "stop" => { - Command::DaemonStop { confirmed: false } - } - [noun, verb] if noun == "daemon" && verb == "stop-process" => { - Command::DaemonStopProcess - } - [noun, verb] if noun == "daemon" && verb == "disable-startup" => { - Command::DaemonDisableStartup - } - [noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus, - [noun, verb, flag, mode] - if noun == "daemon" && verb == "enable" && flag == "--mode" => - { - Command::DaemonEnable { mode: mode.clone() } - } - [noun, verb, bundle_flag, bundle] - if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" => - { - Command::Install { - bundle: bundle.clone(), - operator: None, - } - } - [noun, verb, bundle_flag, bundle, operator_flag, operator] - if noun == "daemon" - && verb == "install" - && bundle_flag == "--bundle" - && operator_flag == "--operator" => - { - Command::Install { - bundle: bundle.clone(), - operator: Some(operator.clone()), - } - } - _ => return Err("Unknown command. Run `iota --help`.".into()), }; Ok(Self { - theme_override, + theme_override: parsed.theme.map(Into::into), + output: parsed.output, + color: if parsed.no_color { CapabilityPolicy::Never } else { parsed.color }, + unicode: parsed.unicode, command, }) } + + fn special(command: Command) -> Self { + Self { theme_override: None, output: OutputFormat::Text, color: CapabilityPolicy::Auto, unicode: CapabilityPolicy::Auto, command } + } + + pub fn help_text() -> String { + Cli::command().render_long_help().to_string() + } + + pub fn command_paths() -> Vec { + fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec) { + for subcommand in command.get_subcommands() { + let path = if prefix.is_empty() { + subcommand.get_name().to_owned() + } else { + format!("{prefix} {}", subcommand.get_name()) + }; + if subcommand.get_subcommands().next().is_some() { + collect(subcommand, &path, paths); + } else { + paths.push(path); + } + } + } + let command = Cli::command(); + let mut paths = Vec::new(); + collect(&command, "", &mut paths); + paths + } } #[cfg(test)] @@ -126,6 +227,55 @@ mod tests { assert!(error.contains(ThemeName::supported_names())); } + #[test] + fn parses_structured_output_as_a_global_option() { + let invocation = + CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap(); + assert_eq!(invocation.output, OutputFormat::Json); + assert_eq!(invocation.command, Command::UsersList); + } + + #[test] + fn parses_terminal_capability_overrides() { + let invocation = CliInvocation::parse([ + "--color=never".into(), + "--unicode".into(), + "always".into(), + ]) + .unwrap(); + assert_eq!(invocation.color, CapabilityPolicy::Never); + assert_eq!(invocation.unicode, CapabilityPolicy::Always); + assert_eq!(invocation.command, Command::Dashboard); + } + + #[test] + fn no_color_is_a_compatible_alias() { + let invocation = CliInvocation::parse(["--no-color".into()]).unwrap(); + assert_eq!(invocation.color, CapabilityPolicy::Never); + } + + #[test] + fn supports_standard_help_and_version_flags() { + assert_eq!( + CliInvocation::parse(["-h".into()]).unwrap().command, + Command::Help + ); + assert_eq!( + CliInvocation::parse(["--version".into()]).unwrap().command, + Command::Version + ); + } + + #[test] + fn command_schema_drives_help_and_completion_paths() { + let paths = CliInvocation::command_paths(); + assert!(paths.contains(&"users remove".to_owned())); + assert!(paths.contains(&"daemon install".to_owned())); + let help = CliInvocation::help_text(); + assert!(help.contains("users")); + assert!(help.contains("--output")); + } + #[test] fn parses_install_operator_without_raw_slice_matching() { let invocation = CliInvocation::parse([ @@ -151,4 +301,92 @@ mod tests { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); } + + #[test] + fn parses_users_add() { + let invocation = + CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::UsersAdd { + username: "alice".into() + } + ); + } + + #[test] + fn rejects_malformed_users_add_shape() { + assert!( + CliInvocation::parse([ + "users".into(), + "incorrect".into(), + "add".into(), + "alice".into() + ]) + .is_err() + ); + } + + #[test] + fn rejects_unknown_destructive_option() { + assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err()); + } + + #[test] + fn parses_users_remove_without_confirmation() { + let invocation = + CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::UsersRemove { + user_id: 42, + confirmed: false, + } + ); + } + + #[test] + fn parses_users_remove_with_confirmation() { + let invocation = + CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) + .unwrap(); + assert_eq!( + invocation.command, + Command::UsersRemove { + user_id: 42, + confirmed: true, + } + ); + } + + #[test] + fn parses_omikron_reconnect() { + let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); + assert_eq!(invocation.command, Command::OmikronReconnect); + } + + #[test] + fn parses_identity_rotate_requires_yes() { + let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::IdentityRotate { confirmed: false } + ); + let invocation = + CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); + assert_eq!( + invocation.command, + Command::IdentityRotate { confirmed: true } + ); + } + + #[test] + fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() { + assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err()); + } + + #[test] + fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() { + assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err()); + } } diff --git a/iota/src/main.rs b/iota/src/main.rs index 0e85d36..0df1c0f 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -2,7 +2,7 @@ use iota_cli::{ ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_theme, }; -use iota_ipc::{LocalRequest, ResponseResult}; +use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult}; use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; @@ -11,7 +11,7 @@ mod daemon_setup_flow; mod local_daemon; mod startup_error; -use cli_args::{CliInvocation, Command}; +use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -30,6 +30,13 @@ async fn main() -> ExitCode { async fn run() -> Result<(), StartupError> { let invocation = CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; + let CliInvocation { + theme_override, + output, + color, + unicode, + command, + } = invocation; let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? .ipc_endpoint @@ -57,11 +64,20 @@ async fn run() -> Result<(), StartupError> { system: system_endpoint, }; - match invocation.command { + match command { Command::Help => { print_help(); Ok(()) } + Command::Version => { + println!("iota {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Command::Completions { shell } => print_completions(&shell), + Command::ManPage => { + print_man_page(); + Ok(()) + } Command::Install { bundle, operator } => { iota_installer::install_linux_bundle_with_operator( Path::new(&bundle), @@ -72,7 +88,12 @@ async fn run() -> Result<(), StartupError> { command => { if matches!( command, - Command::DaemonEnable { .. } | Command::DaemonDisableStartup + Command::DaemonEnable { .. } + | Command::DaemonDisableStartup + | Command::DaemonStartupStatus + | Command::DaemonStart + | Command::DaemonRestartService + | Command::DaemonStopService ) { return run_startup_command(command).await; } @@ -87,9 +108,9 @@ async fn run() -> Result<(), StartupError> { result = connect_available(&endpoints) => result?, _ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled), }; - return run_command(ipc, command).await; + return run_command(ipc, command, output).await; } - run_dashboard(invocation.theme_override, endpoints).await + run_dashboard(theme_override, color, unicode, endpoints).await } } } @@ -118,6 +139,42 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> { .disable_startup() .await .map_err(|e| StartupError::Other(e.to_string()))?, + Command::DaemonStartupStatus => { + let status = manager + .iota_startup_status() + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("service active: {}", status.service.active); + println!("service enabled: {}", status.service.enabled); + println!("socket active: {}", status.socket.active); + println!("socket enabled: {}", status.socket.enabled); + println!("detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonStart => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Start) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon started. detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonRestartService => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Restart) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon restarted. detected mode: {:?}", status.detected); + return Ok(()); + } + Command::DaemonStopService => { + let status = manager + .process_action(iota_process_manager::ProcessAction::Stop) + .await + .map_err(|e| StartupError::Other(e.to_string()))?; + println!("Daemon stopped. detected mode: {:?}", status.detected); + return Ok(()); + } _ => unreachable!(), }; println!("deployment status: {:?}", status.detected); @@ -149,6 +206,8 @@ async fn connect_available( async fn run_dashboard( theme_override: Option, + color_policy: CapabilityPolicy, + unicode_policy: CapabilityPolicy, endpoints: daemon_setup_flow::DaemonEndpoints, ) -> Result<(), StartupError> { use std::io::IsTerminal; @@ -162,9 +221,55 @@ async fn run_dashboard( "TERM=dumb does not support the interactive dashboard".into(), )); } - let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme( - theme_override, - ))) + let stored_terminal = theme::UiConfig::load().unwrap_or_default(); + let color_policy = match color_policy { + CapabilityPolicy::Auto => match stored_terminal.color { + theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, + theme::TerminalPolicy::Always => CapabilityPolicy::Always, + theme::TerminalPolicy::Never => CapabilityPolicy::Never, + }, + policy => policy, + }; + let unicode_policy = match unicode_policy { + CapabilityPolicy::Auto => match stored_terminal.unicode { + theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, + theme::TerminalPolicy::Always => CapabilityPolicy::Always, + theme::TerminalPolicy::Never => CapabilityPolicy::Never, + }, + policy => policy, + }; + let color_enabled = match color_policy { + CapabilityPolicy::Always => true, + CapabilityPolicy::Never => false, + CapabilityPolicy::Auto => { + std::env::var_os("NO_COLOR").is_none() + && std::env::var("TERM").as_deref() != Ok("dumb") + } + }; + let unicode_enabled = match unicode_policy { + CapabilityPolicy::Always => true, + CapabilityPolicy::Never => false, + CapabilityPolicy::Auto => std::env::var("LC_ALL") + .or_else(|_| std::env::var("LC_CTYPE")) + .or_else(|_| std::env::var("LANG")) + .map(|locale| { + let locale = locale.to_ascii_lowercase(); + locale.contains("utf-8") || locale.contains("utf8") + }) + .unwrap_or(false), + }; + let truecolor_enabled = std::env::var("COLORTERM") + .map(|value| { + let value = value.to_ascii_lowercase(); + value.contains("truecolor") || value.contains("24bit") + }) + .unwrap_or(false); + let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile( + theme::UiConfig::resolve_theme(theme_override), + color_enabled, + unicode_enabled, + truecolor_enabled, + )) .map_err(|error| StartupError::Terminal(error.to_string()))?; let ui = session.ui(); let result = async { @@ -258,32 +363,105 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - println!( - "Iota operator console\n\nUsage:\n iota [--theme ] Open the dashboard\n iota daemon install --bundle [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms." - ); + println!("{}", CliInvocation::help_text()); } -async fn run_command(ipc: Arc, command: Command) -> Result<(), StartupError> { +fn print_completions(shell: &str) -> Result<(), StartupError> { + let command_paths = CliInvocation::command_paths(); + let words = command_paths + .iter() + .flat_map(|command| command.split_whitespace()) + .collect::>() + .into_iter() + .collect::>() + .join(" "); + match shell { + "bash" => println!( + "_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota", + words + ), + "zsh" => println!( + "#compdef iota\n_arguments '1:command:({})' '*::argument:->args'", + words + ), + "fish" => { + for command in words.split_whitespace() { + println!("complete -c iota -f -a '{command}'"); + } + } + _ => { + return Err(StartupError::InvalidCommand( + "completion shell must be bash, zsh, or fish".into(), + )); + } + } + Ok(()) +} + +fn print_man_page() { + println!(".TH IOTA 1"); + println!(".SH NAME\n iota \\- Iota operator console"); + println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); + println!(".SH COMMANDS"); + for command in CliInvocation::command_paths() { + println!(".TP\n.B {command}"); + } + println!(".SH GLOBAL OPTIONS"); + println!(".TP\n.B --output text|json|yaml"); + println!(".TP\n.B --color auto|always|never"); + println!(".TP\n.B --unicode auto|always|never"); +} + +async fn run_command( + ipc: Arc, + command: Command, + output: OutputFormat, +) -> Result<(), StartupError> { let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, + Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, + Command::UsersAdd { username } => LocalRequest::CreateUser { username }, + Command::UsersRemove { + user_id, + confirmed: true, + } => LocalRequest::RemoveUser { user_id }, + Command::UsersImport { username } => LocalRequest::ImportUser { username }, + Command::OmikronReconnect => LocalRequest::ReconnectOmikron, + Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, + Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Restart, }, Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Stop, }, - Command::DaemonStopProcess => LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - }, Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus, - Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => { + Command::OmikronStatus => LocalRequest::GetOmikronStatus, + Command::ConfigGet => LocalRequest::GetConfig, + Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value }, + Command::ConfigReload => LocalRequest::ReloadConfig, + Command::Components => LocalRequest::ListComponents, + Command::Logs { limit } => LocalRequest::GetLogs { limit }, + Command::UpdateCheck => LocalRequest::CheckUpdate, + Command::CommunityList => LocalRequest::ListCommunities, + Command::UsersRemove { + confirmed: false, .. + } + | Command::IdentityRotate { confirmed: false } + | Command::RegenerateKeys { confirmed: false } + | Command::DaemonRestart { confirmed: false } + | Command::DaemonStop { confirmed: false } => { return Err(StartupError::InvalidCommand( "Refusing destructive command without --yes.".into(), )); } - _ => { + Command::Dashboard | Command::Help | Command::Version | Command::Completions { .. } + | Command::ManPage | Command::Install { .. } + | Command::DaemonEnable { .. } | Command::DaemonDisableStartup + | Command::DaemonStartupStatus | Command::DaemonStart + | Command::DaemonRestartService | Command::DaemonStopService => { return Err(StartupError::InvalidCommand( "Command cannot be run headlessly.".into(), )); @@ -294,12 +472,139 @@ async fn run_command(ipc: Arc, command: Command) -> Result<(), Startu .await .map_err(|e| StartupError::Other(e.to_string()))? { - ResponseResult::Ok(message) => { - println!("{message}"); + ResponseResult::Ok(payload) => { + if !matches!(output, OutputFormat::Text) { + return render_structured(&payload, output); + } + match payload { + ResponsePayload::Status(status) => { + print!("Phase: {}", status.phase); + if !status.tasks.is_empty() { + print!(", Tasks: {}", status.tasks.join(", ")); + } + if let Some(reason) = status.degraded_reason { + print!(", Degraded: {}", reason); + } + println!(); + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + println!("No active tasks."); + } else { + for task in &tasks { + println!("{}", task.name); + } + } + } + ResponsePayload::Users(users) => { + if users.is_empty() { + println!("No users."); + } else { + for user in &users { + println!("{} ({})", user.username, user.user_id); + } + } + } + ResponsePayload::UserCreated { user_id, username } => { + println!("Created user {} ({})", username, user_id); + } + ResponsePayload::UserRemoved { user_id } => { + println!("Removed user {}", user_id); + } + ResponsePayload::Acknowledged { message } => { + println!("{}", message); + } + ResponsePayload::DaemonStatus(status) => { + println!("{}", status.formatted); + } + ResponsePayload::Config(config) => { + println!("{}", config.yaml); + } + ResponsePayload::OmikronStatus(status) => { + println!("Connected: {}", status.connected); + if let Some(id) = status.iota_id { + println!("Iota ID: {}", id); + } + } + ResponsePayload::Components(components) => { + if components.is_empty() { + println!("No component health data available."); + } else { + for comp in &components { + let status_str = match comp.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + let suffix = comp + .message + .as_deref() + .map(|m| format!(" ({m})")) + .unwrap_or_default(); + println!("{:?}: {}{}", comp.id, status_str, suffix); + } + } + } + ResponsePayload::UserDetail(user) => { + println!("User: {} ({})", user.username, user.user_id); + if let Some(ref name) = user.display_name { + println!("Display Name: {name}"); + } + println!("Created At: {}", user.created_at); + if !user.trusted_apps.is_empty() { + println!("Trusted Apps: {}", user.trusted_apps.join(", ")); + } + } + ResponsePayload::LogEntries(logs) => { + for entry in &logs.entries { + let ts = entry.timestamp_ms; + let level = if entry.is_error { "ERR" } else { "INF" }; + println!("[{ts}] {level} {}: {}", entry.sender, entry.message); + } + } + ResponsePayload::UpdateStatus(status) => { + if status.available { + println!("Update available."); + } else { + println!("Up to date."); + } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + println!("No communities."); + } else { + for c in &communities { + println!("{} ({})", c.title, c.name); + } + } + } + } Ok(()) } ResponseResult::Error(code) => Err(StartupError::Other(format!( - "Daemon request failed: {code:?}" + "Daemon request failed: {code}" ))), } } + +/// The IPC payload is the versioned, tagged schema used by headless clients. +/// Text remains an operator-oriented presentation; JSON and YAML must never +/// require consumers to parse it. +fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { + match output { + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!( + "Cannot encode JSON output: {error}" + )))? + ), + OutputFormat::Yaml => print!( + "{}", + serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!( + "Cannot encode YAML output: {error}" + )))? + ), + OutputFormat::Text => unreachable!(), + } + Ok(()) +} From 1744357350a4a0566f2b8abb5b05050033fe8f90 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 25 Jul 2026 22:55:02 +0200 Subject: [PATCH 091/119] (feat): improve config stuff --- README.md | 3 +- flake.nix | 9 +++--- iota-paths/src/lib.rs | 35 +++++++++++----------- iota-process-manager/src/lib.rs | 51 +++++++++++++++++++++++++++------ 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4a278f3..8739e1d 100644 --- a/README.md +++ b/README.md @@ -39,4 +39,5 @@ usermod -aG iota-operators USER ``` The user must start a new login session before supplementary group membership -is visible. `IOTA_SOCKET` remains authoritative for custom deployments. +is visible. Unix per-user deployments must set `IOTA_SOCKET` to an absolute +path; Iota does not derive its IPC socket from `XDG_RUNTIME_DIR`. diff --git a/flake.nix b/flake.nix index ed9a49a..e2ca8a1 100644 --- a/flake.nix +++ b/flake.nix @@ -183,7 +183,7 @@ users.groups.iota = {}; - systemd.sockets.iota-daemon = { + systemd.sockets.iota = { description = "${descriptionText} IPC socket"; wantedBy = ["sockets.target"]; socketConfig = { @@ -198,10 +198,11 @@ }; }; - systemd.services.iota-daemon = { + systemd.services.iota = { description = descriptionText; - after = ["network.target"]; - requires = ["iota-daemon.socket"]; + wantedBy = ["multi-user.target"]; + after = ["network.target" "iota.socket"]; + requires = ["iota.socket"]; serviceConfig = { diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs index 35a51bd..9143ea1 100644 --- a/iota-paths/src/lib.rs +++ b/iota-paths/src/lib.rs @@ -25,6 +25,7 @@ pub enum IpcEndpoint { #[derive(Debug)] pub enum PathError { MissingPlatformDirectory(&'static str), + MissingRequiredOverride(&'static str), EmptyOverride(&'static str), RelativeOverride { variable: &'static str, @@ -37,6 +38,7 @@ impl fmt::Display for PathError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"), + Self::MissingRequiredOverride(name) => write!(f, "{name} must be set"), Self::EmptyOverride(name) => write!(f, "{name} must not be empty"), Self::RelativeOverride { variable, value } => { write!(f, "{variable} must be absolute, got {}", value.display()) @@ -81,7 +83,11 @@ impl IotaPaths { let install_root = override_first(&["IOTA_INSTALL_ROOT"])?.unwrap_or(defaults.install_root); let config_file = override_first(&["IOTA_CONFIG_FILE"])? .unwrap_or_else(|| config_dir.join("config.yaml")); - let ipc_endpoint = resolve_ipc(scope, runtime_dir.as_deref(), defaults.ipc_endpoint)?; + let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint)?; + let runtime_dir = runtime_dir.or_else(|| match &ipc_endpoint { + IpcEndpoint::UnixSocket(path) => path.parent().map(Path::to_path_buf), + IpcEndpoint::WindowsPipe(_) => None, + }); Ok(Self { scope, config_dir, @@ -234,7 +240,7 @@ struct Defaults { log_dir: PathBuf, asset_dir: PathBuf, install_root: PathBuf, - ipc_endpoint: IpcEndpoint, + ipc_endpoint: Option, } impl Defaults { fn for_scope(scope: Scope) -> Result { @@ -250,7 +256,7 @@ impl Defaults { log_dir: "/var/log/iota".into(), asset_dir: "/usr/local/share/iota/web".into(), install_root: "/usr/local/libexec/iota".into(), - ipc_endpoint: IpcEndpoint::UnixSocket("/run/iota/iota.sock".into()), + ipc_endpoint: Some(IpcEndpoint::UnixSocket("/run/iota/iota.sock".into())), }) } #[cfg(not(target_os = "linux"))] @@ -271,18 +277,15 @@ fn user_defaults() -> Result { let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?; let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?; let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?; - let runtime = absolute_env("XDG_RUNTIME_DIR")? - .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR"))? - .join("iota"); Ok(Defaults { config_dir: config_base.join("iota"), state_dir: state_base.join("iota"), cache_dir: cache_base.join("iota"), - runtime_dir: Some(runtime.clone()), + runtime_dir: None, log_dir: state_base.join("iota/logs"), asset_dir: data_base.join("iota/web"), install_root: data_base.join("iota/bin"), - ipc_endpoint: IpcEndpoint::UnixSocket(runtime.join("iota.sock")), + ipc_endpoint: None, }) } #[cfg(windows)] @@ -301,7 +304,9 @@ fn user_defaults() -> Result { log_dir: local.join("logs"), asset_dir: local.join("data"), install_root: local.join("bin"), - ipc_endpoint: IpcEndpoint::WindowsPipe(r"\\.\pipe\Tensamin.Iota.User".into()), + ipc_endpoint: Some(IpcEndpoint::WindowsPipe( + r"\\.\pipe\Tensamin.Iota.User".into(), + )), }) } @@ -332,20 +337,14 @@ fn override_first(names: &[&'static str]) -> Result, PathError> } Ok(None) } -fn resolve_ipc( - scope: Scope, - runtime: Option<&Path>, - default: IpcEndpoint, -) -> Result { +fn resolve_ipc(scope: Scope, default: Option) -> Result { #[cfg(unix)] { if let Some(path) = absolute_env("IOTA_SOCKET")? { return Ok(IpcEndpoint::UnixSocket(path)); } if scope == Scope::User { - return runtime - .map(|dir| IpcEndpoint::UnixSocket(dir.join("iota.sock"))) - .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR")); + return Err(PathError::MissingRequiredOverride("IOTA_SOCKET")); } } #[cfg(windows)] @@ -358,7 +357,7 @@ fn resolve_ipc( return Ok(IpcEndpoint::WindowsPipe(name)); } } - Ok(default) + default.ok_or(PathError::UnsupportedScope) } fn create_directory(path: &Path, private: bool) -> std::io::Result<()> { std::fs::create_dir_all(path)?; diff --git a/iota-process-manager/src/lib.rs b/iota-process-manager/src/lib.rs index e3a03d5..c0ebb0d 100644 --- a/iota-process-manager/src/lib.rs +++ b/iota-process-manager/src/lib.rs @@ -218,6 +218,8 @@ mod systemd { pub struct SystemdManager { executor: Arc, + service: &'static str, + socket: &'static str, } impl SystemdManager { pub async fn detect() -> Option { @@ -225,16 +227,29 @@ mod systemd { return None; } let executor: Arc = Arc::new(RealExecutor); - executor + let mut manager = executor .output("systemctl", &["--version", &COMMON[0], &COMMON[1]]) .await .ok() .filter(|r| r.success) - .map(|_| Self { executor }) + .map(|_| Self { + executor, + service: SERVICE, + socket: SOCKET, + })?; + if manager.status("iota.service").await.is_ok() { + manager.service = "iota.service"; + manager.socket = "iota.socket"; + } + Some(manager) } #[cfg(test)] pub fn with_executor(executor: Arc) -> Self { - Self { executor } + Self { + executor, + service: SERVICE, + socket: SOCKET, + } } async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> { let mut args = COMMON.to_vec(); @@ -358,19 +373,25 @@ mod systemd { async fn unit_status(&self, unit: &str) -> Result { self.status(unit).await } + async fn iota_startup_status(&self) -> Result { + Ok(DaemonStartupStatus::classify( + self.status(self.service).await?, + self.status(self.socket).await?, + )) + } async fn set_iota_startup_mode( &self, mode: StartupMode, ) -> Result { match mode { StartupMode::AlwaysOn => { - self.run(&["disable", SOCKET]).await?; - self.run(&["enable", "--now", SERVICE]).await?; + self.run(&["disable", self.socket]).await?; + self.run(&["enable", "--now", self.service]).await?; self.verify(DetectedStartupMode::AlwaysOn).await } StartupMode::SocketActivated => { - self.run(&["disable", "--now", SERVICE]).await?; - self.run(&["enable", "--now", SOCKET]).await?; + self.run(&["disable", "--now", self.service]).await?; + self.run(&["enable", "--now", self.socket]).await?; self.verify(DetectedStartupMode::SocketActivated).await } } @@ -378,9 +399,21 @@ mod systemd { async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> { self.run(action).await } + async fn process_action( + &self, + action: ProcessAction, + ) -> Result { + let verb = match action { + ProcessAction::Start => "start", + ProcessAction::Stop => "stop", + ProcessAction::Restart => "restart", + }; + self.run(&[verb, self.service]).await?; + self.iota_startup_status().await + } async fn disable_startup(&self) -> Result { - self.run(&["disable", "--now", SERVICE]).await?; - self.run(&["disable", "--now", SOCKET]).await?; + self.run(&["disable", "--now", self.service]).await?; + self.run(&["disable", "--now", self.socket]).await?; self.verify(DetectedStartupMode::Disabled).await } } From 009173a97d254d3021ce595b1de602bde8e59e8b Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 25 Jul 2026 22:59:25 +0200 Subject: [PATCH 092/119] caching --- Cargo.lock | 2 + README.md | 21 ++ client/src/client_connection.rs | 6 + iota-cli/src/controls/button.rs | 11 +- iota-cli/src/controls/mod.rs | 2 +- iota-cli/src/controls/panel.rs | 51 ++- iota-cli/src/controls/scroll.rs | 51 ++- iota-cli/src/elements/console_card.rs | 7 +- iota-cli/src/elements/graph_card.rs | 74 +++-- iota-cli/src/elements/log_card.rs | 47 ++- iota-cli/src/ipc_client.rs | 55 +++- iota-cli/src/screens/daemon_setup.rs | 20 +- iota-cli/src/screens/main_screen.rs | 69 +++- iota-cli/src/screens/md_viewer.rs | 4 +- iota-cli/src/screens/metrics.rs | 11 +- iota-cli/src/screens/overview.rs | 43 ++- iota-cli/src/screens/settings.rs | 59 +++- iota-cli/src/screens/terms_checker.rs | 9 +- iota-cli/src/screens/terms_updater.rs | 9 +- iota-cli/src/screens/users.rs | 20 +- iota-cli/src/theme/model.rs | 15 +- iota-cli/src/theme/presets.rs | 24 +- iota-cli/src/ui.rs | 41 ++- iota-cli/tests/settings_snapshot.rs | 2 +- iota-connection/src/message_handlers.rs | 202 +++++++++--- iota-daemon-lib/src/command_router.rs | 105 +++--- iota-daemon-lib/src/ipc_server.rs | 14 +- iota-daemon-lib/src/log_broadcaster.rs | 5 +- iota-daemon-lib/src/services.rs | 46 ++- iota-daemon-lib/tests/command_router.rs | 8 +- iota-daemon/Cargo.toml | 1 + iota-daemon/src/main.rs | 55 ++++ iota-ipc/src/lib.rs | 12 +- iota-ipc/src/protocol.rs | 24 +- iota-ipc/src/text_commands.rs | 20 +- iota-storage/src/util/chat_files.rs | 161 ++++++++- iota-storage/src/util/chats_util.rs | 23 +- iota-storage/src/util/db.rs | 52 ++- iota-storage/src/util/mod.rs | 1 + iota-storage/src/util/sync.rs | 160 +++++++++ iota-terms/src/consent.rs | 89 +++++ iota-terms/src/lib.rs | 1 + iota/Cargo.toml | 1 + iota/src/cli_args.rs | 342 ++++++++++++++++---- iota/src/daemon_setup_flow.rs | 20 +- iota/src/main.rs | 54 +++- iota/src/terms.rs | 118 +++++++ omikron-connector/src/omikron_connection.rs | 7 + type-maps.yaml | 9 + 49 files changed, 1791 insertions(+), 392 deletions(-) create mode 100644 iota-storage/src/util/sync.rs create mode 100644 iota-terms/src/consent.rs create mode 100644 iota/src/terms.rs diff --git a/Cargo.lock b/Cargo.lock index 175c62e..b8631b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,7 @@ dependencies = [ "iota-ipc", "iota-paths", "iota-process-manager", + "iota-terms", "serde_json", "serde_yaml", "tokio", @@ -2350,6 +2351,7 @@ dependencies = [ "iota-paths", "iota-state", "iota-storage", + "iota-terms", "iota-util", "omikron-connector", "tokio", diff --git a/README.md b/README.md index 4a278f3..2f85cf6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ theme: surface An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start. +## Accepting terms without the TUI + +Iota services do not start until the required agreements have been accepted for +the deployment. Use the terminal flow to read each current document and type +the document-specific acceptance phrase: + +```text +iota terms accept +``` + +For a system-managed daemon, accept its deployment-scoped terms as an account +that can write the system Iota state directory (normally via `sudo`): + +```text +sudo iota terms accept --system +``` + +`iota terms status` reports the stored state, and `iota terms show eula`, +`iota terms show tos`, or `iota terms show privacy` displays an individual +document without accepting it. + # Linux daemon installation The system-managed daemon runs as the dedicated `iota` account and listens on diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index b20dca3..da728d4 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -250,6 +250,12 @@ impl ClientConnection { return; } + if cv.is_type(CommunicationType::ClientStateAck) { + self.send_message(&message_handlers::handle_client_state_ack(&cv)) + .await; + return; + } + // ************************************************ // // Direct messages // // ************************************************ // diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs index b57833b..1ce27e4 100644 --- a/iota-cli/src/controls/button.rs +++ b/iota-cli/src/controls/button.rs @@ -39,8 +39,15 @@ pub fn render_button( } }; frame.render_widget( - Paragraph::new(Span::styled(if button.focused { format!("› {}", button.label) } else { button.label.to_owned() }, style)) - .alignment(Alignment::Center), + Paragraph::new(Span::styled( + if button.focused { + format!("› {}", button.label) + } else { + button.label.to_owned() + }, + style, + )) + .alignment(Alignment::Center), area, ); } diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 7ce1cdd..fae2452 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -1,8 +1,8 @@ pub mod action; pub mod button; pub mod checkbox_group; -pub mod header; pub mod choice; +pub mod header; pub mod navigation; pub mod panel; pub mod radio_group; diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs index 6e10e91..7307a03 100644 --- a/iota-cli/src/controls/panel.rs +++ b/iota-cli/src/controls/panel.rs @@ -1,23 +1,60 @@ use crate::theme::{ChromeMode, ResolvedTheme}; -use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Block, Borders, Paragraph}, +}; /// Draw a conventional outlined panel or a filled surface from the same call /// site. Screens can migrate without embedding theme branches in layouts. -pub fn render_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool, theme: &ResolvedTheme) -> Rect { +pub fn render_panel( + frame: &mut Frame, + area: Rect, + title: &str, + focused: bool, + theme: &ResolvedTheme, +) -> Rect { match theme.chrome { ChromeMode::Bordered => { - let block = Block::default().title(title).borders(Borders::ALL).border_style(if focused { theme.borders.focused } else { theme.borders.normal }); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(if focused { + theme.borders.focused + } else { + theme.borders.normal + }); let inner = block.inner(area); frame.render_widget(block, area); inner } ChromeMode::Surfaces => { - frame.render_widget(Block::default().style(if focused { theme.surfaces.panel_focused } else { theme.surfaces.panel }), area); - let header = Rect { x: area.x, y: area.y, width: area.width, height: area.height.min(1) }; - frame.render_widget(Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), header); + frame.render_widget( + Block::default().style(if focused { + theme.surfaces.panel_focused + } else { + theme.surfaces.panel + }), + area, + ); + let header = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height.min(1), + }; + frame.render_widget( + Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), + header, + ); // Surface panels use a single header row. A one-cell inset keeps // compact controls such as the console usable at height three. - Rect { x: area.x.saturating_add(1), y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: area.height.saturating_sub(1) } + Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(1), + } } } } diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs index c3036c4..585bf3c 100644 --- a/iota-cli/src/controls/scroll.rs +++ b/iota-cli/src/controls/scroll.rs @@ -1,20 +1,53 @@ -use ratatui::{Frame, layout::Rect, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}}; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, +}; /// Reusable viewport policy for long, vertically stacked terminal content. #[derive(Clone, Copy, Debug)] -pub struct ScrollOptions { pub show_scrollbar: bool, pub render_partial_components: bool } -impl Default for ScrollOptions { fn default() -> Self { Self { show_scrollbar: true, render_partial_components: true } } } +pub struct ScrollOptions { + pub show_scrollbar: bool, + pub render_partial_components: bool, +} +impl Default for ScrollOptions { + fn default() -> Self { + Self { + show_scrollbar: true, + render_partial_components: true, + } + } +} #[derive(Clone, Debug, Default)] -pub struct ScrollField { pub offset: u16, pub options: ScrollOptions } +pub struct ScrollField { + pub offset: u16, + pub options: ScrollOptions, +} impl ScrollField { - pub fn up(&mut self, amount: u16) { self.offset = self.offset.saturating_sub(amount); } - pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { self.offset = (self.offset.saturating_add(amount)).min(content_height.saturating_sub(viewport_height)); } - pub fn render(&self, frame: &mut Frame, area: Rect, content: Paragraph<'_>, content_height: u16) { + pub fn up(&mut self, amount: u16) { + self.offset = self.offset.saturating_sub(amount); + } + pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { + self.offset = (self.offset.saturating_add(amount)) + .min(content_height.saturating_sub(viewport_height)); + } + pub fn render( + &self, + frame: &mut Frame, + area: Rect, + content: Paragraph<'_>, + content_height: u16, + ) { frame.render_widget(content.scroll((self.offset, 0)), area); if self.options.show_scrollbar && content_height > area.height { - let mut state = ScrollbarState::new(content_height as usize).position(self.offset as usize); - frame.render_stateful_widget(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, &mut state); + let mut state = + ScrollbarState::new(content_height as usize).position(self.offset as usize); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + area, + &mut state, + ); } } } diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index e314f1d..db861e6 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -174,12 +174,7 @@ impl ConsoleCard { fn is_destructive(command: &str) -> bool { matches!( command.trim_start_matches('/').trim(), - "restart" - | "reload" - | "stop" - | "shutdown" - | "regenerate keys" - | "identity rotate" + "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate" ) || command .trim_start_matches('/') .trim_start() diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index a336772..f47aab6 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -40,9 +40,18 @@ impl GRAPHS { Err(_) => return Vec::new(), }; match self { - GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(), - GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(), - GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(), + GRAPHS::Ram => state + .with_width(sample_width.min(u16::MAX as usize) as u16) + .ram + .clone(), + GRAPHS::Cpu => state + .with_width(sample_width.min(u16::MAX as usize) as u16) + .cpu + .clone(), + GRAPHS::Ping => state + .with_width(sample_width.min(u16::MAX as usize) as u16) + .ping + .clone(), } } @@ -141,17 +150,32 @@ impl Element for GraphCard { }; let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); - let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64); - let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r }; + let title = format!( + "{}: {}{} {}min/{}max", + self.title, + graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, + unit, + min_y as i64, + max_y as i64 + ); + let plot_area = if surface { + crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) + } else { + r + }; let block = Block::default() - .title(if surface { String::new() } else { format!( - "{}:─{}{}─{}min/{}max", - self.title, - graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, - unit, - min_y as i64, - max_y as i64, - ) }) + .title(if surface { + String::new() + } else { + format!( + "{}:─{}{}─{}min/{}max", + self.title, + graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, + unit, + min_y as i64, + max_y as i64, + ) + }) .borders(if surface { Borders::NONE } else { self.borders }) .border_style(if self.focused { context.theme.graphs.focused_border @@ -186,17 +210,19 @@ impl Element for GraphCard { }); f.render_widget(block, r); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( - f, - r, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); } + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + draw_block_joins( + f, + r, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); + } } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index 3d5187b..a7d72eb 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -322,9 +322,22 @@ impl Element for LogCard { let entries = self.get_logs(); let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme) + crate::controls::panel::render_panel( + f, + area, + &self.build_title(), + self.focused, + context.theme, + ) } else { - let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border }); + let block = Block::default() + .title(self.build_title()) + .borders(self.borders) + .border_style(if self.focused { + context.theme.logs.focused_border + } else { + context.theme.logs.border + }); let inner = block.inner(area); f.render_widget(block, area); inner @@ -363,7 +376,11 @@ impl Element for LogCard { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); - let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix }; + let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + "" + } else { + prefix + }; if !prefix.is_empty() { spans.push(Span::styled( @@ -404,17 +421,19 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( - f, - area, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); } + if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + draw_block_joins( + f, + area, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); + } } } diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 904947c..3ffa8d1 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -459,14 +459,22 @@ impl IpcClient { if tasks.is_empty() { "No active tasks.".into() } else { - tasks.iter().map(|t| t.name.as_str()).collect::>().join(", ") + tasks + .iter() + .map(|t| t.name.as_str()) + .collect::>() + .join(", ") } } ResponsePayload::Users(users) => { if users.is_empty() { "No users.".into() } else { - users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::>().join("\n") + users + .iter() + .map(|u| format!("{} ({})", u.username, u.user_id)) + .collect::>() + .join("\n") } } ResponsePayload::UserCreated { user_id, username } => { @@ -489,14 +497,18 @@ impl IpcClient { if components.is_empty() { "No component health data available.".into() } else { - components.iter().map(|c| { - let status_str = match c.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - format!("{:?}: {}", c.id, status_str) - }).collect::>().join("\n") + components + .iter() + .map(|c| { + let status_str = match c.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + format!("{:?}: {}", c.id, status_str) + }) + .collect::>() + .join("\n") } } ResponsePayload::UserDetail(user) => { @@ -510,20 +522,31 @@ impl IpcClient { } msg } - ResponsePayload::LogEntries(logs) => { - logs.entries.iter().map(|e| { + ResponsePayload::LogEntries(logs) => logs + .entries + .iter() + .map(|e| { let level = if e.is_error { "ERR" } else { "INF" }; format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) - }).collect::>().join("\n") - } + }) + .collect::>() + .join("\n"), ResponsePayload::UpdateStatus(status) => { - if status.available { "Update available.".into() } else { "Up to date.".into() } + if status.available { + "Update available.".into() + } else { + "Up to date.".into() + } } ResponsePayload::Communities(communities) => { if communities.is_empty() { "No communities.".into() } else { - communities.iter().map(|c| format!("{} ({})", c.title, c.name)).collect::>().join("\n") + communities + .iter() + .map(|c| format!("{} ({})", c.title, c.name)) + .collect::>() + .join("\n") } } } diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index 627b1f2..b9cae99 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -29,7 +29,13 @@ impl Screen for DaemonStartingScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + _hits: &mut HitMap, + ) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -191,7 +197,13 @@ impl Screen for DaemonSetupScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + _hits: &mut HitMap, + ) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -267,7 +279,9 @@ impl Screen for DaemonSetupScreen { ); } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; match event.code { KeyCode::Esc => { self.complete(DaemonSetupDecision::Exit); diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 8676a76..40abb83 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -212,9 +212,7 @@ impl MainScreen { let mut seen: Vec> = Vec::new(); for (y, row) in self.nav_grid.iter().enumerate() { for (x, elem_opt) in row.iter().enumerate() { - if x == 1 - && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) - { + if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) { continue; } if elem_opt.is_some() && !seen.contains(elem_opt) { @@ -511,25 +509,64 @@ impl Screen for MainScreen { fn key_hints(&self) -> Vec { if self.selected_coords == (2, 0) { vec![ - KeyHint { keys: "Enter", action: "Send" }, - KeyHint { keys: "Up/Down", action: "History" }, - KeyHint { keys: "Tab", action: "Complete" }, - KeyHint { keys: "F6", action: "Header" }, + KeyHint { + keys: "Enter", + action: "Send", + }, + KeyHint { + keys: "Up/Down", + action: "History", + }, + KeyHint { + keys: "Tab", + action: "Complete", + }, + KeyHint { + keys: "F6", + action: "Header", + }, ] } else if self.selected_coords == (0, 0) { vec![ - KeyHint { keys: "J/K", action: "Scroll logs" }, - KeyHint { keys: "Enter", action: "Lock scroll" }, - KeyHint { keys: "/", action: "Filter" }, - KeyHint { keys: "M", action: "Metrics screen" }, - KeyHint { keys: "Tab", action: "Next panel" }, - KeyHint { keys: "F6", action: "Header" }, + KeyHint { + keys: "J/K", + action: "Scroll logs", + }, + KeyHint { + keys: "Enter", + action: "Lock scroll", + }, + KeyHint { + keys: "/", + action: "Filter", + }, + KeyHint { + keys: "M", + action: "Metrics screen", + }, + KeyHint { + keys: "Tab", + action: "Next panel", + }, + KeyHint { + keys: "F6", + action: "Header", + }, ] } else { vec![ - KeyHint { keys: "Enter", action: "Toggle metrics" }, - KeyHint { keys: "Tab", action: "Next panel" }, - KeyHint { keys: "F6", action: "Header" }, + KeyHint { + keys: "Enter", + action: "Toggle metrics", + }, + KeyHint { + keys: "Tab", + action: "Next panel", + }, + KeyHint { + keys: "F6", + action: "Header", + }, ] } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index c9c4518..8c46d3d 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -34,7 +34,9 @@ impl Screen for FileViewer { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs index 705560c..1243979 100644 --- a/iota-cli/src/screens/metrics.rs +++ b/iota-cli/src/screens/metrics.rs @@ -63,7 +63,13 @@ impl Screen for MetricsScreen { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { let block = Block::default() .title(" Metrics ") .borders(Borders::ALL) @@ -80,8 +86,7 @@ impl Screen for MetricsScreen { frame.render_widget( Paragraph::new(format!( "Range: {} ({} samples) Left/Right to change", - RANGES[self.range_index].1, - RANGES[self.range_index].0 + RANGES[self.range_index].1, RANGES[self.range_index].0 )) .style(context.theme.text.heading), rows[0], diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs index 0c6feda..4d4aea6 100644 --- a/iota-cli/src/screens/overview.rs +++ b/iota-cli/src/screens/overview.rs @@ -53,17 +53,11 @@ impl OverviewScreen { let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - "Connection", - theme.text.heading, - ))); + lines.push(Line::from(Span::styled("Connection", theme.text.heading))); lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Daemon", - theme.text.heading, - ))); + lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); lines.push(Line::from(format!( " Version: {}", version_or_unknown(&daemon.version) @@ -113,10 +107,7 @@ impl OverviewScreen { if !daemon.components.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Components", - theme.text.heading, - ))); + lines.push(Line::from(Span::styled("Components", theme.text.heading))); for (id, health) in &daemon.components { let status_str = match health.status { iota_ipc::HealthStatus::Healthy => "[OK] healthy", @@ -187,7 +178,13 @@ impl Screen for OverviewScreen { .borders(Borders::ALL) .border_style(context.theme.borders.normal) .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner }; + let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) + } else { + let inner = block.inner(rect); + f.render_widget(block, rect); + inner + }; let rows = ratatui::layout::Layout::vertical([ ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), @@ -273,10 +270,22 @@ impl Screen for OverviewScreen { } fn key_hints(&self) -> Vec { vec![ - KeyHint { keys: "Up/Down", action: "Scroll" }, - KeyHint { keys: "PgUp/PgDn", action: "Page" }, - KeyHint { keys: "Esc/B", action: "Back" }, - KeyHint { keys: "F6", action: "Header" }, + KeyHint { + keys: "Up/Down", + action: "Scroll", + }, + KeyHint { + keys: "PgUp/PgDn", + action: "Page", + }, + KeyHint { + keys: "Esc/B", + action: "Back", + }, + KeyHint { + keys: "F6", + action: "Header", + }, ] } } diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index 474dfae..dd26878 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -49,8 +49,12 @@ impl SettingsScreen { selected, saved: current, message: "Left/Right previews. Enter saves.".into(), - color: UiConfig::load().map(|config| config.color).unwrap_or_default(), - unicode: UiConfig::load().map(|config| config.unicode).unwrap_or_default(), + color: UiConfig::load() + .map(|config| config.color) + .unwrap_or_default(), + unicode: UiConfig::load() + .map(|config| config.unicode) + .unwrap_or_default(), focus: Focus::Theme, dialog: None, pending: false, @@ -64,14 +68,16 @@ impl SettingsScreen { fn apply(&self, persist: bool) -> InteractionResult { let theme = self.selected_theme(); InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::ApplyTheme { theme, persist }) - }), + task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }), } } fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { - match policy { TerminalPolicy::Auto => TerminalPolicy::Always, TerminalPolicy::Always => TerminalPolicy::Never, TerminalPolicy::Never => TerminalPolicy::Auto } + match policy { + TerminalPolicy::Auto => TerminalPolicy::Always, + TerminalPolicy::Always => TerminalPolicy::Never, + TerminalPolicy::Never => TerminalPolicy::Auto, + } } fn next_focus(&mut self) { @@ -100,9 +106,7 @@ impl SettingsScreen { self.pending = true; self.message = "Regenerating keys…".into(); return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(AppEvent::RegenerateKeysRequested) - }), + task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }), }; } } @@ -113,7 +117,15 @@ impl SettingsScreen { let theme = self.selected_theme(); let color = self.color; let unicode = self.unicode; - InteractionResult::AppTask { task: Box::pin(async move { UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) }) } + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + }) + }), + } } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); @@ -146,8 +158,7 @@ impl Screen for SettingsScreen { .border_style(context.theme.borders.focused); let inner = block.inner(area); frame.render_widget(block, area); - let rows = - Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); + let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); frame.render_widget( Paragraph::new(format!( "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", @@ -156,7 +167,9 @@ impl Screen for SettingsScreen { " [saved]" } else { " [preview]" - }, self.color, self.unicode + }, + self.color, + self.unicode )) .style(context.theme.text.heading), rows[0], @@ -331,8 +344,14 @@ impl Screen for SettingsScreen { self.prev_focus(); InteractionResult::Handled } - KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); InteractionResult::Handled } - KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } + KeyCode::Char('c') | KeyCode::Char('C') => { + self.color = Self::next_policy(self.color); + InteractionResult::Handled + } + KeyCode::Char('u') | KeyCode::Char('U') => { + self.unicode = Self::next_policy(self.unicode); + InteractionResult::Handled + } KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { InteractionResult::CloseScreen } @@ -378,8 +397,14 @@ impl Screen for SettingsScreen { keys: "Enter", action: "Save/Activate", }, - KeyHint { keys: "Tab", action: "Move focus" }, - KeyHint { keys: "C/U", action: "Color/Unicode" }, + KeyHint { + keys: "Tab", + action: "Move focus", + }, + KeyHint { + keys: "C/U", + action: "Color/Unicode", + }, KeyHint { keys: "Esc/B", action: "Back", diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 99e28e3..628134f 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,7 +2,10 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, + screens::{ + md_viewer::FileViewer, + screens::{HitMap, Screen, UiEvent}, + }, util::{buttons::draw_buttons, terms_focus::Focus}, }; use crossterm::event::KeyCode; @@ -271,7 +274,9 @@ impl Screen for TermsCheckerScreen { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index c298e9a..2dc502e 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -3,7 +3,10 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}}, + screens::{ + md_viewer::FileViewer, + screens::{HitMap, Screen, UiEvent}, + }, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; @@ -594,7 +597,9 @@ impl Screen for TermsUpdaterScreen { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; let mut possible_states = Vec::new(); if self.eula_needed { diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index 14f1662..ac271a8 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -89,7 +89,12 @@ impl UsersScreen { let title = if self.filter.is_empty() { format!("Users ({})", self.users.len()) } else { - format!("Users ({}/{}) filter: {}", visible_indices.len(), self.users.len(), self.filter) + format!( + "Users ({}/{}) filter: {}", + visible_indices.len(), + self.users.len(), + self.filter + ) }; let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel( @@ -124,14 +129,18 @@ impl UsersScreen { } let mut lines = Vec::new(); - self.viewport_height.store(inner.height as usize, Ordering::Relaxed); + self.viewport_height + .store(inner.height as usize, Ordering::Relaxed); let labels: Vec<(usize, String)> = visible_indices .iter() .skip(self.scroll_offset) .take(inner.height as usize) .map(|user_index| { let user = &self.users[*user_index]; - (*user_index, format!("{:>6} {}", user.user_id, user.username)) + ( + *user_index, + format!("{:>6} {}", user.user_id, user.username), + ) }) .collect(); for (user_index, label) in &labels { @@ -291,7 +300,10 @@ impl UsersScreen { fn keep_focused_user_visible(&mut self) { let indices = self.filtered_indices(); - let Some(position) = indices.iter().position(|index| *index == self.focused_index) else { + let Some(position) = indices + .iter() + .position(|index| *index == self.focused_index) + else { self.scroll_offset = 0; return; }; diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs index e107f39..5d4955e 100644 --- a/iota-cli/src/theme/model.rs +++ b/iota-cli/src/theme/model.rs @@ -25,11 +25,20 @@ pub struct BorderStyles { } #[derive(Clone, Debug)] pub struct SurfaceStyles { - pub canvas: Style, pub toolbar: Style, pub panel: Style, pub panel_alternate: Style, - pub panel_focused: Style, pub panel_selected: Style, pub footer: Style, pub overlay: Style, + pub canvas: Style, + pub toolbar: Style, + pub panel: Style, + pub panel_alternate: Style, + pub panel_focused: Style, + pub panel_selected: Style, + pub footer: Style, + pub overlay: Style, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChromeMode { Bordered, Surfaces } +pub enum ChromeMode { + Bordered, + Surfaces, +} #[derive(Clone, Debug)] pub struct ChoiceItemStyle { pub marker: Style, diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs index 33b8ff0..301339a 100644 --- a/iota-cli/src/theme/presets.rs +++ b/iota-cli/src/theme/presets.rs @@ -1,7 +1,7 @@ use super::{ - BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, - ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles, - ThemeName, + BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles, + CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, + StatusStyles, SurfaceStyles, TextStyles, ThemeName, }; use ratatui::style::{Color, Modifier, Style}; @@ -46,7 +46,16 @@ fn base( ResolvedTheme { name, unicode: true, - surfaces: SurfaceStyles { canvas: Style::default(), toolbar: Style::default(), panel: Style::default(), panel_alternate: Style::default(), panel_focused: focused, panel_selected: selected, footer: Style::default(), overlay: Style::default() }, + surfaces: SurfaceStyles { + canvas: Style::default(), + toolbar: Style::default(), + panel: Style::default(), + panel_alternate: Style::default(), + panel_focused: focused, + panel_selected: selected, + footer: Style::default(), + overlay: Style::default(), + }, chrome: ChromeMode::Bordered, text: TextStyles { normal, @@ -300,11 +309,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme { CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); theme.chrome = ChromeMode::Surfaces; theme.surfaces = SurfaceStyles { - canvas: plain.bg(Color::Black), toolbar: plain.fg(Color::White).bg(Color::DarkGray), + canvas: plain.bg(Color::Black), + toolbar: plain.fg(Color::White).bg(Color::DarkGray), panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)), panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)), - panel_selected: selected, footer: plain.fg(Color::DarkGray).bg(Color::Black), overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), + panel_selected: selected, + footer: plain.fg(Color::DarkGray).bg(Color::Black), + overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), }; theme } diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 25f7405..90d0b0b 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -328,7 +328,12 @@ impl UI { } return; } - if let UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) = &event { + if let UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + }) = &event + { self.set_theme(theme::resolve(*theme)).await; let mut config = theme::UiConfig::load().unwrap_or_default(); config.theme = *theme; @@ -337,7 +342,9 @@ impl UI { let result = config .save() .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self.app_event_tx.send(UiEvent::App(AppEvent::ThemeSaved(result))); + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::ThemeSaved(result))); return; } if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { @@ -386,12 +393,14 @@ impl UI { KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), KeyCode::Enter | KeyCode::Char(' ') => { - action = Some([ - AppAction::OpenOverview, - AppAction::OpenUsers, - AppAction::OpenSettings, - AppAction::Quit, - ][index]); + action = Some( + [ + AppAction::OpenOverview, + AppAction::OpenUsers, + AppAction::OpenSettings, + AppAction::Quit, + ][index], + ); *focus = None; } KeyCode::Esc => *focus = None, @@ -516,7 +525,8 @@ impl UI { AppAction::OpenUsers => self.open_users().await, AppAction::OpenSettings => { let current = self.theme_name().await; - self.set_screen(Box::new(SettingsScreen::new(current))).await; + self.set_screen(Box::new(SettingsScreen::new(current))) + .await; } AppAction::OpenMetrics => { if let Some(screen) = MetricsScreen::new(self.clone()).await { @@ -552,7 +562,9 @@ impl UI { }) .collect()) } - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot load users: {error}")) + } Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), Err(error) => Err(format!("Cannot load users: {error}")), }; @@ -611,8 +623,13 @@ impl UI { .join(" ") }; f.render_widget( - ratatui::widgets::Paragraph::new(format!(" {hints}")) - .style(context.theme.surfaces.footer.patch(context.theme.text.muted)), + ratatui::widgets::Paragraph::new(format!(" {hints}")).style( + context + .theme + .surfaces + .footer + .patch(context.theme.text.muted), + ), rows[2], ); screen.render(f, rows[1], &context, &mut hits); diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 0161e2d..470e514 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -1,3 +1,4 @@ +use crossterm::event::{KeyCode, KeyEvent}; use iota_cli::{ interaction_result::InteractionResult, render_context::RenderContext, @@ -8,7 +9,6 @@ use iota_cli::{ theme::{ThemeName, resolve}, }; use ratatui::{Terminal, backend::TestBackend}; -use crossterm::event::{KeyCode, KeyEvent}; fn buffer_text(terminal: &Terminal) -> String { terminal diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index b3c08a9..079c3c5 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -108,6 +108,10 @@ fn stored_message_value( partner_id: i64, ) -> DataValue { let mut fields = vec![ + ( + DataType::MessageId, + DataValue::SignedNumber(message.id as i128), + ), ( DataType::SendTime, DataValue::SignedNumber(message.message_time as i128), @@ -261,56 +265,160 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .with_receiver(sender_id as u64) } -pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - - let contacts = chats_util::get_users(user_id); - let mut contacts_array = Vec::new(); - - for (i, contact) in contacts.iter().enumerate() { - let mut contact_container = Vec::new(); - contact_container.push(( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )); - contact_container.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), - )); - - if let Some(ref name) = contact.user_name { - contact_container.push((DataType::Username, DataValue::Str(name.clone()))); - } - - let amount = if i < 10 { 20 } else { 1 }; - let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); - - let mut msg_array = Vec::new(); - for m in &messages { - msg_array.push(stored_message_value(m, user_id, contact.user_id)); - - if msg_array.len() == 1 { - let sender_id = if m.sent_by_self { - user_id - } else { - contact.user_id - }; - let mut last_msg = Vec::new(); - last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); - last_msg.push(( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - )); - contact_container.push((DataType::LastMessage, typed_container(last_msg))); - } - } - contact_container.push((DataType::Messages, DataValue::Array(msg_array))); - contacts_array.push(typed_container(contact_container)); +fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue { + let mut fields = vec![( + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )]; + if let Some(name) = &contact.user_name { + fields.push((DataType::Username, DataValue::Str(name.clone()))); } + if let Some(last_message_at) = contact.last_message_at { + fields.push(( + DataType::LastMessageAt, + DataValue::SignedNumber(last_message_at as i128), + )); + } + typed_container(fields) +} - CommunicationValue::new(CommunicationType::ClientConnected) +fn sync_error(cv: &CommunicationValue) -> CommunicationValue { + error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( + DataType::SessionId, + cv.get_data(DataType::SessionId).clone(), + ) +} + +/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. +pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { + use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; + let user_id = match i64::try_from(cv.get_sender()) { + Ok(id) if id > 0 => id, + _ => return sync_error(cv), + }; + let session_id = match data_i64(cv, DataType::SessionId) { + Some(id) if id > 0 => id, + _ => return sync_error(cv), + }; + let reported_version = match data_i64(cv, DataType::VersionNumber) { + Some(version) if version >= 0 => version, + _ => return sync_error(cv), + }; + let cache_valid = cv.get_data(DataType::CacheValid).as_bool().unwrap_or(false); + let schema = data_i64(cv, DataType::CacheSchemaVersion).unwrap_or(0); + let head = match sync::head(user_id) { + Ok(version) => version, + Err(_) => return sync_error(cv), + }; + let known_session = sync::has_session(user_id, session_id).unwrap_or(false); + let full = !cache_valid + || reported_version == 0 + || !known_session + || reported_version > head + || schema != CACHE_SCHEMA_VERSION; + let (contacts, messages, deleted_messages, deleted_contacts, mode) = if full { + ( + chats_util::get_users(user_id), + chat_files::get_all_messages(user_id), + Vec::new(), + Vec::new(), + "full", + ) + } else { + match sync::delta(user_id, reported_version, head) { + Ok(delta) => ( + chats_util::get_users_by_ids(user_id, &delta.contact_upserts), + chat_files::get_messages_by_ids(user_id, &delta.message_upserts), + delta.deleted_message_ids, + delta.deleted_contact_ids, + "delta", + ), + Err(_) => ( + chats_util::get_users(user_id), + chat_files::get_all_messages(user_id), + Vec::new(), + Vec::new(), + "full", + ), + } + }; + let all_contact_ids = chats_util::get_users(user_id) + .into_iter() + .map(|contact| DataValue::SignedNumber(contact.user_id as i128)) + .collect(); + let message_values = messages + .iter() + .map(|message| stored_message_value(message, user_id, message.external_user)) + .collect(); + CommunicationValue::new(CommunicationType::ClientStateSync) .with_id(cv.get_id()) - .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)) + .with_receiver(cv.get_sender()) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(head as i128), + ) + .add_typed_default( + DataType::CacheSchemaVersion, + DataValue::SignedNumber(CACHE_SCHEMA_VERSION as i128), + ) + .add_typed_default(DataType::SyncMode, DataValue::Str(mode.into())) + .add_typed_default( + DataType::Contacts, + DataValue::Array(contacts.iter().map(contact_value).collect()), + ) + .add_typed_default(DataType::Messages, DataValue::Array(message_values)) + .add_typed_default( + DataType::DeletedMessageIds, + DataValue::Array( + deleted_messages + .into_iter() + .map(|id| DataValue::SignedNumber(id as i128)) + .collect(), + ), + ) + .add_typed_default( + DataType::DeletedContactIds, + DataValue::Array( + deleted_contacts + .into_iter() + .map(|id| DataValue::SignedNumber(id as i128)) + .collect(), + ), + ) + .add_typed_default(DataType::UserIds, DataValue::Array(all_contact_ids)) + .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())) +} + +pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { + use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; + let user_id = match i64::try_from(cv.get_sender()) { + Ok(id) if id > 0 => id, + _ => return sync_error(cv), + }; + let session_id = match data_i64(cv, DataType::SessionId) { + Some(id) if id > 0 => id, + _ => return sync_error(cv), + }; + let version = match data_i64(cv, DataType::VersionNumber) { + Some(version) if version >= 0 => version, + _ => return sync_error(cv), + }; + if sync::acknowledge(user_id, session_id, version, CACHE_SCHEMA_VERSION).is_err() { + return sync_error(cv); + } + success_response(cv) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(version as i128), + ) } pub fn handle_message_state(cv: &CommunicationValue) { diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index f5d4d6d..ebc3e0e 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,8 +1,8 @@ -use crate::{DaemonRuntime, DaemonServices}; use crate::log_buffer::LogBuffer; +use crate::{DaemonRuntime, DaemonServices}; use iota_ipc::{ - ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode, - LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, + CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode, + LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; @@ -23,8 +23,16 @@ pub struct CommandRouter { } impl CommandRouter { - pub fn new(runtime: Arc, services: Arc, log_buffer: Arc>) -> Self { - Self { runtime, services, log_buffer } + pub fn new( + runtime: Arc, + services: Arc, + log_buffer: Arc>, + ) -> Self { + Self { + runtime, + services, + log_buffer, + } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { @@ -34,6 +42,14 @@ impl CommandRouter { } async fn execute(&self, request: LocalRequest) -> ResponseResult { + if !self.services.active + && !matches!( + request, + LocalRequest::GetStatus | LocalRequest::GetDaemonStatus + ) + { + return ResponseResult::Error(IpcErrorCode::Unauthorized); + } let needs_omikron = matches!( request, LocalRequest::CreateUser { .. } @@ -96,12 +112,10 @@ impl CommandRouter { ) .await { - (Some(user), _) => { - ResponseResult::Ok(ResponsePayload::UserCreated { - user_id: user.user_id, - username: user.username, - }) - } + (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { + user_id: user.user_id, + username: user.username, + }), _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -154,13 +168,11 @@ impl CommandRouter { message: "process exit accepted".into(), }) } - LocalRequest::GetDaemonStatus => { - ResponseResult::Ok(ResponsePayload::DaemonStatus( - iota_ipc::DaemonStatusResponse { - formatted: format!("{:?}", self.runtime.snapshot()), - }, - )) - } + LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus( + iota_ipc::DaemonStatusResponse { + formatted: format!("{:?}", self.runtime.snapshot()), + }, + )), LocalRequest::RestartDaemon => { self.runtime.shutdown(ShutdownReason::Restart); ResponseResult::Ok(ResponsePayload::Acknowledged { @@ -195,12 +207,10 @@ impl CommandRouter { LocalRequest::GetOmikronStatus => { let connected = self.services.omikron.is_connected().await; let iota_id = config_util::CONFIG.load().iota_id; - ResponseResult::Ok(ResponsePayload::OmikronStatus( - OmikronStatusResponse { - connected, - iota_id, - }, - )) + ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse { + connected, + iota_id, + })) } LocalRequest::ListComponents => { let snapshot = self.runtime.snapshot(); @@ -215,20 +225,16 @@ impl CommandRouter { .collect(); ResponseResult::Ok(ResponsePayload::Components(components)) } - LocalRequest::GetUser { user_id } => { - match user_manager::get_user(user_id) { - Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail( - UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - }, - )), - None => ResponseResult::Error(IpcErrorCode::NotFound), - } - } + LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { + Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + })), + None => ResponseResult::Error(IpcErrorCode::NotFound), + }, LocalRequest::ImportUser { username } => { match user_manager::load_from_tu(&username).await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { @@ -245,17 +251,22 @@ impl CommandRouter { }; ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) } - LocalRequest::CheckUpdate => { - match iota_updater::check_update().await { - Ok(available) => ResponseResult::Ok(ResponsePayload::UpdateStatus( - UpdateStatusResponse { available }, - )), - Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), + LocalRequest::CheckUpdate => match iota_updater::check_update().await { + Ok(available) => { + ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse { + available, + })) } - } + Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), + }, LocalRequest::ListCommunities => { - let iota_id = config_util::CONFIG.load().iota_id.map(|id| id as i64).unwrap_or(0); - let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); + let iota_id = config_util::CONFIG + .load() + .iota_id + .map(|id| id as i64) + .unwrap_or(0); + let stored = + iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); let summaries: Vec = stored .into_iter() .map(|c| CommunitySummary { diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index d138107..97b5dd6 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,5 +1,5 @@ -use crate::log_buffer::LogBuffer; use crate::deployment::from_environment; +use crate::log_buffer::LogBuffer; use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, @@ -136,8 +136,16 @@ impl IpcServer { let state_rx = self.state_rx.clone(); let instance_id = self.instance_id.clone(); tokio::spawn(async move { - if let Err(error) = - handle_client(stream, runtime, services, log_tx, log_buffer, state_rx, instance_id).await + if let Err(error) = handle_client( + stream, + runtime, + services, + log_tx, + log_buffer, + state_rx, + instance_id, + ) + .await { eprintln!("IPC client error: {error}"); } diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs index b00eb9d..9bb1d05 100644 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -6,10 +6,7 @@ use tokio::sync::broadcast; /* The daemon adapts logger output to the wire protocol so the logger stays * independent from both the socket implementation and TUI state. */ -pub fn spawn( - message_tx: broadcast::Sender, - buffer: Arc>, -) { +pub fn spawn(message_tx: broadcast::Sender, buffer: Arc>) { let Some(mut logs) = subscribe() else { return; }; diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index a04002c..bd2ac1c 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -1,5 +1,8 @@ -use omikron_connector::{OmikronClient, OmikronConnection}; +use async_trait::async_trait; +use mtp::codec::CommunicationValue; +use omikron_connector::{OmikronClient, OmikronConnection, OmikronError}; use std::sync::Arc; +use std::time::Duration; #[derive(Default)] pub struct UserService; @@ -10,6 +13,7 @@ pub struct DaemonServices { pub omikron: Arc, pub users: Arc, pub config: Arc, + pub active: bool, } impl DaemonServices { @@ -18,6 +22,46 @@ impl DaemonServices { omikron, users: Arc::new(UserService), config: Arc::new(ConfigService), + active: true, + }) + } + + /// Services used while the daemon is awaiting terms acceptance. They can + /// never initiate a connection; the command router exposes status only. + pub fn inactive() -> Arc { + Arc::new(Self { + omikron: Arc::new(InactiveOmikron), + users: Arc::new(UserService), + config: Arc::new(ConfigService), + active: false, }) } } + +struct InactiveOmikron; + +#[async_trait] +impl OmikronClient for InactiveOmikron { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + Err(OmikronError::Disconnected( + "terms have not been accepted".into(), + )) + } + async fn await_response( + &self, + _: &CommunicationValue, + _: Duration, + ) -> Result { + Err(OmikronError::Disconnected( + "terms have not been accepted".into(), + )) + } + async fn reconnect(&self) -> Result<(), OmikronError> { + Err(OmikronError::Disconnected( + "terms have not been accepted".into(), + )) + } + async fn is_connected(&self) -> bool { + false + } +} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 2a67cf1..3f80199 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_daemon_lib::log_buffer::LogBuffer; +use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{LocalRequest, ResponseResult}; use mtp::codec::CommunicationValue; use omikron_connector::{OmikronClient, OmikronError}; @@ -44,7 +44,11 @@ async fn reconnect_uses_the_injected_client() { users: Default::default(), config: Default::default(), }); - let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services, Arc::new(Mutex::new(LogBuffer::new(100)))); + let router = CommandRouter::new( + Arc::new(DaemonRuntime::new()), + services, + Arc::new(Mutex::new(LogBuffer::new(100))), + ); assert!(matches!( router.route(1, LocalRequest::ReconnectOmikron).await.result, ResponseResult::Ok(_) diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 074f91b..29de9e1 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -11,6 +11,7 @@ iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } +iota-terms = { path = "../iota-terms" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 0204f7d..d971f91 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -22,6 +22,61 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; + // Bind a deliberately dormant IPC daemon before terms are accepted. This + // makes socket activation and `iota terms accept --system` usable, while + // the router exposes status only and the inactive service cannot connect. + if !iota_terms::consent::load(&paths.state_dir).has_all_required() { + let socket = match &paths.ipc_endpoint { + iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), + iota_paths::IpcEndpoint::WindowsPipe(_) => { + eprintln!("Windows named-pipe daemon transport is not implemented yet"); + return ExitCode::FAILURE; + } + }; + let runtime = Arc::new(DaemonRuntime::new()); + let (log_tx, _) = broadcast::channel(64); + let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64))); + let (_, state_rx) = watch::channel(runtime.snapshot()); + let server = match IpcServer::bind( + socket, + runtime.clone(), + DaemonServices::inactive(), + log_tx, + log_buffer, + state_rx, + ) + .await + { + Ok(server) => server, + Err(error) => { + eprintln!("Cannot bind dormant daemon IPC socket: {error}"); + return ExitCode::FAILURE; + } + }; + tokio::spawn(async move { + let _ = server.serve().await; + }); + eprintln!( + "Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.", + if paths.scope == iota_paths::Scope::System { + " --system" + } else { + "" + } + ); + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(1)) => { + if iota_terms::consent::load(&paths.state_dir).has_all_required() { + // systemd restarts this daemon; a locally-launched daemon can + // simply be started again after accepting the documents. + return ExitCode::from(75); + } + } + _ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS, + } + } + } if let Err(error) = paths.migrate_legacy_layout() { eprintln!("Cannot migrate legacy Iota layout: {error}"); return ExitCode::FAILURE; diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index ed5c3c1..0ff6284 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -3,12 +3,12 @@ pub mod text_commands; pub mod transport; pub use protocol::{ - ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, - ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, - HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry, - LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, - ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, - TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary, + ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, + ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, + ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, + LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, + ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, + SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 10b1d27..d31bee4 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -136,16 +136,9 @@ pub enum ResponsePayload { Status(StatusResponse), Tasks(Vec), Users(Vec), - UserCreated { - user_id: i64, - username: String, - }, - UserRemoved { - user_id: i64, - }, - Acknowledged { - message: String, - }, + UserCreated { user_id: i64, username: String }, + UserRemoved { user_id: i64 }, + Acknowledged { message: String }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), OmikronStatus(OmikronStatusResponse), @@ -264,8 +257,15 @@ mod error_tests { #[test] fn error_codes_have_operator_facing_messages() { - assert_eq!(IpcErrorCode::NotReady.to_string(), "the daemon is not ready yet"); - assert!(!IpcErrorCode::InternalFailure.to_string().contains("InternalFailure")); + assert_eq!( + IpcErrorCode::NotReady.to_string(), + "the daemon is not ready yet" + ); + assert!( + !IpcErrorCode::InternalFailure + .to_string() + .contains("InternalFailure") + ); } } diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs index 1e824e3..f8ee611 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -37,7 +37,9 @@ pub fn validation_error(line: &str) -> Option { if normalized == "help" || parse(normalized).is_some() { None } else { - Some(format!("Unknown command `{normalized}`. Use /help or Tab completion.")) + Some(format!( + "Unknown command `{normalized}`. Use /help or Tab completion." + )) } } @@ -121,10 +123,7 @@ mod tests { #[test] fn accepts_the_headless_cli_user_vocabulary() { - assert!(matches!( - parse("users list"), - Some(LocalRequest::ListUsers) - )); + assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); assert!(matches!( parse("users add alice"), Some(LocalRequest::CreateUser { .. }) @@ -203,10 +202,7 @@ mod tests { #[test] fn parses_config_get() { - assert!(matches!( - parse("config get"), - Some(LocalRequest::GetConfig) - )); + assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig))); } #[test] @@ -319,6 +315,10 @@ mod tests { fn validation_distinguishes_help_and_unknown_commands() { assert_eq!(validation_error("/help"), None); assert!(validation_error("status").is_none()); - assert!(validation_error("statuz").unwrap().contains("Unknown command")); + assert!( + validation_error("statuz") + .unwrap() + .contains("Unknown command") + ); } } diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index b09d750..da05aad 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,5 +1,6 @@ use crate::storage_error::StorageError; use crate::util::db; +use crate::util::sync::{self, EntityType, Operation}; use iota_logger::log; use rusqlite::params; @@ -46,6 +47,7 @@ impl MessageState { #[derive(Debug, Clone)] pub struct StoredMessage { pub id: i64, + pub external_user: i64, pub message_time: i64, pub content: String, pub edited: bool, @@ -150,7 +152,8 @@ fn update_message_content( .unwrap() .as_millis() as i64; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) VALUES (?1, ?2, ?3, ?4, ?5) @@ -158,7 +161,7 @@ fn update_message_content( params![msg_id, old_content, new_content, now, editor_id], )?; - conn.execute( + tx.execute( r#" UPDATE messages SET content = ?1, edited_count = edited_count + 1 @@ -166,6 +169,14 @@ fn update_message_content( "#, params![new_content, msg_id], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) @@ -187,15 +198,24 @@ pub fn hard_delete_message( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( "DELETE FROM message_edits WHERE message_id = ?1", params![msg_id], )?; - conn.execute( + tx.execute( "DELETE FROM reactions WHERE message_id = ?1", params![msg_id], )?; - conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; + tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Delete, + )?; + tx.commit()?; Ok(()) }) } @@ -261,7 +281,8 @@ pub fn flag_deleted_by_external( message_time: i64, ) -> Result<(), StorageError> { db::with_db(|conn| { - let affected = conn.execute( + let tx = conn.unchecked_transaction()?; + let affected = tx.execute( r#" UPDATE messages SET deleted_by_external = 1 @@ -272,6 +293,15 @@ pub fn flag_deleted_by_external( if affected == 0 { return Err(StorageError::Other("Message not found".into())); } + let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Delete, + )?; + tx.commit()?; Ok(()) }) } @@ -297,10 +327,19 @@ pub fn delete_edit_history( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( "DELETE FROM message_edits WHERE message_id = ?1", params![msg_id], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) } @@ -328,13 +367,22 @@ pub fn add_reaction( .unwrap() .as_millis() as i64; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) VALUES (?1, ?2, ?3, ?4) "#, params![msg_id, user_id, reaction, now], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) } @@ -357,10 +405,19 @@ pub fn remove_reaction( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3", params![msg_id, user_id, reaction], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) } @@ -383,7 +440,8 @@ pub fn add_message( }; if let Err(e) = db::with_db(|conn| { - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO messages ( storage_owner, external_user, message_time, content, @@ -405,6 +463,15 @@ pub fn add_message( reply_to, ], )?; + let msg_id = tx.last_insert_rowid(); + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) { log!("Failed to insert message into sqlite: {}", e); @@ -447,7 +514,8 @@ pub fn change_message_state( .as_str() .to_string(); - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" UPDATE messages SET message_state = ?1 @@ -459,6 +527,13 @@ pub fn change_message_state( "#, params![upgraded, storage_owner, external_user, timestamp], )?; + let msg_id: i64 = tx.query_row( + "SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", + params![storage_owner, external_user, timestamp], + |row| row.get(0), + )?; + sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?; + tx.commit()?; Ok(()) }) .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) @@ -532,6 +607,7 @@ pub fn get_messages( |row| { Ok(StoredMessage { id: row.get(0)?, + external_user, message_time: row.get(1)?, content: row.get(2)?, sent_by_self: row.get::<_, i64>(3)? != 0, @@ -568,6 +644,69 @@ pub fn get_messages( } } +pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + let wanted: std::collections::HashSet = ids.iter().copied().collect(); + // A journal id uniquely identifies a row. Load all messages for this owner and retain only + // those ids; this keeps reaction hydration identical to normal message loading. + match db::with_db(|conn| { + let mut stmt = conn.prepare("SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0")?; + let rows = stmt.query_map([storage_owner], |row| { + let external_user: i64 = row.get(8)?; + Ok(StoredMessage { + id: row.get(0)?, + external_user, + message_time: row.get(1)?, + content: row.get(2)?, + sent_by_self: row.get::<_, i64>(3)? != 0, + message_state: row.get(4)?, + height: row.get(5).unwrap_or(0), + reply_to: row.get(6).ok().flatten(), + edited: row.get::<_, i64>(7).unwrap_or(0) > 0, + reactions: Vec::new(), + }) + })?; + let mut messages = Vec::new(); + for row in rows { + let message = row?; + if wanted.contains(&message.id) { + messages.push(message); + } + } + let reaction_map = load_reactions(conn, &messages.iter().map(|m| m.id).collect::>()); + for message in &mut messages { + message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default(); + } + Ok(messages) + }) { + Ok(messages) => messages, + Err(e) => { + log!("Failed to query messages by id: {}", e); + Vec::new() + } + } +} + +pub fn get_all_messages(storage_owner: i64) -> Vec { + let ids = match db::with_db(|conn| { + let mut stmt = conn.prepare( + "SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0", + )?; + Ok(stmt + .query_map([storage_owner], |row| row.get::<_, i64>(0))? + .collect::, _>>()?) + }) { + Ok(ids) => ids, + Err(e) => { + log!("Failed to query all messages: {}", e); + return Vec::new(); + } + }; + get_messages_by_ids(storage_owner, &ids) +} + #[cfg(test)] mod tests { use super::MessageState; diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index eb8f069..634b8f6 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -1,10 +1,12 @@ use crate::users::contact::Contact; use crate::util::db; +use crate::util::sync::{self, EntityType, Operation}; use rusqlite::params; pub fn mod_user(storage_owner: i64, contact: &Contact) { if let Err(e) = db::with_db(|conn| { - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) VALUES (?1, ?2, ?3, ?4) @@ -19,12 +21,31 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) { contact.last_message_at, ], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Contact, + contact.user_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) { eprintln!("Failed to mod_user: {}", e); } } +pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + let wanted: std::collections::HashSet = ids.iter().copied().collect(); + get_users(storage_owner) + .into_iter() + .filter(|contact| wanted.contains(&contact.user_id)) + .collect() +} + pub fn get_user(storage_owner: i64, user_id: i64) -> Option { match db::with_db(|conn| { match conn.query_row( diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 63debe3..1a4ca7e 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -220,6 +220,37 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 6 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS sync_heads ( + user_id INTEGER PRIMARY KEY, + version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sync_events ( + user_id INTEGER NOT NULL, + version INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + operation TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, version) + ); + CREATE INDEX IF NOT EXISTS idx_sync_events_user_version + ON sync_events (user_id, version); + CREATE TABLE IF NOT EXISTS client_sync_state ( + user_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + acknowledged_version INTEGER NOT NULL, + cache_schema_version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, session_id) + ); + PRAGMA user_version = 6; + "#, + )?; + } + Ok(()) } @@ -289,7 +320,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 5); + assert_eq!(version, 6); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -298,4 +329,23 @@ mod tests { Ok(()) } + + #[test] + fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch("PRAGMA user_version = 5;")?; + run_migrations_on_connection(&conn)?; + run_migrations_on_connection(&conn)?; + let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + assert_eq!(version, 6); + for table in ["sync_heads", "sync_events", "client_sync_state"] { + let exists: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + )?; + assert_eq!(exists, 1); + } + Ok(()) + } } diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 2fa8220..7412c7b 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -5,3 +5,4 @@ pub mod config_util; pub mod db; pub mod e2ee_storage; pub mod settings; +pub mod sync; diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs new file mode 100644 index 0000000..b84c94a --- /dev/null +++ b/iota-storage/src/util/sync.rs @@ -0,0 +1,160 @@ +//! Durable per-user state journal used by device cache synchronization. +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::{Transaction, params}; +use std::collections::BTreeMap; + +pub const CACHE_SCHEMA_VERSION: i64 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntityType { + Message, + Contact, +} +impl EntityType { + fn as_str(self) -> &'static str { + match self { + Self::Message => "message", + Self::Contact => "contact", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operation { + Upsert, + Delete, +} +impl Operation { + fn as_str(self) -> &'static str { + match self { + Self::Upsert => "upsert", + Self::Delete => "delete", + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct Delta { + pub message_upserts: Vec, + pub deleted_message_ids: Vec, + pub contact_upserts: Vec, + pub deleted_contact_ids: Vec, +} + +pub fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub fn record_event( + tx: &Transaction<'_>, + user_id: i64, + entity: EntityType, + entity_id: i64, + operation: Operation, +) -> Result { + tx.execute( + "INSERT INTO sync_heads (user_id, version) VALUES (?1, 0) ON CONFLICT(user_id) DO NOTHING", + [user_id], + )?; + let previous: i64 = tx.query_row( + "SELECT version FROM sync_heads WHERE user_id = ?1", + [user_id], + |r| r.get(0), + )?; + let version = previous + .checked_add(1) + .ok_or_else(|| StorageError::Other("sync version overflow".into()))?; + tx.execute( + "UPDATE sync_heads SET version = ?2 WHERE user_id = ?1", + params![user_id, version], + )?; + tx.execute("INSERT INTO sync_events (user_id, version, entity_type, entity_id, operation, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user_id, version, entity.as_str(), entity_id, operation.as_str(), now_millis()])?; + Ok(version) +} + +pub fn head(user_id: i64) -> Result { + db::with_db(|conn| { + Ok(conn + .query_row( + "SELECT version FROM sync_heads WHERE user_id = ?1", + [user_id], + |r| r.get(0), + ) + .unwrap_or(0)) + }) +} + +pub fn has_session(user_id: i64, session_id: i64) -> Result { + db::with_db(|conn| { + Ok(conn + .query_row( + "SELECT 1 FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2", + params![user_id, session_id], + |_| Ok(()), + ) + .is_ok()) + }) +} + +pub fn acknowledge( + user_id: i64, + session_id: i64, + version: i64, + cache_schema_version: i64, +) -> Result<(), StorageError> { + if user_id <= 0 || session_id <= 0 || version < 0 { + return Err(StorageError::Other("invalid sync acknowledgement".into())); + } + db::with_db(|conn| { + let head = conn + .query_row( + "SELECT version FROM sync_heads WHERE user_id = ?1", + [user_id], + |r| r.get(0), + ) + .unwrap_or(0); + if version > head { + return Err(StorageError::Other( + "acknowledgement is ahead of head".into(), + )); + } + conn.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, now_millis()])?; + Ok(()) + }) +} + +/// Returns the final operation for each entity after `from_version`. +pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result { + if from_version < 0 || from_version > captured_head { + return Err(StorageError::Other("invalid sync cursor".into())); + } + db::with_db(|conn| { + let mut stmt = conn.prepare("SELECT entity_type, entity_id, operation FROM sync_events WHERE user_id = ?1 AND version > ?2 AND version <= ?3 ORDER BY version ASC")?; + let mut final_events = BTreeMap::<(String, i64), String>::new(); + for row in stmt.query_map(params![user_id, from_version, captured_head], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)?, + )) + })? { + let (kind, id, operation) = row?; + final_events.insert((kind, id), operation); + } + let mut out = Delta::default(); + for ((kind, id), operation) in final_events { + match (kind.as_str(), operation.as_str()) { + ("message", "delete") => out.deleted_message_ids.push(id), + ("message", _) => out.message_upserts.push(id), + ("contact", "delete") => out.deleted_contact_ids.push(id), + ("contact", _) => out.contact_upserts.push(id), + _ => {} + } + } + Ok(out) + }) +} diff --git a/iota-terms/src/consent.rs b/iota-terms/src/consent.rs new file mode 100644 index 0000000..ef968f9 --- /dev/null +++ b/iota-terms/src/consent.rs @@ -0,0 +1,89 @@ +//! Durable, deployment-scoped consent records. +//! +//! This deliberately contains no UI code. Both the terminal client and the +//! daemon use the same record so a UI-local decision can never start services. + +use crate::{Doc, TermsType as Type}; +use std::fs; +use std::io; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const FILE_NAME: &str = "terms-consent-v1"; + +#[derive(Clone, Debug, Default)] +pub struct ConsentRecord { + pub eula: Option<(String, String)>, + pub tos: Option<(String, String)>, + pub privacy: Option<(String, String)>, +} + +impl ConsentRecord { + pub fn has_all_required(&self) -> bool { + self.eula.is_some() && self.tos.is_some() && self.privacy.is_some() + } + + pub fn accepts(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool { + matches_doc(&self.eula, eula) + && matches_doc(&self.tos, tos) + && matches_doc(&self.privacy, privacy) + } + + pub fn accept(&mut self, doc: &Doc) { + let value = Some((doc.get_version(), doc.get_hash())); + match doc.doc_type { + Type::EULA => self.eula = value, + Type::TOS => self.tos = value, + Type::PP => self.privacy = value, + } + } +} + +fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool { + matches!(value, Some((version, hash)) if version == &doc.get_version() && hash == &doc.get_hash()) +} + +pub fn load(state_dir: &Path) -> ConsentRecord { + let Ok(text) = fs::read_to_string(state_dir.join(FILE_NAME)) else { + return ConsentRecord::default(); + }; + let mut record = ConsentRecord::default(); + for line in text.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let Some((version, hash)) = value.split_once(':') else { + continue; + }; + let value = Some((version.to_owned(), hash.to_owned())); + match key { + "eula" => record.eula = value, + "tos" => record.tos = value, + "privacy" => record.privacy = value, + _ => {} + } + } + record +} + +pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> { + fs::create_dir_all(state_dir)?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut text = format!("# Iota terms consent record; accepted_at_unix={timestamp}\n"); + for (name, value) in [ + ("eula", &record.eula), + ("tos", &record.tos), + ("privacy", &record.privacy), + ] { + if let Some((version, hash)) = value { + text.push_str(&format!("{name}={version}:{hash}\n")); + } + } + let path = state_dir.join(FILE_NAME); + let temporary = state_dir.join(format!(".{FILE_NAME}.{}.tmp", std::process::id())); + fs::write(&temporary, text)?; + fs::rename(temporary, path) +} diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index 216997b..e878f9b 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -1,3 +1,4 @@ +pub mod consent; pub mod terms_getter; pub use terms_getter::Type as TermsType; diff --git a/iota/Cargo.toml b/iota/Cargo.toml index cc4494b..3c38d65 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -10,6 +10,7 @@ iota-installer = { path = "../iota-installer" } iota-core = { path = "../iota-core" } iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } +iota-terms = { path = "../iota-terms" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 005d9fb..a90e106 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,5 +1,6 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::ThemeName; +use iota_terms::TermsType; #[derive(Debug)] pub struct CliInvocation { @@ -25,61 +26,220 @@ pub enum OutputFormat { } #[derive(Debug, Clone, Copy, ValueEnum)] -enum CliTheme { Monospace, Binary, Ansi, Surface } +enum CliTheme { + Monospace, + Binary, + Ansi, + Surface, +} impl From for ThemeName { fn from(value: CliTheme) -> Self { - match value { CliTheme::Monospace => Self::Monospace, CliTheme::Binary => Self::Binary, CliTheme::Ansi => Self::Ansi, CliTheme::Surface => Self::Surface } + match value { + CliTheme::Monospace => Self::Monospace, + CliTheme::Binary => Self::Binary, + CliTheme::Ansi => Self::Ansi, + CliTheme::Surface => Self::Surface, + } } } #[derive(Parser, Debug)] -#[command(name = "iota", version, about = "Iota operator console", arg_required_else_help = false)] +#[command( + name = "iota", + version, + about = "Iota operator console", + arg_required_else_help = false +)] struct Cli { - #[arg(long, global = true, value_enum)] theme: Option, - #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] color: CapabilityPolicy, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] unicode: CapabilityPolicy, - #[arg(long, global = true)] no_color: bool, - #[command(subcommand)] command: Option, + #[arg(long, global = true, value_enum)] + theme: Option, + #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] + output: OutputFormat, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] + color: CapabilityPolicy, + #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] + unicode: CapabilityPolicy, + #[arg(long, global = true)] + no_color: bool, + #[command(subcommand)] + command: Option, } #[derive(Subcommand, Debug)] enum CliCommand { - Status, Tasks, - Users(UsersArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), Config(ConfigArgs), - RegenerateKeys { #[arg(long)] yes: bool }, + Status, + Tasks, + Users(UsersArgs), + Omikron(OmikronArgs), + Identity(IdentityArgs), + Daemon(DaemonArgs), + Config(ConfigArgs), + Terms(TermsArgs), + RegenerateKeys { + #[arg(long)] + yes: bool, + }, Components, - Logs { #[arg(long, default_value_t = 100)] limit: usize }, + Logs { + #[arg(long, default_value_t = 100)] + limit: usize, + }, Update(UpdateArgs), Community(CommunityArgs), - Completions { shell: String }, + Completions { + shell: String, + }, Man, } -#[derive(Args, Debug)] struct UsersArgs { #[command(subcommand)] action: UsersAction } -#[derive(Subcommand, Debug)] enum UsersAction { List, Show { user_id: i64 }, Add { username: String }, Remove { user_id: i64, #[arg(long)] yes: bool }, Import { username: String } } -#[derive(Args, Debug)] struct OmikronArgs { #[command(subcommand)] action: OmikronAction } -#[derive(Subcommand, Debug)] enum OmikronAction { Reconnect, Status } -#[derive(Args, Debug)] struct IdentityArgs { #[command(subcommand)] action: IdentityAction } -#[derive(Subcommand, Debug)] enum IdentityAction { Rotate { #[arg(long)] yes: bool } } -#[derive(Args, Debug)] struct ConfigArgs { #[command(subcommand)] action: ConfigAction } -#[derive(Subcommand, Debug)] enum ConfigAction { Get, Set { key: String, value: String }, Reload } -#[derive(Args, Debug)] struct DaemonArgs { #[command(subcommand)] action: DaemonAction } -#[derive(Subcommand, Debug)] enum DaemonAction { - Restart { #[arg(long)] yes: bool }, Stop { #[arg(long)] yes: bool }, - Enable { #[arg(long, value_parser = ["socket", "always-on"])] mode: String }, DisableStartup, Status, StartupStatus, Start, RestartService, StopService, - Install { #[arg(long)] bundle: String, #[arg(long)] operator: Option }, +#[derive(Args, Debug)] +struct UsersArgs { + #[command(subcommand)] + action: UsersAction, +} +#[derive(Subcommand, Debug)] +enum UsersAction { + List, + Show { + user_id: i64, + }, + Add { + username: String, + }, + Remove { + user_id: i64, + #[arg(long)] + yes: bool, + }, + Import { + username: String, + }, +} +#[derive(Args, Debug)] +struct OmikronArgs { + #[command(subcommand)] + action: OmikronAction, +} +#[derive(Subcommand, Debug)] +enum OmikronAction { + Reconnect, + Status, +} +#[derive(Args, Debug)] +struct IdentityArgs { + #[command(subcommand)] + action: IdentityAction, +} +#[derive(Subcommand, Debug)] +enum IdentityAction { + Rotate { + #[arg(long)] + yes: bool, + }, +} +#[derive(Args, Debug)] +struct ConfigArgs { + #[command(subcommand)] + action: ConfigAction, +} +#[derive(Subcommand, Debug)] +enum ConfigAction { + Get, + Set { key: String, value: String }, + Reload, +} +#[derive(Args, Debug)] +struct DaemonArgs { + #[command(subcommand)] + action: DaemonAction, +} +#[derive(Subcommand, Debug)] +enum DaemonAction { + Restart { + #[arg(long)] + yes: bool, + }, + Stop { + #[arg(long)] + yes: bool, + }, + Enable { + #[arg(long, value_parser = ["socket", "always-on"])] + mode: String, + }, + DisableStartup, + Status, + StartupStatus, + Start, + RestartService, + StopService, + Install { + #[arg(long)] + bundle: String, + #[arg(long)] + operator: Option, + }, +} +#[derive(Args, Debug)] +struct UpdateArgs { + #[command(subcommand)] + action: UpdateAction, +} +#[derive(Subcommand, Debug)] +enum UpdateAction { + Check, +} +#[derive(Args, Debug)] +struct CommunityArgs { + #[command(subcommand)] + action: CommunityAction, +} +#[derive(Subcommand, Debug)] +enum CommunityAction { + List, +} +#[derive(Args, Debug)] +struct TermsArgs { + #[command(subcommand)] + action: TermsAction, +} +#[derive(Subcommand, Debug)] +enum TermsAction { + Status { + #[arg(long)] + system: bool, + }, + Show { + document: TermsDocument, + }, + Accept { + #[arg(long)] + system: bool, + }, +} +#[derive(Clone, Copy, Debug, ValueEnum)] +enum TermsDocument { + Eula, + Tos, + Privacy, +} +impl From for TermsType { + fn from(value: TermsDocument) -> Self { + match value { + TermsDocument::Eula => TermsType::EULA, + TermsDocument::Tos => TermsType::TOS, + TermsDocument::Privacy => TermsType::PP, + } + } } -#[derive(Args, Debug)] struct UpdateArgs { #[command(subcommand)] action: UpdateAction } -#[derive(Subcommand, Debug)] enum UpdateAction { Check } -#[derive(Args, Debug)] struct CommunityArgs { #[command(subcommand)] action: CommunityAction } -#[derive(Subcommand, Debug)] enum CommunityAction { List } #[derive(Debug, PartialEq, Eq)] pub enum Command { Dashboard, Help, Version, - Completions { shell: String }, + Completions { + shell: String, + }, ManPage, Install { bundle: String, @@ -88,7 +248,9 @@ pub enum Command { Status, Tasks, UsersList, - UsersShow { user_id: i64 }, + UsersShow { + user_id: i64, + }, UsersAdd { username: String, }, @@ -96,7 +258,9 @@ pub enum Command { user_id: i64, confirmed: bool, }, - UsersImport { username: String }, + UsersImport { + username: String, + }, OmikronReconnect, IdentityRotate { confirmed: bool, @@ -117,16 +281,30 @@ pub enum Command { DaemonRestartService, DaemonStopService, ConfigGet, - ConfigSet { key: String, value: String }, + ConfigSet { + key: String, + value: String, + }, ConfigReload, OmikronStatus, RegenerateKeys { confirmed: bool, }, Components, - Logs { limit: usize }, + Logs { + limit: usize, + }, UpdateCheck, CommunityList, + TermsStatus { + system: bool, + }, + TermsShow { + document: TermsType, + }, + TermsAccept { + system: bool, + }, } impl CliInvocation { pub fn parse(args: impl IntoIterator) -> Result { @@ -134,13 +312,14 @@ impl CliInvocation { if args.as_slice() == ["help"] { return Ok(Self::special(Command::Help)); } - let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { - match error.kind() { - ErrorKind::DisplayHelp => return "__help__".to_owned(), - ErrorKind::DisplayVersion => return "__version__".to_owned(), - _ => error.to_string(), - } - }); + let parsed = + Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { + match error.kind() { + ErrorKind::DisplayHelp => return "__help__".to_owned(), + ErrorKind::DisplayVersion => return "__version__".to_owned(), + _ => error.to_string(), + } + }); let parsed = match parsed { Ok(parsed) => parsed, Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), @@ -154,33 +333,77 @@ impl CliInvocation { Some(CliCommand::Components) => Command::Components, Some(CliCommand::Completions { shell }) => Command::Completions { shell }, Some(CliCommand::Man) => Command::ManPage, - Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, confirmed: yes }, UsersAction::Import { username } => Command::UsersImport { username } }, - Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus }, - Some(CliCommand::Identity(identity)) => match identity.action { IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes } }, - Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload }, + Some(CliCommand::Users(users)) => match users.action { + UsersAction::List => Command::UsersList, + UsersAction::Show { user_id } => Command::UsersShow { user_id }, + UsersAction::Add { username } => Command::UsersAdd { username }, + UsersAction::Remove { user_id, yes } => Command::UsersRemove { + user_id, + confirmed: yes, + }, + UsersAction::Import { username } => Command::UsersImport { username }, + }, + Some(CliCommand::Omikron(omikron)) => match omikron.action { + OmikronAction::Reconnect => Command::OmikronReconnect, + OmikronAction::Status => Command::OmikronStatus, + }, + Some(CliCommand::Identity(identity)) => match identity.action { + IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes }, + }, + Some(CliCommand::Config(config)) => match config.action { + ConfigAction::Get => Command::ConfigGet, + ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, + ConfigAction::Reload => Command::ConfigReload, + }, Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, Some(CliCommand::Logs { limit }) => Command::Logs { limit }, - Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck }, - Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList }, + Some(CliCommand::Update(update)) => match update.action { + UpdateAction::Check => Command::UpdateCheck, + }, + Some(CliCommand::Community(community)) => match community.action { + CommunityAction::List => Command::CommunityList, + }, + Some(CliCommand::Terms(terms)) => match terms.action { + TermsAction::Status { system } => Command::TermsStatus { system }, + TermsAction::Show { document } => Command::TermsShow { + document: document.into(), + }, + TermsAction::Accept { system } => Command::TermsAccept { system }, + }, Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, - DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, - DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::StartupStatus => Command::DaemonStartupStatus, - DaemonAction::Start => Command::DaemonStart, DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService, + DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, + DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, + DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, + DaemonAction::DisableStartup => Command::DaemonDisableStartup, + DaemonAction::Status => Command::DaemonDaemonStatus, + DaemonAction::StartupStatus => Command::DaemonStartupStatus, + DaemonAction::Start => Command::DaemonStart, + DaemonAction::RestartService => Command::DaemonRestartService, + DaemonAction::StopService => Command::DaemonStopService, DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, }, }; Ok(Self { theme_override: parsed.theme.map(Into::into), output: parsed.output, - color: if parsed.no_color { CapabilityPolicy::Never } else { parsed.color }, + color: if parsed.no_color { + CapabilityPolicy::Never + } else { + parsed.color + }, unicode: parsed.unicode, command, }) } fn special(command: Command) -> Self { - Self { theme_override: None, output: OutputFormat::Text, color: CapabilityPolicy::Auto, unicode: CapabilityPolicy::Auto, command } + Self { + theme_override: None, + output: OutputFormat::Text, + color: CapabilityPolicy::Auto, + unicode: CapabilityPolicy::Auto, + command, + } } pub fn help_text() -> String { @@ -237,12 +460,9 @@ mod tests { #[test] fn parses_terminal_capability_overrides() { - let invocation = CliInvocation::parse([ - "--color=never".into(), - "--unicode".into(), - "always".into(), - ]) - .unwrap(); + let invocation = + CliInvocation::parse(["--color=never".into(), "--unicode".into(), "always".into()]) + .unwrap(); assert_eq!(invocation.color, CapabilityPolicy::Never); assert_eq!(invocation.unicode, CapabilityPolicy::Always); assert_eq!(invocation.command, Command::Dashboard); diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs index c88f875..1953e78 100644 --- a/iota/src/daemon_setup_flow.rs +++ b/iota/src/daemon_setup_flow.rs @@ -65,19 +65,23 @@ pub async fn run( endpoints: &DaemonEndpoints, caps: Capabilities, ) -> Result { - // Try connecting to an already-running daemon before starting a new one. + // The initial dashboard probe may have raced a daemon that was still + // accepting connections. Re-check both endpoints before offering setup: + // a system-managed daemon normally listens on a different socket from a + // locally launched one. if let Ok(ipc) = IpcClient::connect(&endpoints.local).await { return Ok(ConnectionContext { ipc }); } + if endpoints.system != endpoints.local { + if let Ok(ipc) = IpcClient::connect(&endpoints.system).await { + return Ok(ConnectionContext { ipc }); + } + } let options = caps.options(); if !options.iter().any(|o| o.enabled) { - let _ = show( - ui, - options, - "Daemon cannot be started. Correct the reported problem, then Retry, or Exit.", - ) - .await?; - return Err(StartupError::Cancelled); + return Err(StartupError::Other( + "No running daemon could be reached, and no daemon launch method is available.".into(), + )); } if UiConfig::load() .map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi) diff --git a/iota/src/main.rs b/iota/src/main.rs index 0df1c0f..1f19d89 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -10,6 +10,7 @@ mod cli_args; mod daemon_setup_flow; mod local_daemon; mod startup_error; +mod terms; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use startup_error::StartupError; @@ -78,6 +79,9 @@ async fn run() -> Result<(), StartupError> { print_man_page(); Ok(()) } + Command::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await, + Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await, + Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await, Command::Install { bundle, operator } => { iota_installer::install_linux_bundle_with_operator( Path::new(&bundle), @@ -98,11 +102,13 @@ async fn run() -> Result<(), StartupError> { return run_startup_command(command).await; } if !matches!(command, Command::Dashboard) { - match iota_core::consent_state::non_interactive_consent() { - iota_core::consent_state::NonInteractiveConsent::Accepted => {} - iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => { - return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into())); - } + let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .map_err(|error| { + StartupError::Other(format!("Cannot resolve consent storage: {error}")) + })? + .state_dir; + if !iota_terms::consent::load(&state_dir).has_all_required() { + return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into())); } let ipc = tokio::select! { result = connect_available(&endpoints) => result?, @@ -242,8 +248,7 @@ async fn run_dashboard( CapabilityPolicy::Always => true, CapabilityPolicy::Never => false, CapabilityPolicy::Auto => { - std::env::var_os("NO_COLOR").is_none() - && std::env::var("TERM").as_deref() != Ok("dumb") + std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb") } }; let unicode_enabled = match unicode_policy { @@ -278,6 +283,7 @@ async fn run_dashboard( if consent != (true, true) { return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); } + persist_dashboard_consent().await?; let initial = tokio::select! { result = connect_available(&endpoints) => result, _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), @@ -328,6 +334,20 @@ async fn run_dashboard( } } +async fn persist_dashboard_consent() -> Result<(), StartupError> { + let docs = iota_terms::get_current_docs().await.ok_or_else(|| { + StartupError::Consent("Could not verify the current agreements after acceptance.".into()) + })?; + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?; + let mut record = iota_terms::consent::load(&paths.state_dir); + for document in [&docs.0, &docs.1, &docs.2] { + record.accept(document); + } + iota_terms::consent::save(&paths.state_dir, &record) + .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}"))) +} + fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { use iota_process_manager::ProcessManagerErrorKind::*; match error.kind() { @@ -457,11 +477,21 @@ async fn run_command( "Refusing destructive command without --yes.".into(), )); } - Command::Dashboard | Command::Help | Command::Version | Command::Completions { .. } - | Command::ManPage | Command::Install { .. } - | Command::DaemonEnable { .. } | Command::DaemonDisableStartup - | Command::DaemonStartupStatus | Command::DaemonStart - | Command::DaemonRestartService | Command::DaemonStopService => { + Command::Dashboard + | Command::Help + | Command::Version + | Command::Completions { .. } + | Command::ManPage + | Command::Install { .. } + | Command::TermsStatus { .. } + | Command::TermsShow { .. } + | Command::TermsAccept { .. } + | Command::DaemonEnable { .. } + | Command::DaemonDisableStartup + | Command::DaemonStartupStatus + | Command::DaemonStart + | Command::DaemonRestartService + | Command::DaemonStopService => { return Err(StartupError::InvalidCommand( "Command cannot be run headlessly.".into(), )); diff --git a/iota/src/terms.rs b/iota/src/terms.rs new file mode 100644 index 0000000..b7cdbcb --- /dev/null +++ b/iota/src/terms.rs @@ -0,0 +1,118 @@ +use crate::startup_error::StartupError; +use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms}; +use std::io::{self, IsTerminal, Write}; + +pub enum TermsCommand { + Status { system: bool }, + Show { document: TermsType }, + Accept { system: bool }, +} + +fn state_dir(system: bool) -> Result { + let scope = if system { + iota_paths::Scope::System + } else { + iota_paths::Scope::User + }; + iota_paths::IotaPaths::resolve(scope) + .map(|paths| paths.state_dir) + .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}"))) +} + +pub async fn run(command: TermsCommand) -> Result<(), StartupError> { + match command { + TermsCommand::Status { system } => { + let record = consent::load(&state_dir(system)?); + println!( + "EULA: {}", + if record.eula.is_some() { + "accepted" + } else { + "not accepted" + } + ); + println!( + "Terms of Service: {}", + if record.tos.is_some() { + "accepted" + } else { + "not accepted" + } + ); + println!( + "Privacy Policy: {}", + if record.privacy.is_some() { + "accepted" + } else { + "not accepted" + } + ); + Ok(()) + } + TermsCommand::Show { document } => { + let text = get_terms(document).await.ok_or_else(|| { + StartupError::Consent("Could not fetch the requested terms document.".into()) + })?; + print!("{text}"); + Ok(()) + } + TermsCommand::Accept { system } => accept(system).await, + } +} + +async fn accept(system: bool) -> Result<(), StartupError> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into())); + } + let documents = get_current_docs().await.ok_or_else(|| { + StartupError::Consent( + "Could not fetch the current agreements from the legal endpoint.".into(), + ) + })?; + let mut record = consent::load(&state_dir(system)?); + for document in [&documents.0, &documents.1, &documents.2] { + let text = get_terms(document.doc_type).await.ok_or_else(|| { + StartupError::Consent(format!( + "Could not fetch {}.", + document.doc_type.to_string() + )) + })?; + println!( + "\n===== {} =====\nVersion: {}\nDocument hash: {}\n", + document.doc_type.to_string(), + document.get_version(), + document.get_hash() + ); + print!("{text}\n"); + if !confirm(document)? { + return Err(StartupError::Consent( + "No terms were accepted. Iota remains inactive.".into(), + )); + } + record.accept(document); + } + consent::save(&state_dir(system)?, &record) + .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?; + println!("Terms accepted. Start Iota again to enable services."); + Ok(()) +} + +fn confirm(document: &Doc) -> Result { + let hash = document.get_hash(); + let prefix = hash.get(..10).unwrap_or(&hash); + let expected = format!( + "ACCEPT {} {} {}", + document.doc_type.to_str().to_ascii_uppercase(), + document.get_version(), + prefix + ); + print!("To accept this exact document, type:\n{expected}\n> "); + io::stdout() + .flush() + .map_err(|error| StartupError::Consent(error.to_string()))?; + let mut response = String::new(); + io::stdin() + .read_line(&mut response) + .map_err(|error| StartupError::Consent(error.to_string()))?; + Ok(response.trim() == expected) +} diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 6e745e6..bbf2bf2 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -889,6 +889,7 @@ impl OmikronConnection { dispatch!(CreateApp, handle_create_app); dispatch!(DeleteApp, handle_delete_app); dispatch!(ClientConnected, handle_client_connected); + dispatch!(ClientStateAck, handle_client_state_ack); dispatch!(MessageState, handle_message_state); dispatch!(MessageSend, handle_message_send); dispatch!(MessageEdit, handle_message_edit); @@ -1166,6 +1167,12 @@ impl OmikronConnection { .await; } + async fn handle_client_state_ack(self: Arc, cv: &CommunicationValue) { + let _ = self + .send_message(&message_handlers::handle_client_state_ack(cv)) + .await; + } + async fn handle_message_state(self: Arc, cv: &CommunicationValue) { message_handlers::handle_message_state(cv); } diff --git a/type-maps.yaml b/type-maps.yaml index ce3d5e6..53684cc 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -149,6 +149,9 @@ type_maps: MessageReactionRemove: 147 MessageReactionLive: 148 MessageDeleteLive: 150 + ClientStateSync: 151 + ClientStateAck: 152 + StateSubscribe: 153 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -267,3 +270,9 @@ type_maps: Reactions: 156 Reaction: 157 ReplyId: 158 + CacheValid: 159 + CacheSchemaVersion: 160 + SyncMode: 161 + MessageId: 162 + DeletedMessageIds: 163 + DeletedContactIds: 164 From 14df716cf1ceb4967c044f885f543f01293c0837 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Jul 2026 20:37:33 +0200 Subject: [PATCH 093/119] [Fix] Stability --- .cargo/config.toml | 2 +- .gitmodules | 3 + Cargo.lock | 38 +-- iota-cli/src/ipc_client.rs | 2 +- iota-cli/src/screens/overview.rs | 11 + iota-daemon-lib/src/command_router.rs | 47 ++-- iota-daemon-lib/src/services.rs | 5 + iota-daemon-lib/tests/command_router.rs | 33 ++- iota-daemon/src/main.rs | 62 ++++- iota-installer/src/lib.rs | 2 - iota-logger/src/lib.rs | 17 +- iota-storage/src/users/user_manager.rs | 12 +- iota-storage/src/util/e2ee_storage.rs | 2 - iota-util/src/file_util.rs | 43 ++- mtp-type-maps | 1 + omikron-connector/src/client.rs | 8 +- omikron-connector/src/omikron_connection.rs | 106 +++++++- omikron-connector/src/user_ops.rs | 222 +++++++++++++--- type-maps.yaml | 278 -------------------- 19 files changed, 486 insertions(+), 408 deletions(-) create mode 100644 .gitmodules create mode 160000 mtp-type-maps delete mode 100644 type-maps.yaml diff --git a/.cargo/config.toml b/.cargo/config.toml index d363b83..46adaad 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,2 @@ [env] -MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true } +MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true } diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3069632 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "mtp-type-maps"] + path = mtp-type-maps + url = ssh://git@git.methanium.net/tensamin/mtp-type-maps diff --git a/Cargo.lock b/Cargo.lock index b8631b7..ebb658a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ "foldhash", "futures-core", "futures-util", - "impl-more 0.3.2", + "impl-more 0.3.5", "itoa", "language-tags", "log", @@ -651,9 +651,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -1327,9 +1327,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encoding_rs" @@ -2136,9 +2136,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "impl-more" -version = "0.3.2" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indexmap" @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "async-trait", "bytes", @@ -4263,9 +4263,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 3ffa8d1..ad0796f 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -594,7 +594,7 @@ impl IpcClient { return Err(error); } - match tokio::time::timeout(Duration::from_secs(30), response_rx).await { + match tokio::time::timeout(Duration::from_secs(45), response_rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), Err(_) => { diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs index 4d4aea6..6f32393 100644 --- a/iota-cli/src/screens/overview.rs +++ b/iota-cli/src/screens/overview.rs @@ -55,6 +55,17 @@ impl OverviewScreen { lines.push(Line::from(Span::styled("Connection", theme.text.heading))); lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); + let omikron = daemon.components.get(&iota_ipc::ComponentId::Omikron); + let omikron_label = match omikron.map(|health| health.status) { + Some(iota_ipc::HealthStatus::Healthy) => "[OK] Connected", + Some(iota_ipc::HealthStatus::Degraded) => "[WARN] Connecting or unavailable", + Some(iota_ipc::HealthStatus::Failed) => "[FAIL] Authentication failed", + None => "Unknown", + }; + lines.push(Line::from(format!(" Omikron: {omikron_label}"))); + if let Some(message) = omikron.and_then(|health| health.message.as_deref()) { + lines.push(Line::from(format!(" Omikron detail: {message}"))); + } lines.push(Line::from("")); lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index ebc3e0e..b47366a 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -8,7 +8,7 @@ use iota_ipc::{ }; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; -use iota_storage::util::config_util::{self, modify_config}; +use iota_storage::util::config_util::{self}; use mtp::codec::{CommunicationType, CommunicationValue}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -52,10 +52,7 @@ impl CommandRouter { } let needs_omikron = matches!( request, - LocalRequest::CreateUser { .. } - | LocalRequest::RemoveUser { .. } - | LocalRequest::ReconnectOmikron - | LocalRequest::RotateIotaIdentity + LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } ); if needs_omikron && !self.services.omikron.is_connected().await { return ResponseResult::Error( @@ -112,11 +109,33 @@ impl CommandRouter { ) .await { - (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { + Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated { user_id: user.user_id, username: user.username, }), - _ => ResponseResult::Error(IpcErrorCode::StorageFailure), + Err(error) => { + log!("User creation failed: {error:?}"); + match error { + omikron_connector::user_ops::CreateUserError::InvalidUsername => { + ResponseResult::Error(IpcErrorCode::InvalidRequest) + } + omikron_connector::user_ops::CreateUserError::Transport( + omikron_connector::OmikronError::Timeout(_), + ) => ResponseResult::Error(IpcErrorCode::Timeout), + omikron_connector::user_ops::CreateUserError::Transport(_) => { + ResponseResult::Error(IpcErrorCode::OmikronUnavailable) + } + omikron_connector::user_ops::CreateUserError::RemoteRejected => { + ResponseResult::Error(IpcErrorCode::Conflict) + } + omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => { + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + omikron_connector::user_ops::CreateUserError::InvalidResponse => { + ResponseResult::Error(IpcErrorCode::InternalFailure) + } + } + } } } LocalRequest::RemoveUser { user_id } => { @@ -139,16 +158,14 @@ impl CommandRouter { Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, LocalRequest::RotateIotaIdentity => { - modify_config(|config| { - config.public_key = None; - config.private_key = None; - config.iota_id = None; - }); - match self.services.omikron.reconnect().await { + match self.services.omikron.rotate_identity().await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Key pair regenerated and Omikron reconnection requested".into(), + message: "New identity registered with Omikron".into(), }), - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + Err(error) => { + log!("Iota identity rotation failed: {}", error); + ResponseResult::Error(IpcErrorCode::OmikronUnavailable) + } } } LocalRequest::RequestProcessExit { intent } => { diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index bd2ac1c..b63b574 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -61,6 +61,11 @@ impl OmikronClient for InactiveOmikron { "terms have not been accepted".into(), )) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + Err(OmikronError::Disconnected( + "terms have not been accepted".into(), + )) + } async fn is_connected(&self) -> bool { false } diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 3f80199..2bf8bd7 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -29,8 +29,12 @@ impl OmikronClient for FakeOmikron { self.reconnects.fetch_add(1, Ordering::SeqCst); Ok(()) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + self.reconnects.fetch_add(1, Ordering::SeqCst); + Ok(()) + } async fn is_connected(&self) -> bool { - true + false } } @@ -43,6 +47,7 @@ async fn reconnect_uses_the_injected_client() { omikron: fake.clone(), users: Default::default(), config: Default::default(), + active: true, }); let router = CommandRouter::new( Arc::new(DaemonRuntime::new()), @@ -55,3 +60,29 @@ async fn reconnect_uses_the_injected_client() { )); assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); } + +#[tokio::test] +async fn identity_rotation_is_available_while_omikron_is_offline() { + let fake = Arc::new(FakeOmikron { + reconnects: AtomicUsize::new(0), + }); + let services = Arc::new(DaemonServices { + omikron: fake.clone(), + users: Default::default(), + config: Default::default(), + active: true, + }); + let router = CommandRouter::new( + Arc::new(DaemonRuntime::new()), + services, + Arc::new(Mutex::new(LogBuffer::new(100))), + ); + assert!(matches!( + router + .route(1, LocalRequest::RotateIotaIdentity) + .await + .result, + ResponseResult::Ok(_) + )); + assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); +} diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index d971f91..47a0a51 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -140,19 +140,75 @@ async fn main() -> ExitCode { ); connection } - Err(omikron_connector::OmikronStartupError::Authentication) => { + Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { runtime.set_component_failed( iota_ipc::ComponentId::Omikron, - "Omikron authentication failed".into(), + "Omikron authentication failed; regenerate the Iota identity to register again" + .into(), ); - return ExitCode::FAILURE; + // Keep IPC alive: identity rotation is the supported recovery + // action and must remain available after authentication fails. + connection } Err(omikron_connector::OmikronStartupError::Construction(error)) => { eprintln!("Cannot construct Omikron connection: {error}"); return ExitCode::FAILURE; } }; + let omikron_health = omikron.clone(); let services = DaemonServices::new(omikron); + let health_runtime = runtime.clone(); + runtime + .tasks + .spawn_tracked("omikron-health", async move { + let mut states = omikron_health.connection_state(); + loop { + let state = *states.borrow(); + match state { + omikron_connector::omikron_connection::ConnectionState::Connected { + .. + } => { + let ping_ms = *omikron_health.last_ping.lock().await; + let message = if ping_ms >= 0 { + format!("connected (RTT: {ping_ms} ms)") + } else { + "connected (waiting for RTT sample)".into() + }; + health_runtime + .set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message)); + } + omikron_connector::omikron_connection::ConnectionState::Connecting => { + health_runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + "connecting to Omikron".into(), + ); + } + omikron_connector::omikron_connection::ConnectionState::Disconnected => { + let message = omikron_health + .get_auth_failure() + .await + .unwrap_or_else(|| "disconnected; retrying".into()); + if omikron_health.has_auth_failure().await { + health_runtime + .set_component_failed(iota_ipc::ComponentId::Omikron, message); + } else { + health_runtime + .set_component_degraded(iota_ipc::ComponentId::Omikron, message); + } + } + } + tokio::select! { + changed = states.changed() => if changed.is_err() { break }, + // RTT is updated by MTP's heartbeat independently of a + // connection-state transition, so periodically refresh + // the component detail while connected. + _ = tokio::time::sleep(Duration::from_secs(1)) => {}, + _ = health_runtime.cancellation.cancelled() => break, + } + } + Ok(()) + }) + .await; let ipc_server = match IpcServer::bind( socket.clone(), runtime.clone(), diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 62f663a..29724d6 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -171,5 +171,3 @@ fn run(program: &str, args: &[&str]) -> Result<()> { bail!("{program} failed; run the installer as root") } } - - diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index fa1e2f0..fe3ed8e 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -98,19 +98,19 @@ pub fn startup_with_log_dir(log_dir: Option) { format!("{} ", msg.prefix) }; - let line1 = format!( - "{} {}{}", + let line = format!( + "{} {} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), + timestamp, prefix, resolved_message ); - let line2 = format!(" {}", timestamp); if let Some(file) = file.as_mut() { - let _ = writeln!(file, "{}\n{}", line1, line2); + let _ = writeln!(file, "{}", line); } - let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); + let _ = writeln!(std::io::stderr(), "{}", line); let entry = UiLogEntry { timestamp_ms: msg.timestamp_ms, @@ -329,8 +329,11 @@ pub fn format_cv(cv: &CommunicationValue) -> String { parts.push(format!("> {}", receiver)); } - let comm_type = cv.get_type().to_string(); - parts.push(format!("{}", comm_type)); + let comm_type = cv + .get_comm_type_enum() + .map(|kind| kind.to_string()) + .unwrap_or_else(|| cv.get_type().to_string()); + parts.push(format!("{} (id={})", comm_type, cv.get_id())); let data = cv.data(); diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index d6a0b47..af842e5 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -7,7 +7,13 @@ use rand_core::{OsRng, RngCore}; use rusqlite::params; pub fn add_user(user: UserProfile) { - if let Err(e) = db::with_db(|conn| { + if let Err(e) = try_add_user(user) { + eprintln!("Failed to add_user: {}", e); + } +} + +pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { + db::with_db(|conn| { conn.execute( r#" INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) @@ -40,9 +46,7 @@ pub fn add_user(user: UserProfile) { )?; } Ok(()) - }) { - eprintln!("Failed to add_user: {}", e); - } + }) } pub fn update_user(user: UserProfile) { diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index bfa70c4..9d628d4 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -239,5 +239,3 @@ fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result Result, std::io::Error> } pub fn save_file(path: &str, name: &str, value: &str) { - let Ok(file_path) = storage_file(path, name) else { - eprintln!("[IMPORTANT] Refusing unsafe storage path"); - return; - }; - let Some(dir) = file_path.parent() else { - return; - }; - - if !dir.exists() { - if let Err(e) = fs::create_dir_all(&dir) { - println!("[IMPORTANT] Couldn't create directories: {}", e); - return; - } + if let Err(error) = try_save_file(path, name, value) { + eprintln!("[IMPORTANT] Couldn't save file: {error}"); } +} + +pub fn try_save_file(path: &str, name: &str, value: &str) -> io::Result<()> { + let file_path = storage_file(path, name)?; + let dir = file_path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file has no parent"))?; + + fs::create_dir_all(dir)?; // Write to a temp file first, then atomically rename to prevent partial writes. let tmp_name = format!(".{}.tmp", name); let tmp_path = dir.join(&tmp_name); - if let Err(e) = fs::write(&tmp_path, value) { - println!( - "[IMPORTANT] Couldn't write temp file {}: {}", - tmp_path.display(), - e - ); - return; - } - if let Err(e) = fs::rename(&tmp_path, &file_path) { - println!( - "[IMPORTANT] Couldn't rename {} to {}: {}", - tmp_path.display(), - file_path.display(), - e - ); + fs::write(&tmp_path, value)?; + if let Err(error) = fs::rename(&tmp_path, &file_path) { let _ = fs::remove_file(&tmp_path); + return Err(error); } + Ok(()) } pub fn get_children(path: &str) -> Vec { diff --git a/mtp-type-maps b/mtp-type-maps new file mode 160000 index 0000000..594646a --- /dev/null +++ b/mtp-type-maps @@ -0,0 +1 @@ +Subproject commit 594646ac39d986f0787aa614a99d580035a67318 diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs index 872ff67..0561212 100644 --- a/omikron-connector/src/client.rs +++ b/omikron-connector/src/client.rs @@ -27,7 +27,9 @@ pub enum OmikronStartupError { InitialConnectionTimeout { connection: std::sync::Arc, }, - Authentication, + Authentication { + connection: std::sync::Arc, + }, } #[async_trait] @@ -39,5 +41,9 @@ pub trait OmikronClient: Send + Sync { timeout: Duration, ) -> Result; async fn reconnect(&self) -> Result<(), OmikronError>; + /// Replace the local Iota identity and wait for the new identity to + /// register/authenticate. This is deliberately available while offline: + /// it is the recovery operation for an authentication failure. + async fn rotate_identity(&self) -> Result<(), OmikronError>; async fn is_connected(&self) -> bool; } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index bbf2bf2..02fd8f4 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -13,7 +13,7 @@ use std::env; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; @@ -221,6 +221,11 @@ impl OmikronConnection { let _ = self.state_watch_tx.send(new_state); } + /// Subscribe to connection transitions for daemon health reporting. + pub fn connection_state(&self) -> watch::Receiver { + self.state_watch_tx.subscribe() + } + // ------------------------------------------------------------------------- // Connection Management // ------------------------------------------------------------------------- @@ -1880,10 +1885,11 @@ impl OmikronConnection { let reason = response_cv .get_data(DataType::Message) .as_str() + .or_else(|| response_cv.get_data(DataType::ErrorType).as_str()) .unwrap_or("connection error") .to_string(); Err(format!( - "Request failed due to disconnect (msg_id={}, reason={})", + "Request rejected (msg_id={}, reason={})", msg_id, reason )) } else { @@ -1955,6 +1961,75 @@ impl OmikronConnection { self.stop().await; self.connect().await; } + + /// Create a new local keyring and register it as a new Iota identity. + /// The existing keyring is retained as a timestamped backup so a failed + /// recovery does not silently destroy the user's previous identity. + pub async fn rotate_identity(self: &Arc) -> Result<(), OmikronError> { + log!("Iota identity rotation requested"); + self.stop().await; + + let path = identity_path(); + if path.exists() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let backup = path.with_extension(format!("mk.backup-{stamp}")); + std::fs::rename(path, &backup).map_err(|error| { + OmikronError::Internal(format!( + "could not back up identity {}: {error}", + path.display() + )) + })?; + log!("Existing Iota identity backed up to {}", backup.display()); + } + + let keyring = crypto_helper::generate_keyring(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + OmikronError::Internal(format!( + "could not create identity directory {}: {error}", + parent.display() + )) + })?; + } + mtp::files::save_keyring_raw(&keyring, path).map_err(|error| { + OmikronError::Internal(format!( + "could not save new identity {}: {error}", + path.display() + )) + })?; + modify_config(|config| { + config.iota_id = None; + config.keyring = None; + config.public_key = None; + config.private_key = None; + }); + log!("New Iota identity generated; registration started"); + + self.clear_auth_failure().await; + self.connect().await; + match self.await_connection(Some(CONNECTION_TIMEOUT)).await { + Ok(()) => { + let id = CONFIG.load().iota_id; + log!( + "New Iota identity registered{}", + id.map(|v| format!(" (Iota-ID: {v})")).unwrap_or_default() + ); + Ok(()) + } + Err(timeout) => { + if let Some(reason) = self.get_auth_failure().await { + log!("Iota identity registration failed: {}", reason); + Err(OmikronError::Authentication(reason)) + } else { + log!("Iota identity registration did not complete: {}", timeout); + Err(OmikronError::Timeout(timeout)) + } + } + } + } } // ============================================================================ @@ -1975,7 +2050,7 @@ pub async fn connect_initial( match conn.await_connection(Some(CONNECTION_TIMEOUT)).await { Ok(()) => Ok(conn), Err(_) if conn.has_auth_failure().await => { - Err(crate::client::OmikronStartupError::Authentication) + Err(crate::client::OmikronStartupError::Authentication { connection: conn }) } Err(_) => { Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn }) @@ -2027,6 +2102,8 @@ impl OmikronClient for OmikronConnection { .map_err(|error| { if error.contains("timed out") { OmikronError::Timeout(error) + } else if error.starts_with("Request rejected") { + OmikronError::Internal(error) } else { OmikronError::Disconnected(error) } @@ -2057,6 +2134,29 @@ impl OmikronClient for OmikronConnection { Ok(()) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + let this = Arc::new(Self { + state: self.state.clone(), + state_watch_tx: self.state_watch_tx.clone(), + sender: self.sender.clone(), + connection_loop_handle: self.connection_loop_handle.clone(), + last_ping: self.last_ping.clone(), + heartbeat_handle: self.heartbeat_handle.clone(), + connection_id: self.connection_id, + shutdown_tx: self.shutdown_tx.clone(), + reconnect_on_close: self.reconnect_on_close.clone(), + auth_failure: self.auth_failure.clone(), + app_challenges: self.app_challenges.clone(), + app_sessions: self.app_sessions.clone(), + missed_pongs: self.missed_pongs.clone(), + handler_semaphore: self.handler_semaphore.clone(), + cancellation: self.cancellation.clone(), + active_tasks: self.active_tasks.clone(), + app: self.app.clone(), + }); + Self::rotate_identity(&this).await + } + async fn is_connected(&self) -> bool { Self::is_connected(self).await } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 00733e1..fde043f 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,9 +1,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; -use iota_storage::users::user_manager::{add_user, save_users}; +use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_profile::UserProfile; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::save_file; +use iota_util::file_util::try_save_file; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; use std::time::Duration; @@ -11,34 +11,75 @@ use std::time::Duration; use crate::OmikronClient; use crate::omega_discovery; +#[derive(Debug)] +pub enum CreateUserError { + InvalidUsername, + Transport(crate::OmikronError), + InvalidResponse, + RemoteRejected, + LocalPersistence(String), +} + +fn valid_username(username: &str) -> bool { + !username.is_empty() + && username.chars().count() <= 15 + && !username.chars().any(char::is_control) + && !username.contains(['/', '\\']) +} + +async fn request_user_id(connection: &dyn OmikronClient) -> Result<(i64, String), CreateUserError> { + let request = CommunicationValue::new(CommunicationType::GetRegister); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await + .map_err(CreateUserError::Transport)?; + + if !response.is_type(CommunicationType::GetRegister) { + return Err(CreateUserError::InvalidResponse); + } + + let user_id = response + .get_data(DataType::UserId) + .as_number() + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| (1..(1_i64 << 48)).contains(id)) + .ok_or(CreateUserError::InvalidResponse)?; + let registration_token = response + .get_data(DataType::RegisterId) + .as_str() + .filter(|token| uuid::Uuid::parse_str(token).is_ok()) + .map(str::to_owned) + .ok_or(CreateUserError::InvalidResponse)?; + Ok((user_id, registration_token)) +} + +/// A completion response can be lost after Omega commits the user. Confirm +/// the exact remote record before treating that transport failure as success. +async fn registration_committed(connection: &dyn OmikronClient, profile: &UserProfile) -> bool { + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(profile.user_id.into()), + ); + let Ok(response) = connection + .await_response(&request, Duration::from_secs(5)) + .await + else { + return false; + }; + response.get_data(DataType::UserId).as_number() == Some(profile.user_id.into()) + && response.get_data(DataType::Username).as_str() == Some(profile.username.as_str()) + && response.get_data(DataType::PublicKey).as_str() == Some(profile.public_key.as_str()) +} + pub async fn create_user( connection: &dyn OmikronClient, username: &str, -) -> (Option, Option) { - let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); - - let response_communication_value = match connection - .await_response(®ister_communication_value, Duration::from_secs(20)) - .await - { - Ok(communication_value) => communication_value, - Err(e) => { - log_t!("User creation: {}", e.to_string()); - return (None, None); - } - }; - log_cv!(PrintType::Omega, response_communication_value); - - let user_id = match response_communication_value - .get_data(DataType::UserId) - .as_number() - { - Some(id) => id, - None => { - log_t!("User creation: Response returned none"); - return (None, None); - } - }; +) -> Result { + if !valid_username(username) { + return Err(CreateUserError::InvalidUsername); + } + let (user_id, registration_token) = request_user_id(connection).await?; + log!("User creation: Omega allocated user ID {user_id}"); let keyring = crypto_helper::generate_keyring(); let pub_key_bundle = keyring.public_key_bundle(); let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); @@ -50,7 +91,7 @@ pub async fn create_user( let reset_token = STANDARD.encode(&bytes); let user_profile = UserProfile::new( - user_id as i64, + user_id, username.to_string(), None, public_key_bundle_to_base64(&pub_key_bundle), @@ -59,30 +100,43 @@ pub async fn create_user( ); let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id as i128)) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) .add_typed_default( DataType::PublicKey, DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), ) - .add_typed_default(DataType::IotaId, DataValue::SignedNumber(user_id as i128)) - .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)); + .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)) + .add_typed_default(DataType::RegisterId, DataValue::Str(registration_token)); let response_communication_value = connection .await_response(&communication_value, Duration::from_secs(20)) .await; - if let Ok(response) = response_communication_value { - log_cv!(PrintType::Omega, response); - if !response.is_type(CommunicationType::Success) { - return (None, None); + match response_communication_value { + Ok(response) => { + log_cv!(PrintType::Omega, response); + if !response.is_type(CommunicationType::Success) { + return Err(CreateUserError::RemoteRejected); + } + } + Err(error) => { + if registration_committed(connection, &user_profile).await { + log!( + "User creation: completion response was lost; verified user {} remotely", + user_id + ); + } else { + log_t!("User creation: {}", error.to_string()); + return Err(match error { + crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, + error => CreateUserError::Transport(error), + }); + } } - } else { - log_t!("User creation: Response returned none"); - return (None, None); } log!("Created User"); - save_file( + try_save_file( "", &format!("{}.tu", username), &format!( @@ -91,9 +145,91 @@ pub async fn create_user( omega_discovery::omega_host(), keyring_b64 ), - ); + ) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - add_user(user_profile.clone()); - save_users(); - (Some(user_profile), Some(keyring_b64)) + try_add_user(user_profile.clone()) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; + Ok(user_profile) +} + +#[cfg(test)] +mod tests { + use super::{CreateUserError, request_user_id, valid_username}; + use crate::{OmikronClient, OmikronError}; + use async_trait::async_trait; + use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + use std::time::Duration; + + struct RegistrationClient { + response: CommunicationValue, + } + + #[async_trait] + impl OmikronClient for RegistrationClient { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + unreachable!() + } + + async fn await_response( + &self, + request: &CommunicationValue, + _: Duration, + ) -> Result { + assert!(request.is_type(CommunicationType::GetRegister)); + Ok(self.response.clone().with_id(request.get_id())) + } + + async fn reconnect(&self) -> Result<(), OmikronError> { + unreachable!() + } + + async fn rotate_identity(&self) -> Result<(), OmikronError> { + unreachable!() + } + + async fn is_connected(&self) -> bool { + true + } + } + + #[test] + fn validates_usernames_before_remote_registration() { + assert!(valid_username("alice")); + assert!(valid_username("fifteen_char_ok")); + assert!(!valid_username("")); + assert!(!valid_username("sixteen_chars_bad")); + assert!(!valid_username("path/name")); + assert!(!valid_username("line\nbreak")); + } + + #[tokio::test] + async fn uses_user_id_allocated_by_omega() { + let client = RegistrationClient { + response: CommunicationValue::new(CommunicationType::GetRegister) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(4_294_967_311)) + .add_typed_default( + DataType::RegisterId, + DataValue::Str("00000000-0000-4000-8000-000000000001".into()), + ), + }; + + assert_eq!( + request_user_id(&client).await.unwrap(), + (4_294_967_311, "00000000-0000-4000-8000-000000000001".into()) + ); + } + + #[tokio::test] + async fn rejects_registration_response_without_a_positive_user_id() { + let client = RegistrationClient { + response: CommunicationValue::new(CommunicationType::GetRegister) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(0)), + }; + + assert!(matches!( + request_user_id(&client).await, + Err(CreateUserError::InvalidResponse) + )); + } } diff --git a/type-maps.yaml b/type-maps.yaml deleted file mode 100644 index 53684cc..0000000 --- a/type-maps.yaml +++ /dev/null @@ -1,278 +0,0 @@ -protocol_version: "1.0" - -# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended - -# Fixed CommunicationType markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorBadVersion: 2 -# Disconnect: 3 -# Redirect: 4 -# Shutdown: 5 -# BadRequest: 6 -# Unauthorized: 7 -# Forbidden: 8 -# NotFound: 9 -# TooManyRequests: 10 -# InternalServerError: 11 -# BadGateway: 12 -# ServiceUnavailable: 13 -# GatewayTimeout: 14 -# Identification: 15 -# IdentificationResponse: 16 -# Register: 17 -# RegisterResponse: 18 -# Ping: 19 -# Pong: 20 - -# Fixed Data Type markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorMessage: 2 -# Version: 3 -# Description: 4 -# Timestamp: 5 -# Id: 6 -# ClientNonce: 7 -# ServerNonce: 8 -# PublicKeys: 9 -# Signature: 10 -# Connected: 11 - -type_maps: - "1.0": - CommunicationTypes: - ErrorProtocol: 33 - ErrorAnonymous: 34 - ErrorInternal: 35 - ErrorInvalidData: 36 - ErrorInvalidUserId: 37 - ErrorInvalidOmikronId: 38 - ErrorNotFound: 39 - ErrorNotAuthenticated: 40 - ErrorNoIota: 41 - ErrorInvalidChallenge: 42 - ErrorInvalidSecret: 43 - ErrorInvalidPrivateKey: 44 - ErrorInvalidPublicKey: 45 - ErrorNoUserId: 46 - ErrorNoCallId: 47 - ErrorInvalidCallId: 48 - Success: 49 - ShortenLink: 50 - SettingsSave: 51 - SettingsLoad: 52 - SettingsList: 53 - GlobalSettingsSave: 54 - GlobalSettingsLoad: 55 - Message: 56 - MessageState: 57 - MessageSend: 58 - MessageLive: 59 - MessageOtherIota: 60 - MessageChunk: 61 - MessageGet: 143 - MessagesGet: 62 - PushNotification: 63 - ReadNotification: 64 - GetNotifications: 65 - TauriIdentification: 66 - ChangeConfirm: 67 - ConfirmReceive: 68 - ConfirmRead: 69 - GetChats: 70 - GetStates: 71 - AddCommunity: 72 - RemoveCommunity: 73 - GetCommunities: 74 - RegisterIota: 81 - RegisterIotaSuccess: 82 - AddConversation: 85 - SendChat: 86 - ClientChanged: 87 - ClientConnected: 88 - ClientDisconnected: 89 - ClientClosed: 90 - PublicKey: 91 - PrivateKey: 92 - WebrtcSdp: 93 - WebrtcIce: 94 - StartStream: 95 - EndStream: 96 - WatchStream: 97 - CallToken: 98 - CallInvite: 99 - CallDisconnectUser: 100 - CallTimeoutUser: 101 - CallSetAnonymousJoining: 102 - CallData: 103 - EndCall: 104 - Function: 105 - Update: 106 - CreateUser: 107 - RhoUpdate: 108 - UserConnected: 109 - UserDisconnected: 110 - IotaConnected: 111 - IotaDisconnected: 112 - SyncClientIotaStatus: 113 - GetUserData: 114 - GetIotaData: 115 - IotaUserData: 116 - ChangeUserData: 117 - ChangeIotaData: 118 - GetRegister: 119 - CompleteRegisterUser: 120 - CompleteRegisterIota: 121 - DeleteUser: 122 - DeleteIota: 123 - StartRegister: 124 - CompleteRegister: 125 - GetApp: 126 - CreateApp: 127 - DeleteApp: 128 - SaveAppData: 129 - LoadAppData: 130 - AppIdentification: 131 - AppChallenge: 132 - AppChallengeResponse: 133 - AppIdentificationResponse: 134 - LoadTxtRecord: 135 - ErrorNotSet: 136 - SetChatSecret: 139 - GetChatSecret: 140 - ChatSecretResponse: 141 - ChatSecretForward: 142 - MessageEditLive: 144 - MessageEdit: 145 - MessageReactionAdd: 146 - MessageReactionRemove: 147 - MessageReactionLive: 148 - MessageDeleteLive: 150 - ClientStateSync: 151 - ClientStateAck: 152 - StateSubscribe: 153 - DataTypes: - ErrorType: 32 - ErrorProtocol: 33 - AcceptedIds: 34 - Uuid: 35 - RegisterId: 36 - Link: 37 - Settings: 38 - SettingsName: 39 - ChatPartnerId: 40 - ChatPartnerName: 41 - IotaId: 42 - UserId: 43 - UserIds: 44 - IotaIds: 45 - UserState: 46 - UserStates: 47 - UserPings: 48 - CallState: 49 - ScreenShare: 50 - PrivateKeyHash: 51 - # Accepted: 52 now part of default MTP - AcceptedProfiles: 53 - DeniedProfiles: 54 - Content: 55 - Messages: 56 - Notifications: 57 - SendTime: 58 - GetTime: 59 - GetVariant: 60 - SharedSecretOwn: 61 - SharedSecretOther: 62 - SharedSecretSign: 63 - SharedSecret: 64 - CallId: 65 - CallToken: 66 - CallSecret: 67 - Untill: 68 - Enabled: 69 - StartDate: 70 - EndDate: 71 - ReceiverId: 72 - SenderId: 73 - Signed: 75 - Message: 76 - MessageState: 77 - LastPing: 78 - PingIota: 79 - PingClients: 80 - Matches: 81 - Omikron: 82 - Offset: 83 - Amount: 84 - Position: 85 - Name: 86 - Path: 87 - Codec: 88 - Function: 89 - Payload: 90 - Result: 91 - Interactables: 92 - WantToWatch: 93 - Watcher: 94 - CreatedAt: 95 - Username: 96 - Display: 97 - Avatar: 98 - About: 99 - Status: 100 - PublicKey: 101 - SubLevel: 102 - SubEnd: 103 - CommunityAddress: 104 - CommunityTitle: 106 - Communities: 107 - RhoConnections: 108 - User: 109 - OnlineStatus: 110 - OmikronId: 111 - OmikronConnections: 112 - ResetToken: 113 - NewToken: 114 - CallInvited: 115 - CallMembers: 116 - Calls: 117 - Timeout: 118 - HasAdmin: 119 - LastMessageAt: 120 - Height: 121 - SentBySelf: 122 - SessionId: 123 - Contacts: 124 - LastMessage: 125 - AppIdentifier: 127 - AppPrivateKey: 128 - AppPublicKey: 129 - AppSession: 130 - AppData: 131 - TauriToken: 132 - Challenge: 133 - EncryptedPayload: 134 - SecurePayload: 135 - DeviceId: 136 - ClientId: 137 - SecretId: 142 - VersionNumber: 143 - EncryptedSecret: 144 - WrappingScheme: 146 - UpdatedAt: 147 - ChatId: 148 - KemCiphertext: 149 - SenderUserId: 152 - RecipientUserId: 153 - Recipients: 154 - Edited: 155 - Reactions: 156 - Reaction: 157 - ReplyId: 158 - CacheValid: 159 - CacheSchemaVersion: 160 - SyncMode: 161 - MessageId: 162 - DeletedMessageIds: 163 - DeletedContactIds: 164 From a97092d653ce86713f67548937f3d0f4045964cb Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 27 Jul 2026 22:54:33 +0200 Subject: [PATCH 094/119] (feat): update mtp --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebb658a..7d6d7af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1987,7 +1987,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" +source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" dependencies = [ "async-trait", "bytes", @@ -3810,7 +3810,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.19", "tokio", "tracing", @@ -3851,7 +3851,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", "windows-sys 0.61.2", ] @@ -4835,7 +4835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 471f539116c687f760178f74888b73aded1db814 Mon Sep 17 00:00:00 2001 From: Alois Date: Tue, 28 Jul 2026 01:02:04 +0200 Subject: [PATCH 095/119] (fix): fix connection issues --- iota-connection/src/message_handlers.rs | 23 +++++++++++++++++++-- omikron-connector/src/omikron_connection.rs | 14 ++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 079c3c5..1330ef2 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -265,7 +265,11 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .with_receiver(sender_id as u64) } -fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue { +fn contact_value( + contact: &iota_storage::users::contact::Contact, + messages: &[chat_files::StoredMessage], + storage_owner: i64, +) -> DataValue { let mut fields = vec![( DataType::UserId, DataValue::SignedNumber(contact.user_id as i128), @@ -279,6 +283,16 @@ fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue { DataValue::SignedNumber(last_message_at as i128), )); } + fields.push(( + DataType::Messages, + DataValue::Array( + messages + .iter() + .filter(|message| message.external_user == contact.user_id) + .map(|message| stored_message_value(message, storage_owner, contact.user_id)) + .collect(), + ), + )); typed_container(fields) } @@ -368,7 +382,12 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .add_typed_default(DataType::SyncMode, DataValue::Str(mode.into())) .add_typed_default( DataType::Contacts, - DataValue::Array(contacts.iter().map(contact_value).collect()), + DataValue::Array( + contacts + .iter() + .map(|contact| contact_value(contact, &messages, user_id)) + .collect(), + ), ) .add_typed_default(DataType::Messages, DataValue::Array(message_values)) .add_typed_default( diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 02fd8f4..e7e7b2c 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -6,7 +6,7 @@ use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; -use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; +use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; @@ -394,16 +394,16 @@ impl OmikronConnection { log!("Registered with Iota-ID: {}", connection.client_id); } - let sender_arc = Arc::new(connection.sender); + let sender_arc = Arc::new(connection.sender.clone()); *self.sender.write().await = Some(sender_arc.clone()); self.set_state(ConnectionState::Connected { identified: true }) .await; // Start read loop - let mut receiver = connection.receiver; + let connection = Arc::new(connection); let read_self = self.clone(); let read_handle = tokio::spawn(async move { - read_self.read_loop(&mut receiver).await; + read_self.read_loop(connection).await; }); log_t!("omikron_authenticated"); @@ -592,9 +592,9 @@ impl OmikronConnection { // Read Loop & Heartbeat // ------------------------------------------------------------------------- - async fn read_loop(self: Arc, receiver: &mut Receiver) { + async fn read_loop(self: Arc, connection: Arc) { loop { - let result = receiver.receive().await; + let result = connection.receive().await; match result { Ok(cv) => { let msg_id = cv.get_id(); @@ -624,7 +624,7 @@ impl OmikronConnection { break; } } - if !receiver.is_open() { + if !connection.receiver.is_open() { self.fail_all_waiting_tasks(format!( "Connection closed (connection_id={}, receiver_open=false)", self.connection_id From 7399cf8fc3fa6a0f06a6d68263586eecec654799 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 02:20:15 +0200 Subject: [PATCH 096/119] [Imp] UI & UX --- iota-cli/src/controls/dialog.rs | 271 ++++++++++++++++++++ iota-cli/src/controls/header.rs | 130 +++++++--- iota-cli/src/controls/mod.rs | 1 + iota-cli/src/help_overlay.rs | 219 +++++++++++++++++ iota-cli/src/lib.rs | 2 + iota-cli/src/notification.rs | 128 ++++++++++ iota-cli/src/screens/main_screen.rs | 58 ----- iota-cli/src/screens/screens.rs | 5 +- iota-cli/src/screens/settings.rs | 123 +++++++--- iota-cli/src/screens/users.rs | 11 +- iota-cli/src/theme/config.rs | 140 ++++++++++- iota-cli/src/theme/mod.rs | 2 +- iota-cli/src/ui.rs | 134 +++++++--- iota-cli/tests/settings_snapshot.rs | 4 +- iota/src/cli_args.rs | 64 ++++- iota/src/cli_color.rs | 105 ++++++++ iota/src/main.rs | 367 ++++++++++++++++++++++++++-- iota/src/startup_error.rs | 64 +++++ 18 files changed, 1635 insertions(+), 193 deletions(-) create mode 100644 iota-cli/src/controls/dialog.rs create mode 100644 iota-cli/src/help_overlay.rs create mode 100644 iota-cli/src/notification.rs create mode 100644 iota/src/cli_color.rs diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs new file mode 100644 index 0000000..eb750ef --- /dev/null +++ b/iota-cli/src/controls/dialog.rs @@ -0,0 +1,271 @@ +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{HitMap, KeyHint, Screen, UiEvent}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogButton { + Cancel, + Confirm, + Custom(usize), +} + +pub struct ConfirmDialog { + title: String, + message: Vec, + buttons: Vec, + focused_button: usize, + on_confirm: Option InteractionResult + Send + Sync>>, + on_cancel: Option InteractionResult + Send + Sync>>, +} + +struct DialogButtonConfig { + label: String, + intent: ButtonIntent, + enabled: bool, +} + +impl ConfirmDialog { + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: vec![message.into()], + buttons: vec![ + DialogButtonConfig { + label: "Cancel".to_owned(), + intent: ButtonIntent::Cancel, + enabled: true, + }, + DialogButtonConfig { + label: "Confirm".to_owned(), + intent: ButtonIntent::Primary, + enabled: true, + }, + ], + focused_button: 0, + on_confirm: None, + on_cancel: None, + } + } + + pub fn destructive(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: vec![message.into()], + buttons: vec![ + DialogButtonConfig { + label: "Cancel".to_owned(), + intent: ButtonIntent::Cancel, + enabled: true, + }, + DialogButtonConfig { + label: "Delete".to_owned(), + intent: ButtonIntent::Destructive, + enabled: true, + }, + ], + focused_button: 0, + on_confirm: None, + on_cancel: None, + } + } + + pub fn with_message_line(mut self, line: impl Into) -> Self { + self.message.push(line.into()); + self + } + + pub fn with_button(mut self, label: impl Into, intent: ButtonIntent) -> Self { + self.buttons.push(DialogButtonConfig { + label: label.into(), + intent, + enabled: true, + }); + self + } + + pub fn with_confirm_action InteractionResult + Send + Sync + 'static>( + mut self, + action: F, + ) -> Self { + self.on_confirm = Some(Box::new(action)); + self + } + + pub fn with_cancel_action InteractionResult + Send + Sync + 'static>( + mut self, + action: F, + ) -> Self { + self.on_cancel = Some(Box::new(action)); + self + } + + fn activate(&self) -> InteractionResult { + match self.focused_button { + 0 => { + if let Some(action) = &self.on_cancel { + action() + } else { + InteractionResult::CloseScreen + } + } + 1 => { + if let Some(action) = &self.on_confirm { + action() + } else { + InteractionResult::CloseScreen + } + } + _ => InteractionResult::CloseScreen, + } + } + + fn next_button(&mut self) { + self.focused_button = (self.focused_button + 1) % self.buttons.len(); + } + + fn prev_button(&mut self) { + if self.focused_button == 0 { + self.focused_button = self.buttons.len() - 1; + } else { + self.focused_button -= 1; + } + } +} + +impl Screen for ConfirmDialog { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let area = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 50, + height: (self.message.len() + 8) as u16, + }, + ); + + f.render_widget(Clear, area); + let block = Block::default() + .title(format!(" {} ", self.title)) + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Min(self.message.len() as u16), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let lines: Vec = self + .message + .iter() + .map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal))) + .collect(); + f.render_widget(Paragraph::new(lines), rows[0]); + + let buttons_area = rows[2]; + let button_widths: Vec = self + .buttons + .iter() + .map(|b| { + crate::controls::button::button_minimum_width(&b.label) + }) + .collect(); + + let total_width: u16 = button_widths.iter().sum(); + let spacing = self.buttons.len().saturating_sub(1) as u16; + let available = buttons_area.width; + let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; + + let mut x = start_x; + for (i, (button_config, &width)) in + self.buttons.iter().zip(&button_widths).enumerate() + { + let button_area = Rect { + x, + y: buttons_area.y, + width, + height: 1, + }; + x = x.saturating_add(width + 1); + + render_button( + f, + button_area, + ActionButton { + label: &button_config.label, + intent: button_config.intent, + focused: self.focused_button == i, + enabled: button_config.enabled, + }, + context.theme, + ); + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Tab => { + self.next_button(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_button(); + InteractionResult::Handled + } + KeyCode::Left => { + self.prev_button(); + InteractionResult::Handled + } + KeyCode::Right => { + self.next_button(); + InteractionResult::Handled + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Tab", + action: "Switch button", + }, + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } +} diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index 71af229..09a589f 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -1,25 +1,58 @@ +use crate::ipc_client::{DaemonStatus, IpcConnectionState}; use crate::theme::ResolvedTheme; use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, + controls::button::ButtonIntent, screens::screens::{AppAction, HitMap}, }; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, - text::Span, + text::{Line, Span}, widgets::Paragraph, }; -/// Shared application bar. The brand cell is deliberately an action so it is -/// a reliable way home from every screen. +fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) { + match state { + IpcConnectionState::Connected => ("OK", theme.status.success), + IpcConnectionState::Connecting => ("..", theme.status.warning), + IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error), + IpcConnectionState::Disconnected => ("WARN", theme.status.warning), + } +} + +fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) { + use iota_ipc::ComponentId; + let health = daemon.components.get(&ComponentId::Omikron); + let (label, style) = match health.map(|h| h.status) { + Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success), + Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning), + Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error), + None => ("--", theme.text.muted), + }; + let detail = health + .and_then(|h| h.message.as_deref()) + .map(|m| format!(" {m}")) + .unwrap_or_default(); + (format!("{label}{detail}"), style) +} + pub fn render_header( frame: &mut Frame, area: Rect, - title: &str, + connection: &IpcConnectionState, + daemon: &DaemonStatus, theme: &ResolvedTheme, hits: &mut HitMap, focused_action: Option, ) { + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + + let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); let cells = Layout::horizontal([ Constraint::Min(28), Constraint::Length(12), @@ -27,36 +60,75 @@ pub fn render_header( Constraint::Length(12), Constraint::Length(8), ]) - .split(area); + .split(rows[0]); + let cells2 = Layout::horizontal([ + Constraint::Min(28), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(8), + ]) + .split(rows[1]); + + let (ipc_label, ipc_style) = connection_badge(connection, theme); + let (omikron_text, omikron_style) = omikron_badge(daemon, theme); + + let brand_line1 = Line::from(vec![ + Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar), + Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style), + ]); + let brand_line2 = Line::from(vec![ + Span::styled(" Omikron: ", theme.surfaces.toolbar), + Span::styled(format!("[{omikron_text}]"), omikron_style), + ]); + let brand_area = Rect { + x: area.x, + y: area.y, + width: cells[0].width, + height: area.height, + }; frame.render_widget( - Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), - cells[0], + Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar), + brand_area, ); - hits.register(cells[0], AppAction::OpenMain); - for (index, (area, label, action)) in [ - (cells[1], "Overview", AppAction::OpenOverview), - (cells[2], "Users", AppAction::OpenUsers), - (cells[3], "Settings", AppAction::OpenSettings), - (cells[4], "Quit", AppAction::Quit), + hits.register(brand_area, AppAction::OpenMain); + + for (index, (top, _bottom, label, intent, action)) in [ + (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview), + (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers), + (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings), + (cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit), ] .into_iter() .enumerate() { - render_button( - frame, - area, - ActionButton { - label, - intent: if action == AppAction::Quit { - ButtonIntent::Destructive - } else { - ButtonIntent::Neutral - }, - focused: focused_action == Some(index), - enabled: true, - }, - theme, + let button_area = Rect { + x: top.x, + y: top.y, + width: top.width, + height: area.height, + }; + let style = match (intent, focused_action == Some(index)) { + (ButtonIntent::Primary, true) => theme.buttons.primary_focused, + (ButtonIntent::Primary, false) => theme.buttons.primary, + (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, + (ButtonIntent::Neutral, false) => theme.buttons.neutral, + (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, + (ButtonIntent::Cancel, false) => theme.buttons.cancel, + (ButtonIntent::Destructive, _) => theme.buttons.destructive, + }; + let display_label = if focused_action == Some(index) { + format!("› {label}") + } else { + label.to_owned() + }; + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(display_label, style)), + Line::from(""), + ]), + button_area, ); - hits.register(area, action); + hits.register(button_area, action); } } diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index fae2452..02eaa84 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -2,6 +2,7 @@ pub mod action; pub mod button; pub mod checkbox_group; pub mod choice; +pub mod dialog; pub mod header; pub mod navigation; pub mod panel; diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs new file mode 100644 index 0000000..007c223 --- /dev/null +++ b/iota-cli/src/help_overlay.rs @@ -0,0 +1,219 @@ +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{HitMap, Screen, UiEvent}, + theme::ResolvedTheme, +}; + +pub struct HelpOverlay { + scroll: usize, +} + +impl HelpOverlay { + pub fn new() -> Self { + Self { scroll: 0 } + } + + fn build_lines(&self, theme: &ResolvedTheme) -> Vec> { + vec![ + Line::from(""), + Line::from(Span::styled( + "Global Keyboard Shortcuts", + theme.text.heading, + )), + Line::from(""), + Line::from(vec![ + Span::styled(" F6", theme.text.link), + Span::styled(" Toggle header navigation", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Tab", theme.text.link), + Span::styled(" Move focus to next panel", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Shift+Tab", theme.text.link), + Span::styled(" Move focus to previous panel", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Esc", theme.text.link), + Span::styled(" Go back / Close dialog", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Ctrl+C", theme.text.link), + Span::styled(" Quit the application", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Dashboard Navigation", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" o/O", theme.text.link), + Span::styled(" Open Overview screen", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" u/U", theme.text.link), + Span::styled(" Open Users screen", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" m/M", theme.text.link), + Span::styled(" Open Metrics screen", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Log Panel", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" j/Down", theme.text.link), + Span::styled(" Scroll down", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" k/Up", theme.text.link), + Span::styled(" Scroll up", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Enter", theme.text.link), + Span::styled(" Lock/unlock scroll", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /", theme.text.link), + Span::styled(" Filter logs", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Console Panel", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" Enter", theme.text.link), + Span::styled(" Send command", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Up/Down", theme.text.link), + Span::styled(" Command history", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Tab", theme.text.link), + Span::styled(" Auto-complete", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /help", theme.text.link), + Span::styled(" List available commands", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("List Navigation", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" j/Down", theme.text.link), + Span::styled(" Next item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" k/Up", theme.text.link), + Span::styled(" Previous item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" PgUp/PgDn", theme.text.link), + Span::styled(" Page up/down", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Home", theme.text.link), + Span::styled(" First item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" End", theme.text.link), + Span::styled(" Last item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /", theme.text.link), + Span::styled(" Filter list", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled( + "Press ? or Esc to close this overlay", + theme.text.muted, + )), + ] + } +} + +impl Screen for HelpOverlay { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let area = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 52, + height: 40, + }, + ); + + f.render_widget(Clear, area); + let block = Block::default() + .title(" Keyboard Shortcuts (?) ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + + let inner = block.inner(area); + f.render_widget(block, area); + + let lines = self.build_lines(context.theme); + let paragraph = Paragraph::new(lines) + .scroll((self.scroll as u16, 0)) + .style(context.theme.text.normal); + f.render_widget(paragraph, inner); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { + InteractionResult::CloseScreen + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll = self.scroll.saturating_add(1); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.scroll = self.scroll.saturating_sub(1); + InteractionResult::Handled + } + KeyCode::PageDown => { + self.scroll = self.scroll.saturating_add(10); + InteractionResult::Handled + } + KeyCode::PageUp => { + self.scroll = self.scroll.saturating_sub(10); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + crate::screens::screens::KeyHint { + keys: "Up/Down", + action: "Scroll", + }, + crate::screens::screens::KeyHint { + keys: "Esc/?", + action: "Close", + }, + ] + } +} + +use std::any::Any; diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index eee1487..bd2e44d 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -23,10 +23,12 @@ pub mod util { } pub mod app_state; pub mod controls; +pub mod help_overlay; pub mod input_handler; pub mod interaction_result; pub mod ipc_client; pub mod layout; +pub mod notification; pub mod render_context; pub mod theme; pub mod ui; diff --git a/iota-cli/src/notification.rs b/iota-cli/src/notification.rs new file mode 100644 index 0000000..0fd3cb7 --- /dev/null +++ b/iota-cli/src/notification.rs @@ -0,0 +1,128 @@ +use std::time::{Duration, Instant}; + +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::theme::ResolvedTheme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationKind { + Success, + Warning, + Error, + Info, +} + +#[derive(Clone)] +pub struct Notification { + pub message: String, + pub kind: NotificationKind, + pub created_at: Instant, + pub duration: Duration, +} + +impl Notification { + pub fn success(message: impl Into) -> Self { + Self::new(message, NotificationKind::Success, Duration::from_secs(3)) + } + + pub fn warning(message: impl Into) -> Self { + Self::new(message, NotificationKind::Warning, Duration::from_secs(4)) + } + + pub fn error(message: impl Into) -> Self { + Self::new(message, NotificationKind::Error, Duration::from_secs(5)) + } + + pub fn info(message: impl Into) -> Self { + Self::new(message, NotificationKind::Info, Duration::from_secs(3)) + } + + fn new(message: impl Into, kind: NotificationKind, duration: Duration) -> Self { + Self { + message: message.into(), + kind, + created_at: Instant::now(), + duration, + } + } + + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() >= self.duration + } + + pub fn remaining(&self) -> Duration { + self.duration.saturating_sub(self.created_at.elapsed()) + } + + pub fn progress(&self) -> f64 { + let elapsed = self.created_at.elapsed().as_secs_f64(); + let total = self.duration.as_secs_f64(); + (elapsed / total).min(1.0) + } +} + +pub fn render_notification( + frame: &mut Frame, + area: Rect, + notification: &Notification, + theme: &ResolvedTheme, +) { + let (prefix, style) = match notification.kind { + NotificationKind::Success => ("✓ ", theme.status.success), + NotificationKind::Warning => ("⚠ ", theme.status.warning), + NotificationKind::Error => ("✗ ", theme.status.error), + NotificationKind::Info => ("ℹ ", theme.status.info), + }; + + let remaining = notification.remaining().as_secs(); + let progress = notification.progress(); + + let mut spans = vec![ + Span::styled(prefix, style), + Span::styled(¬ification.message, theme.text.normal), + ]; + + if remaining > 0 { + let bar_width = 10; + let filled = ((1.0 - progress) * bar_width as f64) as usize; + let empty = bar_width - filled; + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + spans.push(Span::styled( + format!(" [{bar}] {remaining}s"), + theme.text.muted, + )); + } + + let paragraph = Paragraph::new(Line::from(spans)); + frame.render_widget(paragraph, area); +} + +pub fn render_notification_area( + frame: &mut Frame, + area: Rect, + notifications: &[Notification], + theme: &ResolvedTheme, +) { + if notifications.is_empty() { + return; + } + + let visible_height = area.height as usize; + let start = notifications.len().saturating_sub(visible_height); + let visible = ¬ifications[start..]; + + for (i, notification) in visible.iter().enumerate() { + let row = Rect { + x: area.x, + y: area.y + i as u16, + width: area.width, + height: 1, + }; + render_notification(frame, row, notification, theme); + } +} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 40abb83..53e68b3 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -49,28 +49,6 @@ impl MainScreen { pub fn daemon_status(&self) -> watch::Receiver { self.daemon_status_rx.clone() } - fn status_summary(&self) -> String { - let connection = match self.connection_status_rx.borrow().clone() { - IpcConnectionState::Connected => "[OK] Connected".to_owned(), - IpcConnectionState::Connecting => "[..] Connecting".to_owned(), - IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { - "[FAIL] Failed".to_owned() - } - IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), - }; - let daemon = self.daemon_status_rx.borrow().clone(); - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - let ready = daemon - .startup_phase - .map(|phase| format!(" {:?}", phase)) - .unwrap_or_default(); - format!("IOTA{version} {connection}{ready}") - } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -254,39 +232,6 @@ impl Screen for MainScreen { fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { self.layout_width.store(rect.width, Ordering::Relaxed); - // A watch Ref blocks senders until it is dropped. Rendering may do - // terminal I/O, so retain only owned snapshots for the whole frame. - let status = self.connection_status_rx.borrow().clone(); - let daemon = self.daemon_status_rx.borrow().clone(); - let status_text = match status { - IpcConnectionState::Connected => "Connected".to_string(), - IpcConnectionState::Connecting => "Connecting...".to_string(), - IpcConnectionState::Reconnecting { attempt } => { - format!("Reconnecting (attempt {})...", attempt) - } - IpcConnectionState::Incompatible { message } => { - format!("Incompatible protocol: {}", message) - } - IpcConnectionState::Failed { message } => { - format!("Connection failed: {}", message) - } - IpcConnectionState::Disconnected => "Disconnected".to_string(), - }; - let readiness = daemon - .startup_phase - .map(|phase| format!("{:?}", phase)) - .unwrap_or_else(|| "Waiting for status".into()); - let health = daemon - .degraded_reason - .as_deref() - .map(|reason| format!(" — {reason}")) - .unwrap_or_default(); - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - let _ = (status_text, readiness, health, version); f.render_widget( ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), rect, @@ -503,9 +448,6 @@ impl Screen for MainScreen { _ => InteractionResult::Unhandled, } } - fn app_title(&self) -> String { - self.status_summary() - } fn key_hints(&self) -> Vec { if self.selected_coords == (2, 0) { vec![ diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index f2e61f5..5299b3b 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -30,6 +30,8 @@ pub enum AppEvent { theme: crate::theme::ThemeName, color: crate::theme::TerminalPolicy, unicode: crate::theme::TerminalPolicy, + cli_output: crate::theme::CliOutputFormat, + cli_require_confirmation: bool, }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), @@ -118,9 +120,6 @@ pub trait Screen: Send + Sync + Any { fn handle_action(&mut self, _action: AppAction) -> InteractionResult { InteractionResult::Unhandled } - fn app_title(&self) -> String { - "IOTA".to_owned() - } fn key_hints(&self) -> Vec { vec![ KeyHint { diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index dd26878..56293c5 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -13,12 +13,14 @@ use crate::{ interaction_result::InteractionResult, render_context::RenderContext, screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, - theme::{TerminalPolicy, ThemeName, UiConfig}, + theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig}, }; #[derive(Clone, Copy, PartialEq, Eq)] enum Focus { Theme, + CliOutput, + CliConfirm, RegenerateKeys, Back, } @@ -34,6 +36,8 @@ pub struct SettingsScreen { message: String, color: TerminalPolicy, unicode: TerminalPolicy, + cli_output: CliOutputFormat, + cli_require_confirmation: bool, focus: Focus, dialog: Option, pending: bool, @@ -45,16 +49,15 @@ impl SettingsScreen { .iter() .position(|theme| *theme == current) .unwrap_or(0); + let config = UiConfig::load_or_default(); Self { selected, saved: current, message: "Left/Right previews. Enter saves.".into(), - color: UiConfig::load() - .map(|config| config.color) - .unwrap_or_default(), - unicode: UiConfig::load() - .map(|config| config.unicode) - .unwrap_or_default(), + color: config.color, + unicode: config.unicode, + cli_output: config.cli_output, + cli_require_confirmation: config.cli_require_confirmation, focus: Focus::Theme, dialog: None, pending: false, @@ -82,7 +85,9 @@ impl SettingsScreen { fn next_focus(&mut self) { self.focus = match self.focus { - Focus::Theme => Focus::RegenerateKeys, + Focus::Theme => Focus::CliOutput, + Focus::CliOutput => Focus::CliConfirm, + Focus::CliConfirm => Focus::RegenerateKeys, Focus::RegenerateKeys => Focus::Back, Focus::Back => Focus::Theme, }; @@ -92,7 +97,9 @@ impl SettingsScreen { self.focus = match self.focus { Focus::Theme => Focus::Back, Focus::Back => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::Theme, + Focus::RegenerateKeys => Focus::CliConfirm, + Focus::CliConfirm => Focus::CliOutput, + Focus::CliOutput => Focus::Theme, }; } @@ -114,24 +121,44 @@ impl SettingsScreen { match self.focus { Focus::Theme => { self.message = "Saving theme…".into(); - let theme = self.selected_theme(); - let color = self.color; - let unicode = self.unicode; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - }) - }), - } + } + Focus::CliOutput => { + self.message = "Output format updated.".into(); + return InteractionResult::Handled; + } + Focus::CliConfirm => { + self.cli_require_confirmation = !self.cli_require_confirmation; + self.message = format!( + "Confirm: {}", + if self.cli_require_confirmation { + "On" + } else { + "Off" + }, + ); + return InteractionResult::Handled; } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); - InteractionResult::Handled + return InteractionResult::Handled; } - Focus::Back => InteractionResult::CloseScreen, + Focus::Back => return InteractionResult::CloseScreen, + } + let theme = self.selected_theme(); + let color = self.color; + let unicode = self.unicode; + let cli_output = self.cli_output; + let cli_require_confirmation = self.cli_require_confirmation; + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + cli_output, + cli_require_confirmation, + }) + }), } } } @@ -152,13 +179,16 @@ impl Screen for SettingsScreen { context: &RenderContext<'_>, hits: &mut HitMap, ) { - let block = Block::default() + let header_block = Block::default() .title(" Settings ") .borders(Borders::ALL) .border_style(context.theme.borders.focused); - let inner = block.inner(area); - frame.render_widget(block, area); - let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); + let inner = header_block.inner(area); + frame.render_widget(header_block, area); + + let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]) + .split(inner); + frame.render_widget( Paragraph::new(format!( "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", @@ -169,18 +199,28 @@ impl Screen for SettingsScreen { " [preview]" }, self.color, - self.unicode + self.unicode, )) .style(context.theme.text.heading), - rows[0], + sections[0], + ); + + let cli_line = format!( + "CLI output: {:?} (L) Confirm: {} (K)", + self.cli_output, + if self.cli_require_confirmation { + "required" + } else { + "disabled" + }, ); let bottom_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]); let lines = vec![ Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(""), + Line::from(Span::styled(&cli_line, context.theme.text.normal)), Line::from("Preview"), Line::from("[OK] Healthy"), Line::from("[WARN] Degraded"), @@ -352,6 +392,23 @@ impl Screen for SettingsScreen { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } + KeyCode::Char('l') | KeyCode::Char('L') => { + self.cli_output = self.cli_output.next(); + self.message = format!("CLI output: {:?}", self.cli_output); + InteractionResult::Handled + } + KeyCode::Char('k') | KeyCode::Char('K') => { + self.cli_require_confirmation = !self.cli_require_confirmation; + self.message = format!( + "CLI confirm: {}", + if self.cli_require_confirmation { + "On" + } else { + "Off" + }, + ); + InteractionResult::Handled + } KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { InteractionResult::CloseScreen } @@ -405,6 +462,10 @@ impl Screen for SettingsScreen { keys: "C/U", action: "Color/Unicode", }, + KeyHint { + keys: "L/K", + action: "CLI Out/Confirm", + }, KeyHint { keys: "Esc/B", action: "Back", diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index ac271a8..fab710c 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -19,7 +19,7 @@ use std::{ any::Any, sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicU8, AtomicUsize, Ordering}, }, }; @@ -56,6 +56,7 @@ pub struct UsersScreen { viewport_height: AtomicUsize, filter: String, filtering: bool, + tick: AtomicU8, } impl UsersScreen { @@ -74,6 +75,7 @@ impl UsersScreen { viewport_height: AtomicUsize::new(1), filter: String::new(), filtering: false, + tick: AtomicU8::new(0), } } @@ -115,7 +117,12 @@ impl UsersScreen { }; if self.loading { - f.render_widget(Paragraph::new("Loading users…"), inner); + const SPINNERS: &[u8] = b"|/-\\"; + let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; + f.render_widget( + Paragraph::new(format!("{ch} Loading users…")), + inner, + ); return; } if visible_indices.is_empty() { diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index 79d1990..26d6046 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -17,6 +17,55 @@ pub struct UiConfig { pub color: TerminalPolicy, #[serde(default)] pub unicode: TerminalPolicy, + /// Default CLI output format for headless commands. + #[serde(default)] + pub cli_output: CliOutputFormat, + /// Whether destructive CLI operations require --yes by default. + #[serde(default = "default_false")] + pub cli_require_confirmation: bool, +} + +fn default_false() -> bool { + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CliOutputFormat { + #[default] + Text, + Json, + Yaml, + Table, +} + +impl CliOutputFormat { + pub fn all() -> &'static [CliOutputFormat] { + &[ + CliOutputFormat::Text, + CliOutputFormat::Json, + CliOutputFormat::Yaml, + CliOutputFormat::Table, + ] + } + + pub fn name(&self) -> &'static str { + match self { + CliOutputFormat::Text => "text", + CliOutputFormat::Json => "json", + CliOutputFormat::Yaml => "yaml", + CliOutputFormat::Table => "table", + } + } + + pub fn next(&self) -> Self { + match self { + CliOutputFormat::Text => CliOutputFormat::Json, + CliOutputFormat::Json => CliOutputFormat::Yaml, + CliOutputFormat::Yaml => CliOutputFormat::Table, + CliOutputFormat::Table => CliOutputFormat::Text, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -28,6 +77,16 @@ pub enum TerminalPolicy { Never, } +impl TerminalPolicy { + pub fn next(&self) -> Self { + match self { + TerminalPolicy::Auto => TerminalPolicy::Always, + TerminalPolicy::Always => TerminalPolicy::Never, + TerminalPolicy::Never => TerminalPolicy::Auto, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DaemonStartPolicy { #[default] @@ -62,8 +121,21 @@ impl UiConfig { pub fn path() -> PathBuf { iota_paths::config_dir().join("ui.yaml") } + + fn fallback_path() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|d| d.join(".config").join("iota").join("ui.yaml")) + } + pub fn load() -> Result { - Self::load_from(&Self::path()) + let path = match (|| std::panic::catch_unwind(|| Self::path()))() { + Ok(path) => path, + Err(_) => Self::fallback_path().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "could not determine config path") + })?, + }; + Self::load_from(&path) } fn load_from(path: &Path) -> Result { @@ -82,6 +154,12 @@ impl UiConfig { fs::write(path, yaml) } + /// Load from config path, or return defaults if the config path can't be resolved. + /// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests). + pub fn load_or_default() -> Self { + Self::load().unwrap_or_default() + } + pub fn resolve_theme(override_theme: Option) -> ThemeName { Self::resolve_theme_from( override_theme, @@ -117,6 +195,33 @@ impl UiConfig { } } } + + /// Resolve the default CLI output format from config file and environment. + /// Priority: IOTA_OUTPUT env var > config file > "text" default. + pub fn resolve_cli_output(&self) -> CliOutputFormat { + if let Ok(value) = std::env::var("IOTA_OUTPUT") { + match value.to_ascii_lowercase().as_str() { + "json" => return CliOutputFormat::Json, + "yaml" | "yml" => return CliOutputFormat::Yaml, + "table" => return CliOutputFormat::Table, + _ => {} + } + } + self.cli_output + } + + /// Resolve the default --yes behavior from config file and environment. + /// Priority: IOTA_YES env var > config file > false default. + pub fn resolve_cli_require_confirmation(&self) -> bool { + if let Ok(value) = std::env::var("IOTA_YES") { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "y" => return false, + "0" | "false" | "no" | "n" => return true, + _ => {} + } + } + self.cli_require_confirmation + } } #[cfg(test)] @@ -173,4 +278,37 @@ mod tests { ThemeName::Ansi ); } + + #[test] + fn cli_output_defaults_to_text() { + let config = UiConfig::default(); + assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text); + } + + #[test] + fn cli_output_cycles_through_variants() { + assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json); + assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml); + assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table); + assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text); + } + + #[test] + fn require_confirmation_defaults_to_false() { + let config = UiConfig::default(); + assert!(!config.resolve_cli_require_confirmation()); + } + + #[test] + fn cli_output_serializes_roundtrip() { + let config = UiConfig { + cli_output: CliOutputFormat::Table, + cli_require_confirmation: true, + ..Default::default() + }; + let yaml = serde_yaml::to_string(&config).unwrap(); + let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(loaded.cli_output, CliOutputFormat::Table); + assert!(loaded.cli_require_confirmation); + } } diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index d185c6f..3154704 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,7 +3,7 @@ mod model; mod name; mod presets; -pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; +pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 90d0b0b..b06dab1 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,8 +1,10 @@ use crate::{ controls::header::render_header, + help_overlay::HelpOverlay, input_handler::setup_input_handler, interaction_result::InteractionResult, - ipc_client::IpcClient, + ipc_client::{DaemonStatus, IpcClient, IpcConnectionState}, + notification::{Notification, render_notification_area}, render_context::RenderContext, screens::{ main_screen::MainScreen, @@ -21,7 +23,7 @@ use once_cell::sync::Lazy; use ratatui::{ Terminal, backend::CrosstermBackend, - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, }; use std::{ io, @@ -53,6 +55,7 @@ pub struct UI { app_event_tx: mpsc::UnboundedSender, app_event_rx: Mutex>>, header_focus: Mutex>, + notifications: Arc>>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -237,6 +240,7 @@ impl UI { app_event_tx, app_event_rx: Mutex::new(Some(app_event_rx)), header_focus: Mutex::new(None), + notifications: Arc::new(Mutex::new(Vec::new())), }) } @@ -284,6 +288,27 @@ impl UI { self.cancellation.clone() } + pub async fn push_notification(&self, notification: Notification) { + if let Ok(mut notifications) = self.notifications.lock() { + notifications.push(notification); + self.invalidate(); + } + } + + pub async fn clear_expired_notifications(&self) { + if let Ok(mut notifications) = self.notifications.lock() { + let before = notifications.len(); + notifications.retain(|n| !n.is_expired()); + if notifications.len() != before { + self.invalidate(); + } + } + } + + pub async fn notifications(&self) -> Vec { + self.notifications.lock().map(|n| n.clone()).unwrap_or_default() + } + pub async fn set_screen(&self, screen: Box) { self.screen_stack.write().await.push(screen); self.invalidate(); @@ -332,6 +357,8 @@ impl UI { theme, color, unicode, + cli_output, + cli_require_confirmation, }) = &event { self.set_theme(theme::resolve(*theme)).await; @@ -339,6 +366,8 @@ impl UI { config.theme = *theme; config.color = *color; config.unicode = *unicode; + config.cli_output = *cli_output; + config.cli_require_confirmation = *cli_require_confirmation; let result = config .save() .map_err(|error| format!("Could not save UI settings: {error}")); @@ -385,6 +414,18 @@ impl UI { self.invalidate(); return; } + if key.code == KeyCode::Char('?') { + let has_help_overlay = self + .screen_stack + .read() + .await + .iter() + .any(|s| s.as_any().downcast_ref::().is_some()); + if !has_help_overlay { + self.set_screen(Box::new(HelpOverlay::new())).await; + } + return; + } if header_is_focused { let mut action = None; if let Ok(mut focus) = self.header_focus.lock() { @@ -551,35 +592,61 @@ impl UI { self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) .await; let sender = self.app_event_tx.clone(); + let ui = self.clone(); tokio::spawn(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - }) - .collect()) + let load = async { + match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|u| UserEntry { + user_id: u.user_id, + username: u.username, + }) + .collect()) + } + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot load users: {error}")) + } + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) + }; + tokio::pin!(load); + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let result = loop { + tokio::select! { + result = &mut load => break result, + _ = ticker.tick() => { + ui.invalidate(); + } } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), - Err(error) => Err(format!("Cannot load users: {error}")), }; let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); }); } pub async fn render(&self) -> io::Result<()> { + self.clear_expired_notifications().await; let theme = self.theme.read().await.clone(); let context = RenderContext { theme: theme.as_ref(), }; // The renderer is the only task that takes the terminal lock. Screen // mutations use the stack lock briefly before invalidating a frame. - if let Some(screen) = self.screen_stack.read().await.last() { + let stack_guard = self.screen_stack.read().await; + let (connection, daemon) = stack_guard + .iter() + .find_map(|item| item.as_any().downcast_ref::()) + .map(|main| { + ( + main.connection_status().borrow().clone(), + main.daemon_status().borrow().clone(), + ) + }) + .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); + if let Some(screen) = stack_guard.last() { let mut terminal = self .terminal .lock() @@ -592,22 +659,12 @@ impl UI { Constraint::Length(1), ]) .split(f.area()); - let header_title = self - .screen_stack - .try_read() - .ok() - .and_then(|stack| { - stack - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| main.app_title()) - }) - .unwrap_or_else(|| screen.app_title()); let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); render_header( f, rows[0], - &header_title, + &connection, + &daemon, context.theme, &mut hits, header_focus, @@ -615,12 +672,15 @@ impl UI { let hints = if header_focus.is_some() { " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() } else { - screen + let mut screen_hints: Vec = screen .key_hints() .into_iter() .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect::>() - .join(" ") + .collect(); + if !screen_hints.iter().any(|h| h.contains("?")) { + screen_hints.push("?: Help".to_owned()); + } + screen_hints.join(" ") }; f.render_widget( ratatui::widgets::Paragraph::new(format!(" {hints}")).style( @@ -633,6 +693,18 @@ impl UI { rows[2], ); screen.render(f, rows[1], &context, &mut hits); + + if let Ok(notifications) = self.notifications.try_lock() { + if !notifications.is_empty() { + let notification_area = Rect { + x: rows[1].x + rows[1].width.saturating_sub(40), + y: rows[1].y, + width: 40.min(rows[1].width), + height: 3.min(rows[1].height), + }; + render_notification_area(f, notification_area, ¬ifications, context.theme); + } + } })?; if let Ok(mut current) = self.hits.lock() { *current = hits; diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 470e514..666bffe 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -44,7 +44,9 @@ async fn settings_preview_and_save_emit_typed_application_events() { UiEvent::App(AppEvent::SaveSettings { theme: ThemeName::Surface, color: _, - unicode: _ + unicode: _, + cli_output: _, + cli_require_confirmation: _ }) )); } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index a90e106..48c8ead 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,5 +1,5 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; -use iota_cli::theme::ThemeName; +use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; use iota_terms::TermsType; #[derive(Debug)] @@ -23,6 +23,7 @@ pub enum OutputFormat { Text, Json, Yaml, + Table, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -43,6 +44,17 @@ impl From for ThemeName { } } +impl From for OutputFormat { + fn from(value: CliOutputFormat) -> Self { + match value { + CliOutputFormat::Text => OutputFormat::Text, + CliOutputFormat::Json => OutputFormat::Json, + CliOutputFormat::Yaml => OutputFormat::Yaml, + CliOutputFormat::Table => OutputFormat::Table, + } + } +} + #[derive(Parser, Debug)] #[command( name = "iota", @@ -312,6 +324,11 @@ impl CliInvocation { if args.as_slice() == ["help"] { return Ok(Self::special(Command::Help)); } + + let config = UiConfig::load_or_default(); + let config_output: OutputFormat = config.cli_output.into(); + let require_confirmation = config.resolve_cli_require_confirmation(); + let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { match error.kind() { @@ -326,6 +343,20 @@ impl CliInvocation { Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), Err(error) => return Err(error), }; + + let output = if parsed.output == OutputFormat::Text { + config_output + } else { + parsed.output + }; + + let resolve_confirmed = |yes_flag: bool| -> bool { + if yes_flag { + return true; + } + !require_confirmation + }; + let command = match parsed.command { None => Command::Dashboard, Some(CliCommand::Status) => Command::Status, @@ -339,7 +370,7 @@ impl CliInvocation { UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, - confirmed: yes, + confirmed: resolve_confirmed(yes), }, UsersAction::Import { username } => Command::UsersImport { username }, }, @@ -348,14 +379,18 @@ impl CliInvocation { OmikronAction::Status => Command::OmikronStatus, }, Some(CliCommand::Identity(identity)) => match identity.action { - IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes }, + IdentityAction::Rotate { yes } => Command::IdentityRotate { + confirmed: resolve_confirmed(yes), + }, }, Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload, }, - Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, + Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { + confirmed: resolve_confirmed(yes), + }, Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck, @@ -371,8 +406,12 @@ impl CliInvocation { TermsAction::Accept { system } => Command::TermsAccept { system }, }, Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, - DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, + DaemonAction::Restart { yes } => Command::DaemonRestart { + confirmed: resolve_confirmed(yes), + }, + DaemonAction::Stop { yes } => Command::DaemonStop { + confirmed: resolve_confirmed(yes), + }, DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, DaemonAction::Status => Command::DaemonDaemonStatus, @@ -385,7 +424,7 @@ impl CliInvocation { }; Ok(Self { theme_override: parsed.theme.map(Into::into), - output: parsed.output, + output, color: if parsed.no_color { CapabilityPolicy::Never } else { @@ -519,7 +558,12 @@ mod tests { #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); + assert_eq!( + invocation.command, + Command::DaemonStop { + confirmed: true + } + ); } #[test] @@ -560,7 +604,7 @@ mod tests { invocation.command, Command::UsersRemove { user_id: 42, - confirmed: false, + confirmed: true, } ); } @@ -590,7 +634,7 @@ mod tests { let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); assert_eq!( invocation.command, - Command::IdentityRotate { confirmed: false } + Command::IdentityRotate { confirmed: true } ); let invocation = CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs new file mode 100644 index 0000000..2f7cb38 --- /dev/null +++ b/iota/src/cli_color.rs @@ -0,0 +1,105 @@ +use std::env; + +#[derive(Debug, Clone, Copy)] +pub struct ColorConfig { + pub enabled: bool, +} + +impl Default for ColorConfig { + fn default() -> Self { + Self::new() + } +} + +impl ColorConfig { + pub fn new() -> Self { + let enabled = env::var("NO_COLOR").is_err() + && env::var("TERM") + .map(|t| t != "dumb") + .unwrap_or(true); + Self { enabled } + } + + pub fn colorize(&self, text: &str, style: Style) -> String { + if !self.enabled { + return text.to_string(); + } + format!("{}{}\x1b[0m", style.prefix(), text) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub bold: bool, +} + +impl Style { + pub const fn new() -> Self { + Self { + fg: None, + bg: None, + bold: false, + } + } + + pub const fn fg(mut self, color: u8) -> Self { + self.fg = Some(color); + self + } + + pub const fn bold(mut self) -> Self { + self.bold = true; + self + } + + fn prefix(&self) -> String { + let mut codes = Vec::new(); + if self.bold { + codes.push("1".to_string()); + } + if let Some(fg) = self.fg { + codes.push(format!("3{}", fg)); + } + if let Some(bg) = self.bg { + codes.push(format!("4{}", bg)); + } + if codes.is_empty() { + String::new() + } else { + format!("\x1b[{}m", codes.join(";")) + } + } +} + +pub const SUCCESS: Style = Style::new().fg(2); +pub const WARNING: Style = Style::new().fg(3); +pub const ERROR: Style = Style::new().fg(1); +pub const INFO: Style = Style::new().fg(4); +pub const MUTED: Style = Style::new().fg(8); +pub const HEADING: Style = Style::new().bold(); + +pub fn success(config: &ColorConfig, text: &str) -> String { + config.colorize(text, SUCCESS) +} + +pub fn warning(config: &ColorConfig, text: &str) -> String { + config.colorize(text, WARNING) +} + +pub fn error(config: &ColorConfig, text: &str) -> String { + config.colorize(text, ERROR) +} + +pub fn info(config: &ColorConfig, text: &str) -> String { + config.colorize(text, INFO) +} + +pub fn muted(config: &ColorConfig, text: &str) -> String { + config.colorize(text, MUTED) +} + +pub fn heading(config: &ColorConfig, text: &str) -> String { + config.colorize(text, HEADING) +} diff --git a/iota/src/main.rs b/iota/src/main.rs index 1f19d89..192f1c8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -7,12 +7,14 @@ use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; mod cli_args; +mod cli_color; mod daemon_setup_flow; mod local_daemon; mod startup_error; mod terms; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; +use cli_color::ColorConfig; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -21,7 +23,7 @@ async fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(error) => { if !matches!(error, StartupError::Cancelled) { - eprintln!("{error}"); + startup_error::print_error(&error); } startup_error::exit_code(&error) } @@ -383,7 +385,93 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - println!("{}", CliInvocation::help_text()); + let color = cli_color::ColorConfig::new(); + println!( + "{}", + cli_color::heading(&color, "Iota Operator Console") + ); + println!(); + println!("Usage: iota [OPTIONS] [COMMAND]"); + println!(); + println!( + "{}", + cli_color::info(&color, "Commands:") + ); + println!(" (no command) Launch the interactive dashboard"); + println!(" status Show daemon status"); + println!(" tasks List active tasks"); + println!(" users list List all users"); + println!(" users show Show user details"); + println!(" users add Create a new user"); + println!(" users remove Remove a user (requires --yes)"); + println!(" omikron status Show Omikron connection status"); + println!(" omikron reconnect Reconnect to Omikron"); + println!(" identity rotate Rotate identity keys (requires --yes)"); + println!(" config get Show current configuration"); + println!(" config set Set a configuration value"); + println!(" config reload Reload configuration"); + println!(" components Show component health"); + println!(" logs [--limit N] Show recent log entries"); + println!(" update check Check for updates"); + println!(" community list List communities"); + println!(" terms status Show terms acceptance status"); + println!(" terms show Show a terms document"); + println!(" terms accept Accept required terms"); + println!(" daemon restart Restart the daemon (requires --yes)"); + println!(" daemon stop Stop the daemon (requires --yes)"); + println!(" daemon enable Enable daemon at startup"); + println!(" daemon disable-startup Disable daemon at startup"); + println!(" daemon startup-status Show startup configuration"); + println!(" daemon start Start the daemon"); + println!(" daemon restart-service Restart the daemon service"); + println!(" daemon stop-service Stop the daemon service"); + println!(" daemon install Install from a bundle"); + println!(" help Show this help message"); + println!(" completions Generate shell completions"); + println!(" man Show the man page"); + println!(); + println!( + "{}", + cli_color::info(&color, "Options:") + ); + println!(" --theme Theme: monospace, binary, ansi, surface"); + println!(" --output Output format: text, json, yaml, table"); + println!(" --color Color: auto, always, never"); + println!(" --unicode Unicode: auto, always, never"); + println!(" --no-color Disable colored output"); + println!(" --yes, -y Confirm destructive operations"); + println!(" -h, --help Show help"); + println!(" -V, --version Show version"); + println!(); + println!( + "{}", + cli_color::info(&color, "Examples:") + ); + println!(" iota Launch the interactive dashboard"); + println!(" iota status Show daemon status"); + println!(" iota users list --output=json List users in JSON format"); + println!(" iota users add alice Create a user named 'alice'"); + println!(" iota users remove 42 --yes Remove user 42"); + println!(" iota config get --output=yaml Show config in YAML format"); + println!(" iota logs --limit 50 Show last 50 log entries"); + println!(" iota completions bash Generate bash completions"); + println!(); + println!( + "{}", + cli_color::info(&color, "Exit Codes:") + ); + println!(" 0 Success"); + println!(" 1 General error"); + println!(" 2 Invalid command or arguments"); + println!(" 130 Interrupted (Ctrl+C)"); + println!(); + println!( + "{}", + cli_color::muted(&color, "Environment Variables:") + ); + println!(" NO_COLOR Disable colored output when set"); + println!(" TERM Terminal type (dumb disables colors)"); + println!(" IOTA_THEME Default theme override"); } fn print_completions(shell: &str) -> Result<(), StartupError> { @@ -422,14 +510,47 @@ fn print_man_page() { println!(".TH IOTA 1"); println!(".SH NAME\n iota \\- Iota operator console"); println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); + println!(".SH DESCRIPTION"); + println!("Iota is the operator console for managing Iota daemon instances."); + println!("It provides both an interactive dashboard and headless CLI commands."); println!(".SH COMMANDS"); for command in CliInvocation::command_paths() { println!(".TP\n.B {command}"); } println!(".SH GLOBAL OPTIONS"); - println!(".TP\n.B --output text|json|yaml"); + println!(".TP\n.B --output text|json|yaml|table"); + println!("Set the output format for headless commands."); println!(".TP\n.B --color auto|always|never"); + println!("Control colored output."); println!(".TP\n.B --unicode auto|always|never"); + println!("Control Unicode character rendering."); + println!(".TP\n.B --yes, -y"); + println!("Confirm destructive operations without prompting."); + println!(".SH EXIT CODES"); + println!(".TP\n.B 0"); + println!("Success"); + println!(".TP\n.B 1"); + println!("General error"); + println!(".TP\n.B 2"); + println!("Invalid command or arguments"); + println!(".TP\n.B 130"); + println!("Interrupted (Ctrl+C)"); + println!(".SH EXAMPLES"); + println!(".TP\n.B iota"); + println!("Launch the interactive dashboard"); + println!(".TP\n.B iota status"); + println!("Show daemon status"); + println!(".TP\n.B iota users list --output=json"); + println!("List users in JSON format"); + println!(".TP\n.B iota users add alice"); + println!("Create a user named 'alice'"); + println!(".SH ENVIRONMENT"); + println!(".TP\n.B NO_COLOR"); + println!("Disable colored output when set"); + println!(".TP\n.B TERM"); + println!("Terminal type (dumb disables colors)"); + println!(".TP\n.B IOTA_THEME"); + println!("Default theme override"); } async fn run_command( @@ -437,6 +558,7 @@ async fn run_command( command: Command, output: OutputFormat, ) -> Result<(), StartupError> { + let color = ColorConfig::new(); let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, @@ -508,18 +630,31 @@ async fn run_command( } match payload { ResponsePayload::Status(status) => { - print!("Phase: {}", status.phase); + let phase_color = if status.degraded_reason.is_some() { + cli_color::WARNING + } else { + cli_color::SUCCESS + }; + print!( + "{} {}", + cli_color::info(&color, "Phase:"), + color.colorize(&status.phase, phase_color) + ); if !status.tasks.is_empty() { - print!(", Tasks: {}", status.tasks.join(", ")); + print!( + ", {} {}", + cli_color::info(&color, "Tasks:"), + status.tasks.join(", ") + ); } if let Some(reason) = status.degraded_reason { - print!(", Degraded: {}", reason); + print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason); } println!(); } ResponsePayload::Tasks(tasks) => { if tasks.is_empty() { - println!("No active tasks."); + println!("{}", cli_color::muted(&color, "No active tasks.")); } else { for task in &tasks { println!("{}", task.name); @@ -528,18 +663,31 @@ async fn run_command( } ResponsePayload::Users(users) => { if users.is_empty() { - println!("No users."); + println!("{}", cli_color::muted(&color, "No users.")); } else { for user in &users { - println!("{} ({})", user.username, user.user_id); + println!( + "{} ({})", + cli_color::heading(&color, &user.username), + user.user_id + ); } } } ResponsePayload::UserCreated { user_id, username } => { - println!("Created user {} ({})", username, user_id); + println!( + "{} {} ({})", + cli_color::success(&color, "Created user"), + cli_color::heading(&color, &username), + user_id + ); } ResponsePayload::UserRemoved { user_id } => { - println!("Removed user {}", user_id); + println!( + "{} {}", + cli_color::warning(&color, "Removed user"), + user_id + ); } ResponsePayload::Acknowledged { message } => { println!("{}", message); @@ -551,32 +699,57 @@ async fn run_command( println!("{}", config.yaml); } ResponsePayload::OmikronStatus(status) => { - println!("Connected: {}", status.connected); + println!( + "{}: {}", + cli_color::info(&color, "Connected"), + status.connected + ); if let Some(id) = status.iota_id { - println!("Iota ID: {}", id); + println!( + "{}: {}", + cli_color::info(&color, "Iota ID"), + id + ); } } ResponsePayload::Components(components) => { if components.is_empty() { - println!("No component health data available."); + println!( + "{}", + cli_color::muted(&color, "No component health data available.") + ); } else { for comp in &components { - let status_str = match comp.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", + let (status_str, style) = match comp.status { + iota_ipc::HealthStatus::Healthy => { + ("healthy", cli_color::SUCCESS) + } + iota_ipc::HealthStatus::Degraded => { + ("degraded", cli_color::WARNING) + } + iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR), }; let suffix = comp .message .as_deref() .map(|m| format!(" ({m})")) .unwrap_or_default(); - println!("{:?}: {}{}", comp.id, status_str, suffix); + println!( + "{:?}: {}{}", + comp.id, + color.colorize(status_str, style), + suffix + ); } } } ResponsePayload::UserDetail(user) => { - println!("User: {} ({})", user.username, user.user_id); + println!( + "{}: {} ({})", + cli_color::info(&color, "User"), + cli_color::heading(&color, &user.username), + user.user_id + ); if let Some(ref name) = user.display_name { println!("Display Name: {name}"); } @@ -588,23 +761,42 @@ async fn run_command( ResponsePayload::LogEntries(logs) => { for entry in &logs.entries { let ts = entry.timestamp_ms; - let level = if entry.is_error { "ERR" } else { "INF" }; - println!("[{ts}] {level} {}: {}", entry.sender, entry.message); + let (level, style) = if entry.is_error { + ("ERR", cli_color::ERROR) + } else { + ("INF", cli_color::INFO) + }; + println!( + "[{ts}] {} {}: {}", + color.colorize(level, style), + entry.sender, + entry.message + ); } } ResponsePayload::UpdateStatus(status) => { if status.available { - println!("Update available."); + println!( + "{}", + cli_color::success(&color, "Update available.") + ); } else { - println!("Up to date."); + println!( + "{}", + cli_color::info(&color, "Up to date.") + ); } } ResponsePayload::Communities(communities) => { if communities.is_empty() { - println!("No communities."); + println!("{}", cli_color::muted(&color, "No communities.")); } else { for c in &communities { - println!("{} ({})", c.title, c.name); + println!( + "{} ({})", + cli_color::heading(&color, &c.title), + c.name + ); } } } @@ -634,7 +826,130 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result< "Cannot encode YAML output: {error}" )))? ), + OutputFormat::Table => render_table(payload), OutputFormat::Text => unreachable!(), } Ok(()) } + +fn render_table(payload: &ResponsePayload) { + match payload { + ResponsePayload::Users(users) => { + if users.is_empty() { + println!("No users."); + return; + } + println!("{:<8} {}", "ID", "USERNAME"); + println!("{:<8} {}", "--------", "--------"); + for user in users { + println!("{:<8} {}", user.user_id, user.username); + } + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + println!("No active tasks."); + return; + } + println!("{}", "NAME"); + println!("{}", "--------"); + for task in tasks { + println!("{}", task.name); + } + } + ResponsePayload::Components(components) => { + if components.is_empty() { + println!("No component health data available."); + return; + } + println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE"); + println!("{:<20} {:<10} {}", "--------", "--------", "--------"); + for comp in components { + let status_str = match comp.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + let message = comp.message.as_deref().unwrap_or("-"); + println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message); + } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + println!("No communities."); + return; + } + println!("{:<20} {}", "NAME", "TITLE"); + println!("{:<20} {}", "--------", "--------"); + for c in communities { + println!("{:<20} {}", c.name, c.title); + } + } + ResponsePayload::LogEntries(logs) => { + if logs.entries.is_empty() { + println!("No log entries."); + return; + } + println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"); + println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------"); + for entry in &logs.entries { + let level = if entry.is_error { "ERR" } else { "INF" }; + println!( + "{:<20} {:<6} {:<12} {}", + entry.timestamp_ms, level, entry.sender, entry.message + ); + } + } + ResponsePayload::Status(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Phase", status.phase); + if !status.tasks.is_empty() { + println!("{:<15} {}", "Tasks", status.tasks.join(", ")); + } + if let Some(reason) = &status.degraded_reason { + println!("{:<15} {}", "Degraded", reason); + } + } + ResponsePayload::DaemonStatus(status) => { + println!("{}", status.formatted); + } + ResponsePayload::Config(config) => { + println!("{}", config.yaml); + } + ResponsePayload::OmikronStatus(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Connected", status.connected); + if let Some(id) = &status.iota_id { + println!("{:<15} {}", "Iota ID", id); + } + } + ResponsePayload::UpdateStatus(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Available", status.available); + } + ResponsePayload::UserCreated { user_id, username } => { + println!("Created user {} ({})", username, user_id); + } + ResponsePayload::UserRemoved { user_id } => { + println!("Removed user {}", user_id); + } + ResponsePayload::Acknowledged { message } => { + println!("{}", message); + } + ResponsePayload::UserDetail(user) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Username", user.username); + println!("{:<15} {}", "User ID", user.user_id); + if let Some(ref name) = user.display_name { + println!("{:<15} {}", "Display Name", name); + } + println!("{:<15} {}", "Created At", user.created_at); + if !user.trusted_apps.is_empty() { + println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", ")); + } + } + } +} diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs index 461e272..583c281 100644 --- a/iota/src/startup_error.rs +++ b/iota/src/startup_error.rs @@ -28,6 +28,52 @@ impl StartupError { _ => 1, } } + + pub fn suggestion(&self) -> Option<&'static str> { + match self { + Self::DaemonExecutableMissing(_) => { + Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.") + } + Self::LocalSocketNotWritable(_, _) => { + Some("Check permissions on the parent directory or run as your user (not root).") + } + Self::SystemManagerUnavailable => { + Some("Install systemd or another supported process manager.") + } + Self::SystemPermissionDenied(_) => { + Some("Run with appropriate privileges or use a user-level daemon instead.") + } + Self::SocketPermissionDenied(_) => { + Some( + "Check file permissions on the socket or ensure the daemon is running as your user.", + ) + } + Self::IpcTimedOut(_) => { + Some( + "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", + ) + } + Self::ProtocolMismatch { .. } => { + Some("Update your CLI or daemon to match versions.") + } + Self::DaemonExited { .. } => { + Some("Restart the daemon with `iota daemon restart`.") + } + Self::IpcBindUnavailable(_) => { + Some("Another instance may be running. Stop it first or use a different socket path.") + } + Self::Terminal(_) => { + Some("Use a terminal that supports interactive mode, or run commands headlessly.") + } + Self::Consent(_) => { + Some("Run `iota terms accept` in an interactive terminal to review and accept terms.") + } + Self::InvalidCommand(_) => { + Some("Run `iota --help` to see available commands.") + } + _ => None, + } + } } impl fmt::Display for StartupError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -75,6 +121,18 @@ pub fn exit_code(error: &StartupError) -> ExitCode { ExitCode::from(error.exit_code()) } +pub fn print_error(error: &StartupError) { + let color = crate::cli_color::ColorConfig::new(); + eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error); + if let Some(suggestion) = error.suggestion() { + eprintln!( + " {} {}", + crate::cli_color::info(&color, "hint:"), + suggestion + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -89,4 +147,10 @@ mod tests { assert!(error.to_string().contains("authorization")); assert!(error.to_string().contains("administrator")); } + #[test] + fn most_errors_have_suggestions() { + assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some()); + assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some()); + assert!(StartupError::Cancelled.suggestion().is_none()); + } } From 1de479ce8de29db8fc0720bfa13f42e8217cfdef Mon Sep 17 00:00:00 2001 From: Alois Date: Tue, 28 Jul 2026 21:05:25 +0200 Subject: [PATCH 097/119] (feat): update mtp --- Cargo.lock | 34 +++++----- client/src/client_connection.rs | 32 +-------- communities/src/community_connection.rs | 19 ------ iota-daemon-lib/src/command_router.rs | 19 ------ mtp-type-maps | 2 +- omikron-connector/src/lib.rs | 1 - omikron-connector/src/omikron_connection.rs | 75 +++++++-------------- omikron-connector/src/ping_pong_task.rs | 44 ------------ 8 files changed, 46 insertions(+), 180 deletions(-) delete mode 100644 omikron-connector/src/ping_pong_task.rs diff --git a/Cargo.lock b/Cargo.lock index 7d6d7af..32418ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,13 +1266,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1987,7 +1987,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#bcf8aee3716f1690f1284748ac1ff3cd22799fb0" +source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" dependencies = [ "async-trait", "bytes", @@ -3810,7 +3810,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -3851,7 +3851,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -4835,7 +4835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index da728d4..cda5cf8 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -25,7 +25,6 @@ pub struct ClientConnection { sender: Arc>>>, receiver: Receiver, connection_loop_handle: Arc>>>, - pub ping: Arc>, pub connection_id: Uuid, shutdown_tx: Arc>>>, pub waiting_tasks: @@ -38,7 +37,6 @@ impl ClientConnection { sender: Arc>>>, receiver: Receiver, connection_loop_handle: Arc>>>, - ping: Arc>, connection_id: Uuid, shutdown_tx: Arc>>>, waiting_tasks: DashMap< @@ -51,7 +49,6 @@ impl ClientConnection { sender, receiver, connection_loop_handle, - ping, connection_id, shutdown_tx, waiting_tasks, @@ -96,34 +93,11 @@ impl ClientConnection { // ------------------------------------------------------------------------- // Message Handling // ------------------------------------------------------------------------- - async fn handle_ping(self: Arc, cv: CommunicationValue) { - // Update our ping if provided - if let DataValue::SignedNumber(last_ping) = cv.get_data(DataType::LastPing) { - let current = now_millis_i64(); - let mut ping_guard = self.ping.write().await; - *ping_guard = current - *last_ping as i64; - } - - // Send pong response - let response = CommunicationValue::new(CommunicationType::Pong) - .with_id(cv.get_id()) - .add_typed_default(DataType::PingIota, DataValue::SignedNumber(0)); - - self.send_message(&response).await; - } - pub async fn handle_message(self: Arc, cv: CommunicationValue) { - if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { - log_cv_in!(&cv); - } + log_cv_in!(&cv); let _msg_id = cv.get_id(); - if cv.is_type(CommunicationType::Ping) { - self.handle_ping(cv).await; - return; - } - if cv.is_type(CommunicationType::Challenge) { self.handle_challenge(&cv).await; return; @@ -566,9 +540,7 @@ impl ClientConnection { let sender_clone = Arc::clone(sender); drop(sender_guard); - if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { - log_cv_out!(&cv); - } + log_cv_out!(&cv); if let Err(e) = sender_clone.send(cv).await { return Err(e.to_string()); diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 3ceca2b..36a23c0 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -30,7 +30,6 @@ pub struct CommunityConnection { challenged: Arc>, challenge: Arc>, auth: Arc>>, - pub ping: Arc>, } impl CommunityConnection { pub fn new( @@ -47,7 +46,6 @@ impl CommunityConnection { challenged: Arc::new(RwLock::new(false)), challenge: Arc::new(RwLock::new(String::new())), auth: Arc::new(RwLock::new(None)), - ping: Arc::new(RwLock::new(-1)), }) } pub async fn send_message(&self, message: &CommunicationValue) { @@ -84,11 +82,6 @@ impl CommunityConnection { return; } - if cv.is_type(CommunicationType::Ping) { - self.handle_ping(cv).await; - return; - } - if cv.is_type(CommunicationType::ClientChanged) { //self.handle_client_changed(cv).await; return; @@ -381,16 +374,4 @@ impl CommunityConnection { } } - async fn handle_ping(&self, cv: CommunicationValue) { - if let Some(last_ping) = cv.get_data(DataType::LastPing) { - if let Ok(ping_val) = last_ping.to_string().parse::() { - let mut ping_guard = self.ping.write().await; - *ping_guard = ping_val; - } - } - - let response = CommunicationValue::new(CommunicationType::Pong).with_id(cv.get_id()); - - self.send_message(&response).await; - } } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index b47366a..58e627d 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -11,7 +11,6 @@ use iota_storage::users::user_manager; use iota_storage::util::config_util::{self}; use mtp::codec::{CommunicationType, CommunicationValue}; use std::sync::{Arc, Mutex}; -use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -295,22 +294,4 @@ impl CommandRouter { } } } - - pub async fn ping(&self, seconds: u64) -> Result { - let response = self - .services - .omikron - .await_response( - &CommunicationValue::new(CommunicationType::Ping), - Duration::from_secs(seconds), - ) - .await; - match response { - Ok(value) => { - log!("{}", iota_logger::format_cv(&value)); - Ok("Ping response received".into()) - } - Err(error) => Err(format!("Ping error: {error:?}")), - } - } } diff --git a/mtp-type-maps b/mtp-type-maps index 594646a..f430cd3 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 594646ac39d986f0787aa614a99d580035a67318 +Subproject commit f430cd358b3d07a4cfd6982eb8917fec80c24a7d diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index cce748b..69044c0 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -1,7 +1,6 @@ pub mod client; pub mod omega_discovery; pub mod omikron_connection; -pub mod ping_pong_task; pub mod user_ops; pub use client::{OmikronClient, OmikronError, OmikronStartupError}; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e7e7b2c..e132b37 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -11,7 +11,6 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; @@ -110,10 +109,10 @@ const OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb"; const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); -const MAX_MISSED_PONGS: u32 = 3; +const MAX_MISSED_PINGS: usize = 3; const MAX_CONCURRENT_HANDLERS: usize = 20; // ============================================================================ @@ -168,14 +167,13 @@ pub struct OmikronConnection { sender: Arc>>>, connection_loop_handle: Arc>>>, pub last_ping: Arc>, - heartbeat_handle: Arc>>>, + maintenance_handle: Arc>>>, pub connection_id: Uuid, shutdown_tx: Arc>>>, reconnect_on_close: Arc>, auth_failure: Arc>>, pub app_challenges: Arc>, pub app_sessions: Arc>, - pub(crate) missed_pongs: Arc, handler_semaphore: Arc, cancellation: CancellationToken, pub(crate) active_tasks: Arc>, @@ -201,14 +199,13 @@ impl OmikronConnection { sender: Arc::new(RwLock::new(None)), connection_loop_handle: Arc::new(Mutex::new(None)), last_ping: Arc::new(Mutex::new(-1)), - heartbeat_handle: Arc::new(Mutex::new(None)), + maintenance_handle: Arc::new(Mutex::new(None)), connection_id: Uuid::new_v4(), shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), auth_failure: Arc::new(RwLock::new(None)), app_challenges: Arc::new(DashMap::new()), app_sessions: Arc::new(DashMap::new()), - missed_pongs: Arc::new(AtomicU32::new(0)), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), cancellation, active_tasks, @@ -267,7 +264,7 @@ impl OmikronConnection { handle.abort(); } - if let Some(handle) = self.heartbeat_handle.lock().await.take() { + if let Some(handle) = self.maintenance_handle.lock().await.take() { handle.abort(); } @@ -363,7 +360,9 @@ impl OmikronConnection { persistent_stream_max_retries: 5, persistent_stream_retry_backoff: Duration::from_secs(5), max_frames_per_stream: None, - }); + }) + .with_ping_interval(MAINTENANCE_INTERVAL) + .with_max_missed_pings(MAX_MISSED_PINGS); let connection = match Client::auth_connect_or_register( client_config, @@ -402,18 +401,18 @@ impl OmikronConnection { // Start read loop let connection = Arc::new(connection); let read_self = self.clone(); + let read_connection = connection.clone(); let read_handle = tokio::spawn(async move { - read_self.read_loop(connection).await; + read_self.read_loop(read_connection).await; }); log_t!("omikron_authenticated"); - // Start heartbeat - let heartbeat_self = self.clone(); - let heartbeat_handle = tokio::spawn(async move { - heartbeat_self.heartbeat_loop().await; + let maintenance_self = self.clone(); + let maintenance_handle = tokio::spawn(async move { + maintenance_self.maintenance_loop(connection).await; }); - *self.heartbeat_handle.lock().await = Some(heartbeat_handle); + *self.maintenance_handle.lock().await = Some(maintenance_handle); { self.active_tasks.insert("Omikron Listener".to_string()); @@ -427,7 +426,7 @@ impl OmikronConnection { self.active_tasks.remove("Omikron Listener"); } - if let Some(handle) = self.heartbeat_handle.lock().await.take() { + if let Some(handle) = self.maintenance_handle.lock().await.take() { handle.abort(); } @@ -589,7 +588,7 @@ impl OmikronConnection { } // ------------------------------------------------------------------------- - // Read Loop & Heartbeat + // Read Loop & Maintenance // ------------------------------------------------------------------------- async fn read_loop(self: Arc, connection: Arc) { @@ -603,11 +602,6 @@ impl OmikronConnection { continue; } } - if cv.is_type(CommunicationType::Pong) { - self.handle_pong(&cv).await; - continue; - } - let permit = self.handler_semaphore.clone().acquire_owned().await; let self_clone = self.clone(); tokio::spawn(async move { @@ -635,9 +629,9 @@ impl OmikronConnection { } } - async fn heartbeat_loop(self: Arc) { + async fn maintenance_loop(self: Arc, connection: Arc) { loop { - sleep(HEARTBEAT_INTERVAL).await; + sleep(MAINTENANCE_INTERVAL).await; if !self.state.read().await.is_connected() { break; @@ -651,19 +645,13 @@ impl OmikronConnection { break; } - if self.missed_pongs.load(Ordering::Relaxed) > MAX_MISSED_PONGS { - log!( - "Connection appears dead ({} consecutive missed pongs), closing sender", - self.missed_pongs.load(Ordering::Relaxed) - ); - if let Some(sender) = self.sender.read().await.as_ref() { - sender.close().await; - } - break; + if let Some(ping) = connection.get_ping() { + let ping_ms = ping.as_millis() as i64; + *self.last_ping.lock().await = ping_ms; + self.app.lock().unwrap().push_ping_val(ping_ms as f64); } self.flush_pending_chat_secret_forwards().await; - self.send_ping().await; } } @@ -854,9 +842,7 @@ impl OmikronConnection { // ------------------------------------------------------------------------- pub async fn handle_message(self: Arc, cv: CommunicationValue) { - if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { - log_cv_in!(&cv); - } + log_cv_in!(&cv); let msg_id = cv.get_id(); @@ -866,11 +852,6 @@ impl OmikronConnection { } } - if cv.is_type(CommunicationType::Pong) { - self.handle_pong(&cv).await; - return; - } - self.clone().handle_message_impl(cv).await; } @@ -1804,9 +1785,7 @@ impl OmikronConnection { let sender_clone = Arc::clone(sender); drop(sender_guard); - if !cv.is_type(CommunicationType::Ping) && !cv.is_type(CommunicationType::Pong) { - log_cv_out!(&cv); - } + log_cv_out!(&cv); if let Err(e) = sender_clone.send(cv).await { self.fail_all_waiting_tasks(format!( @@ -2117,14 +2096,13 @@ impl OmikronClient for OmikronConnection { sender: self.sender.clone(), connection_loop_handle: self.connection_loop_handle.clone(), last_ping: self.last_ping.clone(), - heartbeat_handle: self.heartbeat_handle.clone(), + maintenance_handle: self.maintenance_handle.clone(), connection_id: self.connection_id, shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), - missed_pongs: self.missed_pongs.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), @@ -2141,14 +2119,13 @@ impl OmikronClient for OmikronConnection { sender: self.sender.clone(), connection_loop_handle: self.connection_loop_handle.clone(), last_ping: self.last_ping.clone(), - heartbeat_handle: self.heartbeat_handle.clone(), + maintenance_handle: self.maintenance_handle.clone(), connection_id: self.connection_id, shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), - missed_pongs: self.missed_pongs.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs deleted file mode 100644 index d55cec7..0000000 --- a/omikron-connector/src/ping_pong_task.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::omikron_connection::OmikronConnection; -use dashmap::DashMap; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use std::sync::LazyLock; -use std::sync::atomic::Ordering; -use std::time::Instant; -use tokio::time::Duration; - -static PING_TIMES: LazyLock> = LazyLock::new(|| DashMap::new()); - -impl OmikronConnection { - pub async fn send_ping(&self) { - let id = rand::random(); - - PING_TIMES.insert(id, Instant::now()); - - PING_TIMES.retain(|_, v| v.elapsed() < Duration::from_secs(30)); - - self.missed_pongs.fetch_add(1, Ordering::Relaxed); - - let ping_message = CommunicationValue::new(CommunicationType::Ping) - .with_id(id) - .add_typed_default( - DataType::LastPing, - DataValue::Array(vec![DataValue::SignedNumber( - *self.last_ping.lock().await as i128, - )]), - ); - - let _ = self.send_message(&ping_message).await; - } - - pub async fn handle_pong(&self, cv: &CommunicationValue) { - self.missed_pongs.store(0, Ordering::Relaxed); - - let id = cv.get_id(); - - if let Some((_, send_time)) = PING_TIMES.remove(&id) { - let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64; - *self.last_ping.lock().await = ping_ms; - self.app.lock().unwrap().push_ping_val(ping_ms as f64); - } - } -} From 6aef7d3c09746e0f701fb3f4c8b9729512be6eb7 Mon Sep 17 00:00:00 2001 From: Alois Date: Tue, 28 Jul 2026 23:53:39 +0200 Subject: [PATCH 098/119] (fix): connections (again) --- Cargo.lock | 22 ++++++++++----------- omikron-connector/src/omikron_connection.rs | 5 ++--- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32418ca..602f28a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#590810ce59709efccd81ca04401c855be314b9c0" +source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" dependencies = [ "async-trait", "bytes", @@ -4835,7 +4835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e132b37..53da661 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -112,7 +112,6 @@ const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); -const MAX_MISSED_PINGS: usize = 3; const MAX_CONCURRENT_HANDLERS: usize = 20; // ============================================================================ @@ -353,7 +352,7 @@ impl OmikronConnection { accept_stream_timeout: Duration::from_millis(10_000), read_timeout: Duration::from_millis(30_000), keep_alive_interval: Some(Duration::from_secs(6)), - max_idle_timeout: Some(Duration::from_secs(30)), + max_idle_timeout: None, force_close_delay: Duration::from_millis(300), receiver_queue_capacity: 1000, max_concurrent_stream_tasks: 10, @@ -362,7 +361,7 @@ impl OmikronConnection { max_frames_per_stream: None, }) .with_ping_interval(MAINTENANCE_INTERVAL) - .with_max_missed_pings(MAX_MISSED_PINGS); + .with_max_missed_pings(0); let connection = match Client::auth_connect_or_register( client_config, From d184fcb16327060cf0014f94826c3c8ba76caaaf Mon Sep 17 00:00:00 2001 From: Alois Date: Wed, 29 Jul 2026 02:37:18 +0200 Subject: [PATCH 099/119] (feat): update mtp --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 602f28a..61cce6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1987,7 +1987,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#6f673ba7f2834b95ce724babe131241df8a119c6" +source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" dependencies = [ "async-trait", "bytes", @@ -3810,7 +3810,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.19", "tokio", "tracing", @@ -3851,7 +3851,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", "windows-sys 0.61.2", ] From 6b8d65cd4dc25dde9f76aa799fb01eba0c2ee13f Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Wed, 5 Aug 2026 20:15:42 +0200 Subject: [PATCH 100/119] Add renovate.json --- renovate.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7190a60 --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json" +} From 0949bcc0b8f5a6affcd7f7ab4a5f33518c387753 Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Wed, 5 Aug 2026 22:01:43 +0200 Subject: [PATCH 101/119] Update Rust crate open to v5.4.1 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61cce6d..c28e23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3298,9 +3298,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "is-wsl", "libc", From 2a6aa647955f4f36e2d3ec953fbf25906f5b9376 Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Wed, 5 Aug 2026 23:01:48 +0200 Subject: [PATCH 102/119] Update Rust crate rustls to v0.23.43 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61cce6d..0163171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,9 +4226,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", From 5d1bc35659c345c04e93ecaa16a189f7467c352c Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 01:03:38 +0200 Subject: [PATCH 103/119] Update Rust crate rusqlite to 0.40.0 --- Cargo.lock | 22 +++++++++++----------- client/Cargo.toml | 2 +- communities/Cargo.toml | 2 +- iota-auth/Cargo.toml | 2 +- iota-cli/Cargo.toml | 2 +- iota-storage/Cargo.toml | 2 +- other-iota/Cargo.toml | 2 +- web-ui/Cargo.toml | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61cce6d..a24bcbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1782,11 +1782,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1987,7 +1987,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -2733,9 +2733,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "pkg-config", "vcpkg", @@ -3810,7 +3810,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -3851,7 +3851,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -4174,9 +4174,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -4835,7 +4835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/client/Cargo.toml b/client/Cargo.toml index bdf08ad..43e27d3 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -43,7 +43,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 812ff55..0e867f2 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -42,7 +42,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index 5a9ad09..dbc740c 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -39,7 +39,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 54666e1..f7fe9a4 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -57,7 +57,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 50f7cc6..0080576 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -27,7 +27,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" sha2 = "0.10.9" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index 641de58..b4fd14e 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -42,7 +42,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index a6fbba1..5034f88 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -42,7 +42,7 @@ rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" -rusqlite = "0.39.0" +rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" From 50922fadfd6c04f61ea86e35a710e6919af8420c Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 17:01:51 +0200 Subject: [PATCH 104/119] Update Rust crate clap to v4.6.6 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61cce6d..72382bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,9 +734,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -744,9 +744,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1353,7 +1353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3853,7 +3853,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4221,7 +4221,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4289,7 +4289,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4838,7 +4838,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5643,7 +5643,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From e95974b082598d2c9fe92c1148a7461d5cd035e8 Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 18:03:06 +0200 Subject: [PATCH 105/119] Update Rust crate sha2 to 0.11.0 --- Cargo.lock | 16 ++++++++-------- client/Cargo.toml | 2 +- communities/Cargo.toml | 2 +- iota-auth/Cargo.toml | 2 +- iota-cli/Cargo.toml | 2 +- iota-storage/Cargo.toml | 2 +- iota-updater/Cargo.toml | 2 +- omikron-connector/Cargo.toml | 2 +- other-iota/Cargo.toml | 2 +- web-ui/Cargo.toml | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58189ec..1b17528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,7 +810,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -2233,7 +2233,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -2290,7 +2290,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -2466,7 +2466,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "sysinfo", "thiserror 2.0.19", "tokio", @@ -2508,7 +2508,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "sysinfo", "tempfile", "tokio", @@ -3271,7 +3271,7 @@ dependencies = [ "rand 0.8.7", "rand_core 0.6.4", "reqwest", - "sha2 0.10.9", + "sha2 0.11.0", "tokio", "tokio-util", "uuid", @@ -3395,7 +3395,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", @@ -5526,7 +5526,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo", diff --git a/client/Cargo.toml b/client/Cargo.toml index 43e27d3..f159246 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -47,7 +47,7 @@ rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 0e867f2..76054de 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -46,7 +46,7 @@ rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index dbc740c..d961329 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -43,7 +43,7 @@ rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index f7fe9a4..381a8a3 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -63,7 +63,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 0080576..fd87d9c 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -28,7 +28,7 @@ rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" reqwest = "0.13.2" rusqlite = "0.40.0" -sha2 = "0.10.9" +sha2 = "0.11.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index d089180..742b2a6 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -21,7 +21,7 @@ zip = "6.0.0" aes-gcm = "0.10.3" base64 = "0.22.1" rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.10.9" +sha2 = "0.11.0" x448 = { version = "*" } hkdf = "0.12.4" once_cell = "1.21.3" diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index c869941..1e3e21d 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -26,5 +26,5 @@ base64 = "0.22.1" hex = "*" rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.10.9" +sha2 = "0.11.0" x448 = { version = "*" } diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index b4fd14e..e23231b 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -46,7 +46,7 @@ rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 5034f88..fb38dee 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -46,7 +46,7 @@ rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.10.9" +sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.27.2" sysinfo = "0.38.3" From a3815b30c9067ad3cea2cfa24e55a8a422807c5b Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 19:02:08 +0200 Subject: [PATCH 106/119] Update Rust crate strum_macros to 0.28.0 --- Cargo.lock | 24 ++++++------------------ client/Cargo.toml | 2 +- communities/Cargo.toml | 2 +- iota-auth/Cargo.toml | 2 +- iota-cli/Cargo.toml | 2 +- other-iota/Cargo.toml | 2 +- web-ui/Cargo.toml | 2 +- 7 files changed, 12 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58189ec..d3be1b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,7 +812,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros", "sysinfo", "tokio", "tokio-tungstenite", @@ -2235,7 +2235,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros", "sysinfo", "tokio", "tokio-tungstenite", @@ -2292,7 +2292,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros", "sysinfo", "tempfile", "tokio", @@ -3397,7 +3397,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros", "sysinfo", "tokio", "tokio-tungstenite", @@ -4707,19 +4707,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", + "strum_macros", ] [[package]] @@ -5528,7 +5516,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros", "sysinfo", "tokio", "tokio-tungstenite", diff --git a/client/Cargo.toml b/client/Cargo.toml index 43e27d3..e2725ad 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -49,7 +49,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 0e867f2..9621999 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -48,7 +48,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index dbc740c..22ae173 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -45,7 +45,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index f7fe9a4..83f0328 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -65,7 +65,7 @@ serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index b4fd14e..66cf720 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -48,7 +48,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 5034f88..378c947 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -48,7 +48,7 @@ rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.10.9" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } From 7a0746893c222ef5e99363814eb238654db081cc Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 21:02:54 +0200 Subject: [PATCH 107/119] Update Rust crate strum to 0.28.0 --- Cargo.lock | 20 +++++++------------- client/Cargo.toml | 2 +- communities/Cargo.toml | 2 +- iota-auth/Cargo.toml | 2 +- iota-cli/Cargo.toml | 2 +- other-iota/Cargo.toml | 2 +- web-ui/Cargo.toml | 2 +- 7 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77154c0..1621120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,7 +811,7 @@ dependencies = [ "rustls-pemfile", "serde_json", "sha2 0.11.0", - "strum 0.27.2", + "strum", "strum_macros", "sysinfo", "tokio", @@ -2234,7 +2234,7 @@ dependencies = [ "rustls-pemfile", "serde_json", "sha2 0.11.0", - "strum 0.27.2", + "strum", "strum_macros", "sysinfo", "tokio", @@ -2291,7 +2291,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", - "strum 0.27.2", + "strum", "strum_macros", "sysinfo", "tempfile", @@ -3396,7 +3396,7 @@ dependencies = [ "rustls-pemfile", "serde_json", "sha2 0.11.0", - "strum 0.27.2", + "strum", "strum_macros", "sysinfo", "tokio", @@ -3981,7 +3981,7 @@ dependencies = [ "lru", "palette", "serde", - "strum 0.28.0", + "strum", "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", @@ -4045,7 +4045,7 @@ dependencies = [ "line-clipping", "ratatui-core", "serde", - "strum 0.28.0", + "strum", "time", "unicode-segmentation", "unicode-width", @@ -4695,12 +4695,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - [[package]] name = "strum" version = "0.28.0" @@ -5515,7 +5509,7 @@ dependencies = [ "rustls-pemfile", "serde_json", "sha2 0.11.0", - "strum 0.27.2", + "strum", "strum_macros", "sysinfo", "tokio", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8e665f2..ed48d6c 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -48,7 +48,7 @@ rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 6ad8198..d7ac493 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -47,7 +47,7 @@ rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index cababc4..6e1a10c 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -44,7 +44,7 @@ rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index c5199fa..31b0950 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -64,7 +64,7 @@ serde_json = "1.0.149" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index a128cbe..14dd8fd 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -47,7 +47,7 @@ rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 2a2e01a..bd1bc40 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -47,7 +47,7 @@ rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.27.2" +strum = "0.28.0" strum_macros = "0.28.0" sysinfo = "0.38.3" tokio = { version = "1.50.0", features = ["full"] } From e4a33578eb4849c12ec10ac13d6c30ceb2323b04 Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 6 Aug 2026 21:03:15 +0200 Subject: [PATCH 108/119] Update Rust crate sysinfo to 0.39.0 --- Cargo.lock | 65 +++++++++++++++++++++++++++++++++----- client/Cargo.toml | 2 +- communities/Cargo.toml | 2 +- iota-auth/Cargo.toml | 2 +- iota-cli/Cargo.toml | 2 +- iota-daemon-lib/Cargo.toml | 2 +- iota-state/Cargo.toml | 2 +- iota-storage/Cargo.toml | 2 +- iota-updater/Cargo.toml | 2 +- iota-util/Cargo.toml | 2 +- other-iota/Cargo.toml | 2 +- web-ui/Cargo.toml | 2 +- 12 files changed, 68 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77154c0..2c3fe0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1264,6 +1264,16 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -1353,7 +1363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3219,6 +3229,15 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3226,6 +3245,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", ] [[package]] @@ -3238,6 +3275,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "octets" version = "0.3.6" @@ -3853,7 +3901,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4221,7 +4269,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4289,7 +4337,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4783,15 +4831,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", + "objc2-open-directory", "windows", ] @@ -4826,7 +4875,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5631,7 +5680,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index 8e665f2..7da750e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -50,7 +50,7 @@ serde_json = "1.0.149" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 6ad8198..22cb443 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -49,7 +49,7 @@ serde_json = "1.0.149" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index cababc4..c922377 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -46,7 +46,7 @@ serde_json = "1.0.149" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index c5199fa..0c8ee3b 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -66,7 +66,7 @@ serde_yaml = "0.9" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 5e0c67c..4eb0b7c 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -15,7 +15,7 @@ omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" libc = "0.2" -sysinfo = "0.38.3" +sysinfo = "0.39.0" serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index 4275556..8c9ab8a 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -12,6 +12,6 @@ dashmap = "6.1.0" once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" -sysinfo = "0.38.3" +sysinfo = "0.39.0" mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } serde = { version = "1", features = ["derive"] } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index fd87d9c..caf6417 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -29,7 +29,7 @@ ratatui = "0.30.0" reqwest = "0.13.2" rusqlite = "0.40.0" sha2 = "0.11.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 742b2a6..2f6bf04 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -14,7 +14,7 @@ pnet = "0.35.0" ratatui = "0.30.0" reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.38.3" +sysinfo = "0.39.0" uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" zip = "6.0.0" diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index d61786e..e9907eb 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -11,7 +11,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.38.3" +sysinfo = "0.39.0" uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" zip = "6.0.0" diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index a128cbe..1a1dd45 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -49,7 +49,7 @@ serde_json = "1.0.149" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 2a2e01a..453cffc 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -49,7 +49,7 @@ serde_json = "1.0.149" sha2 = "0.11.0" strum = "0.27.2" strum_macros = "0.28.0" -sysinfo = "0.38.3" +sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" From 45da43805af25bf4696f77072e16bd2d3f07e5fb Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 7 Aug 2026 23:53:12 +0200 Subject: [PATCH 109/119] [Fix] User States --- iota-connection/src/message_handlers.rs | 54 ++++++++++++++++++++++--- mtp-type-maps | 2 +- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 1330ef2..73244d7 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -296,6 +296,45 @@ fn contact_value( typed_container(fields) } +fn current_contact_ids(user_id: i64) -> DataValue { + contact_ids_value( + chats_util::get_users(user_id) + .into_iter() + .map(|contact| contact.user_id), + ) +} + +fn contact_ids_value(ids: impl IntoIterator) -> DataValue { + let mut contact_ids = ids.into_iter().collect::>(); + contact_ids.sort_unstable(); + contact_ids.dedup(); + + DataValue::Array( + contact_ids + .into_iter() + .map(|user_id| DataValue::SignedNumber(user_id as i128)) + .collect(), + ) +} + +#[cfg(test)] +mod presence_tests { + use super::contact_ids_value; + use mtp::codec::DataValue; + + #[test] + fn contact_snapshot_is_sorted_and_deduplicated() { + assert_eq!( + contact_ids_value([9, 3, 9, 4, 3]), + DataValue::Array(vec![ + DataValue::SignedNumber(3), + DataValue::SignedNumber(4), + DataValue::SignedNumber(9), + ]) + ); + } +} + fn sync_error(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( DataType::SessionId, @@ -356,10 +395,6 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { ), } }; - let all_contact_ids = chats_util::get_users(user_id) - .into_iter() - .map(|contact| DataValue::SignedNumber(contact.user_id as i128)) - .collect(); let message_values = messages .iter() .map(|message| stored_message_value(message, user_id, message.external_user)) @@ -408,7 +443,7 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .collect(), ), ) - .add_typed_default(DataType::UserIds, DataValue::Array(all_contact_ids)) + .add_typed_default(DataType::UserIds, current_contact_ids(user_id)) .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())) } @@ -511,6 +546,10 @@ pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { let user_id = cv.get_sender(); + let session_id = match data_i64(cv, DataType::SessionId) { + Some(id) if id > 0 => id, + _ => return sync_error(cv), + }; let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { Some(n) => n as i64, None => cv @@ -532,6 +571,11 @@ pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { CommunicationValue::new(CommunicationType::AddConversation) .with_id(cv.get_id()) .with_receiver(user_id) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) + .add_typed_default(DataType::UserIds, current_contact_ids(user_id as i64)) } pub fn handle_add_community(cv: &CommunicationValue) -> CommunicationValue { diff --git a/mtp-type-maps b/mtp-type-maps index f430cd3..486541b 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit f430cd358b3d07a4cfd6982eb8917fec80c24a7d +Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e From 326ebf3b3728fded062193b6b74b6f7e17432a9e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 8 Aug 2026 02:09:38 +0200 Subject: [PATCH 110/119] [Fix] Replies --- Cargo.lock | 320 +++++++++++--------- client/src/client_connection.rs | 6 + iota-connection/src/message_handlers.rs | 36 ++- iota-storage/src/util/chat_files.rs | 56 ++++ omikron-connector/src/omikron_connection.rs | 25 ++ 5 files changed, 303 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 149db71..c8e129d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,16 +44,16 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.1" +version = "3.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +checksum = "53200bd1513e569e6e644181c922cec072c121a27db5b38d45e88e630c369366" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-tls", "actix-utils", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "brotli", "bytes", @@ -119,9 +119,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "3716aae056e2f869b7b5cfd8a08fcf98890f8455bec61d69c7dff5d8576f9d2b" dependencies = [ "actix-rt", "actix-service", @@ -129,7 +129,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -211,7 +211,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.5", + "socket2", "time", "tracing", "url", @@ -290,9 +290,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -320,9 +320,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -483,9 +483,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -494,9 +494,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -511,6 +511,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -651,9 +657,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" dependencies = [ "find-msvc-tools", "jobserver", @@ -780,7 +786,7 @@ dependencies = [ "actix-web-actors", "aes-gcm", "async-trait", - "base64", + "base64 0.22.1", "chrono", "crossterm", "dashmap", @@ -1089,6 +1095,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -1102,9 +1124,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" dependencies = [ "darling_core", "darling_macro", @@ -1112,26 +1134,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1150,9 +1172,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deflate64" @@ -1173,7 +1195,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", "zeroize", ] @@ -1184,6 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", + "pem-rfc7468", "zeroize", ] @@ -1310,20 +1332,45 @@ dependencies = [ "signature 2.2.0", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "ed448-goldilocks" version = "0.7.2" @@ -1397,12 +1444,6 @@ dependencies = [ "regex", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastbloom" version = "0.17.0" @@ -1433,6 +1474,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1446,9 +1493,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "finl_unicode" @@ -1698,7 +1745,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.2", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -1715,7 +1762,7 @@ dependencies = [ "bytes", "fastrand", "futures-util", - "http 1.4.2", + "http 1.5.0", "pin-project-lite", "tokio", ] @@ -1756,7 +1803,7 @@ dependencies = [ "futures-util", "h3", "h3-datagram", - "http 1.4.2", + "http 1.5.0", "pin-project-lite", "tokio", "tracing", @@ -1805,10 +1852,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", - "http 1.4.2", + "http 1.5.0", "httpdate", "mime", "sha1 0.10.7", @@ -1820,7 +1867,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.2", + "http 1.5.0", ] [[package]] @@ -1890,9 +1937,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1905,7 +1952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.2", + "http 1.5.0", ] [[package]] @@ -1916,7 +1963,7 @@ checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -1935,9 +1982,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", "typenum", @@ -1954,7 +2001,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.15", - "http 1.4.2", + "http 1.5.0", "http-body", "httparse", "httpdate", @@ -1971,7 +2018,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", + "http 1.5.0", "hyper", "hyper-util", "rustls", @@ -1986,18 +2033,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2180,15 +2227,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2215,7 +2262,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "async-trait", - "base64", + "base64 0.22.1", "chrono", "crossterm", "dashmap", @@ -2265,7 +2312,7 @@ dependencies = [ "actix-web-actors", "aes-gcm", "async-trait", - "base64", + "base64 0.22.1", "chrono", "crossterm", "dashmap", @@ -2457,7 +2504,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "arc-swap", - "base64", + "base64 0.22.1", "hex", "hkdf 0.12.4", "iota-logger", @@ -2502,8 +2549,8 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "base64", - "ed25519-dalek", + "base64 0.22.1", + "ed25519-dalek 2.2.0", "hex", "hkdf 0.12.4", "iota-logger", @@ -2532,7 +2579,7 @@ dependencies = [ name = "iota-util" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "hex", "iota-paths", "mtp", @@ -2546,9 +2593,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ipnetwork" @@ -2697,9 +2744,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -2753,9 +2800,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ "bitflags 2.13.1", ] @@ -2812,9 +2859,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.1", ] @@ -2966,7 +3013,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "mtp-client", "mtp-codec", @@ -2982,7 +3029,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "mtp-codec", "mtp-common", @@ -2995,9 +3042,9 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ - "base64", + "base64 0.23.1", "byteorder", "mtp-common", "mtp-crypto", @@ -3008,7 +3055,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "quinn", "rustls", @@ -3019,11 +3066,11 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ - "base64", + "base64 0.23.1", "chacha20poly1305", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.4.3", "hkdf 0.13.0", "ml-dsa", @@ -3033,7 +3080,7 @@ dependencies = [ "rustls", "serde", "sha2 0.11.0", - "thiserror 1.0.69", + "thiserror 2.0.19", "tokio", "zeroize", ] @@ -3041,24 +3088,24 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "mtp-crypto", "rand 0.10.2", - "thiserror 1.0.69", + "thiserror 2.0.19", "zeroize", ] [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand 0.8.7", + "rand 0.10.2", "tokio", "tracing", "wtransport", @@ -3067,7 +3114,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "async-trait", "mtp-codec", @@ -3085,7 +3132,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "serde", "serde_yaml", @@ -3094,14 +3141,14 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#a692bed326dbfc8eac1a05825f4a287cbab6fd3e" +source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ "async-trait", "bytes", "h3", "h3-quinn", "h3-webtransport", - "http 1.4.2", + "http 1.5.0", "http-body-util", "hyper", "hyper-util", @@ -3306,7 +3353,7 @@ name = "omikron-connector" version = "0.1.0" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "dashmap", "hex", "iota-connection", @@ -3414,7 +3461,7 @@ dependencies = [ "actix-web-actors", "aes-gcm", "async-trait", - "base64", + "base64 0.22.1", "chrono", "crossterm", "dashmap", @@ -3459,21 +3506,21 @@ dependencies = [ [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", @@ -3481,6 +3528,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -3520,15 +3576,15 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -3858,7 +3914,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2", "thiserror 2.0.19", "tokio", "tracing", @@ -3899,7 +3955,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -4137,9 +4193,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4164,12 +4220,12 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "h2 0.4.15", - "http 1.4.2", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -4567,7 +4623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", "sponge-cursor", ] @@ -4667,16 +4723,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -4913,7 +4959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "fancy-regex", "filedescriptor", @@ -4990,9 +5036,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -5057,20 +5103,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -5157,7 +5203,7 @@ dependencies = [ "bitflags 2.13.1", "bytes", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body", "pin-project-lite", "tower", @@ -5224,7 +5270,7 @@ checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http 1.5.0", "httparse", "log", "native-tls", @@ -5399,7 +5445,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.2", + "http 1.5.0", "http-body", "http-body-util", "log", @@ -5492,7 +5538,7 @@ name = "web-server" version = "0.1.0" dependencies = [ "bytes", - "http 1.4.2", + "http 1.5.0", "iota-logger", "iota-util", "mtp", @@ -5528,7 +5574,7 @@ dependencies = [ "actix-web-actors", "aes-gcm", "async-trait", - "base64", + "base64 0.22.1", "chrono", "crossterm", "dashmap", @@ -5912,7 +5958,7 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "sha2 0.11.0", - "socket2 0.6.5", + "socket2", "thiserror 2.0.19", "time", "tokio", @@ -5940,7 +5986,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", @@ -6011,18 +6057,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -6132,9 +6178,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index cda5cf8..512cc31 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -407,6 +407,12 @@ impl ClientConnection { return; } + if cv.is_type(CommunicationType::MessageGet) { + self.send_message(&message_handlers::handle_message_get(&cv)) + .await; + return; + } + if cv.is_type(CommunicationType::GetChats) { self.send_message(&message_handlers::handle_get_chats(&cv)) .await; diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 73244d7..dc1d4dc 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -102,11 +102,11 @@ pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue { } } -fn stored_message_value( +fn stored_message_fields( message: &chat_files::StoredMessage, storage_owner: i64, partner_id: i64, -) -> DataValue { +) -> Vec<(DataType, DataValue)> { let mut fields = vec![ ( DataType::MessageId, @@ -162,7 +162,15 @@ fn stored_message_value( .collect(); fields.push((DataType::Reactions, DataValue::Array(reactions))); } - typed_container(fields) + fields +} + +fn stored_message_value( + message: &chat_files::StoredMessage, + storage_owner: i64, + partner_id: i64, +) -> DataValue { + typed_container(stored_message_fields(message, storage_owner, partner_id)) } pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { @@ -520,6 +528,28 @@ pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { .add_typed_default(DataType::Messages, DataValue::Array(msg_array)) } +pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { + let Some(send_time) = data_i64(cv, DataType::SendTime) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let partner_id = data_i64(cv, DataType::ChatPartnerId); + let owner = cv.get_sender() as i64; + + let message = match chat_files::get_message(owner, send_time, partner_id) { + Ok(Some(message)) => message, + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + + let mut response = CommunicationValue::new(CommunicationType::MessageGet) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + for (data_type, value) in stored_message_fields(&message, owner, message.external_user) { + response = response.add_typed_default(data_type, value); + } + response +} + pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { let user_id = cv.get_sender(); let users = chats_util::get_users(user_id as i64); diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index da05aad..024c302 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -644,6 +644,62 @@ pub fn get_messages( } } +pub fn get_message( + storage_owner: i64, + message_time: i64, + external_user: Option, +) -> Result, StorageError> { + db::with_db(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT id, message_time, content, sent_by_self, message_state, height, + reply_to, edited_count, external_user + FROM messages + WHERE storage_owner = ?1 + AND message_time = ?2 + AND deleted_by_external = 0 + AND (?3 IS NULL OR external_user = ?3) + ORDER BY id DESC + "#, + )?; + + let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| { + Ok(StoredMessage { + id: row.get(0)?, + message_time: row.get(1)?, + content: row.get(2)?, + sent_by_self: row.get::<_, i64>(3)? != 0, + message_state: row.get(4)?, + height: row.get(5).unwrap_or(0), + reply_to: row.get(6).ok().flatten(), + edited: row.get::<_, i64>(7).unwrap_or(0) > 0, + external_user: row.get(8)?, + reactions: Vec::new(), + }) + })?; + + let messages: Vec = rows.collect::>()?; + if messages.is_empty() { + return Ok(None); + } + if external_user.is_none() + && messages + .iter() + .map(|message| message.external_user) + .collect::>() + .len() + > 1 + { + return Ok(None); + } + + let mut message = messages.into_iter().next().expect("checked non-empty"); + let reaction_map = load_reactions(conn, &[message.id]); + message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default(); + Ok(Some(message)) + }) +} + pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { if ids.is_empty() { return Vec::new(); diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 53da661..81d6ae6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -884,6 +884,7 @@ impl OmikronConnection { dispatch!(MessageReactionLive, handle_message_reaction_live); dispatch!(MessageDeleteLive, handle_message_delete_live); dispatch!(MessageOtherIota, handle_message_other_iota); + dispatch!(MessageGet, handle_message_get); dispatch!(MessagesGet, handle_messages_get); dispatch!(GetChats, handle_get_chats); dispatch!(AddConversation, handle_add_conversation); @@ -1447,6 +1448,24 @@ impl OmikronConnection { let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); + if let Some(reply_to) = reply_to { + match chat_files::get_message(sender_id as i64, reply_to, Some(receiver_id)) { + Ok(Some(_)) => {} + Ok(None) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) + .await; + return; + } + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + } + } + let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); if is_local { @@ -1701,6 +1720,12 @@ impl OmikronConnection { .await; } + async fn handle_message_get(self: Arc, cv: &CommunicationValue) { + let _ = self + .send_message(&message_handlers::handle_message_get(cv)) + .await; + } + async fn handle_get_chats(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_get_chats(cv)) From 7dc98ef29b3c428c20b777295e93fdea8a21a214 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 9 Aug 2026 02:51:47 +0200 Subject: [PATCH 111/119] [Fix] User deletion & migration --- Cargo.lock | 1 + iota-cli/src/ipc_client.rs | 3 + iota-cli/src/screens/users.rs | 32 +++-- iota-cli/src/ui.rs | 3 + iota-daemon-lib/src/command_router.rs | 96 +++++++++---- iota-daemon/src/main.rs | 17 +++ iota-ipc/src/lib.rs | 2 +- iota-ipc/src/protocol.rs | 42 ++++++ iota-storage/src/users/user_manager.rs | 136 +++++++++++++----- iota-storage/src/users/user_profile.rs | 10 +- iota-storage/src/util/db.rs | 21 ++- iota-storage/src/util/e2ee_storage.rs | 17 +++ iota-util/src/file_util.rs | 63 ++++++++- iota-util/src/lib.rs | 1 + iota-util/src/tu.rs | 95 +++++++++++++ iota/Cargo.toml | 1 + iota/src/cli_args.rs | 106 +++++++++----- iota/src/main.rs | 52 ++++++- omikron-connector/src/omikron_connection.rs | 29 ++++ omikron-connector/src/user_ops.rs | 144 ++++++++++++++++++-- 20 files changed, 742 insertions(+), 129 deletions(-) create mode 100644 iota-util/src/tu.rs diff --git a/Cargo.lock b/Cargo.lock index c8e129d..1003ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,7 @@ dependencies = [ "iota-paths", "iota-process-manager", "iota-terms", + "iota-util", "serde_json", "serde_yaml", "tokio", diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index ad0796f..ca912fc 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -483,6 +483,9 @@ impl IpcClient { ResponsePayload::UserRemoved { user_id } => { format!("Removed user {}", user_id) } + ResponsePayload::UserDataPurged { user_id } => { + format!("Purged hosted data for {}", user_id) + } ResponsePayload::Acknowledged { message } => message.clone(), ResponsePayload::DaemonStatus(status) => status.formatted.clone(), ResponsePayload::Config(config) => config.yaml.clone(), diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index fab710c..93d4b13 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -27,6 +27,9 @@ use std::{ pub struct UserEntry { pub user_id: i64, pub username: String, + pub state: iota_ipc::LocalUserState, + pub data_present: bool, + pub credential_present: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -146,7 +149,9 @@ impl UsersScreen { let user = &self.users[*user_index]; ( *user_index, - format!("{:>6} {}", user.user_id, user.username), + format!("{:>6} {} {}{}", user.user_id, user.username, + match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" }, + if user.data_present { "" } else { ", data purged" }), ) }) .collect(); @@ -211,7 +216,7 @@ impl UsersScreen { f, buttons_area[2], ActionButton { - label: "Remove", + label: "Release", intent: ButtonIntent::Destructive, focused: self.focus == Focus::RemoveButton, enabled: !self.loading && !self.pending && !self.users.is_empty(), @@ -235,7 +240,7 @@ impl UsersScreen { return InteractionResult::AppTask { task: Box::pin(async move { let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }), Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), Err(error) => Err(format!("Cannot create user: {error}")), @@ -249,14 +254,14 @@ impl UsersScreen { let ipc = self.ipc.clone(); let id = user.user_id; self.pending = true; - self.message = Some(format!("Removing {}…", user.username)); + self.message = Some(format!("Releasing {}…", user.username)); return InteractionResult::AppTask { task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), - Err(error) => Err(format!("Cannot remove user: {error}")), + let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()), + Err(error) => Err(format!("Cannot release user: {error}")), }; UiEvent::App(AppEvent::UserRemoved { user_id: id, @@ -528,10 +533,11 @@ impl Screen for UsersScreen { match result { Ok(()) => { self.pending_dialog = None; - self.users.retain(|user| user.user_id != user_id); - self.focused_index = - self.focused_index.min(self.users.len().saturating_sub(1)); - self.message = Some(format!("Removed user {user_id}.")); + if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) { + user.state = iota_ipc::LocalUserState::Released; + user.credential_present = false; + } + self.message = Some(format!("Released user {user_id}; hosted data retained.")); } Err(error) => { self.dialog = self.pending_dialog.take(); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index b06dab1..dad4c80 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -602,6 +602,9 @@ impl UI { .map(|u| UserEntry { user_id: u.user_id, username: u.username, + state: u.state, + data_present: u.data_present, + credential_present: u.credential_present, }) .collect()) } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 58e627d..74f5f96 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -9,8 +9,9 @@ use iota_ipc::{ use iota_logger::{log, log_command}; use iota_storage::users::user_manager; use iota_storage::util::config_util::{self}; -use mtp::codec::{CommunicationType, CommunicationValue}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -51,7 +52,10 @@ impl CommandRouter { } let needs_omikron = matches!( request, - LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } + LocalRequest::CreateUser { .. } + | LocalRequest::AttachUserFromTu { .. } + | LocalRequest::ReleaseUser { .. } + | LocalRequest::CompleteDeleteUser { .. } ); if needs_omikron && !self.services.omikron.is_connected().await { return ResponseResult::Error( @@ -92,11 +96,15 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_users() + let users: Vec = user_manager::get_residency() .into_iter() .map(|user| UserSummary { + credential_present: user.state == user_manager::LocalUserState::Managed + && user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()), user_id: user.user_id, username: user.username, + state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released }, + data_present: user.data_present, }) .collect(); ResponseResult::Ok(ResponsePayload::Users(users)) @@ -137,18 +145,61 @@ impl CommandRouter { } } } - LocalRequest::RemoveUser { user_id } => { - let user = match user_manager::get_user(user_id) { - Some(user) => user, - None => return ResponseResult::Error(IpcErrorCode::NotFound), + LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }), + Err(error) => { + log!("User data purge failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + LocalRequest::AttachUserFromTu { credential } => { + match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await { + Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }), + Err(error) => { + log!("Credential attach failed: {error:?}"); + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + } + } + LocalRequest::CompleteDeleteUser { user_id, credential } => { + let contents = match credential { + Some(value) => Ok(value.0), + None => user_manager::get_user(user_id) + .ok_or(()) + .and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ())) + .and_then(|value| value.ok_or(())), }; - let message = CommunicationValue::new(CommunicationType::DeleteUser) - .with_sender(user.user_id as u64); - if let Err(_e) = self.services.omikron.send_message(&message).await { - return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); + let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); }; + match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }), + Err(error) => { + log!("Credential deletion failed for {user_id}: {error:?}"); + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + } + } + LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), + LocalRequest::ReleaseUser { user_id } => { + if user_manager::get_user(user_id).is_none() { + return ResponseResult::Error(IpcErrorCode::NotFound); + } + let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); + match self.services.omikron.await_response(&request, Duration::from_secs(20)).await { + Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Released user {user_id}; hosted data was retained"), + }), + Err(error) => { + log!("Remote release succeeded but local cleanup failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized), + Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict), + Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout), + Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } - user_manager::remove_user(user.user_id); - ResponseResult::Ok(ResponsePayload::UserRemoved { user_id }) } LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { @@ -242,23 +293,22 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Components(components)) } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { + Some(user) => { + let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some(); + ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { user_id: user.user_id, username: user.username, display_name: user.display_name, created_at: user.created_at, trusted_apps: user.trusted_apps.keys().cloned().collect(), - })), + state: iota_ipc::LocalUserState::Managed, + data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present), + credential_present, + })) + }, None => ResponseResult::Error(IpcErrorCode::NotFound), }, - LocalRequest::ImportUser { username } => { - match user_manager::load_from_tu(&username).await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Imported user {username}"), - }), - Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), - } - } + LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { let entries = if let Ok(buf) = self.log_buffer.lock() { buf.recent(limit) diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 47a0a51..76eeaf0 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -156,6 +156,7 @@ async fn main() -> ExitCode { } }; let omikron_health = omikron.clone(); + let omikron_reconcile = omikron.clone(); let services = DaemonServices::new(omikron); let health_runtime = runtime.clone(); runtime @@ -209,6 +210,22 @@ async fn main() -> ExitCode { Ok(()) }) .await; + runtime + .tasks + .spawn_tracked("user-lifecycle-reconciliation", async move { + let mut states = omikron_reconcile.connection_state(); + loop { + if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) { + omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await; + } + tokio::select! { + changed = states.changed() => if changed.is_err() { break }, + _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + } + } + Ok(()) + }) + .await; let ipc_server = match IpcServer::bind( socket.clone(), runtime.clone(), diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 0ff6284..bd3d102 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -8,7 +8,7 @@ pub use protocol::{ ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, - SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, + SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index d31bee4..2e2be1a 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -1,5 +1,17 @@ use serde::{Deserialize, Serialize}; +/// IPC credentials are supplied by the interactive CLI, never a daemon-side +/// path lookup. Debug is deliberately redacted because command routing logs +/// the request value. +#[derive(Clone, Deserialize, Serialize)] +pub struct SecretString(pub String); + +impl std::fmt::Debug for SecretString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + // --------------------------------------------------------------------------- // Client → Daemon // --------------------------------------------------------------------------- @@ -36,6 +48,21 @@ pub enum LocalRequest { CreateUser { username: String, }, + AttachUserFromTu { + credential: SecretString, + }, + PurgeUserData { + user_id: i64, + }, + ReleaseUser { + user_id: i64, + }, + CompleteDeleteUser { + user_id: i64, + credential: Option, + }, + /// Retained only to return an actionable deprecation error to old IPC + /// clients. It must never select lifecycle semantics implicitly. RemoveUser { user_id: i64, }, @@ -137,7 +164,9 @@ pub enum ResponsePayload { Tasks(Vec), Users(Vec), UserCreated { user_id: i64, username: String }, + /// Retained only for wire compatibility. New lifecycle code never emits it. UserRemoved { user_id: i64 }, + UserDataPurged { user_id: i64 }, Acknowledged { message: String }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), @@ -174,6 +203,9 @@ pub struct UserDetailResponse { pub display_name: Option, pub created_at: i64, pub trusted_apps: Vec, + pub state: LocalUserState, + pub data_present: bool, + pub credential_present: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -208,6 +240,16 @@ pub struct TaskSummary { pub struct UserSummary { pub user_id: i64, pub username: String, + pub state: LocalUserState, + pub data_present: bool, + pub credential_present: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LocalUserState { + Managed, + Released, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index af842e5..ccd3c9e 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,10 +1,26 @@ use crate::users::user_profile::UserProfile; use crate::util::db; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64}; -use iota_util::file_util::{load_file, save_file}; -use rand_core::{OsRng, RngCore}; +use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; use rusqlite::params; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LocalUserState { + Managed, + Released, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserResidency { + pub user_id: i64, + pub username: String, + pub state: LocalUserState, + pub data_present: bool, +} + +fn now_millis() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64 +} pub fn add_user(user: UserProfile) { if let Err(e) = try_add_user(user) { @@ -45,6 +61,12 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora params![user.user_id, app_id, app_secret], )?; } + conn.execute( + r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) + VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#, + params![user.user_id, user.username, now_millis()], + )?; Ok(()) }) } @@ -205,42 +227,92 @@ pub fn remove_user(user_id: i64) { } } +/// Remove only local management authority. Hosted content is intentionally +/// retained and is indexed as released for a later purge operation. +pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + let username = get_user(user_id).map(|user| user.username).ok_or_else(|| { + crate::storage_error::StorageError::Other("managed user was not found".into()) + })?; + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + tx.execute( + r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) + VALUES (?1, ?2, 'released', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'released', updated_at = excluded.updated_at"#, + params![user_id, username, now_millis()], + )?; + tx.commit()?; + Ok(()) + })?; + remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +/// Authoritative hosted-data erasure used by local purge and future Omega +/// erasure delivery. Management metadata and credentials are left intact. +pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; + tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; + tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", + params![user_id, now_millis()], + )?; + tx.commit()?; + Ok(()) + })?; + crate::util::e2ee_storage::purge_user(user_id) + .map_err(crate::storage_error::StorageError::Other)?; + delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +/// Complete local erasure is idempotent and is the target for a durable +/// Omega-hosted erasure request after account deletion. +pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + purge_user_data(user_id)?; + db::with_db(|conn| { + conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?; + Ok(()) + })?; + remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +pub fn get_residency() -> Vec { + db::with_db(|conn| { + let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?; + let rows = stmt.query_map([], |row| { + let lifecycle: String = row.get(2)?; + Ok(UserResidency { + user_id: row.get(0)?, username: row.get(1)?, + state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released }, + data_present: row.get::<_, String>(3)? == "present", + }) + })?; + rows.collect::, _>>().map_err(Into::into) + }).unwrap_or_default() +} + pub fn clear() { if let Err(e) = db::with_db(|conn| { - conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?; + conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?; Ok(()) }) { eprintln!("Failed to clear users: {}", e); } } -#[allow(dead_code)] -pub async fn load_from_tu(username: &str) -> Result<(), ()> { - let file_content = load_file("", &format!("{}.tu", username)); - let segments = file_content.split("::").collect::>(); - let (uuid_str, _omega_host) = segments[0].split_once('@').unwrap_or((segments[0], "")); - let uuid = uuid_str.parse::().unwrap_or(0); - let b64_private_key = segments[1]; - - let keyring = keyring_from_base64(b64_private_key).unwrap(); - let pub_key_bundle = keyring.public_key_bundle(); - let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); - - let mut bytes = [0u8; 192]; - OsRng.fill_bytes(&mut bytes); - let reset_token = STANDARD.encode(&bytes); - - let user_profile = UserProfile::new( - uuid, - username.to_string(), - Some(username.to_string()), - public_key_bundle_to_base64(&pub_key_bundle), - hex_hash(&keyring_b64), - reset_token, - ); - add_user(user_profile); - Ok(()) -} pub fn save_users() { // No-op: users are auto-saved via SQLite. diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index 2ecb9cc..5f2abd5 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -1,7 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose}; -use iota_util::file_util::{has_file, load_file, used_dir_space}; +use iota_util::file_util::{read_user_credential_with_legacy, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; @@ -55,9 +55,11 @@ impl UserProfile { if let Some(d) = &self.display_name { obj["display_name"] = d.clone().into(); } - if has_file("", &format!("{}.tu", self.username.clone())) { - obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); - } + // Frontend consumers must never receive private credential material. + obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username) + .map(|credential| credential.is_some()) + .unwrap_or(false) + .into(); obj } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 1a4ca7e..82968e7 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -251,6 +251,21 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 7 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS user_residency ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')), + data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')), + updated_at INTEGER NOT NULL + ); + PRAGMA user_version = 7; + "#, + )?; + } + Ok(()) } @@ -320,7 +335,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); + assert_eq!(version, 7); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -337,8 +352,8 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); - for table in ["sync_heads", "sync_events", "client_sync_state"] { + assert_eq!(version, 7); + for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 9d628d4..6b13018 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -188,6 +188,23 @@ pub fn delete_pending_chat_secret_forward( }) } +/// Erase every E2EE record owned by, or queued for, a user. The operation is +/// intentionally idempotent so it can be retried after an interrupted remote +/// erasure request. +pub fn purge_user(user_id: i64) -> Result<(), StorageError> { + let user_id = user_id.to_string(); + db::with_conn(&E2EE_DB, |conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1", + params![user_id], + )?; + tx.commit()?; + Ok(()) + }) +} + pub fn get_chat_secret(query: ChatSecretQuery) -> Result, StorageError> { if query.user_id.is_empty() || query.chat_id.is_empty() { return Ok(None); diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 1e1cad4..8412b10 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -33,11 +33,70 @@ fn delete_dir_recursive(directory: &Path) -> bool { } #[allow(dead_code)] -pub fn delete_user_directory(user_id: i64) { +pub fn delete_user_directory(user_id: i64) -> io::Result<()> { let user_dir = Path::new(&get_directory()) .join("users") .join(user_id.to_string()); - let _ = delete_dir_recursive(&user_dir); + if !user_dir.exists() { + return Ok(()); + } + fs::remove_dir_all(user_dir) +} + +pub fn credential_path(user_id: i64) -> PathBuf { + storage_directory().join("credentials").join(format!("{user_id}.tu")) +} + +pub fn read_user_credential(user_id: i64) -> io::Result> { + let path = credential_path(user_id); + match fs::read_to_string(path) { + Ok(value) => Ok(Some(value)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +/// Resolve a credential by immutable account id. A valid legacy +/// `.tu` is migrated atomically the first time it is encountered. +pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result> { + if let Some(credential) = read_user_credential(user_id)? { + return Ok(Some(credential)); + } + let legacy = storage_file("", format!("{username}.tu"))?; + let credential = match fs::read_to_string(&legacy) { + Ok(value) => value, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let parsed = crate::tu::TuCredential::parse(&credential) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if parsed.user_id != user_id { + return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch")); + } + write_user_credential(user_id, &parsed.to_canonical_string())?; + fs::remove_file(legacy)?; + Ok(Some(parsed.to_canonical_string())) +} + +pub fn write_user_credential(user_id: i64, credential: &str) -> io::Result<()> { + let path = credential_path(user_id); + let parent = path.parent().expect("credential path has parent"); + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".{user_id}.tu.tmp")); + fs::write(&temporary, credential)?; + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + Ok(()) +} + +pub fn remove_user_credential(user_id: i64) -> io::Result<()> { + match fs::remove_file(credential_path(user_id)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 2277b2b..5d32aef 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,3 +1,4 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; +pub mod tu; diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs new file mode 100644 index 0000000..e1a8334 --- /dev/null +++ b/iota-util/src/tu.rs @@ -0,0 +1,95 @@ +//! Strict parsing and storage-independent handling of user credentials. +//! +//! A `.tu` file is deliberately identified by the account id embedded in its +//! contents. Its filename is presentation data owned by the CLI, never an +//! account authority. + +use crate::crypto_helper::{keyring_from_base64, keyring_to_base64}; +use mtp::crypto::{Keyring, PublicKeyBundle}; +use std::fmt; + +pub const MAX_PROTOCOL_ID: i64 = (1_i64 << 48) - 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuError { + InvalidFormat, + InvalidUserId, + InvalidKeyring, +} + +impl fmt::Display for TuError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidFormat => "invalid .tu credential format", + Self::InvalidUserId => "invalid .tu user id", + Self::InvalidKeyring => "invalid .tu keyring", + }) + } +} + +impl std::error::Error for TuError {} + +pub struct TuCredential { + pub user_id: i64, + pub omega_host: String, + pub keyring: Keyring, +} + +impl fmt::Debug for TuCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TuCredential") + .field("user_id", &self.user_id) + .field("omega_host", &self.omega_host) + .field("keyring", &"") + .finish() + } +} + +impl TuCredential { + pub fn parse(input: &str) -> Result { + let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?; + if encoded_keyring.is_empty() || encoded_keyring.contains("::") { + return Err(TuError::InvalidFormat); + } + let (user_id, omega_host) = identity.split_once('@').ok_or(TuError::InvalidFormat)?; + if omega_host.trim().is_empty() || omega_host.contains('@') { + return Err(TuError::InvalidFormat); + } + let user_id = user_id.parse::().map_err(|_| TuError::InvalidUserId)?; + if !(1..=MAX_PROTOCOL_ID).contains(&user_id) { + return Err(TuError::InvalidUserId); + } + let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; + Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring }) + } + + pub fn public_key_bundle(&self) -> PublicKeyBundle { + self.keyring.public_key_bundle() + } + + pub fn to_canonical_string(&self) -> String { + format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto_helper::generate_keyring; + + #[test] + fn round_trip_is_canonical() { + let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() }; + let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); + assert_eq!(parsed.user_id, 42); + assert_eq!(parsed.omega_host, "omega.example:443"); + assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string()); + } + + #[test] + fn rejects_malformed_credentials() { + for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] { + assert!(TuCredential::parse(value).is_err(), "{value}"); + } + } +} diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 3c38d65..b05cfe8 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -11,6 +11,7 @@ iota-core = { path = "../iota-core" } iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } iota-terms = { path = "../iota-terms" } +iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 48c8ead..b1447a0 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,6 +1,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; use iota_terms::TermsType; +use std::path::PathBuf; #[derive(Debug)] pub struct CliInvocation { @@ -115,15 +116,33 @@ enum UsersAction { user_id: i64, }, Add { - username: String, + username: Option, + #[arg(long, value_name = "PATH")] + tu: Option, }, - Remove { + Release { user_id: i64, #[arg(long)] yes: bool, }, - Import { - username: String, + Data { + #[command(subcommand)] + action: UserDataAction, + }, + CompleteDelete { + user_id: i64, + #[arg(long, value_name = "PATH")] + tu: Option, + #[arg(long)] + yes: bool, + }, +} +#[derive(Subcommand, Debug)] +enum UserDataAction { + Purge { + user_id: i64, + #[arg(long)] + yes: bool, }, } #[derive(Args, Debug)] @@ -264,14 +283,21 @@ pub enum Command { user_id: i64, }, UsersAdd { - username: String, + username: Option, + tu: Option, }, - UsersRemove { + UsersRelease { user_id: i64, confirmed: bool, }, - UsersImport { - username: String, + UsersPurgeData { + user_id: i64, + confirmed: bool, + }, + UsersCompleteDelete { + user_id: i64, + tu: Option, + confirmed: bool, }, OmikronReconnect, IdentityRotate { @@ -367,12 +393,29 @@ impl CliInvocation { Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, - UsersAction::Add { username } => Command::UsersAdd { username }, - UsersAction::Remove { user_id, yes } => Command::UsersRemove { + UsersAction::Add { username, tu } => { + if username.is_some() == tu.is_some() { + return Err( + "users add requires exactly one of or --tu ".into(), + ); + } + Command::UsersAdd { username, tu } + } + UsersAction::Release { user_id, yes } => Command::UsersRelease { user_id, confirmed: resolve_confirmed(yes), }, - UsersAction::Import { username } => Command::UsersImport { username }, + UsersAction::Data { + action: UserDataAction::Purge { user_id, yes }, + } => Command::UsersPurgeData { + user_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete { + user_id, + tu, + confirmed: resolve_confirmed(yes), + }, }, Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, @@ -445,6 +488,7 @@ impl CliInvocation { } } + #[allow(unused)] pub fn help_text() -> String { Cli::command().render_long_help().to_string() } @@ -528,7 +572,7 @@ mod tests { #[test] fn command_schema_drives_help_and_completion_paths() { let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users remove".to_owned())); + assert!(paths.contains(&"users release".to_owned())); assert!(paths.contains(&"daemon install".to_owned())); let help = CliInvocation::help_text(); assert!(help.contains("users")); @@ -558,12 +602,7 @@ mod tests { #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::DaemonStop { - confirmed: true - } - ); + assert_eq!(invocation.command, Command::DaemonStop { confirmed: true }); } #[test] @@ -573,7 +612,8 @@ mod tests { assert_eq!( invocation.command, Command::UsersAdd { - username: "alice".into() + username: Some("alice".into()), + tu: None, } ); } @@ -597,26 +637,24 @@ mod tests { } #[test] - fn parses_users_remove_without_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::UsersRemove { - user_id: 42, - confirmed: true, - } - ); + fn rejects_ambiguous_users_remove() { + let error = + CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err(); + assert!(error.contains("remove")); } #[test] - fn parses_users_remove_with_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) - .unwrap(); + fn parses_users_release_with_confirmation() { + let invocation = CliInvocation::parse([ + "users".into(), + "release".into(), + "42".into(), + "--yes".into(), + ]) + .unwrap(); assert_eq!( invocation.command, - Command::UsersRemove { + Command::UsersRelease { user_id: 42, confirmed: true, } diff --git a/iota/src/main.rs b/iota/src/main.rs index 192f1c8..e05b63f 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -403,7 +403,9 @@ fn print_help() { println!(" users list List all users"); println!(" users show Show user details"); println!(" users add Create a new user"); - println!(" users remove Remove a user (requires --yes)"); + println!(" users add --tu Add an existing account credential"); + println!(" users data purge Purge hosted data (requires --yes)"); + println!(" users release Release this Iota (requires --yes)"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -451,7 +453,7 @@ fn print_help() { println!(" iota status Show daemon status"); println!(" iota users list --output=json List users in JSON format"); println!(" iota users add alice Create a user named 'alice'"); - println!(" iota users remove 42 --yes Remove user 42"); + println!(" iota users data purge 42 --yes Purge hosted data"); println!(" iota config get --output=yaml Show config in YAML format"); println!(" iota logs --limit 50 Show last 50 log entries"); println!(" iota completions bash Generate bash completions"); @@ -564,12 +566,40 @@ async fn run_command( Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { username } => LocalRequest::CreateUser { username }, - Command::UsersRemove { + Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username }, + Command::UsersAdd { username: None, tu: Some(path) } => { + let contents = std::fs::read_to_string(&path) + .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; + iota_util::tu::TuCredential::parse(&contents) + .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) } + } + Command::UsersAdd { .. } => { + return Err(StartupError::InvalidCommand( + "users add requires exactly one of or --tu ".into(), + )); + } + Command::UsersRelease { user_id, confirmed: true, - } => LocalRequest::RemoveUser { user_id }, - Command::UsersImport { username } => LocalRequest::ImportUser { username }, + } => LocalRequest::ReleaseUser { user_id }, + Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id }, + Command::UsersCompleteDelete { user_id, tu, confirmed: true } => { + let credential = match tu { + Some(path) => { + let contents = std::fs::read_to_string(&path) + .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; + let parsed = iota_util::tu::TuCredential::parse(&contents) + .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + if parsed.user_id != user_id { + return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into())); + } + Some(iota_ipc::SecretString(contents)) + } + None => None, + }; + LocalRequest::CompleteDeleteUser { user_id, credential } + } Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -588,9 +618,11 @@ async fn run_command( Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::UpdateCheck => LocalRequest::CheckUpdate, Command::CommunityList => LocalRequest::ListCommunities, - Command::UsersRemove { + Command::UsersRelease { confirmed: false, .. } + | Command::UsersPurgeData { confirmed: false, .. } + | Command::UsersCompleteDelete { confirmed: false, .. } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -689,6 +721,9 @@ async fn run_command( user_id ); } + ResponsePayload::UserDataPurged { user_id } => { + println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id); + } ResponsePayload::Acknowledged { message } => { println!("{}", message); } @@ -935,6 +970,9 @@ fn render_table(payload: &ResponsePayload) { ResponsePayload::UserRemoved { user_id } => { println!("Removed user {}", user_id); } + ResponsePayload::UserDataPurged { user_id } => { + println!("Purged hosted data for {}", user_id); + } ResponsePayload::Acknowledged { message } => { println!("{}", message); } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 81d6ae6..a555398 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -896,12 +896,41 @@ impl OmikronConnection { dispatch!(SettingsSave, handle_settings_save); dispatch!(SettingsLoad, handle_settings_load); dispatch!(SettingsList, handle_settings_list); + dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); } // ------------------------------------------------------------------------- // Message Handlers // ------------------------------------------------------------------------- + /// Omega-authorized account cleanup. The storage operation is idempotent; + /// acknowledgement is therefore safe to retry after a reconnect. + async fn handle_erase_hosted_user_data(self: Arc, cv: &CommunicationValue) { + let Some(user_id) = cv + .get_data(DataType::UserId) + .as_signed_number() + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| *id > 0) + else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + + if iota_storage::users::user_manager::erase_user_locally(user_id).is_err() { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + + let acknowledgement = CommunicationValue::new(CommunicationType::EraseHostedUserDataAck) + .with_id(cv.get_id()) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); + let _ = self.send_message(&acknowledgement).await; + } + async fn handle_set_chat_secret(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender().to_string(); let recipients = match chat_secret_recipients(cv) { diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index fde043f..241fd46 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -2,9 +2,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_profile::UserProfile; +use iota_storage::util::config_util::CONFIG; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::try_save_file; +use iota_util::file_util::write_user_credential; +use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; use rand_core::{OsRng, RngCore}; use std::time::Duration; @@ -20,6 +23,133 @@ pub enum CreateUserError { LocalPersistence(String), } +#[derive(Debug)] +pub enum LifecycleUserError { + InvalidCredential(String), + OmegaHostMismatch, + RemoteRejected, + Transport(crate::OmikronError), + LocalPersistence(String), +} + +impl From for LifecycleUserError { + fn from(value: crate::OmikronError) -> Self { Self::Transport(value) } +} + +fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { + let mut payload = Vec::with_capacity(domain.len() + 24); + payload.extend_from_slice(domain); + payload.extend_from_slice(&user_id.to_be_bytes()); + payload.extend_from_slice(&iota_id.to_be_bytes()); + payload.extend_from_slice(&nonce.to_be_bytes()); + payload +} + +fn configured_iota_id() -> Result { + CONFIG.load().iota_id + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| *id > 0) + .ok_or_else(|| LifecycleUserError::InvalidCredential("Iota identity is not registered".into())) +} + +fn sign_lifecycle_payload(credential: &TuCredential, payload: &[u8]) -> Result<(Vec, Vec), LifecycleUserError> { + let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let pq = MlDsaSigner::new(&credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + Ok((classical, pq)) +} + +async fn inspect_credential_account( + connection: &dyn OmikronClient, + credential: &TuCredential, +) -> Result<(String, String), LifecycleUserError> { + if credential.omega_host != omega_discovery::omega_host() { + return Err(LifecycleUserError::OmegaHostMismatch); + } + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); + let response = connection.await_response(&request, Duration::from_secs(20)).await?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(LifecycleUserError::RemoteRejected); + } + let username = response.get_data(DataType::Username).as_str().map(str::to_owned) + .ok_or(LifecycleUserError::RemoteRejected)?; + let public_key = response.get_data(DataType::PublicKey).as_str().map(str::to_owned) + .ok_or(LifecycleUserError::RemoteRejected)?; + if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { + return Err(LifecycleUserError::RemoteRejected); + } + Ok((username, public_key)) +} + +async fn credential_proof( + connection: &dyn OmikronClient, + credential: &TuCredential, + begin: CommunicationType, + challenge: CommunicationType, + complete: CommunicationType, + domain: &[u8], +) -> Result<(), LifecycleUserError> { + let iota_id = configured_iota_id()?; + let begin_request = CommunicationValue::new(begin) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); + let challenge_response = connection.await_response(&begin_request, Duration::from_secs(20)).await?; + if !challenge_response.is_type(challenge) { + return Err(LifecycleUserError::RemoteRejected); + } + let nonce = challenge_response.get_data(DataType::ServerNonce).as_signed_number() + .and_then(|value| u64::try_from(value).ok()) + .ok_or(LifecycleUserError::RemoteRejected)?; + let (signature, pq_signature) = sign_lifecycle_payload(credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce))?; + let complete_request = CommunicationValue::new(complete) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())) + .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) + .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); + let response = connection.await_response(&complete_request, Duration::from_secs(20)).await?; + if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) } +} + +/// Attach or migrate an existing account. Local state is written only after +/// Omega has accepted the credential proof and changed its assignment. +pub async fn attach_user_from_tu(connection: &dyn OmikronClient, contents: &str) -> Result { + let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let (username, public_key) = inspect_credential_account(connection, &credential).await?; + credential_proof(connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0").await?; + let profile = UserProfile::new(credential.user_id, username, None, public_key, hex_hash(contents), String::new()); + write_user_credential(profile.user_id, &credential.to_canonical_string()) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + try_add_user(profile.clone()).map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + Ok(profile) +} + +pub async fn complete_delete_user_with_tu(connection: &dyn OmikronClient, contents: &str, expected_user_id: i64) -> Result<(), LifecycleUserError> { + let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + if credential.user_id != expected_user_id { return Err(LifecycleUserError::InvalidCredential("credential user ID does not match deletion target".into())); } + inspect_credential_account(connection, &credential).await?; + credential_proof(connection, &credential, CommunicationType::DeleteUserCredentialBegin, CommunicationType::DeleteUserCredentialChallenge, CommunicationType::DeleteUserCredentialComplete, b"tensamin:user-delete:v1\0").await +} + +/// Repair local management state after a release or migration committed in +/// Omega but local cleanup was interrupted. Hosted data is retained. +pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { + let Ok(local_iota_id) = configured_iota_id() else { return; }; + for user in iota_storage::users::user_manager::get_users() { + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user.user_id.into())); + let Ok(response) = connection.await_response(&request, Duration::from_secs(10)).await else { continue; }; + let remote_iota_id = response.get_data(DataType::IotaId).as_signed_number().and_then(|value| i64::try_from(value).ok()); + if remote_iota_id != Some(local_iota_id) { + let _ = iota_storage::users::user_manager::release_user(user.user_id); + } + } +} + fn valid_username(username: &str) -> bool { !username.is_empty() && username.chars().count() <= 15 @@ -136,15 +266,9 @@ pub async fn create_user( } } log!("Created User"); - try_save_file( - "", - &format!("{}.tu", username), - &format!( - "{}@{}::{}", - user_id, - omega_discovery::omega_host(), - keyring_b64 - ), + write_user_credential( + user_id, + &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), ) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; From e1dd86ec0233252cb9412a0f41ec3beb13e737d5 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:39:02 +0200 Subject: [PATCH 112/119] [WIP] 0.3.0 mtp update --- Cargo.lock | 342 +++--- client/Cargo.toml | 2 +- client/src/client_connection.rs | 253 +--- communities/src/community_connection.rs | 1 + communities/src/interactables/text_chat.rs | 1 + communities/src/interactables/voice_chat.rs | 1 + iota-cli/Cargo.toml | 2 +- iota-cli/src/controls/dialog.rs | 8 +- iota-cli/src/controls/header.rs | 44 +- iota-cli/src/screens/settings.rs | 3 +- iota-cli/src/screens/users.rs | 29 +- iota-cli/src/ui.rs | 16 +- iota-connection/Cargo.toml | 5 +- iota-connection/src/lib.rs | 1 + iota-connection/src/message_common.rs | 79 +- iota-connection/src/message_handlers.rs | 174 ++- iota-connection/src/relay.rs | 350 ++++++ iota-daemon-lib/src/command_router.rs | 131 ++- iota-daemon/src/main.rs | 10 +- iota-ipc/src/lib.rs | 7 +- iota-ipc/src/protocol.rs | 17 +- iota-logger/src/lib.rs | 31 +- iota-storage/src/users/user_manager.rs | 69 +- iota-storage/src/util/db.rs | 101 +- iota-storage/src/util/e2ee_storage.rs | 120 +- iota-storage/src/util/mod.rs | 2 + iota-storage/src/util/relay_queue.rs | 135 +++ iota-storage/src/util/relay_replay.rs | 152 +++ iota-util/Cargo.toml | 2 +- iota-util/src/crypto_util.rs | 82 +- iota-util/src/file_util.rs | 14 +- iota-util/src/lib.rs | 2 + iota-util/src/mtp_compat.rs | 67 ++ iota-util/src/route_target.rs | 44 + iota-util/src/tu.rs | 39 +- iota/src/cli_color.rs | 6 +- iota/src/main.rs | 157 +-- iota/src/startup_error.rs | 52 +- omikron-connector/Cargo.toml | 1 + omikron-connector/src/omikron_connection.rs | 1147 ++++++++----------- omikron-connector/src/user_ops.rs | 168 ++- web-server/Cargo.toml | 2 +- 42 files changed, 2431 insertions(+), 1438 deletions(-) create mode 100644 iota-connection/src/relay.rs create mode 100644 iota-storage/src/util/relay_queue.rs create mode 100644 iota-storage/src/util/relay_replay.rs create mode 100644 iota-util/src/mtp_compat.rs create mode 100644 iota-util/src/route_target.rs diff --git a/Cargo.lock b/Cargo.lock index 1003ddf..7405bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.2" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53200bd1513e569e6e644181c922cec072c121a27db5b38d45e88e630c369366" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.0" +version = "4.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +checksum = "58356675d8c86d2e720480645a0316808471a62d0073f6a3b98810a5e0ca0e73" dependencies = [ "actix-codec", "actix-http", @@ -410,6 +410,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -422,7 +434,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -451,9 +463,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -559,6 +571,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -657,9 +678,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -1493,9 +1514,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "finl_unicode" @@ -1564,9 +1585,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1579,9 +1600,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1589,15 +1610,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1606,38 +1627,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1736,9 +1757,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1957,9 +1978,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2000,7 +2021,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.15", + "h2 0.4.16", "http 1.5.0", "http-body", "httparse", @@ -2078,9 +2099,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2092,9 +2113,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2105,9 +2126,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2119,16 +2140,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2139,15 +2161,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -2371,6 +2393,7 @@ dependencies = [ "iota-storage", "iota-util", "mtp", + "tokio", ] [[package]] @@ -2526,7 +2549,7 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sysinfo", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "uuid", "walkdir", @@ -2659,7 +2682,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2708,9 +2731,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2731,7 +2754,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2791,9 +2814,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "pkg-config", "vcpkg", @@ -2816,9 +2839,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -3013,8 +3036,8 @@ dependencies = [ [[package]] name = "mtp" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-client", "mtp-codec", @@ -3029,8 +3052,8 @@ dependencies = [ [[package]] name = "mtp-client" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-codec", "mtp-common", @@ -3042,8 +3065,8 @@ dependencies = [ [[package]] name = "mtp-codec" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "base64 0.23.1", "byteorder", @@ -3051,25 +3074,27 @@ dependencies = [ "mtp-crypto", "mtp-type-map", "rand 0.10.2", + "thiserror 2.0.20", ] [[package]] name = "mtp-common" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "quinn", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "wtransport", ] [[package]] name = "mtp-crypto" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ - "base64 0.23.1", + "argon2", + "base64 0.22.1", "chacha20poly1305", "ed25519-dalek 3.0.0", "getrandom 0.4.3", @@ -3077,36 +3102,37 @@ dependencies = [ "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.10.1", + "rand_core 0.6.4", "rustls", "serde", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 1.0.69", "tokio", "zeroize", ] [[package]] name = "mtp-files" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-crypto", "rand 0.10.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "zeroize", ] [[package]] name = "mtp-host" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", "rand 0.10.2", + "thiserror 2.0.20", "tokio", "tracing", "wtransport", @@ -3114,13 +3140,14 @@ dependencies = [ [[package]] name = "mtp-transport" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "async-trait", "mtp-codec", "mtp-common", "mtp-crypto", + "rand 0.10.2", "rcgen", "rustls", "rustls-native-certs", @@ -3128,12 +3155,13 @@ dependencies = [ "tokio", "tracing", "wtransport", + "zeroize", ] [[package]] name = "mtp-type-map" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "serde", "serde_yaml", @@ -3141,8 +3169,8 @@ dependencies = [ [[package]] name = "mtp-webserver" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "async-trait", "bytes", @@ -3159,9 +3187,8 @@ dependencies = [ "mtp-host", "mtp-transport", "quinn", - "rand 0.10.2", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -3252,9 +3279,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -3561,6 +3588,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3598,9 +3636,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -3608,9 +3646,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -3618,9 +3656,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -3631,9 +3669,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -3738,9 +3776,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pnet" @@ -3858,15 +3896,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -3916,7 +3954,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3924,9 +3962,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -3941,7 +3979,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4087,7 +4125,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4158,9 +4196,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "aws-lc-rs", "pem", @@ -4225,7 +4263,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.15", + "h2 0.4.16", "http 1.5.0", "http-body", "http-body-util", @@ -4274,14 +4312,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -4405,9 +4443,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -5006,11 +5044,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -5026,9 +5064,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -5069,9 +5107,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -5277,7 +5315,7 @@ dependencies = [ "native-tls", "rand 0.10.2", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5387,9 +5425,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5481,9 +5519,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5494,9 +5532,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5504,9 +5542,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5514,9 +5552,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -5527,9 +5565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5549,9 +5587,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -5941,15 +5979,15 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wtransport" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" dependencies = [ "bytes", "pem", @@ -5960,7 +5998,7 @@ dependencies = [ "rustls-pki-types", "sha2 0.11.0", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -5971,13 +6009,13 @@ dependencies = [ [[package]] name = "wtransport-proto" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -6019,7 +6057,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -6119,9 +6157,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6130,9 +6168,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -6141,13 +6179,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index 174b5fa..d63431f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client", "crypto"] } iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 512cc31..a90df86 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -1,14 +1,14 @@ use dashmap::DashMap; use iota_connection::message_common::*; use iota_connection::message_handlers; +use iota_connection::relay::message_security_class; use iota_logger::{log_cv_in, log_cv_out, log_t}; -use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::CONFIG; -use iota_storage::util::e2ee_storage::{self, StoredChatSecret}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; use std::sync::Arc; use std::time::Duration; @@ -30,6 +30,7 @@ pub struct ClientConnection { pub waiting_tasks: DashMap, CommunicationValue) -> bool + Send + Sync>>, shutdown: Arc>, + keyring: Arc>>>, } impl ClientConnection { @@ -53,9 +54,31 @@ impl ClientConnection { shutdown_tx, waiting_tasks, shutdown, + keyring: Arc::new(RwLock::new(None)), } } + pub async fn set_keyring(&self, keyring: Arc) { + *self.keyring.write().await = Some(keyring); + } + + async fn local_keyring(&self) -> Result, String> { + if let Some(keyring) = self.keyring.read().await.as_ref().cloned() { + return Ok(keyring); + } + + let keyring_data = CONFIG + .load() + .keyring + .clone() + .ok_or_else(|| "Iota keyring is not configured".to_string())?; + let keyring = keyring_from_base64(&keyring_data) + .ok_or_else(|| "Iota keyring is invalid".to_string())?; + let keyring = Arc::new(keyring); + *self.keyring.write().await = Some(keyring.clone()); + Ok(keyring) + } + pub fn start(self: Arc) { let self_clone = self.clone(); tokio::spawn(async move { @@ -96,6 +119,27 @@ impl ClientConnection { pub async fn handle_message(self: Arc, cv: CommunicationValue) { log_cv_in!(&cv); + if cv.is_type(CommunicationType::Relay) { + log_t!( + "relay_from_client_rejected", + "legacy client path has no Relay router".to_string() + ); + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + + if matches!( + message_security_class(&cv), + iota_connection::relay::MessageSecurityClass::RelayOnly + ) { + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + let _msg_id = cv.get_id(); if cv.is_type(CommunicationType::Challenge) { @@ -103,82 +147,12 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::SetChatSecret) { - let sender_id = cv.get_sender().to_string(); - let recipients = match chat_secret_recipients(&cv) { - Some(recipients) => recipients, - None => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let now = now_millis_i64(); - let chat_id = data_string(&cv, DataType::ChatId); - let secret_id = data_string(&cv, DataType::SecretId); - let version = data_i64(&cv, DataType::VersionNumber); - let wrapping_scheme = data_string(&cv, DataType::WrappingScheme); - let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now); - - let Some((((chat_id, secret_id), version), wrapping_scheme)) = - chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) - else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - for recipient in recipients.iter().filter(|item| item.user_id == sender_id) { - if e2ee_storage::put_chat_secret(StoredChatSecret { - user_id: recipient.user_id.clone(), - chat_id: chat_id.clone(), - secret_id: secret_id.clone(), - version, - encrypted_secret: recipient.encrypted_secret.clone(), - kem_ciphertext: recipient.kem_ciphertext.clone(), - wrapping_scheme: wrapping_scheme.clone(), - created_at, - updated_at: now, - }) - .is_err() - { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - } - - for recipient in recipients.iter().filter(|item| item.user_id != sender_id) { - self.send_message(&set_chat_secret_cv_for_recipient(&cv, recipient)) - .await; - } - - self.send_message(&error_response(&cv, CommunicationType::Success)) - .await; - return; - } - if cv.is_type(CommunicationType::GetChatSecret) { self.send_message(&message_handlers::handle_get_chat_secret(&cv)) .await; return; } - if cv.is_type(CommunicationType::ChatSecretForward) { - let sender_id = cv.get_sender().to_string(); - let recipient_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default(); - if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) - || recipient_id.is_empty() - { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - self.send_message(&cv.with_receiver(recipient_id.parse::().unwrap_or(0))) - .await; - return; - } - if cv.is_type(CommunicationType::SaveAppData) { let sender_id = cv.get_sender(); let _app_data = cv @@ -270,137 +244,6 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::MessageOtherIota) { - let sender_id = &cv.get_sender(); - let receiver_id = &cv.get_receiver(); - - // parse send_time safely (number or string), fallback to now - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = now_millis_i64(); - let timestamp = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; - - // content may be missing or non-string; default to empty string - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - chat_files::add_message( - timestamp as u128, - false, - *receiver_id as i64, - *sender_id as i64, - &content, - height, - reply_to, - ); - - // Build user_forward using the parsed numeric timestamp and safe content string - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(cv.get_id()) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default(DataType::Message, { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }); - - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) - .await; - - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - let _ = change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - ms.clone(), - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } else { - // Delivery timed out/failed — update stored state and notify sender with numeric timestamp - let _ = chat_files::change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - MessageState::Sent, - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - return; - } - if cv.is_type(CommunicationType::MessagesGet) { self.send_message(&message_handlers::handle_messages_get(&cv)) .await; @@ -503,9 +346,7 @@ impl ClientConnection { } async fn handle_challenge(&self, cv: &CommunicationValue) { - let kr_str = CONFIG.load().keyring.clone().unwrap(); - - let Some(keyring) = keyring_from_base64(&kr_str) else { + let Ok(keyring) = self.local_keyring().await else { return; }; diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 36a23c0..685d9d8 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -2,6 +2,7 @@ use crate::auth::auth_user::AuthUser; use crate::communities::community::Community; use crate::communities::interactables::interactable::Interactable; use crate::users::user_manager::get_user; +use iota_util::mtp_compat::CommunicationValueCompat; use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures::SinkExt; diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index 967f27b..6b69baa 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -8,6 +8,7 @@ use crate::{ }; use async_trait::async_trait; use json::{JsonValue, array, object}; +use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; use std::fs; use std::path::Path; use std::sync::Arc; diff --git a/communities/src/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs index 5e83181..7e20022 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -1,6 +1,7 @@ use crate::communities::{community::Community, interactables::interactable::Interactable}; use async_trait::async_trait; use json::JsonValue; +use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; use std::sync::Arc; use std::{any::Any, sync::RwLock}; use uuid::Uuid; diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 4c3d508..4489cdf 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -23,7 +23,7 @@ iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } omikron-connector = { path = "../omikron-connector", optional = true } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } actix-web = { version = "4", features = ["rustls-0_23"] } diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs index eb750ef..3756641 100644 --- a/iota-cli/src/controls/dialog.rs +++ b/iota-cli/src/controls/dialog.rs @@ -189,9 +189,7 @@ impl Screen for ConfirmDialog { let button_widths: Vec = self .buttons .iter() - .map(|b| { - crate::controls::button::button_minimum_width(&b.label) - }) + .map(|b| crate::controls::button::button_minimum_width(&b.label)) .collect(); let total_width: u16 = button_widths.iter().sum(); @@ -200,9 +198,7 @@ impl Screen for ConfirmDialog { let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; let mut x = start_x; - for (i, (button_config, &width)) in - self.buttons.iter().zip(&button_widths).enumerate() - { + for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() { let button_area = Rect { x, y: buttons_area.y, diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index 09a589f..c38eda4 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -11,12 +11,17 @@ use ratatui::{ widgets::Paragraph, }; -fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) { +fn connection_badge( + state: &IpcConnectionState, + theme: &ResolvedTheme, +) -> (&'static str, ratatui::style::Style) { match state { IpcConnectionState::Connected => ("OK", theme.status.success), IpcConnectionState::Connecting => ("..", theme.status.warning), IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { + ("FAIL", theme.status.error) + } IpcConnectionState::Disconnected => ("WARN", theme.status.warning), } } @@ -52,7 +57,8 @@ pub fn render_header( format!(" v{}", daemon.version) }; - let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + let rows = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); let cells = Layout::horizontal([ Constraint::Min(28), Constraint::Length(12), @@ -94,10 +100,34 @@ pub fn render_header( hits.register(brand_area, AppAction::OpenMain); for (index, (top, _bottom, label, intent, action)) in [ - (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview), - (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers), - (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings), - (cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit), + ( + cells[1], + cells2[1], + "Overview", + ButtonIntent::Primary, + AppAction::OpenOverview, + ), + ( + cells[2], + cells2[2], + "Users", + ButtonIntent::Neutral, + AppAction::OpenUsers, + ), + ( + cells[3], + cells2[3], + "Settings", + ButtonIntent::Neutral, + AppAction::OpenSettings, + ), + ( + cells[4], + cells2[4], + "Quit", + ButtonIntent::Destructive, + AppAction::Quit, + ), ] .into_iter() .enumerate() diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index 56293c5..a0a37d8 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -186,8 +186,7 @@ impl Screen for SettingsScreen { let inner = header_block.inner(area); frame.render_widget(header_block, area); - let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]) - .split(inner); + let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); frame.render_widget( Paragraph::new(format!( diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index 93d4b13..1ff2689 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -122,10 +122,7 @@ impl UsersScreen { if self.loading { const SPINNERS: &[u8] = b"|/-\\"; let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; - f.render_widget( - Paragraph::new(format!("{ch} Loading users…")), - inner, - ); + f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner); return; } if visible_indices.is_empty() { @@ -149,9 +146,20 @@ impl UsersScreen { let user = &self.users[*user_index]; ( *user_index, - format!("{:>6} {} {}{}", user.user_id, user.username, - match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" }, - if user.data_present { "" } else { ", data purged" }), + format!( + "{:>6} {} {}{}", + user.user_id, + user.username, + match user.state { + iota_ipc::LocalUserState::Managed => "managed", + iota_ipc::LocalUserState::Released => "released", + }, + if user.data_present { + "" + } else { + ", data purged" + } + ), ) }) .collect(); @@ -533,11 +541,14 @@ impl Screen for UsersScreen { match result { Ok(()) => { self.pending_dialog = None; - if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) { + if let Some(user) = + self.users.iter_mut().find(|user| user.user_id == user_id) + { user.state = iota_ipc::LocalUserState::Released; user.credential_present = false; } - self.message = Some(format!("Released user {user_id}; hosted data retained.")); + self.message = + Some(format!("Released user {user_id}; hosted data retained.")); } Err(error) => { self.dialog = self.pending_dialog.take(); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index dad4c80..ece8697 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -306,7 +306,10 @@ impl UI { } pub async fn notifications(&self) -> Vec { - self.notifications.lock().map(|n| n.clone()).unwrap_or_default() + self.notifications + .lock() + .map(|n| n.clone()) + .unwrap_or_default() } pub async fn set_screen(&self, screen: Box) { @@ -611,7 +614,9 @@ impl UI { Ok(iota_ipc::ResponseResult::Error(error)) => { Err(format!("Cannot load users: {error}")) } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Ok(_) => { + Err("Daemon returned an unexpected response while loading users.".into()) + } Err(error) => Err(format!("Cannot load users: {error}")), } }; @@ -705,7 +710,12 @@ impl UI { width: 40.min(rows[1].width), height: 3.min(rows[1].height), }; - render_notification_area(f, notification_area, ¬ifications, context.theme); + render_notification_area( + f, + notification_area, + ¬ifications, + context.theme, + ); } } })?; diff --git a/iota-connection/Cargo.toml b/iota-connection/Cargo.toml index 65eafad..0148648 100644 --- a/iota-connection/Cargo.toml +++ b/iota-connection/Cargo.toml @@ -6,4 +6,7 @@ edition = "2024" [dependencies] iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] } + +[dev-dependencies] +tokio = { version = "1.50.0", features = ["macros", "rt"] } diff --git a/iota-connection/src/lib.rs b/iota-connection/src/lib.rs index 601ca7b..db303ab 100644 --- a/iota-connection/src/lib.rs +++ b/iota-connection/src/lib.rs @@ -1,3 +1,4 @@ pub mod connection_handler; pub mod message_common; pub mod message_handlers; +pub mod relay; diff --git a/iota-connection/src/message_common.rs b/iota-connection/src/message_common.rs index 2ab9733..fff5bf6 100644 --- a/iota-connection/src/message_common.rs +++ b/iota-connection/src/message_common.rs @@ -2,6 +2,8 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::type_map::TypeMap; use std::time::{SystemTime, UNIX_EPOCH}; +pub use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; + pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { use mtp::type_map::{DataTypeId, TypeMap}; let tm = TypeMap::latest(); @@ -15,22 +17,34 @@ pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) - .as_str() + .and_then(DataValue::as_str) .map(|s| s.to_string()) - .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) - .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_number) + .map(|n| n.to_string()) + }) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_signed_number) + .map(|n| n.to_string()) + }) } pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) - .as_number() + .and_then(DataValue::as_number) .and_then(|n| i64::try_from(n).ok()) .or_else(|| { cv.get_data(dt) - .as_signed_number() + .and_then(DataValue::as_signed_number) .and_then(|n| i64::try_from(n).ok()) }) - .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_str) + .and_then(|s| s.parse::().ok()) + }) } #[derive(Debug, Clone)] @@ -67,7 +81,7 @@ pub fn recipient_from_value(value: &DataValue) -> Option { } pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { - let recipients = cv.get_data(DataType::Recipients).as_array()?; + let recipients = cv.get_data(DataType::Recipients)?.as_array()?; let parsed = recipients .iter() .map(recipient_from_value) @@ -80,49 +94,6 @@ pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option CommunicationValue { - let recipient_value = typed_container(vec![ - (DataType::UserId, DataValue::Str(recipient.user_id.clone())), - ( - DataType::EncryptedSecret, - DataValue::Bytes(recipient.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(recipient.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_id(source.get_id()) - .with_sender(source.get_sender()) - .with_receiver(recipient.user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) - .add_typed_default( - DataType::SecretId, - source.get_data(DataType::SecretId).clone(), - ) - .add_typed_default( - DataType::VersionNumber, - source.get_data(DataType::VersionNumber).clone(), - ) - .add_typed_default( - DataType::WrappingScheme, - source.get_data(DataType::WrappingScheme).clone(), - ) - .add_typed_default( - DataType::CreatedAt, - source.get_data(DataType::CreatedAt).clone(), - ) - .add_typed_default( - DataType::Recipients, - DataValue::Array(vec![recipient_value]), - ) -} - pub fn now_millis_i64() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -131,7 +102,9 @@ pub fn now_millis_i64() -> i64 { } pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - CommunicationValue::new(ty) - .with_id(request.get_id()) - .with_receiver(request.get_sender()) + let mut response = CommunicationValue::new(ty).with_id(request.id().unwrap_or_default()); + if let Some(sender) = request.sender() { + response = response.with_receiver(sender); + } + response } diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index dc1d4dc..4d7144f 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -4,7 +4,11 @@ use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::e2ee_storage::{self, ChatSecretQuery}; use iota_storage::util::settings; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent, +}; + +use crate::relay::VerifiedRelayContext; pub struct MessageMutation { pub sender_id: i64, @@ -33,6 +37,170 @@ pub fn success_response(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::Success) } +fn relay_field<'a>( + payload: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Option<&'a DataValue> { + payload.get_field(data_type.try_to_id(type_map)?) +} + +fn relay_string<'a>( + payload: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Option<&'a str> { + relay_field(payload, data_type, type_map)?.as_str() +} + +fn relay_number(payload: &DataValue, data_type: DataType, type_map: &TypeMap) -> Option { + relay_field(payload, data_type, type_map)?.as_number() +} + +fn relay_identity( + payload: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result, String> { + let Some(value) = relay_field(payload, data_type, type_map) else { + return Ok(None); + }; + if let Some(number) = value.as_number() { + return u64::try_from(number) + .map(Some) + .map_err(|_| format!("Relay {data_type:?} is outside the user ID range")); + } + if let Some(text) = value.as_str() { + return text + .parse::() + .map(Some) + .map_err(|_| format!("Relay {data_type:?} is not a user ID")); + } + Err(format!("Relay {data_type:?} has an invalid user ID value")) +} + +fn validate_relay_identity( + context: &VerifiedRelayContext, + payload: &DataValue, +) -> Result<(), String> { + if relay_identity(payload, DataType::SenderId, &context.type_map)? + .is_some_and(|sender_id| sender_id != context.signer_id) + { + return Err("Relay SenderId does not match the authenticated signer".into()); + } + if relay_identity(payload, DataType::ReceiverId, &context.type_map)? + .is_some_and(|receiver_id| receiver_id != context.final_recipient_id) + { + return Err("Relay ReceiverId does not match the authenticated recipient".into()); + } + Ok(()) +} + +/* + * Apply only operations whose actor and recipient can be taken from verified + * Relay metadata. The raw Relay frame never enters these handlers, so outer + * routing fields cannot become application identity. + */ +pub fn apply_verified_relay_content( + context: &VerifiedRelayContext, + content: &VerifiedRelayContent, +) -> Result<(), String> { + validate_relay_identity(context, &content.content)?; + let sender_id = i64::try_from(context.signer_id) + .map_err(|_| "Relay signer ID exceeds the local storage range".to_string())?; + let recipient_id = i64::try_from(context.final_recipient_id) + .map_err(|_| "Relay recipient ID exceeds the local storage range".to_string())?; + let created_at = i64::try_from(context.created_at) + .map_err(|_| "Relay creation time exceeds the local storage range".to_string())?; + + match content.message_type.as_str() { + "MessageSend" => { + let message = relay_string(&content.content, DataType::Content, &context.type_map) + .ok_or_else(|| "Relay MessageSend is missing Content".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or(created_at); + let height = relay_number(&content.content, DataType::Height, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or_default(); + let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map) + .and_then(|value| i64::try_from(value).ok()); + chat_files::add_message( + u128::try_from(send_time) + .map_err(|_| "Relay MessageSend has a negative SendTime".to_string())?, + false, + recipient_id, + sender_id, + message, + height, + reply_to, + ); + Ok(()) + } + "MessageEdit" => { + let message = relay_string(&content.content, DataType::Content, &context.type_map) + .ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay MessageEdit is missing SendTime".to_string())?; + chat_files::apply_remote_edit(recipient_id, sender_id, send_time, sender_id, message) + .map_err(|error| error.to_string()) + } + "MessageReactionAdd" | "MessageReactionRemove" => { + let reaction = relay_string(&content.content, DataType::Reaction, &context.type_map) + .filter(|value| !value.is_empty() && value.len() <= 64) + .ok_or_else(|| "Relay reaction is invalid".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay reaction is missing SendTime".to_string())?; + let result = if content.message_type == "MessageReactionAdd" { + chat_files::add_reaction(recipient_id, sender_id, send_time, sender_id, reaction) + } else { + chat_files::remove_reaction(recipient_id, sender_id, send_time, sender_id, reaction) + }; + result.map_err(|error| error.to_string()) + } + "MessageDeleteLive" => { + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay MessageDeleteLive is missing SendTime".to_string())?; + chat_files::apply_remote_delete(recipient_id, sender_id, send_time, sender_id) + .map_err(|error| error.to_string()) + } + "SetChatSecret" => { + let frame = CommunicationValue::new(CommunicationType::SetChatSecret) + .with_payload(content.content.clone()); + let recipients = chat_secret_recipients(&frame) + .ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?; + let recipient = recipients + .into_iter() + .find(|value| value.user_id == context.final_recipient_id.to_string()) + .ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?; + let chat_id = data_string(&frame, DataType::ChatId) + .ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?; + let secret_id = data_string(&frame, DataType::SecretId) + .ok_or_else(|| "Relay SetChatSecret is missing SecretId".to_string())?; + let version = data_i64(&frame, DataType::VersionNumber) + .ok_or_else(|| "Relay SetChatSecret is missing VersionNumber".to_string())?; + let wrapping_scheme = data_string(&frame, DataType::WrappingScheme) + .ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?; + e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { + user_id: context.final_recipient_id.to_string(), + chat_id, + secret_id, + version, + encrypted_secret: recipient.encrypted_secret, + kem_ciphertext: recipient.kem_ciphertext, + wrapping_scheme, + created_at, + updated_at: now_millis_i64(), + }) + .map_err(|error| error.to_string()) + } + _ => Ok(()), + } +} + pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { let mutation = match message_mutation(cv) { Ok(mutation) => mutation, @@ -346,7 +514,9 @@ mod presence_tests { fn sync_error(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( DataType::SessionId, - cv.get_data(DataType::SessionId).clone(), + cv.get_data(DataType::SessionId) + .cloned() + .unwrap_or(DataValue::Null), ) } diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs new file mode 100644 index 0000000..298a0df --- /dev/null +++ b/iota-connection/src/relay.rs @@ -0,0 +1,350 @@ +use iota_util::route_target::RouteTarget; +use mtp::codec::{ + CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap, + VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, + open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id, +}; +use mtp::crypto::{Keyring, PublicKeyBundle}; +use std::fmt; + +pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy { + signature: SignaturePolicy::Dual, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageSecurityClass { + RelayOnly, + AuthenticatedPeerControl, + AuthenticatedLocalRequest, +} + +pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass { + const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[ + mtp::codec::CommunicationType::MessageSend, + mtp::codec::CommunicationType::MessageLive, + mtp::codec::CommunicationType::MessageState, + mtp::codec::CommunicationType::MessageEdit, + mtp::codec::CommunicationType::MessageEditLive, + mtp::codec::CommunicationType::MessageReactionAdd, + mtp::codec::CommunicationType::MessageReactionRemove, + mtp::codec::CommunicationType::MessageReactionLive, + mtp::codec::CommunicationType::MessageDelete, + mtp::codec::CommunicationType::MessageDeleteLive, + mtp::codec::CommunicationType::MessageOtherIota, + mtp::codec::CommunicationType::SetChatSecret, + mtp::codec::CommunicationType::SendChat, + mtp::codec::CommunicationType::SettingsSave, + mtp::codec::CommunicationType::GlobalSettingsSave, + mtp::codec::CommunicationType::AddConversation, + mtp::codec::CommunicationType::AddCommunity, + mtp::codec::CommunicationType::RemoveCommunity, + ]; + + if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) { + MessageSecurityClass::RelayOnly + } else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret) + || frame.is_type(mtp::codec::CommunicationType::MessageGet) + || frame.is_type(mtp::codec::CommunicationType::MessagesGet) + { + MessageSecurityClass::AuthenticatedPeerControl + } else { + MessageSecurityClass::AuthenticatedLocalRequest + } +} + +#[derive(Debug, Clone)] +pub struct UserIdentity { + pub user_id: u64, + pub iota_id: u64, + pub signing_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelayContext { + pub signer_id: u64, + pub final_recipient_id: u64, + pub message_id: String, + pub created_at: u64, + pub type_map: TypeMap, +} + +#[derive(Debug, Clone)] +pub struct VerifiedRelay { + pub metadata: VerifiedRelayMetadata, + pub context: VerifiedRelayContext, + pub signing_keys: Vec, +} + +#[derive(Debug)] +pub enum RelayValidationError { + WrongNextHop { expected: u64, actual: Option }, + OuterSenderNotAllowed, + MissingSigningKeys(u64), + MissingTypeMap, + InvalidRouteTarget(u64), + KeyLookup(String), + Relay(RelayError), +} + +impl fmt::Display for RelayValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongNextHop { expected, actual } => { + write!( + formatter, + "relay next hop {:?} does not match Iota {expected}", + actual + ) + } + Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"), + Self::MissingSigningKeys(signer_id) => { + write!(formatter, "no trusted signing keys for user {signer_id}") + } + Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"), + Self::InvalidRouteTarget(target) => { + write!(formatter, "relay has invalid route target {target}") + } + Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"), + Self::Relay(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for RelayValidationError {} + +impl From for RelayValidationError { + fn from(error: RelayError) -> Self { + Self::Relay(error) + } +} + +/* + * Relay metadata is opened only after the claimed signer selects trusted key + * history. Replay reservation happens after verification and durable + * acceptance, so a failed delivery can be retried without losing the frame. + */ +pub async fn verify_relay_metadata( + frame: &CommunicationValue, + local_iota_id: u64, + keyring: &Keyring, + resolve_signing_keys: F, +) -> Result +where + F: FnOnce(u64) -> Fut, + Fut: Future, RelayValidationError>>, +{ + let expected_next_hop = RouteTarget::Iota(local_iota_id) + .wire_id() + .ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?; + if frame.receiver() != Some(expected_next_hop) { + return Err(RelayValidationError::WrongNextHop { + expected: expected_next_hop, + actual: frame.receiver(), + }); + } + if frame.sender().is_some() { + return Err(RelayValidationError::OuterSenderNotAllowed); + } + + let claimed_signer = relay_metadata_claimed_signer_id(frame, &[keyring])?; + let signing_keys = resolve_signing_keys(claimed_signer).await?; + if signing_keys.is_empty() { + return Err(RelayValidationError::MissingSigningKeys(claimed_signer)); + } + + let resolver_keys = signing_keys.clone(); + let type_map = frame + .type_map() + .cloned() + .ok_or(RelayValidationError::MissingTypeMap)?; + let metadata = open_relay_metadata_with( + frame, + &[keyring], + Some(claimed_signer), + move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()), + RELAY_PROTECTION_POLICY, + None, + )?; + + let context = VerifiedRelayContext { + signer_id: metadata.signer_id(), + final_recipient_id: metadata.final_recipient_id(), + message_id: metadata.message_id().to_owned(), + created_at: metadata.created_at(), + type_map, + }; + + Ok(VerifiedRelay { + metadata, + context, + signing_keys, + }) +} + +pub fn open_verified_relay_content( + relay: &VerifiedRelay, + keyrings: &[&Keyring], + expected_recipient_id: u64, +) -> Result { + Ok(open_relay_content_with_keyrings( + &relay.metadata, + keyrings, + &relay.signing_keys, + Some(expected_recipient_id), + RELAY_PROTECTION_POLICY, + )?) +} + +pub fn forward_verified_relay( + frame: &CommunicationValue, + target: RouteTarget, +) -> Result { + let next_hop_id = target + .wire_id() + .ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?; + Ok(forward_relay_frame(frame, next_hop_id)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp::codec::SealedRelayBuilder; + use mtp::crypto::{DualSigner, Ed25519Signer, Keyring}; + + fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> { + let signer_keyring = Keyring::generate(); + let recipient_keyring = Keyring::generate(); + let signer = DualSigner::new( + &signer_keyring.sig_cl_secret_key, + &signer_keyring.sig_pq_secret_key, + &signer_keyring.sig_pq_public_key, + ) + .map_err(|error| error.to_string())?; + let frame = SealedRelayBuilder::new( + "MessageSend", + mtp::codec::DataValue::Str("payload".into()), + 7, + 42, + RouteTarget::Iota(99) + .wire_id() + .ok_or("invalid test target")?, + &signer, + ) + .message_id(message_id) + .created_at(123) + .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) + .content_recipients(vec![recipient_keyring.public_key_bundle()]) + .build() + .map_err(|error| error.to_string())?; + Ok((signer_keyring, recipient_keyring, frame)) + } + + #[tokio::test] + async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> { + let (signer, recipient, frame) = relay("accepted")?; + let trusted_key = signer.public_key_bundle(); + let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move { + (signer_id == 7) + .then_some(vec![trusted_key]) + .ok_or(RelayValidationError::MissingSigningKeys(signer_id)) + }) + .await + .map_err(|error| error.to_string())?; + + assert_eq!(verified.context.signer_id, 7); + assert_eq!(verified.context.final_recipient_id, 42); + assert_eq!(verified.context.message_id, "accepted"); + Ok(()) + } + + #[tokio::test] + async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> { + let (_signer, recipient, frame) = relay("wrong-key")?; + let wrong_signer = Keyring::generate(); + let trusted_key = wrong_signer.public_key_bundle(); + let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + + assert!(matches!(result, Err(RelayValidationError::Relay(_)))); + Ok(()) + } + + #[tokio::test] + async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> { + let signer_keyring = Keyring::generate(); + let recipient_keyring = Keyring::generate(); + let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key) + .map_err(|error| error.to_string())?; + let frame = SealedRelayBuilder::new( + "MessageSend", + mtp::codec::DataValue::Str("payload".into()), + 7, + 42, + RouteTarget::Iota(99) + .wire_id() + .ok_or("invalid test target")?, + &signer, + ) + .message_id("classical-only") + .created_at(123) + .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) + .content_recipients(vec![recipient_keyring.public_key_bundle()]) + .build() + .map_err(|error| error.to_string())?; + let trusted_key = signer_keyring.public_key_bundle(); + let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + + assert!(matches!(result, Err(RelayValidationError::Relay(_)))); + Ok(()) + } + + #[tokio::test] + async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> { + let (_signer, recipient, frame) = relay("outer-sender")?; + let frame = frame.with_sender(501); + let result = verify_relay_metadata(&frame, 99, &recipient, |_| async { + Err(RelayValidationError::MissingSigningKeys(7)) + }) + .await; + + assert!(matches!( + result, + Err(RelayValidationError::OuterSenderNotAllowed) + )); + Ok(()) + } + + #[tokio::test] + async fn verification_does_not_commit_replay_state() -> Result<(), String> { + let (signer, recipient, frame) = relay("duplicate")?; + let trusted_key = signer.public_key_bundle(); + + for _ in 0..2 { + let trusted_key = trusted_key.clone(); + let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + let _ = result.map_err(|error| error.to_string())?; + } + Ok(()) + } + + #[test] + fn forwarding_preserves_sealed_payload() -> Result<(), String> { + let (_signer, _recipient, frame) = relay("forwarding")?; + let forwarded = forward_verified_relay(&frame, RouteTarget::User(100)) + .map_err(|error| error.to_string())?; + + assert_eq!(frame.sender(), None); + assert_eq!(forwarded.sender(), None); + assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id()); + assert_eq!(frame.payload(), forwarded.payload()); + Ok(()) + } +} diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 74f5f96..7f9f1c7 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -100,10 +100,25 @@ impl CommandRouter { .into_iter() .map(|user| UserSummary { credential_present: user.state == user_manager::LocalUserState::Managed - && user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()), + && user_manager::get_user(user.user_id).is_some_and(|profile| { + iota_util::file_util::read_user_credential_with_legacy( + user.user_id, + &profile.username, + ) + .ok() + .flatten() + .is_some() + }), user_id: user.user_id, username: user.username, - state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released }, + state: match user.state { + user_manager::LocalUserState::Managed => { + iota_ipc::LocalUserState::Managed + } + user_manager::LocalUserState::Released => { + iota_ipc::LocalUserState::Released + } + }, data_present: user.data_present, }) .collect(); @@ -145,7 +160,8 @@ impl CommandRouter { } } } - LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) { + LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) + { Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }), Err(error) => { log!("User data purge failed for {user_id}: {error}"); @@ -153,25 +169,51 @@ impl CommandRouter { } }, LocalRequest::AttachUserFromTu { credential } => { - match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await { - Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }), + match omikron_connector::user_ops::attach_user_from_tu( + self.services.omikron.as_ref(), + &credential.0, + ) + .await + { + Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Added {} ({}) to this Iota", user.username, user.user_id), + }), Err(error) => { log!("Credential attach failed: {error:?}"); ResponseResult::Error(IpcErrorCode::Unauthorized) } } } - LocalRequest::CompleteDeleteUser { user_id, credential } => { + LocalRequest::CompleteDeleteUser { + user_id, + credential, + } => { let contents = match credential { Some(value) => Ok(value.0), None => user_manager::get_user(user_id) .ok_or(()) - .and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ())) + .and_then(|user| { + iota_util::file_util::read_user_credential_with_legacy( + user_id, + &user.username, + ) + .map_err(|_| ()) + }) .and_then(|value| value.ok_or(())), }; - let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); }; - match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }), + let Ok(contents) = contents else { + return ResponseResult::Error(IpcErrorCode::Unauthorized); + }; + match omikron_connector::user_ops::complete_delete_user_with_tu( + self.services.omikron.as_ref(), + &contents, + user_id, + ) + .await + { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Deleted Tensamin account {user_id}"), + }), Err(error) => { log!("Credential deletion failed for {user_id}: {error:?}"); ResponseResult::Error(IpcErrorCode::Unauthorized) @@ -185,19 +227,34 @@ impl CommandRouter { } let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); - match self.services.omikron.await_response(&request, Duration::from_secs(20)).await { - Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Released user {user_id}; hosted data was retained"), - }), - Err(error) => { - log!("Remote release succeeded but local cleanup failed for {user_id}: {error}"); - ResponseResult::Error(IpcErrorCode::StorageFailure) + match self + .services + .omikron + .await_response(&request, Duration::from_secs(20)) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + match user_manager::release_user(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Released user {user_id}; hosted data was retained" + ), + }), + Err(error) => { + log!( + "Remote release succeeded but local cleanup failed for {user_id}: {error}" + ); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } } - }, - Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized), + } + Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => { + ResponseResult::Error(IpcErrorCode::Unauthorized) + } Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict), - Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout), + Err(omikron_connector::OmikronError::Timeout(_)) => { + ResponseResult::Error(IpcErrorCode::Timeout) + } Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } } @@ -294,18 +351,28 @@ impl CommandRouter { } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { Some(user) => { - let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some(); + let credential_present = + iota_util::file_util::read_user_credential_with_legacy( + user_id, + &user.username, + ) + .ok() + .flatten() + .is_some(); ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - state: iota_ipc::LocalUserState::Managed, - data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present), - credential_present, - })) - }, + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + state: iota_ipc::LocalUserState::Managed, + data_present: user_manager::get_residency() + .iter() + .find(|entry| entry.user_id == user_id) + .is_none_or(|entry| entry.data_present), + credential_present, + })) + } None => ResponseResult::Error(IpcErrorCode::NotFound), }, LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 76eeaf0..cfe0337 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -215,8 +215,14 @@ async fn main() -> ExitCode { .spawn_tracked("user-lifecycle-reconciliation", async move { let mut states = omikron_reconcile.connection_state(); loop { - if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) { - omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await; + if matches!( + *states.borrow(), + omikron_connector::omikron_connection::ConnectionState::Connected { .. } + ) { + omikron_connector::user_ops::reconcile_managed_users( + omikron_reconcile.as_ref(), + ) + .await; } tokio::select! { changed = states.changed() => if changed.is_err() { break }, diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index bd3d102..af695f2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -6,9 +6,10 @@ pub use protocol::{ ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, - ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, - SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState, + LocalUserState, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, + RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, SecretString, StartupPhase, + StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, UpdateStatusResponse, + UserDetailResponse, UserSummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 2e2be1a..01cd14e 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -163,11 +163,20 @@ pub enum ResponsePayload { Status(StatusResponse), Tasks(Vec), Users(Vec), - UserCreated { user_id: i64, username: String }, + UserCreated { + user_id: i64, + username: String, + }, /// Retained only for wire compatibility. New lifecycle code never emits it. - UserRemoved { user_id: i64 }, - UserDataPurged { user_id: i64 }, - Acknowledged { message: String }, + UserRemoved { + user_id: i64, + }, + UserDataPurged { + user_id: i64, + }, + Acknowledged { + message: String, + }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), OmikronStatus(OmikronStatusResponse), diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index fe3ed8e..d7d336a 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -318,28 +318,29 @@ pub fn log_cv_internal( pub fn format_cv(cv: &CommunicationValue) -> String { let mut parts = Vec::new(); - let sender = cv.get_sender(); - let receiver = cv.get_receiver(); - - if sender > 0 && receiver > 0 { - parts.push(format!("{} > {}", sender, receiver)); - } else if sender > 0 { - parts.push(format!("{}", sender)); - } else if receiver > 0 { - parts.push(format!("> {}", receiver)); + match (cv.sender(), cv.receiver()) { + (Some(sender), Some(receiver)) => parts.push(format!("{} > {}", sender, receiver)), + (Some(sender), None) => parts.push(sender.to_string()), + (None, Some(receiver)) => parts.push(format!("> {}", receiver)), + (None, None) => {} } let comm_type = cv .get_comm_type_enum() .map(|kind| kind.to_string()) .unwrap_or_else(|| cv.get_type().to_string()); - parts.push(format!("{} (id={})", comm_type, cv.get_id())); + let id = cv + .id() + .map_or_else(|| "none".to_string(), |value| value.to_string()); + parts.push(format!("{} (id={})", comm_type, id)); - let data = cv.data(); - - let formated_data = format_data_container( - data.iter().map(|(k, v)| (*k, v.clone())).collect(), - Version(1, 0), + let version = cv + .type_map() + .map(|type_map| type_map.version.clone()) + .unwrap_or_else(|| Version(3, 0)); + let formated_data = cv.data().map_or_else( + || "".to_string(), + |data| format_data_container(data.to_vec(), version), ); parts.push(format!("{}", formated_data)); diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index ccd3c9e..ed2294f 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -19,7 +19,10 @@ pub struct UserResidency { } fn now_millis() -> i64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64 + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 } pub fn add_user(user: UserProfile) { @@ -235,7 +238,10 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr })?; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; - tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; tx.execute( r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) @@ -246,7 +252,8 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr tx.commit()?; Ok(()) })?; - remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + remove_user_credential(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } /// Authoritative hosted-data erasure used by local purge and future Omega @@ -256,14 +263,35 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage let tx = conn.unchecked_transaction()?; tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; - tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?; - tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?; - tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?; + tx.execute( + "DELETE FROM messages WHERE storage_owner = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM contacts WHERE storage_owner = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM communities WHERE storage_owner = ?1", + params![user_id], + )?; tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM sync_events WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM sync_heads WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM client_sync_state WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; tx.execute( "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", params![user_id, now_millis()], @@ -273,7 +301,8 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage })?; crate::util::e2ee_storage::purge_user(user_id) .map_err(crate::storage_error::StorageError::Other)?; - delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + delete_user_directory(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } /// Complete local erasure is idempotent and is the target for a durable @@ -281,12 +310,19 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { purge_user_data(user_id)?; db::with_db(|conn| { - conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; - conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM user_residency WHERE user_id = ?1", + params![user_id], + )?; Ok(()) })?; - remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + remove_user_credential(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } pub fn get_residency() -> Vec { @@ -306,14 +342,15 @@ pub fn get_residency() -> Vec { pub fn clear() { if let Err(e) = db::with_db(|conn| { - conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?; + conn.execute_batch( + "DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;", + )?; Ok(()) }) { eprintln!("Failed to clear users: {}", e); } } - pub fn save_users() { // No-op: users are auto-saved via SQLite. } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 82968e7..01ae3a5 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -77,12 +77,22 @@ fn add_column_if_missing( column: &str, definition: &str, ) -> Result<(), StorageError> { - let mut statement = - conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; + add_table_column_if_missing(conn, "messages", column, definition) +} + +fn add_table_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, +) -> Result<(), StorageError> { + let mut statement = conn.prepare(&format!( + "SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1" + ))?; let exists = statement.exists([column])?; if !exists { - conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?; + conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {definition};"))?; } Ok(()) @@ -266,6 +276,72 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 8 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS relay_replay ( + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (signer_id, message_id) + ); + CREATE INDEX IF NOT EXISTS idx_relay_replay_created_at + ON relay_replay (created_at); + CREATE TABLE IF NOT EXISTS pending_relays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + destination_id INTEGER NOT NULL, + target_kind INTEGER NOT NULL DEFAULT 0, + frame BLOB NOT NULL, + created_at INTEGER NOT NULL, + frame_id INTEGER NOT NULL DEFAULT 0, + UNIQUE(destination_id, frame) + ); + CREATE INDEX IF NOT EXISTS idx_pending_relays_destination + ON pending_relays (destination_id, id); + PRAGMA user_version = 8; + "#, + )?; + } + + if current_version < 9 { + add_table_column_if_missing( + conn, + "pending_relays", + "target_kind", + "target_kind INTEGER NOT NULL DEFAULT 0", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "type_map_version", + "type_map_version TEXT NOT NULL DEFAULT '1.0'", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "frame_id", + "frame_id INTEGER NOT NULL DEFAULT 0", + )?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS relay_inbox ( + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + destination_id INTEGER NOT NULL, + frame BLOB NOT NULL, + type_map_version TEXT NOT NULL, + frame_id INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('received', 'applied', 'queued', 'delivered', 'rejected')), + PRIMARY KEY (signer_id, message_id) + ); + CREATE INDEX IF NOT EXISTS idx_relay_inbox_state + ON relay_inbox (state, created_at); + PRAGMA user_version = 9; + "#, + )?; + } + Ok(()) } @@ -335,7 +411,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 7); + assert_eq!(version, 9); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -352,8 +428,16 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 7); - for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] { + assert_eq!(version, 9); + for table in [ + "sync_heads", + "sync_events", + "client_sync_state", + "user_residency", + "relay_replay", + "pending_relays", + "relay_inbox", + ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], @@ -361,6 +445,11 @@ mod tests { )?; assert_eq!(exists, 1); } + for column in ["frame_id", "target_kind", "type_map_version"] { + let mut statement = + conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?; + assert!(statement.exists([column])?); + } Ok(()) } } diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 6b13018..55cf8cd 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -24,19 +24,6 @@ pub struct ChatSecretQuery { pub secret_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingChatSecretForward { - pub recipient_user_id: String, - pub chat_id: String, - pub sender_user_id: String, - pub secret_id: String, - pub version: i64, - pub encrypted_secret: Vec, - pub kem_ciphertext: Vec, - pub wrapping_scheme: String, - pub created_at: i64, -} - static E2EE_DB: LazyLock>> = LazyLock::new(|| { db::create_shared_connection( "e2ee", @@ -63,21 +50,6 @@ static E2EE_DB: LazyLock>> = LazyLock::new(|| { CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner ON chat_secrets (user_id, chat_id, secret_id); - CREATE TABLE IF NOT EXISTS pending_chat_secret_forwards ( - recipient_user_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - sender_user_id TEXT NOT NULL, - secret_id TEXT NOT NULL, - version INTEGER NOT NULL, - encrypted_secret BLOB NOT NULL, - kem_ciphertext BLOB NOT NULL, - wrapping_scheme TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (recipient_user_id, chat_id, secret_id) - ); - - CREATE INDEX IF NOT EXISTS idx_pending_chat_secret_forwards_recipient - ON pending_chat_secret_forwards (recipient_user_id, created_at); "#, ) .expect("Failed to create or initialize E2EE DB") @@ -115,89 +87,15 @@ pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> { }) } -pub fn put_pending_chat_secret_forward( - record: PendingChatSecretForward, -) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - INSERT INTO pending_chat_secret_forwards ( - recipient_user_id, chat_id, sender_user_id, secret_id, version, - encrypted_secret, kem_ciphertext, wrapping_scheme, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(recipient_user_id, chat_id, secret_id) DO UPDATE SET - sender_user_id = excluded.sender_user_id, - version = excluded.version, - encrypted_secret = excluded.encrypted_secret, - kem_ciphertext = excluded.kem_ciphertext, - wrapping_scheme = excluded.wrapping_scheme, - created_at = excluded.created_at - "#, - params![ - record.recipient_user_id, - record.chat_id, - record.sender_user_id, - record.secret_id, - record.version, - record.encrypted_secret, - record.kem_ciphertext, - record.wrapping_scheme, - record.created_at, - ], - )?; - Ok(()) - }) -} - -pub fn get_pending_chat_secret_forwards( - limit: i64, -) -> Result, StorageError> { - db::with_conn(&E2EE_DB, |conn| { - let mut stmt = conn.prepare( - r#" - SELECT recipient_user_id, chat_id, sender_user_id, secret_id, version, - encrypted_secret, kem_ciphertext, wrapping_scheme, created_at - FROM pending_chat_secret_forwards - ORDER BY created_at ASC - LIMIT ?1 - "#, - )?; - let rows = stmt.query_map(params![limit.clamp(1, 500)], pending_forward_from_row)?; - let mut out = Vec::new(); - for row in rows { - out.push(row?); - } - Ok(out) - }) -} - -pub fn delete_pending_chat_secret_forward( - recipient_user_id: &str, - chat_id: &str, - secret_id: &str, -) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - DELETE FROM pending_chat_secret_forwards - WHERE recipient_user_id = ?1 AND chat_id = ?2 AND secret_id = ?3 - "#, - params![recipient_user_id, chat_id, secret_id], - )?; - Ok(()) - }) -} - -/// Erase every E2EE record owned by, or queued for, a user. The operation is +/// Erase every E2EE record owned by a user. The operation is /// intentionally idempotent so it can be retried after an interrupted remote /// erasure request. pub fn purge_user(user_id: i64) -> Result<(), StorageError> { let user_id = user_id.to_string(); db::with_conn(&E2EE_DB, |conn| { let tx = conn.unchecked_transaction()?; - tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?; tx.execute( - "DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1", + "DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id], )?; tx.commit()?; @@ -242,17 +140,3 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { - Ok(PendingChatSecretForward { - recipient_user_id: row.get(0)?, - chat_id: row.get(1)?, - sender_user_id: row.get(2)?, - secret_id: row.get(3)?, - version: row.get(4)?, - encrypted_secret: row.get(5)?, - kem_ciphertext: row.get(6)?, - wrapping_scheme: row.get(7)?, - created_at: row.get(8)?, - }) -} diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 7412c7b..2a1eec6 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -4,5 +4,7 @@ pub mod communities_util; pub mod config_util; pub mod db; pub mod e2ee_storage; +pub mod relay_queue; +pub mod relay_replay; pub mod settings; pub mod sync; diff --git a/iota-storage/src/util/relay_queue.rs b/iota-storage/src/util/relay_queue.rs new file mode 100644 index 0000000..835ebcd --- /dev/null +++ b/iota-storage/src/util/relay_queue.rs @@ -0,0 +1,135 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use iota_util::route_target::RouteTarget; +use rusqlite::params; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingRelay { + pub id: i64, + pub target: RouteTarget, + pub frame: Vec, + pub created_at: i64, + pub frame_id: u32, + pub type_map_version: String, +} + +pub fn enqueue( + target: RouteTarget, + frame: &[u8], + created_at: i64, + frame_id: u32, + type_map_version: &str, +) -> Result<(), StorageError> { + let destination_id = i64::try_from(target.id()) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + let target_kind = match target { + RouteTarget::User(_) => 0_i64, + RouteTarget::Iota(_) => 1_i64, + }; + db::with_db(|connection| { + connection.execute( + "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + destination_id, + target_kind, + frame, + created_at, + i64::from(frame_id), + type_map_version + ], + )?; + Ok(()) + }) +} + +pub fn list(limit: i64) -> Result, StorageError> { + db::with_db(|connection| { + let mut statement = connection.prepare( + "SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version FROM pending_relays ORDER BY id LIMIT ?1", + )?; + let rows = statement.query_map(params![limit.clamp(1, 500)], |row| { + let destination_id = row.get::<_, i64>(1)?; + let target_kind = row.get::<_, i64>(2)?; + let destination_id = u64::try_from(destination_id).map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Integer, + "negative relay destination ID".into(), + ) + })?; + let target = match target_kind { + 0 => RouteTarget::User(destination_id), + 1 => RouteTarget::Iota(destination_id), + _ => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + "invalid relay target kind".into(), + )); + } + }; + Ok(PendingRelay { + id: row.get(0)?, + target, + frame: row.get(3)?, + created_at: row.get(4)?, + frame_id: u32::try_from(row.get::<_, i64>(5)?).map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + "negative relay frame ID".into(), + ) + })?, + type_map_version: row.get(6)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + }) +} + +pub fn acknowledge(destination_id: u64, frame_id: u32) -> Result { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn acknowledge_iota(destination_id: u64, frame_id: u32) -> Result { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn remove_for_frame(target: RouteTarget, frame_id: u32) -> Result { + let destination_id = i64::try_from(target.id()) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + let target_kind = match target { + RouteTarget::User(_) => 0_i64, + RouteTarget::Iota(_) => 1_i64, + }; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = ?2 AND frame_id = ?3", + params![destination_id, target_kind, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn delete(id: i64) -> Result<(), StorageError> { + db::with_db(|connection| { + connection.execute("DELETE FROM pending_relays WHERE id = ?1", params![id])?; + Ok(()) + }) +} diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs new file mode 100644 index 0000000..bc3bda7 --- /dev/null +++ b/iota-storage/src/util/relay_replay.rs @@ -0,0 +1,152 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::params; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayReservation { + New, + Existing { state: String, frame_matches: bool }, +} + +pub fn reserve( + signer_id: u64, + message_id: &str, + created_at: u64, + destination_id: u64, + frame: &[u8], + frame_id: u32, + type_map_version: &str, +) -> Result { + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + let created_at = i64::try_from(created_at) + .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + + db::with_db(|connection| { + let inserted = connection.execute( + "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'received')", + params![ + signer_id, + message_id, + created_at, + destination_id, + frame, + i64::from(frame_id), + type_map_version + ], + )?; + if inserted == 1 { + return Ok(RelayReservation::New); + } + + let (state, existing_destination_id, existing_frame, existing_type_map_version): + (String, i64, Vec, String) = connection.query_row( + "SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_id = ?1 AND message_id = ?2", + params![signer_id, message_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get::<_, Vec>(2)?, + row.get(3)?, + )) + }, + )?; + Ok(RelayReservation::Existing { + state, + frame_matches: existing_destination_id == destination_id + && existing_frame == frame + && existing_type_map_version == type_map_version, + }) + }) +} + +pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<(), StorageError> { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + connection.execute( + "UPDATE relay_inbox SET state = 'delivered' WHERE destination_id = ?1 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(()) + }) +} + +pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), StorageError> { + if !matches!( + state, + "received" | "applied" | "queued" | "delivered" | "rejected" + ) { + return Err(StorageError::Other("invalid relay inbox state".into())); + } + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + connection.execute( + "UPDATE relay_inbox SET state = ?3 WHERE signer_id = ?1 AND message_id = ?2", + params![signer_id, message_id, state], + )?; + Ok(()) + }) +} + +pub fn prune_completed(before_created_at: i64) -> Result<(), StorageError> { + db::with_db(|connection| { + connection.execute( + "DELETE FROM relay_inbox WHERE created_at < ?1 AND state IN ('delivered', 'rejected')", + params![before_created_at], + )?; + connection.execute( + "DELETE FROM relay_replay WHERE created_at < ?1", + params![before_created_at], + )?; + Ok(()) + }) +} + +pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result { + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + let created_at = i64::try_from(created_at) + .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; + + db::with_db(|connection| { + let inserted = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![signer_id, message_id, created_at], + )?; + Ok(inserted == 1) + }) +} + +#[cfg(test)] +mod tests { + use rusqlite::{Connection, params}; + + #[test] + fn replay_identity_uses_signer_and_message_id() -> Result<(), rusqlite::Error> { + let connection = Connection::open_in_memory()?; + connection.execute_batch( + "CREATE TABLE relay_replay (signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_id, message_id));", + )?; + + let first = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![7_i64, "message", 1_i64], + )?; + let duplicate = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![7_i64, "message", 2_i64], + )?; + let other_signer = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![8_i64, "message", 2_i64], + )?; + + assert_eq!((first, duplicate, other_signer), (1, 0, 1)); + Ok(()) + } +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index e9907eb..49bfe51 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "crypto" ] } diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs index 64056d9..bbb884d 100644 --- a/iota-util/src/crypto_util.rs +++ b/iota-util/src/crypto_util.rs @@ -1,5 +1,50 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp::crypto::{EncryptionType, Keyring, PublicKeyBundle, decrypt_with, encrypt_for}; +use mtp::crypto::{ + EncryptionType, Keyring, MultiEncryptedMessage, PublicKeyBundle, decrypt_multi_for, + encrypt_multi_for, +}; + +const CHALLENGE_PURPOSE: u8 = 0x01; +const LEGACY_AAD_DOMAIN: &[u8] = b"IOTA-MTP-AAD-1"; + +fn bind_aad(plaintext: &[u8], aad: &[u8]) -> Result, String> { + let aad_len = u32::try_from(aad.len()) + .map_err(|_| "associated data is too large to encode".to_string())?; + let mut bound = Vec::with_capacity( + LEGACY_AAD_DOMAIN + .len() + .saturating_add(4) + .saturating_add(aad.len()) + .saturating_add(plaintext.len()), + ); + bound.extend_from_slice(LEGACY_AAD_DOMAIN); + bound.extend_from_slice(&aad_len.to_be_bytes()); + bound.extend_from_slice(aad); + bound.extend_from_slice(plaintext); + Ok(bound) +} + +fn unbind_aad(bound: &[u8], aad: &[u8]) -> Result, String> { + let header_len = LEGACY_AAD_DOMAIN.len() + 4; + if bound.len() < header_len || &bound[..LEGACY_AAD_DOMAIN.len()] != LEGACY_AAD_DOMAIN { + return Err("associated-data binding is invalid".to_string()); + } + let length_start = LEGACY_AAD_DOMAIN.len(); + let length_end = length_start + 4; + let aad_len = u32::from_be_bytes( + bound[length_start..length_end] + .try_into() + .map_err(|_| "associated-data length is invalid".to_string())?, + ) as usize; + let aad_start = length_end; + let aad_end = aad_start + .checked_add(aad_len) + .ok_or_else(|| "associated-data length overflows".to_string())?; + if aad_end > bound.len() || &bound[aad_start..aad_end] != aad { + return Err("associated data does not match".to_string()); + } + Ok(bound[aad_end..].to_vec()) +} #[derive(Clone, Copy, Debug)] pub enum DataFormat { @@ -13,17 +58,25 @@ pub fn encrypt( aad: &[u8], recipient_pub_key_bundle: &PublicKeyBundle, ) -> Result, String> { - encrypt_for( + let bound_plaintext = bind_aad(plaintext, aad)?; + let encrypted = encrypt_multi_for( EncryptionType::MlKemChaCha20Poly1305, - recipient_pub_key_bundle, - plaintext, - aad, + CHALLENGE_PURPOSE, + &bound_plaintext, + std::slice::from_ref(recipient_pub_key_bundle), ) - .map_err(|e| format!("encryption error: {:?}", e)) + .map_err(|e| format!("encryption error: {e:?}"))?; + encrypted + .to_bytes() + .map_err(|e| format!("encryption encoding error: {e:?}")) } pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result, String> { - decrypt_with(ciphertext, keyring, aad).map_err(|e| format!("decryption error: {:?}", e)) + let message = MultiEncryptedMessage::from_bytes(ciphertext) + .map_err(|e| format!("decryption envelope error: {e:?}"))?; + let bound_plaintext = decrypt_multi_for(&message, CHALLENGE_PURPOSE, keyring) + .map_err(|e| format!("decryption error: {e:?}"))?; + unbind_aad(&bound_plaintext, aad) } pub fn encrypt_challenge( @@ -51,3 +104,18 @@ pub fn export(data: &[u8], format: DataFormat) -> Result { DataFormat::Hex => Ok(hex::encode(data)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_binds_associated_data() -> Result<(), String> { + let keyring = Keyring::generate(); + let ciphertext = encrypt(b"challenge", b"context", &keyring.public_key_bundle())?; + + assert_eq!(decrypt(&ciphertext, b"context", &keyring)?, b"challenge"); + assert!(decrypt(&ciphertext, b"other-context", &keyring).is_err()); + Ok(()) + } +} diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 8412b10..b70f7cc 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -44,7 +44,9 @@ pub fn delete_user_directory(user_id: i64) -> io::Result<()> { } pub fn credential_path(user_id: i64) -> PathBuf { - storage_directory().join("credentials").join(format!("{user_id}.tu")) + storage_directory() + .join("credentials") + .join(format!("{user_id}.tu")) } pub fn read_user_credential(user_id: i64) -> io::Result> { @@ -58,7 +60,10 @@ pub fn read_user_credential(user_id: i64) -> io::Result> { /// Resolve a credential by immutable account id. A valid legacy /// `.tu` is migrated atomically the first time it is encountered. -pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result> { +pub fn read_user_credential_with_legacy( + user_id: i64, + username: &str, +) -> io::Result> { if let Some(credential) = read_user_credential(user_id)? { return Ok(Some(credential)); } @@ -71,7 +76,10 @@ pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Res let parsed = crate::tu::TuCredential::parse(&credential) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; if parsed.user_id != user_id { - return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch")); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "legacy credential user id mismatch", + )); } write_user_credential(user_id, &parsed.to_canonical_string())?; fs::remove_file(legacy)?; diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 5d32aef..1fdc888 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,4 +1,6 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; +pub mod mtp_compat; +pub mod route_target; pub mod tu; diff --git a/iota-util/src/mtp_compat.rs b/iota-util/src/mtp_compat.rs new file mode 100644 index 0000000..02aced4 --- /dev/null +++ b/iota-util/src/mtp_compat.rs @@ -0,0 +1,67 @@ +use mtp::codec::{CommunicationValue, DataValue}; +use mtp::type_map::DataTypeId; + +/* + * Keep legacy control-plane handlers source-compatible while they migrate to + * MTP's explicit optional routing fields. Relay handlers must use sender() and + * receiver() directly so an absent outer sender cannot become an identity. + */ +pub trait CommunicationValueCompat { + fn get_id(&self) -> u32; + fn get_sender(&self) -> u64; + fn get_receiver(&self) -> u64; +} + +impl CommunicationValueCompat for CommunicationValue { + fn get_id(&self) -> u32 { + self.id().unwrap_or_default() + } + + fn get_sender(&self) -> u64 { + self.sender().unwrap_or_default() + } + + fn get_receiver(&self) -> u64 { + self.receiver().unwrap_or_default() + } +} + +pub trait OptionalDataValueExt<'a> { + fn as_bool(self) -> Option; + fn as_str(self) -> Option<&'a str>; + fn as_string(self) -> Option; + fn as_number(self) -> Option; + fn as_signed_number(self) -> Option; + fn as_array(self) -> Option>; + fn as_container(self) -> Option>; +} + +impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> { + fn as_bool(self) -> Option { + self.and_then(DataValue::as_bool) + } + + fn as_str(self) -> Option<&'a str> { + self.and_then(DataValue::as_str) + } + + fn as_string(self) -> Option { + self.and_then(DataValue::as_string) + } + + fn as_number(self) -> Option { + self.and_then(DataValue::as_number) + } + + fn as_signed_number(self) -> Option { + self.and_then(DataValue::as_signed_number) + } + + fn as_array(self) -> Option> { + self.and_then(DataValue::as_array) + } + + fn as_container(self) -> Option> { + self.and_then(DataValue::as_container) + } +} diff --git a/iota-util/src/route_target.rs b/iota-util/src/route_target.rs new file mode 100644 index 0000000..43e4249 --- /dev/null +++ b/iota-util/src/route_target.rs @@ -0,0 +1,44 @@ +const TARGET_KIND_MASK: u64 = 0xC000_0000_0000_0000; +const TARGET_ID_MASK: u64 = (1_u64 << 48) - 1; +const USER_TARGET_KIND: u64 = 0x4000_0000_0000_0000; +const IOTA_TARGET_KIND: u64 = 0x8000_0000_0000_0000; + +/* + * Relay receivers carry their namespace in the wire identity. This prevents + * a user ID and an Iota ID with the same numeric value from selecting the + * wrong connection at an Omikron. + */ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteTarget { + User(u64), + Iota(u64), +} + +impl RouteTarget { + pub fn wire_id(self) -> Option { + let (kind, id) = match self { + Self::User(id) => (USER_TARGET_KIND, id), + Self::Iota(id) => (IOTA_TARGET_KIND, id), + }; + (id > 0 && id <= TARGET_ID_MASK).then_some(kind | id) + } + + pub fn from_wire_id(value: u64) -> Option { + let id = value & TARGET_ID_MASK; + if id == 0 || value & !(TARGET_KIND_MASK | TARGET_ID_MASK) != 0 { + return None; + } + + match value & TARGET_KIND_MASK { + USER_TARGET_KIND => Some(Self::User(id)), + IOTA_TARGET_KIND => Some(Self::Iota(id)), + _ => None, + } + } + + pub const fn id(self) -> u64 { + match self { + Self::User(id) | Self::Iota(id) => id, + } + } +} diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs index e1a8334..159bea6 100644 --- a/iota-util/src/tu.rs +++ b/iota-util/src/tu.rs @@ -47,7 +47,10 @@ impl fmt::Debug for TuCredential { impl TuCredential { pub fn parse(input: &str) -> Result { - let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?; + let (identity, encoded_keyring) = input + .trim() + .split_once("::") + .ok_or(TuError::InvalidFormat)?; if encoded_keyring.is_empty() || encoded_keyring.contains("::") { return Err(TuError::InvalidFormat); } @@ -60,7 +63,11 @@ impl TuCredential { return Err(TuError::InvalidUserId); } let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; - Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring }) + Ok(Self { + user_id, + omega_host: omega_host.trim().to_owned(), + keyring, + }) } pub fn public_key_bundle(&self) -> PublicKeyBundle { @@ -68,7 +75,12 @@ impl TuCredential { } pub fn to_canonical_string(&self) -> String { - format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring)) + format!( + "{}@{}::{}", + self.user_id, + self.omega_host, + keyring_to_base64(&self.keyring) + ) } } @@ -79,16 +91,31 @@ mod tests { #[test] fn round_trip_is_canonical() { - let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() }; + let credential = TuCredential { + user_id: 42, + omega_host: "omega.example:443".into(), + keyring: generate_keyring(), + }; let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); assert_eq!(parsed.user_id, 42); assert_eq!(parsed.omega_host, "omega.example:443"); - assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string()); + assert_eq!( + parsed.to_canonical_string(), + credential.to_canonical_string() + ); } #[test] fn rejects_malformed_credentials() { - for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] { + for value in [ + "", + "1@omega", + "@omega::abc", + "0@omega::abc", + "281474976710656@omega::abc", + "1@::abc", + "1@omega::abc::def", + ] { assert!(TuCredential::parse(value).is_err(), "{value}"); } } diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs index 2f7cb38..c893805 100644 --- a/iota/src/cli_color.rs +++ b/iota/src/cli_color.rs @@ -13,10 +13,8 @@ impl Default for ColorConfig { impl ColorConfig { pub fn new() -> Self { - let enabled = env::var("NO_COLOR").is_err() - && env::var("TERM") - .map(|t| t != "dumb") - .unwrap_or(true); + let enabled = + env::var("NO_COLOR").is_err() && env::var("TERM").map(|t| t != "dumb").unwrap_or(true); Self { enabled } } diff --git a/iota/src/main.rs b/iota/src/main.rs index e05b63f..d5c79d8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -386,17 +386,11 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { fn print_help() { let color = cli_color::ColorConfig::new(); - println!( - "{}", - cli_color::heading(&color, "Iota Operator Console") - ); + println!("{}", cli_color::heading(&color, "Iota Operator Console")); println!(); println!("Usage: iota [OPTIONS] [COMMAND]"); println!(); - println!( - "{}", - cli_color::info(&color, "Commands:") - ); + println!("{}", cli_color::info(&color, "Commands:")); println!(" (no command) Launch the interactive dashboard"); println!(" status Show daemon status"); println!(" tasks List active tasks"); @@ -432,10 +426,7 @@ fn print_help() { println!(" completions Generate shell completions"); println!(" man Show the man page"); println!(); - println!( - "{}", - cli_color::info(&color, "Options:") - ); + println!("{}", cli_color::info(&color, "Options:")); println!(" --theme Theme: monospace, binary, ansi, surface"); println!(" --output Output format: text, json, yaml, table"); println!(" --color Color: auto, always, never"); @@ -445,10 +436,7 @@ fn print_help() { println!(" -h, --help Show help"); println!(" -V, --version Show version"); println!(); - println!( - "{}", - cli_color::info(&color, "Examples:") - ); + println!("{}", cli_color::info(&color, "Examples:")); println!(" iota Launch the interactive dashboard"); println!(" iota status Show daemon status"); println!(" iota users list --output=json List users in JSON format"); @@ -458,19 +446,13 @@ fn print_help() { println!(" iota logs --limit 50 Show last 50 log entries"); println!(" iota completions bash Generate bash completions"); println!(); - println!( - "{}", - cli_color::info(&color, "Exit Codes:") - ); + println!("{}", cli_color::info(&color, "Exit Codes:")); println!(" 0 Success"); println!(" 1 General error"); println!(" 2 Invalid command or arguments"); println!(" 130 Interrupted (Ctrl+C)"); println!(); - println!( - "{}", - cli_color::muted(&color, "Environment Variables:") - ); + println!("{}", cli_color::muted(&color, "Environment Variables:")); println!(" NO_COLOR Disable colored output when set"); println!(" TERM Terminal type (dumb disables colors)"); println!(" IOTA_THEME Default theme override"); @@ -566,13 +548,26 @@ async fn run_command( Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username }, - Command::UsersAdd { username: None, tu: Some(path) } => { - let contents = std::fs::read_to_string(&path) - .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; - iota_util::tu::TuCredential::parse(&contents) - .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; - LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) } + Command::UsersAdd { + username: Some(username), + tu: None, + } => LocalRequest::CreateUser { username }, + Command::UsersAdd { + username: None, + tu: Some(path), + } => { + let contents = std::fs::read_to_string(&path).map_err(|error| { + StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())) + })?; + iota_util::tu::TuCredential::parse(&contents).map_err(|error| { + StartupError::InvalidCommand(format!( + "Invalid credential {}: {error}", + path.display() + )) + })?; + LocalRequest::AttachUserFromTu { + credential: iota_ipc::SecretString(contents), + } } Command::UsersAdd { .. } => { return Err(StartupError::InvalidCommand( @@ -583,22 +578,43 @@ async fn run_command( user_id, confirmed: true, } => LocalRequest::ReleaseUser { user_id }, - Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id }, - Command::UsersCompleteDelete { user_id, tu, confirmed: true } => { + Command::UsersPurgeData { + user_id, + confirmed: true, + } => LocalRequest::PurgeUserData { user_id }, + Command::UsersCompleteDelete { + user_id, + tu, + confirmed: true, + } => { let credential = match tu { Some(path) => { - let contents = std::fs::read_to_string(&path) - .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; - let parsed = iota_util::tu::TuCredential::parse(&contents) - .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + let contents = std::fs::read_to_string(&path).map_err(|error| { + StartupError::InvalidCommand(format!( + "Cannot read {}: {error}", + path.display() + )) + })?; + let parsed = + iota_util::tu::TuCredential::parse(&contents).map_err(|error| { + StartupError::InvalidCommand(format!( + "Invalid credential {}: {error}", + path.display() + )) + })?; if parsed.user_id != user_id { - return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into())); + return Err(StartupError::InvalidCommand( + "credential user ID does not match complete-delete target".into(), + )); } Some(iota_ipc::SecretString(contents)) } None => None, }; - LocalRequest::CompleteDeleteUser { user_id, credential } + LocalRequest::CompleteDeleteUser { + user_id, + credential, + } } Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -621,8 +637,12 @@ async fn run_command( Command::UsersRelease { confirmed: false, .. } - | Command::UsersPurgeData { confirmed: false, .. } - | Command::UsersCompleteDelete { confirmed: false, .. } + | Command::UsersPurgeData { + confirmed: false, .. + } + | Command::UsersCompleteDelete { + confirmed: false, .. + } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -715,14 +735,14 @@ async fn run_command( ); } ResponsePayload::UserRemoved { user_id } => { - println!( - "{} {}", - cli_color::warning(&color, "Removed user"), - user_id - ); + println!("{} {}", cli_color::warning(&color, "Removed user"), user_id); } ResponsePayload::UserDataPurged { user_id } => { - println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id); + println!( + "{} hosted data for {}. Account remains managed by this Iota.", + cli_color::success(&color, "Purged"), + user_id + ); } ResponsePayload::Acknowledged { message } => { println!("{}", message); @@ -740,11 +760,7 @@ async fn run_command( status.connected ); if let Some(id) = status.iota_id { - println!( - "{}: {}", - cli_color::info(&color, "Iota ID"), - id - ); + println!("{}: {}", cli_color::info(&color, "Iota ID"), id); } } ResponsePayload::Components(components) => { @@ -756,9 +772,7 @@ async fn run_command( } else { for comp in &components { let (status_str, style) = match comp.status { - iota_ipc::HealthStatus::Healthy => { - ("healthy", cli_color::SUCCESS) - } + iota_ipc::HealthStatus::Healthy => ("healthy", cli_color::SUCCESS), iota_ipc::HealthStatus::Degraded => { ("degraded", cli_color::WARNING) } @@ -811,15 +825,9 @@ async fn run_command( } ResponsePayload::UpdateStatus(status) => { if status.available { - println!( - "{}", - cli_color::success(&color, "Update available.") - ); + println!("{}", cli_color::success(&color, "Update available.")); } else { - println!( - "{}", - cli_color::info(&color, "Up to date.") - ); + println!("{}", cli_color::info(&color, "Up to date.")); } } ResponsePayload::Communities(communities) => { @@ -827,11 +835,7 @@ async fn run_command( println!("{}", cli_color::muted(&color, "No communities.")); } else { for c in &communities { - println!( - "{} ({})", - cli_color::heading(&color, &c.title), - c.name - ); + println!("{} ({})", cli_color::heading(&color, &c.title), c.name); } } } @@ -905,7 +909,12 @@ fn render_table(payload: &ResponsePayload) { iota_ipc::HealthStatus::Failed => "failed", }; let message = comp.message.as_deref().unwrap_or("-"); - println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message); + println!( + "{:<20} {:<10} {}", + format!("{:?}", comp.id), + status_str, + message + ); } } ResponsePayload::Communities(communities) => { @@ -924,8 +933,14 @@ fn render_table(payload: &ResponsePayload) { println!("No log entries."); return; } - println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"); - println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------"); + println!( + "{:<20} {:<6} {:<12} {}", + "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE" + ); + println!( + "{:<20} {:<6} {:<12} {}", + "--------", "--------", "--------", "--------" + ); for entry in &logs.entries { let level = if entry.is_error { "ERR" } else { "INF" }; println!( diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs index 583c281..4c46c32 100644 --- a/iota/src/startup_error.rs +++ b/iota/src/startup_error.rs @@ -43,34 +43,24 @@ impl StartupError { Self::SystemPermissionDenied(_) => { Some("Run with appropriate privileges or use a user-level daemon instead.") } - Self::SocketPermissionDenied(_) => { - Some( - "Check file permissions on the socket or ensure the daemon is running as your user.", - ) - } - Self::IpcTimedOut(_) => { - Some( - "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", - ) - } - Self::ProtocolMismatch { .. } => { - Some("Update your CLI or daemon to match versions.") - } - Self::DaemonExited { .. } => { - Some("Restart the daemon with `iota daemon restart`.") - } - Self::IpcBindUnavailable(_) => { - Some("Another instance may be running. Stop it first or use a different socket path.") - } + Self::SocketPermissionDenied(_) => Some( + "Check file permissions on the socket or ensure the daemon is running as your user.", + ), + Self::IpcTimedOut(_) => Some( + "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", + ), + Self::ProtocolMismatch { .. } => Some("Update your CLI or daemon to match versions."), + Self::DaemonExited { .. } => Some("Restart the daemon with `iota daemon restart`."), + Self::IpcBindUnavailable(_) => Some( + "Another instance may be running. Stop it first or use a different socket path.", + ), Self::Terminal(_) => { Some("Use a terminal that supports interactive mode, or run commands headlessly.") } - Self::Consent(_) => { - Some("Run `iota terms accept` in an interactive terminal to review and accept terms.") - } - Self::InvalidCommand(_) => { - Some("Run `iota --help` to see available commands.") - } + Self::Consent(_) => Some( + "Run `iota terms accept` in an interactive terminal to review and accept terms.", + ), + Self::InvalidCommand(_) => Some("Run `iota --help` to see available commands."), _ => None, } } @@ -149,8 +139,16 @@ mod tests { } #[test] fn most_errors_have_suggestions() { - assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some()); - assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some()); + assert!( + StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")) + .suggestion() + .is_some() + ); + assert!( + StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")) + .suggestion() + .is_some() + ); assert!(StartupError::Cancelled.suggestion().is_none()); } } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 1e3e21d..31796b0 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -14,6 +14,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "client", "crypto", "files", + "raw", ] } dashmap = "6.2.1" diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index a555398..1e1f5c7 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,9 +1,9 @@ use dashmap::{DashMap, DashSet}; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::AppState; -use iota_storage::util::chat_files::{self, MessageState, change_message_state}; +use iota_storage::util::chat_files; use iota_storage::util::config_util::{CONFIG, modify_config}; -use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; +use iota_storage::util::{relay_queue, relay_replay}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; @@ -11,7 +11,10 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock}; +use std::sync::{ + Arc, LazyLock, + atomic::{AtomicU32, Ordering}, +}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; @@ -24,68 +27,11 @@ use crate::omega_discovery; use iota_connection::message_common::*; use iota_connection::message_handlers; - -fn pending_chat_secret_forward_from_cv( - cv: &CommunicationValue, -) -> Option { - let recipient = chat_secret_recipients(cv)?.into_iter().next()?; - Some(PendingChatSecretForward { - recipient_user_id: recipient.user_id, - chat_id: data_string(cv, DataType::ChatId)?, - sender_user_id: cv.get_sender().to_string(), - secret_id: data_string(cv, DataType::SecretId)?, - version: data_i64(cv, DataType::VersionNumber)?, - encrypted_secret: recipient.encrypted_secret, - kem_ciphertext: recipient.kem_ciphertext, - wrapping_scheme: data_string(cv, DataType::WrappingScheme)?, - created_at: data_i64(cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), - }) -} - -fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationValue { - let recipient = typed_container(vec![ - ( - DataType::UserId, - DataValue::Str(record.recipient_user_id.clone()), - ), - ( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_sender(record.sender_user_id.parse::().unwrap_or(0)) - .with_receiver(record.recipient_user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id.clone())) - .add_typed_default( - DataType::SenderUserId, - DataValue::Str(record.sender_user_id.clone()), - ) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme.clone()), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default(DataType::Recipients, DataValue::Array(vec![recipient])) -} - -// Helper function to check if read receipts are enabled globally -async fn is_read_receipts_enabled() -> bool { - CONFIG.load().read_receipts_enabled -} +use iota_connection::relay::{ + RelayValidationError, forward_verified_relay, open_verified_relay_content, + verify_relay_metadata, +}; +use iota_util::route_target::RouteTarget; // ============================================================================ // Configuration @@ -113,6 +59,8 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); const MAX_CONCURRENT_HANDLERS: usize = 20; +const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; +static NEXT_RELAY_FRAME_ID: AtomicU32 = AtomicU32::new(1); // ============================================================================ // Waiting Task System @@ -171,6 +119,7 @@ pub struct OmikronConnection { shutdown_tx: Arc>>>, reconnect_on_close: Arc>, auth_failure: Arc>>, + keyring: Arc>>>, pub app_challenges: Arc>, pub app_sessions: Arc>, handler_semaphore: Arc, @@ -179,6 +128,14 @@ pub struct OmikronConnection { pub(crate) app: Arc>, } +fn ensure_relay_frame_id(frame: CommunicationValue) -> CommunicationValue { + if frame.id().is_some_and(|id| id != 0) { + return frame; + } + let id = NEXT_RELAY_FRAME_ID.fetch_add(1, Ordering::Relaxed).max(1); + frame.with_id(id) +} + impl OmikronConnection { pub fn new(active_tasks: Arc>, app: Arc>) -> Self { Self::with_cancellation(CancellationToken::new(), active_tasks, app) @@ -203,6 +160,7 @@ impl OmikronConnection { shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), auth_failure: Arc::new(RwLock::new(None)), + keyring: Arc::new(RwLock::new(None)), app_challenges: Arc::new(DashMap::new()), app_sessions: Arc::new(DashMap::new()), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), @@ -328,7 +286,8 @@ impl OmikronConnection { self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); - let keyring = self.load_or_migrate_keyring().await; + let keyring = Arc::new(self.load_or_migrate_keyring().await); + *self.keyring.write().await = Some(keyring.clone()); let existing_iota_id = CONFIG.load().iota_id; @@ -595,6 +554,15 @@ impl OmikronConnection { let result = connection.receive().await; match result { Ok(cv) => { + if cv.is_type(CommunicationType::Relay) { + let permit = self.handler_semaphore.clone().acquire_owned().await; + let self_clone = self.clone(); + tokio::spawn(async move { + let _permit = permit; + self_clone.handle_relay(cv).await; + }); + continue; + } let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { if (task.task)(cv.clone()) { @@ -650,188 +618,452 @@ impl OmikronConnection { self.app.lock().unwrap().push_ping_val(ping_ms as f64); } - self.flush_pending_chat_secret_forwards().await; + self.flush_pending_relays().await; + if let Err(error) = relay_replay::prune_completed( + now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS), + ) { + log!("Relay replay cleanup failed: {}", error); + } } } - async fn forward_chat_secret(&self, cv: &CommunicationValue) -> bool { - self.await_response(cv, Some(Duration::from_secs(10))) + async fn resolve_relay_signing_keys( + &self, + signer_id: u64, + ) -> Result, RelayValidationError> { + if let Some(user) = iota_storage::users::user_manager::get_user(signer_id as i64) { + let key = iota_util::crypto_helper::public_key_bundle_from_base64(&user.public_key) + .ok_or_else(|| { + RelayValidationError::KeyLookup("stored user key is invalid".into()) + })?; + return Ok(vec![key]); + } + + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::UnsignedNumber(signer_id as u128), + ); + let response = self + .await_response(&request, Some(Duration::from_secs(10))) .await - .is_ok() + .map_err(RelayValidationError::KeyLookup)?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(RelayValidationError::KeyLookup( + "Omega returned an unexpected user lookup response".into(), + )); + } + let public_key = response + .get_data(DataType::PublicKey) + .and_then(|value| value.as_str()) + .ok_or_else(|| RelayValidationError::KeyLookup("Omega returned no user key".into()))?; + let key = iota_util::crypto_helper::public_key_bundle_from_base64(public_key).ok_or_else( + || RelayValidationError::KeyLookup("Omega returned an invalid user key".into()), + )?; + Ok(vec![key]) } - async fn store_pending_chat_secret_forward(&self, cv: &CommunicationValue) { - let Some(record) = pending_chat_secret_forward_from_cv(cv) else { + pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { + if iota_storage::users::user_manager::get_user(user_id as i64).is_some() { + return CONFIG + .load() + .iota_id + .ok_or_else(|| "Iota identity is not configured".into()); + } + + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::UnsignedNumber(user_id as u128)); + let response = self + .await_response(&request, Some(Duration::from_secs(10))) + .await?; + response + .get_data(DataType::IotaId) + .and_then(|value| value.as_number()) + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| "Omega returned no hosting Iota for the user".into()) + } + + async fn send_relay_response(&self, frame_id: Option, response_type: CommunicationType) { + if let Some(frame_id) = frame_id { + let response = CommunicationValue::new(response_type).with_id(frame_id); + if let Err(error) = self.send_message(&response).await { + log!("Relay response could not be sent: {}", error); + } + } + } + + async fn handle_relay(self: Arc, frame: CommunicationValue) { + let frame = ensure_relay_frame_id(frame); + let incoming_frame_id = frame.id(); + let Some(local_iota_id) = CONFIG.load().iota_id else { + log!("Rejecting Relay because this Iota has no registered identity"); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) + .await; return; }; - let _ = e2ee_storage::put_pending_chat_secret_forward(record); - } - - async fn flush_pending_chat_secret_forwards(&self) { - let Ok(records) = e2ee_storage::get_pending_chat_secret_forwards(100) else { + let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { + log!("Rejecting Relay because the Iota keyring is unavailable"); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInternal) + .await; return; }; + let resolver_connection = self.clone(); + let verified = verify_relay_metadata( + &frame, + local_iota_id, + &keyring, + move |signer_id| async move { + resolver_connection + .resolve_relay_signing_keys(signer_id) + .await + }, + ) + .await; + + let verified = match verified { + Ok(value) => value, + Err(error) => { + log!("Relay metadata verification failed: {}", error); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let signer_is_local = + iota_storage::users::user_manager::get_user(verified.context.signer_id as i64) + .is_some(); + let recipient_is_local = + iota_storage::users::user_manager::get_user(verified.context.final_recipient_id as i64) + .is_some(); + if !signer_is_local && !recipient_is_local { + log!( + "Rejecting Relay with no local origin or destination: signer {}, recipient {}", + verified.context.signer_id, + verified.context.final_recipient_id, + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + + let frame_bytes = match frame.clone().without_id().to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!( + "Relay could not be serialized for durable acceptance: {}", + error + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + let type_map_version = verified.context.type_map.version.to_string(); + let frame_id = frame.id().unwrap_or_default(); + let reservation = match relay_replay::reserve( + verified.context.signer_id, + &verified.context.message_id, + verified.context.created_at, + verified.context.final_recipient_id, + &frame_bytes, + frame_id, + &type_map_version, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay durable acceptance failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + let already_applied = match reservation { + relay_replay::RelayReservation::New => false, + relay_replay::RelayReservation::Existing { + frame_matches: false, + .. + } => { + log!( + "Rejecting Relay identity collision for signer {} and message {}", + verified.context.signer_id, + verified.context.message_id + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "delivered" => { + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + return; + } + relay_replay::RelayReservation::Existing { ref state, .. } + if state == "applied" || state == "queued" => + { + true + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "rejected" => { + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + relay_replay::RelayReservation::Existing { .. } => false, + }; + + if signer_is_local && !recipient_is_local { + let router = match self + .hosting_iota_for_user(verified.context.final_recipient_id) + .await + { + Ok(destination_iota) => destination_iota, + Err(error) => { + log!("Relay origin route lookup failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorNoIota) + .await; + return; + } + }; + let forwarded = match forward_verified_relay(&frame, RouteTarget::Iota(router)) { + Ok(value) => value, + Err(error) => { + log!("Relay origin forwarding validation failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!("Relay origin retry could not be serialized: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::Iota(router), + &bytes, + now_millis_i64(), + frame_id, + &type_map_version, + ) { + log!("Relay origin retry queue failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "queued", + ) { + log!("Relay origin state update failed: {}", error); + } + match self + .await_response(&forwarded, Some(Duration::from_secs(20))) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { + log!( + "Relay origin acknowledgement could not clear the queue: {}", + error + ); + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "delivered", + ) { + log!("Relay origin delivery state update failed: {}", error); + } + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + } + Ok(response) => { + log!("Relay origin route returned {}", response.get_type()); + self.send_relay_response( + frame.id(), + response + .get_comm_type_enum() + .unwrap_or(CommunicationType::ErrorInternal), + ) + .await; + } + Err(error) => { + log!("Relay origin forwarding failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + } + } + return; + } + + let destination = verified.context.final_recipient_id; + let forwarded = match forward_verified_relay(&frame, RouteTarget::User(destination)) { + Ok(value) => value, + Err(error) => { + log!("Relay forwarding validation failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!( + "Relay could not be serialized for client delivery: {}", + error + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::User(destination), + &bytes, + now_millis_i64(), + forwarded.id().unwrap_or_default(), + &type_map_version, + ) { + log!("Relay could not be queued for client delivery: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + + if !already_applied { + let content = match open_verified_relay_content( + &verified, + &[&keyring], + verified.context.final_recipient_id, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay content verification failed: {}", error); + if let Err(queue_error) = + relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) + { + log!( + "Relay invalid-content queue cleanup failed: {}", + queue_error + ); + } + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + if let Err(error) = + message_handlers::apply_verified_relay_content(&verified.context, &content) + { + log!("Relay application dispatch failed: {}", error); + if let Err(queue_error) = + relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) + { + log!("Relay application queue cleanup failed: {}", queue_error); + } + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "applied", + ) { + log!("Relay application state update failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + } + + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "queued", + ) { + log!("Relay queue state update failed: {}", error); + } + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + if let Err(error) = self.send_message(&forwarded).await { + log!("Relay delivery to local client failed: {}", error); + } + } + + async fn flush_pending_relays(&self) { + let Ok(records) = relay_queue::list(100) else { + return; + }; for record in records { - let Ok(recipient) = record.recipient_user_id.parse::() else { - let _ = e2ee_storage::delete_pending_chat_secret_forward( - &record.recipient_user_id, - &record.chat_id, - &record.secret_id, + let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else { + log!( + "Retaining pending Relay {} with invalid type-map version {}", + record.id, + record.type_map_version ); continue; }; - - let message = chat_secret_forward_cv(&record).with_receiver(recipient); - if self.forward_chat_secret(&message).await { - let _ = e2ee_storage::delete_pending_chat_secret_forward( - &record.recipient_user_id, - &record.chat_id, - &record.secret_id, + let type_map = mtp::codec::TypeMap::new(version); + let Ok(frame) = CommunicationValue::from_bytes_with(&record.frame, &type_map) else { + log!("Retaining pending Relay {} with invalid frame", record.id); + continue; + }; + let Ok(forwarded) = forward_verified_relay(&frame, record.target) else { + log!( + "Retaining pending Relay {} with invalid route target", + record.id ); - } - } - } - - async fn forward_message_live( - &self, - message_id: u32, - receiver_id: u64, - sender_id: i64, - timestamp: i64, - content: &str, - height: i64, - reply_to: Option, - ) -> Option { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.to_string())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(message_id) - .with_receiver(receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ) - .add_typed_default(DataType::Message, typed_container(msg_fields)); - - match self - .await_response(&user_forward, Some(Duration::from_secs(3))) - .await - { - Ok(user_resp) => { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - Some(MessageState::from_str(&ms_raw).upgrade(MessageState::Received)) - } - Err(_) => None, - } - } - - async fn forward_to_remote_iota( - &self, - cv: &CommunicationValue, - sender_id: i64, - receiver_id: i64, - timestamp: i64, - content: &str, - height: i64, - reply_to: Option, - ) { - let mut fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .with_sender(sender_id as u64) - .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) - .add_typed_default(DataType::Content, DataValue::Str(content.to_string())) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ); - if let Some(rt) = reply_to { - fw_msg = fw_msg.add_typed_default( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - ); - } - - match self - .await_response(&fw_msg, Some(Duration::from_secs(10))) - .await - { - Ok(resp) => { - let ms_raw = resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - let _ = - chat_files::change_message_state(timestamp, sender_id, receiver_id, ms.clone()); - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - Err(_) => { - let _ = chat_files::change_message_state( - timestamp, - sender_id, - receiver_id, - MessageState::Sent, - ); - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; + continue; + }; + match record.target { + RouteTarget::Iota(destination_iota) => { + match self + .await_response(&forwarded, Some(Duration::from_secs(20))) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = + relay_queue::acknowledge_iota(destination_iota, record.frame_id) + { + log!( + "Pending Relay {} acknowledgement could not clear the queue: {}", + record.id, + error + ); + } + } + Ok(response) => log!( + "Pending Relay {} route returned {}", + record.id, + response.get_type() + ), + Err(error) => { + log!("Pending Relay {} delivery failed: {}", record.id, error) + } + } + } + RouteTarget::User(_) => { + if let Err(error) = self.send_message(&forwarded).await { + log!("Pending Relay {} delivery failed: {}", record.id, error); + } + } } } } @@ -843,6 +1075,27 @@ impl OmikronConnection { pub async fn handle_message(self: Arc, cv: CommunicationValue) { log_cv_in!(&cv); + if cv.is_type(CommunicationType::Success) + && let Some(frame_id) = cv.id() + && let Some(destination_id) = cv + .get_data(DataType::UserId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + { + match relay_queue::acknowledge(destination_id, frame_id) { + Ok(true) => { + if let Err(error) = + relay_replay::mark_delivered_for_frame(destination_id, frame_id) + { + log!("Relay delivery state update failed: {}", error); + } + return; + } + Ok(false) => {} + Err(error) => log!("Relay delivery acknowledgement failed: {}", error), + } + } + let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { @@ -855,6 +1108,22 @@ impl OmikronConnection { } async fn handle_message_impl(self: Arc, cv: CommunicationValue) { + if cv.is_type(CommunicationType::Relay) { + self.handle_relay(cv).await; + return; + } + + if matches!( + iota_connection::relay::message_security_class(&cv), + iota_connection::relay::MessageSecurityClass::RelayOnly + ) { + log!("Rejecting sender-based application mutation outside Relay"); + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + macro_rules! dispatch { ($ty:ident, $method:ident) => { if cv.is_type(CommunicationType::$ty) { @@ -864,9 +1133,7 @@ impl OmikronConnection { }; } - dispatch!(SetChatSecret, handle_set_chat_secret); dispatch!(GetChatSecret, handle_get_chat_secret); - dispatch!(ChatSecretForward, handle_chat_secret_forward); dispatch!(AppIdentification, handle_app_identification); dispatch!(AppChallengeResponse, handle_app_challenge_response); dispatch!(SaveAppData, handle_save_app_data); @@ -876,14 +1143,12 @@ impl OmikronConnection { dispatch!(ClientConnected, handle_client_connected); dispatch!(ClientStateAck, handle_client_state_ack); dispatch!(MessageState, handle_message_state); - dispatch!(MessageSend, handle_message_send); dispatch!(MessageEdit, handle_message_edit); dispatch!(MessageEditLive, handle_message_edit_live); dispatch!(MessageReactionAdd, handle_message_reaction_add); dispatch!(MessageReactionRemove, handle_message_reaction_remove); dispatch!(MessageReactionLive, handle_message_reaction_live); dispatch!(MessageDeleteLive, handle_message_delete_live); - dispatch!(MessageOtherIota, handle_message_other_iota); dispatch!(MessageGet, handle_message_get); dispatch!(MessagesGet, handle_messages_get); dispatch!(GetChats, handle_get_chats); @@ -931,123 +1196,12 @@ impl OmikronConnection { let _ = self.send_message(&acknowledgement).await; } - async fn handle_set_chat_secret(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender().to_string(); - let recipients = match chat_secret_recipients(cv) { - Some(recipients) => recipients, - None => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let now = now_millis_i64(); - let chat_id = data_string(cv, DataType::ChatId); - let secret_id = data_string(cv, DataType::SecretId); - let version = data_i64(cv, DataType::VersionNumber); - let wrapping_scheme = data_string(cv, DataType::WrappingScheme); - let created_at = data_i64(cv, DataType::CreatedAt).unwrap_or(now); - - let Some((((chat_id, secret_id), version), wrapping_scheme)) = - chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) - else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - let mut non_local_forwards: Vec = Vec::new(); - - for recipient in &recipients { - let recipient_id = recipient.user_id.parse::().unwrap_or(0); - let is_local = iota_storage::users::user_manager::get_user(recipient_id).is_some(); - - if is_local { - if e2ee_storage::put_chat_secret(StoredChatSecret { - user_id: recipient.user_id.clone(), - chat_id: chat_id.clone(), - secret_id: secret_id.clone(), - version, - encrypted_secret: recipient.encrypted_secret.clone(), - kem_ciphertext: recipient.kem_ciphertext.clone(), - wrapping_scheme: wrapping_scheme.clone(), - created_at, - updated_at: now, - }) - .is_err() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - continue; - } - - if recipient.user_id != sender_id { - non_local_forwards.push(set_chat_secret_cv_for_recipient(cv, recipient)); - } - } - - if !non_local_forwards.is_empty() { - let mut handles = Vec::new(); - for forward in &non_local_forwards { - let self_clone = self.clone(); - let fwd = forward.clone(); - handles.push(tokio::spawn(async move { - self_clone.forward_chat_secret(&fwd).await - })); - } - - for (forward, handle) in non_local_forwards.into_iter().zip(handles) { - match handle.await { - Ok(true) => {} - _ => self.store_pending_chat_secret_forward(&forward).await, - } - } - } - - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } - async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_get_chat_secret(cv)) .await; } - async fn handle_chat_secret_forward(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender().to_string(); - let recipient_user_id = data_string(cv, DataType::RecipientUserId).unwrap_or_default(); - if data_string(cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) - || recipient_user_id.is_empty() - || pending_chat_secret_forward_from_cv(cv).is_none() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - let forward = cv - .clone() - .with_receiver(recipient_user_id.parse::().unwrap_or(0)); - if self.forward_chat_secret(&forward).await { - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } else { - self.store_pending_chat_secret_forward(cv).await; - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } - } - async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender(); let app_identifier = cv @@ -1113,7 +1267,7 @@ impl OmikronConnection { async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender(); if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { - if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { + if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { if expected_challenge == *response { let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) .with_id(cv.get_id()) @@ -1448,301 +1602,6 @@ impl OmikronConnection { let _ = self.send_message(&live).await; } - async fn handle_message_send(self: Arc, cv: &CommunicationValue) { - let sender_id: u64 = cv.get_sender(); - - let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { - s.parse::().unwrap_or(0) - } else { - 0 - }; - - let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() - }; - let timestamp_u128 = timestamp_i64 as u128; - - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - if let Some(reply_to) = reply_to { - match chat_files::get_message(sender_id as i64, reply_to, Some(receiver_id)) { - Ok(Some(_)) => {} - Ok(None) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - } - } - - let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); - - if is_local { - chat_files::add_message( - timestamp_u128, - false, - receiver_id as i64, - sender_id as i64, - &content, - height, - reply_to, - ); - } - - chat_files::add_message( - timestamp_u128, - true, - sender_id as i64, - receiver_id as i64, - &content, - height, - reply_to, - ); - - let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - let _ = self.send_message(&conf_msg).await; - - if !is_local { - self.forward_to_remote_iota( - cv, - sender_id as i64, - receiver_id, - timestamp_i64, - &content, - height, - reply_to, - ) - .await; - } else { - match self - .forward_message_live( - cv.get_id(), - receiver_id as u64, - sender_id as i64, - timestamp_i64, - &content, - height, - reply_to, - ) - .await - { - Some(ms) => { - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id, - sender_id as i64, - ms.clone(), - ); - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id, - ms.clone(), - ); - if is_read_receipts_enabled().await { - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - } - None => { - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id, - sender_id as i64, - MessageState::Sent, - ); - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id, - MessageState::Sent, - ); - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ); - let _ = self.send_message(&push_msg).await; - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - } - } - } - - async fn handle_message_other_iota(self: Arc, cv: &CommunicationValue) { - let sender_id = &cv.get_sender(); - let receiver_id = &cv.get_receiver(); - - let timestamp = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() - }; - - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - chat_files::add_message( - timestamp as u128, - false, - *receiver_id as i64, - *sender_id as i64, - &content, - height, - reply_to, - ); - - match self - .forward_message_live( - cv.get_id(), - *receiver_id, - *sender_id as i64, - timestamp, - &content, - height, - reply_to, - ) - .await - { - Some(ms) => { - let _ = change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - ms.clone(), - ); - - if is_read_receipts_enabled().await { - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - } - None => { - let _ = chat_files::change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - MessageState::Sent, - ); - - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ); - let _ = self.send_message(&push_msg).await; - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - } - } - async fn handle_messages_get(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_messages_get(cv)) @@ -2154,6 +2013,7 @@ impl OmikronClient for OmikronConnection { shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), + keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), handler_semaphore: self.handler_semaphore.clone(), @@ -2177,6 +2037,7 @@ impl OmikronClient for OmikronConnection { shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), + keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), handler_semaphore: self.handler_semaphore.clone(), diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 241fd46..3008120 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -5,6 +5,7 @@ use iota_storage::users::user_profile::UserProfile; use iota_storage::util::config_util::CONFIG; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; use iota_util::file_util::write_user_credential; +use iota_util::mtp_compat::OptionalDataValueExt; use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; @@ -33,7 +34,9 @@ pub enum LifecycleUserError { } impl From for LifecycleUserError { - fn from(value: crate::OmikronError) -> Self { Self::Transport(value) } + fn from(value: crate::OmikronError) -> Self { + Self::Transport(value) + } } fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { @@ -46,21 +49,31 @@ fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> V } fn configured_iota_id() -> Result { - CONFIG.load().iota_id + CONFIG + .load() + .iota_id .and_then(|id| i64::try_from(id).ok()) .filter(|id| *id > 0) - .ok_or_else(|| LifecycleUserError::InvalidCredential("Iota identity is not registered".into())) + .ok_or_else(|| { + LifecycleUserError::InvalidCredential("Iota identity is not registered".into()) + }) } -fn sign_lifecycle_payload(credential: &TuCredential, payload: &[u8]) -> Result<(Vec, Vec), LifecycleUserError> { +fn sign_lifecycle_payload( + credential: &TuCredential, + payload: &[u8], +) -> Result<(Vec, Vec), LifecycleUserError> { let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? .sign(payload) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - let pq = MlDsaSigner::new(&credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? - .sign(payload) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let pq = MlDsaSigner::new( + &credential.keyring.sig_pq_secret_key, + &credential.keyring.sig_pq_public_key, + ) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; Ok((classical, pq)) } @@ -71,15 +84,25 @@ async fn inspect_credential_account( if credential.omega_host != omega_discovery::omega_host() { return Err(LifecycleUserError::OmegaHostMismatch); } - let request = CommunicationValue::new(CommunicationType::GetUserData) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); - let response = connection.await_response(&request, Duration::from_secs(20)).await?; + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await?; if !response.is_type(CommunicationType::GetUserData) { return Err(LifecycleUserError::RemoteRejected); } - let username = response.get_data(DataType::Username).as_str().map(str::to_owned) + let username = response + .get_data(DataType::Username) + .as_str() + .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; - let public_key = response.get_data(DataType::PublicKey).as_str().map(str::to_owned) + let public_key = response + .get_data(DataType::PublicKey) + .as_str() + .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { return Err(LifecycleUserError::RemoteRejected); @@ -96,54 +119,121 @@ async fn credential_proof( domain: &[u8], ) -> Result<(), LifecycleUserError> { let iota_id = configured_iota_id()?; - let begin_request = CommunicationValue::new(begin) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); - let challenge_response = connection.await_response(&begin_request, Duration::from_secs(20)).await?; + let begin_request = CommunicationValue::new(begin).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ); + let challenge_response = connection + .await_response(&begin_request, Duration::from_secs(20)) + .await?; if !challenge_response.is_type(challenge) { return Err(LifecycleUserError::RemoteRejected); } - let nonce = challenge_response.get_data(DataType::ServerNonce).as_signed_number() + let nonce = challenge_response + .get_data(DataType::ServerNonce) + .as_signed_number() .and_then(|value| u64::try_from(value).ok()) .ok_or(LifecycleUserError::RemoteRejected)?; - let (signature, pq_signature) = sign_lifecycle_payload(credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce))?; + let (signature, pq_signature) = sign_lifecycle_payload( + credential, + &lifecycle_payload(domain, credential.user_id, iota_id, nonce), + )?; let complete_request = CommunicationValue::new(complete) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())) + .add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ) .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); - let response = connection.await_response(&complete_request, Duration::from_secs(20)).await?; - if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) } + let response = connection + .await_response(&complete_request, Duration::from_secs(20)) + .await?; + if response.is_type(CommunicationType::Success) { + Ok(()) + } else { + Err(LifecycleUserError::RemoteRejected) + } } /// Attach or migrate an existing account. Local state is written only after /// Omega has accepted the credential proof and changed its assignment. -pub async fn attach_user_from_tu(connection: &dyn OmikronClient, contents: &str) -> Result { - let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; +pub async fn attach_user_from_tu( + connection: &dyn OmikronClient, + contents: &str, +) -> Result { + let credential = TuCredential::parse(contents) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; let (username, public_key) = inspect_credential_account(connection, &credential).await?; - credential_proof(connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0").await?; - let profile = UserProfile::new(credential.user_id, username, None, public_key, hex_hash(contents), String::new()); + credential_proof( + connection, + &credential, + CommunicationType::AttachUserBegin, + CommunicationType::AttachUserChallenge, + CommunicationType::AttachUserComplete, + b"tensamin:user-attach:v1\0", + ) + .await?; + let profile = UserProfile::new( + credential.user_id, + username, + None, + public_key, + hex_hash(contents), + String::new(), + ); write_user_credential(profile.user_id, &credential.to_canonical_string()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - try_add_user(profile.clone()).map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + try_add_user(profile.clone()) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; Ok(profile) } -pub async fn complete_delete_user_with_tu(connection: &dyn OmikronClient, contents: &str, expected_user_id: i64) -> Result<(), LifecycleUserError> { - let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - if credential.user_id != expected_user_id { return Err(LifecycleUserError::InvalidCredential("credential user ID does not match deletion target".into())); } +pub async fn complete_delete_user_with_tu( + connection: &dyn OmikronClient, + contents: &str, + expected_user_id: i64, +) -> Result<(), LifecycleUserError> { + let credential = TuCredential::parse(contents) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + if credential.user_id != expected_user_id { + return Err(LifecycleUserError::InvalidCredential( + "credential user ID does not match deletion target".into(), + )); + } inspect_credential_account(connection, &credential).await?; - credential_proof(connection, &credential, CommunicationType::DeleteUserCredentialBegin, CommunicationType::DeleteUserCredentialChallenge, CommunicationType::DeleteUserCredentialComplete, b"tensamin:user-delete:v1\0").await + credential_proof( + connection, + &credential, + CommunicationType::DeleteUserCredentialBegin, + CommunicationType::DeleteUserCredentialChallenge, + CommunicationType::DeleteUserCredentialComplete, + b"tensamin:user-delete:v1\0", + ) + .await } /// Repair local management state after a release or migration committed in /// Omega but local cleanup was interrupted. Hosted data is retained. pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { - let Ok(local_iota_id) = configured_iota_id() else { return; }; + let Ok(local_iota_id) = configured_iota_id() else { + return; + }; for user in iota_storage::users::user_manager::get_users() { - let request = CommunicationValue::new(CommunicationType::GetUserData) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user.user_id.into())); - let Ok(response) = connection.await_response(&request, Duration::from_secs(10)).await else { continue; }; - let remote_iota_id = response.get_data(DataType::IotaId).as_signed_number().and_then(|value| i64::try_from(value).ok()); + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(user.user_id.into()), + ); + let Ok(response) = connection + .await_response(&request, Duration::from_secs(10)) + .await + else { + continue; + }; + let remote_iota_id = response + .get_data(DataType::IotaId) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()); if remote_iota_id != Some(local_iota_id) { let _ = iota_storage::users::user_manager::release_user(user.user_id); } @@ -268,7 +358,12 @@ pub async fn create_user( log!("Created User"); write_user_credential( user_id, - &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), + &format!( + "{}@{}::{}", + user_id, + omega_discovery::omega_host(), + keyring_b64 + ), ) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; @@ -282,6 +377,7 @@ mod tests { use super::{CreateUserError, request_user_id, valid_username}; use crate::{OmikronClient, OmikronError}; use async_trait::async_trait; + use iota_util::mtp_compat::CommunicationValueCompat; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::time::Duration; diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index be6b65a..ad49fba 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } bytes = "1" http = "1" iota-logger = { path = "../iota-logger" } From ad8555bc6e91b833fbcc43393c14bda97a2c1270 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 20 Aug 2026 17:05:43 +0200 Subject: [PATCH 113/119] [Updt] Mtp 0.3.0 --- Cargo.lock | 619 +------------------ README.md | 8 +- client/Cargo.toml | 47 -- client/src/client_connection.rs | 106 +++- communities/Cargo.toml | 27 - communities/src/community_connection.rs | 65 +- communities/src/interactables/text_chat.rs | 29 +- communities/src/interactables/voice_chat.rs | 8 +- flake.nix | 9 + iota-auth/Cargo.toml | 50 -- iota-cli/Cargo.toml | 58 -- iota-cli/src/ipc_client.rs | 2 +- iota-connection/src/message_common.rs | 54 +- iota-connection/src/message_handlers.rs | 412 ++++++++----- iota-connection/src/relay.rs | 27 +- iota-core/Cargo.toml | 11 - iota-daemon-lib/Cargo.toml | 2 +- iota-daemon-lib/src/command_router.rs | 159 ++++- iota-daemon-lib/src/daemon_state.rs | 12 +- iota-daemon-lib/src/ipc_server.rs | 261 ++++++-- iota-daemon-lib/src/lib.rs | 2 +- iota-daemon-lib/tests/command_router.rs | 61 +- iota-daemon-lib/tests/ipc_server.rs | 252 ++++++++ iota-daemon/Cargo.toml | 2 - iota-ipc/src/lib.rs | 12 +- iota-ipc/src/protocol.rs | 62 +- iota-ipc/src/transport.rs | 40 +- iota-process-manager/Cargo.toml | 4 + iota-process-manager/src/lib.rs | 176 +++++- iota-state/Cargo.toml | 2 - iota-storage/Cargo.toml | 17 - iota-storage/src/util/config_util.rs | 15 + iota-terms/Cargo.toml | 1 - iota-updater/Cargo.toml | 19 - iota-util/src/crypto_helper.rs | 10 +- iota-util/src/mtp_compat.rs | 76 ++- iota/Cargo.toml | 1 - mtp-type-maps | 2 +- omikron-connector/Cargo.toml | 5 - omikron-connector/src/omikron_connection.rs | 631 +++++++++++++++----- omikron-connector/src/user_ops.rs | 4 +- other-iota/Cargo.toml | 54 -- systemd/iota-daemon.service | 1 + web-server/Cargo.toml | 1 - web-ui/Cargo.toml | 44 -- 45 files changed, 2019 insertions(+), 1441 deletions(-) create mode 100644 iota-daemon-lib/tests/ipc_server.rs diff --git a/Cargo.lock b/Cargo.lock index 7405bb8..925c024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,29 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-rt", - "bitflags 2.13.1", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] - [[package]] name = "actix-codec" version = "0.5.2" @@ -217,24 +194,6 @@ dependencies = [ "url", ] -[[package]] -name = "actix-web-actors" -version = "4.3.1+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" -dependencies = [ - "actix", - "actix-codec", - "actix-http", - "actix-web", - "bytes", - "bytestring", - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "actix-web-codegen" version = "4.3.0" @@ -274,20 +233,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -803,52 +748,14 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "client" version = "0.1.0" dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-auth", "iota-connection", "iota-logger", - "iota-state", "iota-storage", "iota-util", - "json", - "lazy_static", "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tokio", - "tokio-tungstenite", - "tungstenite", "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -1008,15 +915,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1082,15 +980,6 @@ dependencies = [ "phf", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "ctutils" version = "0.4.2" @@ -1392,17 +1281,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ed448-goldilocks" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" -dependencies = [ - "fiat-crypto 0.1.20", - "hex", - "subtle", -] - [[package]] name = "either" version = "1.17.0" @@ -1483,12 +1361,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fiat-crypto" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1553,21 +1425,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1720,16 +1577,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "glob" version = "0.3.4" @@ -1867,30 +1714,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64 0.22.1", - "bytes", - "headers-core", - "http 1.5.0", - "httpdate", - "mime", - "sha1 0.10.7", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http 1.5.0", -] - [[package]] name = "heck" version = "0.5.0" @@ -1903,15 +1726,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - [[package]] name = "hkdf" version = "0.13.0" @@ -2276,114 +2090,34 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", - "tokio-util", ] [[package]] name = "iota-auth" version = "0.1.0" dependencies = [ - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", "json", - "lazy_static", - "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", - "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] name = "iota-cli" version = "0.1.0" dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", "chrono", "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", "iota-ipc", - "iota-logger", "iota-paths", - "iota-process-manager", "iota-state", - "iota-storage", "iota-terms", - "iota-util", - "json", - "lazy_static", - "mtp", - "omikron-connector", "once_cell", "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", "serde", - "serde_json", "serde_yaml", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tempfile", "tokio", - "tokio-tungstenite", "tokio-util", - "tungstenite", "unicode-width", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -2400,7 +2134,6 @@ dependencies = [ name = "iota-core" version = "0.1.0" dependencies = [ - "dashmap", "iota-cli", "iota-logger", "iota-paths", @@ -2409,17 +2142,9 @@ dependencies = [ "iota-terms", "iota-updater", "iota-util", - "json", - "mtp", - "omikron-connector", - "once_cell", "pnet", - "ratatui", - "reqwest", "tokio", - "tokio-util", "web-server", - "web-ui", ] [[package]] @@ -2430,13 +2155,11 @@ dependencies = [ "iota-ipc", "iota-logger", "iota-paths", - "iota-state", "iota-storage", "iota-terms", "iota-util", "omikron-connector", "tokio", - "tokio-util", "web-server", ] @@ -2445,7 +2168,6 @@ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ "async-trait", - "dashmap", "iota-ipc", "iota-logger", "iota-state", @@ -2455,6 +2177,7 @@ dependencies = [ "libc", "mtp", "omikron-connector", + "serde_json", "serde_yaml", "sysinfo", "tempfile", @@ -2506,6 +2229,8 @@ name = "iota-process-manager" version = "0.1.0" dependencies = [ "async-trait", + "libc", + "tempfile", "tokio", ] @@ -2515,9 +2240,7 @@ version = "0.1.0" dependencies = [ "dashmap", "json", - "mtp", "once_cell", - "serde", "sysinfo", "tokio", ] @@ -2526,42 +2249,26 @@ dependencies = [ name = "iota-storage" version = "0.1.0" dependencies = [ - "aes-gcm", "arc-swap", "base64 0.22.1", - "hex", - "hkdf 0.12.4", "iota-logger", "iota-paths", - "iota-state", "iota-util", "json", - "mtp", "once_cell", "r2d2", "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", "rusqlite", "serde", - "serde_json", "serde_yaml", - "sha2 0.11.0", - "sysinfo", "thiserror 2.0.20", "tokio", - "uuid", - "walkdir", - "x448", - "zip", ] [[package]] name = "iota-terms" version = "0.1.0" dependencies = [ - "iota-state", "iota-util", "json", "reqwest", @@ -2571,32 +2278,15 @@ dependencies = [ name = "iota-updater" version = "0.1.0" dependencies = [ - "aes-gcm", "anyhow", - "base64 0.22.1", "ed25519-dalek 2.2.0", "hex", - "hkdf 0.12.4", - "iota-logger", "iota-paths", - "json", - "mtp", - "once_cell", - "pnet", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "semver", "serde", "serde_json", "sha2 0.11.0", - "sysinfo", "tempfile", "tokio", - "uuid", - "walkdir", - "x448", - "zip", ] [[package]] @@ -2943,16 +2633,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3037,7 +2717,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "mtp-client", "mtp-codec", @@ -3053,7 +2733,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "mtp-codec", "mtp-common", @@ -3066,7 +2746,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "base64 0.23.1", "byteorder", @@ -3080,7 +2760,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "quinn", "rustls", @@ -3091,14 +2771,14 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "argon2", "base64 0.22.1", "chacha20poly1305", "ed25519-dalek 3.0.0", "getrandom 0.4.3", - "hkdf 0.13.0", + "hkdf", "ml-dsa", "mlkem-tls", "rand 0.10.2", @@ -3114,7 +2794,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3125,7 +2805,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "mtp-codec", "mtp-common", @@ -3141,7 +2821,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "async-trait", "mtp-codec", @@ -3161,7 +2841,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "serde", "serde_yaml", @@ -3170,7 +2850,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "async-trait", "bytes", @@ -3195,23 +2875,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.29.0" @@ -3383,7 +3046,6 @@ dependencies = [ "async-trait", "base64 0.22.1", "dashmap", - "hex", "iota-connection", "iota-logger", "iota-state", @@ -3391,14 +3053,11 @@ dependencies = [ "iota-util", "json", "mtp", - "rand 0.8.7", "rand_core 0.6.4", "reqwest", - "sha2 0.11.0", "tokio", "tokio-util", "uuid", - "x448", ] [[package]] @@ -3429,49 +3088,12 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "4.6.0" @@ -3484,53 +3106,6 @@ dependencies = [ [[package]] name = "other-iota" version = "0.1.0" -dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-auth", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "lazy_static", - "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", - "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] [[package]] name = "palette" @@ -3728,26 +3303,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3882,18 +3437,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.15.0" @@ -4063,12 +3606,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" - [[package]] name = "rand_core" version = "0.6.4" @@ -4492,12 +4029,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -5158,16 +4689,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5189,20 +4710,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -5301,23 +4808,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http 1.5.0", - "httparse", - "log", - "native-tls", - "rand 0.10.2", - "sha1 0.11.0", - "thiserror 2.0.20", -] - [[package]] name = "typenum" version = "1.20.1" @@ -5330,12 +4820,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -5475,33 +4959,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "warp" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http 1.5.0", - "http-body", - "http-body-util", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project", - "scoped-tls", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tokio-util", - "tower-service", - "tracing", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5579,7 +5036,6 @@ dependencies = [ "bytes", "http 1.5.0", "iota-logger", - "iota-util", "mtp", "tokio", "tokio-util", @@ -5610,50 +5066,14 @@ name = "web-ui" version = "0.1.0" dependencies = [ "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", "iota-logger", "iota-state", "iota-storage", "iota-util", - "json", - "lazy_static", - "mtp", - "omikron-connector", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", "rustls", "rustls-pemfile", "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -6031,17 +5451,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "x448" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" -dependencies = [ - "ed448-goldilocks", - "hex", - "rand_core 0.5.1", -] - [[package]] name = "x509-parser" version = "0.18.1" diff --git a/README.md b/README.md index aee09ab..5a8c16a 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,12 @@ document without accepting it. # Linux daemon installation The system-managed daemon runs as the dedicated `iota` account and listens on -`/run/iota/iota.sock` through socket activation. Operator access is granted -through the `iota-operators` group. After installing, add an account with: +`/run/iota/iota.sock` through socket activation. The system IPC socket is the +privilege boundary. Operator access is granted through the `iota-operators` +group, and every account admitted through that socket is authorized for the +full operator-console role, including user management, identity rotation, +configuration, and daemon lifecycle commands. After installing, add an +account with: ```text usermod -aG iota-operators USER diff --git a/client/Cargo.toml b/client/Cargo.toml index d63431f..8759d48 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -9,53 +9,6 @@ iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index a90df86..cfad489 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -140,7 +140,11 @@ impl ClientConnection { return; } - let _msg_id = cv.get_id(); + if cv.require_id().is_err() { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } if cv.is_type(CommunicationType::Challenge) { self.handle_challenge(&cv).await; @@ -154,7 +158,14 @@ impl ClientConnection { } if cv.is_type(CommunicationType::SaveAppData) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let _app_data = cv .get_data(DataType::AppData) .as_str() @@ -162,18 +173,25 @@ impl ClientConnection { .to_string(); let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_id(cv.get_id()) + .with_request_id(&cv) .with_receiver(sender_id); self.send_message(&res).await; return; } if cv.is_type(CommunicationType::LoadAppData) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let app_data = String::new(); let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_id(cv.get_id()) + .with_request_id(&cv) .with_receiver(sender_id) .add_typed_default(DataType::AppData, DataValue::Str(app_data)); self.send_message(&res).await; @@ -287,12 +305,32 @@ impl ClientConnection { } if cv.is_type(CommunicationType::SettingsSave) { - let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataType::SettingsName).as_str().unwrap(); - let settings_value = cv.get_data(DataType::Payload).as_str().unwrap(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; let _ = iota_storage::util::settings::save( - my_id as i64, + my_id_i64, iota_storage::util::settings::GLOBAL_SESSION_ID, settings_name, settings_value, @@ -300,17 +338,33 @@ impl ClientConnection { let response = CommunicationValue::new(CommunicationType::SettingsSave) .with_receiver(my_id) - .with_id(cv.get_id()); + .with_request_id(&cv); self.send_message(&response).await; return; } if cv.is_type(CommunicationType::SettingsLoad) { - let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataType::SettingsName).as_string().unwrap(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_string() else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; let settings_value_str = iota_storage::util::settings::load( - my_id as i64, + my_id_i64, iota_storage::util::settings::GLOBAL_SESSION_ID, &settings_name, ) @@ -318,7 +372,7 @@ impl ClientConnection { .flatten() .unwrap_or_default(); let response = CommunicationValue::new(CommunicationType::SettingsLoad) - .with_id(cv.get_id()) + .with_request_id(&cv) .with_receiver(my_id) .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) .add_typed_default(DataType::SettingsName, DataValue::Str(settings_name)); @@ -328,15 +382,27 @@ impl ClientConnection { } if cv.is_type(CommunicationType::SettingsList) { - let my_id = cv.get_sender(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; let settings = iota_storage::util::settings::list( - my_id as i64, + my_id_i64, iota_storage::util::settings::GLOBAL_SESSION_ID, ) .unwrap_or_default(); let settings_json = settings.into_iter().map(DataValue::Str).collect(); let response = CommunicationValue::new(CommunicationType::SettingsList) - .with_id(cv.get_id()) + .with_request_id(&cv) .with_receiver(my_id) .add_typed_default(DataType::Settings, DataValue::Array(settings_json)); @@ -356,7 +422,7 @@ impl ClientConnection { if let Some(solved) = solved { let response = CommunicationValue::new(CommunicationType::ChallengeResponse) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_typed_default(DataType::Challenge, DataValue::Str(solved)); self.send_message(&response).await; @@ -405,7 +471,9 @@ impl ClientConnection { timeout_duration: Option, ) -> Result { let (tx, mut rx) = mpsc::channel(1); - let msg_id = cv.get_id(); + let msg_id = cv + .require_id() + .map_err(|error| format!("cannot await response without a message id: {error}"))?; let task_tx = tx.clone(); self.waiting_tasks.insert( diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 89c4f57..4279454 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -5,23 +5,11 @@ edition = "2024" [dependencies] mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" aes-gcm = "0.10.3" async-trait = "0.1.89" base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" futures = "*" -futures-util = "*" -hex = "*" hkdf = "0.12.4" hyper = { version = "1.8.1", features = [ "capi", @@ -34,27 +22,12 @@ hyper = { version = "1.8.1", features = [ ] } hyper-util = { version = "*" } json = "*" -lazy_static = "1.5.0" once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-tungstenite = { version = "*", features = ["native-tls"] } tungstenite = "*" uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" x448 = { version = "*" } -zip = "6.0.0" diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 685d9d8..68c0f48 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -2,7 +2,6 @@ use crate::auth::auth_user::AuthUser; use crate::communities::community::Community; use crate::communities::interactables::interactable::Interactable; use crate::users::user_manager::get_user; -use iota_util::mtp_compat::CommunicationValueCompat; use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures::SinkExt; @@ -22,6 +21,20 @@ use tungstenite::Message; use tungstenite::Utf8Bytes; use uuid::Uuid; use x448::PublicKey; + +trait CommunicationResponseExt { + fn with_request_id(self, request: &CommunicationValue) -> Self; +} + +impl CommunicationResponseExt for CommunicationValue { + fn with_request_id(mut self, request: &CommunicationValue) -> Self { + self = self.without_id(); + if let Some(id) = request.id() { + self = self.with_id(id); + } + self + } +} pub struct CommunityConnection { pub sender: Arc>, Message>>>, pub receiver: Arc>>>>, @@ -115,7 +128,7 @@ impl CommunityConnection { .unwrap_or(0); let Some(user) = get_user(user_id) else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; }; @@ -145,7 +158,7 @@ impl CommunityConnection { let user_public_key_bytes = match STANDARD.decode(&user.public_key) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } @@ -154,14 +167,14 @@ impl CommunityConnection { let user_pub_key: PublicKey = match PublicKey::from_bytes(&user_public_key_bytes) { Some(key) => key, __ => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -172,7 +185,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -194,7 +207,7 @@ impl CommunityConnection { let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) { Ok(data) => data, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -209,7 +222,7 @@ impl CommunityConnection { STANDARD.encode(community_public_key.as_bytes()), ) .add_data_str(DataType::Challenge, STANDARD.encode(&encrypted_out)) - .with_id(cv.get_id()); + .with_request_id(&cv); self.send_message(&response).await; } @@ -217,7 +230,7 @@ impl CommunityConnection { let client_challenge_response_b64 = match cv.get_data(DataType::Challenge) { Some(data) => data.to_string(), _ => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } @@ -226,38 +239,38 @@ impl CommunityConnection { let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } }; if challenge_response_bytes.len() < 12 { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } let Some(user) = self.auth.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; }; let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidPublicKey) + self.send_error_response(&cv, CommunicationType::ErrorInvalidPublicKey) .await; return; }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -267,7 +280,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -291,7 +304,7 @@ impl CommunityConnection { let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) { Ok(pt) => pt, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidChallenge) + self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) .await; return; } @@ -300,7 +313,7 @@ impl CommunityConnection { let client_response = match String::from_utf8(decrypted_bytes) { Ok(str) => str, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidData) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } @@ -309,7 +322,7 @@ impl CommunityConnection { let expected_challenge = self.challenge.read().await.clone(); if client_response != expected_challenge { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidChallenge) + self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) .await; self.close().await; return; @@ -321,7 +334,7 @@ impl CommunityConnection { } let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInternal) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -329,7 +342,7 @@ impl CommunityConnection { let user_id = self.get_user_id().await; if user_id == 0 { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } @@ -348,13 +361,17 @@ impl CommunityConnection { } c }) - .with_id(cv.get_id()); + .with_request_id(&cv); self.send_message(&response).await; } - async fn send_error_response(&self, message_id: &Uuid, error_type: CommunicationType) { - let error = CommunicationValue::new(error_type).with_id(*message_id); + async fn send_error_response( + &self, + request: &CommunicationValue, + error_type: CommunicationType, + ) { + let error = CommunicationValue::new(error_type).with_request_id(request); self.send_message(&error).await; } pub async fn close(&self) { diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index 6b69baa..780d8ed 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -8,7 +8,7 @@ use crate::{ }; use async_trait::async_trait; use json::{JsonValue, array, object}; -use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; +use iota_util::mtp_compat::{OptionalDataValueExt, RequiredCommunicationFields}; use std::fs; use std::path::Path; use std::sync::Arc; @@ -31,6 +31,10 @@ impl TextChat { } } pub fn add_message(&self, send_time: u128, sender: i64, message: &str) { + let Ok(send_time) = i64::try_from(send_time) else { + log!("Message timestamp exceeds local storage range"); + return; + }; let user_dir = &format!( "communities/{}/interactables/{}/{}", self.get_community().get_name(), @@ -74,7 +78,7 @@ impl TextChat { } let json_obj = object! { - "timestamp" => send_time as i64, + "timestamp" => send_time, "content" => message, "sender" => sender.to_string(), }; @@ -202,7 +206,7 @@ impl Interactable for TextChat { let mut payload = JsonValue::new_object(); payload["messages"] = messages; return CommunicationValue::new(CommunicationType::Function) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_data_str(DataType::Name, self.name.clone()) .add_data_str(DataType::Path, self.path.clone()) .add_data_str(DataType::Result, "message_chunk".to_string()) @@ -210,19 +214,28 @@ impl Interactable for TextChat { } if cv.get_data(DataType::Function).unwrap().as_str().unwrap() == "send_message" { let message = payload["message"].as_str().unwrap(); + let sender = match cv.require_sender() { + Ok(sender) => sender, + Err(_) => return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_request_id(&cv), + }; let milliseconds_timestamp: u128 = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); - self.add_message(milliseconds_timestamp, cv.get_sender(), message); + let Ok(sender) = i64::try_from(sender) else { + return CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_request_id(&cv); + }; + self.add_message(milliseconds_timestamp, sender, message); let mut distribution_payload = JsonValue::new_object(); distribution_payload["message"] = JsonValue::String(message.to_string()); - distribution_payload["sender_id"] = JsonValue::String(cv.get_sender().to_string()); + distribution_payload["sender_id"] = JsonValue::String(sender.to_string()); distribution_payload["send_time"] = JsonValue::String(milliseconds_timestamp.to_string()); let distribution = CommunicationValue::new(CommunicationType::Update) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_data_str(DataType::Name, self.name.clone()) .add_data_str(DataType::Path, self.path.clone()) .add_data_str(DataType::Result, "message_live".to_string()) @@ -238,13 +251,13 @@ impl Interactable for TextChat { } } return CommunicationValue::new(CommunicationType::Function) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_data_str(DataType::Name, self.name.clone()) .add_data_str(DataType::Path, self.path.clone()) .add_data_str(DataType::Result, "message_received".to_string()) .add_data(DataType::Payload, JsonValue::new_object()); } - CommunicationValue::new(CommunicationType::ErrorInternal).with_id(cv.get_id()) + CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv) } fn to_json(&self) -> JsonValue { JsonValue::new_object() diff --git a/communities/src/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs index 7e20022..7681765 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -1,7 +1,7 @@ use crate::communities::{community::Community, interactables::interactable::Interactable}; use async_trait::async_trait; use json::JsonValue; -use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; +use iota_util::mtp_compat::OptionalDataValueExt; use std::sync::Arc; use std::{any::Any, sync::RwLock}; use uuid::Uuid; @@ -131,7 +131,7 @@ impl Interactable for VoiceChat { response_payload["send_time"] = JsonValue::String(send_time.to_string()); return CommunicationValue::new(CommunicationType::Function) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_data_str(DataType::Name, self.name.clone()) .add_data_str(DataType::Path, self.path.clone()) .add_data_str(DataType::Result, "getting_call".to_string()) @@ -159,13 +159,13 @@ impl Interactable for VoiceChat { response_payload["streaming"] = JsonValue::Boolean(streaming); return CommunicationValue::new(CommunicationType::Update) - .with_id(cv.get_id()) + .with_request_id(&cv) .add_data_str(DataType::Name, self.name.clone()) .add_data_str(DataType::Path, self.path.clone()) .add_data_str(DataType::Result, "user_changed".to_string()) .add_data(DataType::Payload, response_payload); } - CommunicationValue::new(CommunicationType::ErrorInternal).with_id(cv.get_id()) + CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv) } fn to_json(&self) -> JsonValue { diff --git a/flake.nix b/flake.nix index e2ca8a1..5d64dc6 100644 --- a/flake.nix +++ b/flake.nix @@ -140,6 +140,12 @@ description = "Environment files to load for the Iota service."; }; + identitySecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Owner-readable file containing the passphrase for the protected Iota identity."; + }; + openFirewall = lib.mkOption { type = lib.types.bool; default = true; @@ -261,6 +267,9 @@ } // lib.optionalAttrs (cfg.environmentFiles != []) { EnvironmentFile = cfg.environmentFiles; + } + // lib.optionalAttrs (cfg.identitySecretFile != null) { + LoadCredential = "iota-identity:${cfg.identitySecretFile}"; }; }; diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index dc43347..f35935a 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -4,54 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } - -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" -tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 4489cdf..1536c5c 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -3,78 +3,20 @@ name = "iota-cli" version = "0.1.0" edition = "2024" -[features] -legacy-commands = [ - "dep:iota-logger", - "dep:iota-storage", - "dep:iota-util", - "dep:mtp", - "dep:omikron-connector", -] - [dependencies] -iota-logger = { path = "../iota-logger", optional = true } iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage", optional = true } iota-terms = { path = "../iota-terms" } -iota-util = { path = "../iota-util", optional = true } iota-ipc = { path = "../iota-ipc" } -iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } -omikron-connector = { path = "../omikron-connector", optional = true } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } - - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" chrono = "0.4.43" crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" once_cell = "1.21.3" open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" unicode-width = "0.2" [dev-dependencies] diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index ca912fc..45ca8fc 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -572,7 +572,7 @@ impl IpcClient { iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.", iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.", iota_ipc::IpcErrorCode::Unauthorized => { - "The daemon rejected this operation as unauthorized." + "The daemon rejected this operation: the connected IPC account lacks the required role. Use the configured operator socket or ask an administrator to grant access." } iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.", } diff --git a/iota-connection/src/message_common.rs b/iota-connection/src/message_common.rs index fff5bf6..742d9b6 100644 --- a/iota-connection/src/message_common.rs +++ b/iota-connection/src/message_common.rs @@ -2,7 +2,21 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::type_map::TypeMap; use std::time::{SystemTime, UNIX_EPOCH}; -pub use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; +pub use iota_util::mtp_compat::{MtpFieldError, OptionalDataValueExt, RequiredCommunicationFields}; + +pub trait CommunicationResponseExt { + fn with_request_id(self, request: &CommunicationValue) -> Self; +} + +impl CommunicationResponseExt for CommunicationValue { + fn with_request_id(mut self, request: &CommunicationValue) -> Self { + self = self.without_id(); + if let Some(id) = request.id() { + self = self.with_id(id); + } + self + } +} pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { use mtp::type_map::{DataTypeId, TypeMap}; @@ -95,16 +109,48 @@ pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option i64 { - SystemTime::now() + let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() - .as_millis() as i64 + .as_millis(); + i64::try_from(millis).unwrap_or(i64::MAX) } pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - let mut response = CommunicationValue::new(ty).with_id(request.id().unwrap_or_default()); + let mut response = CommunicationValue::new(ty).without_id(); + if let Some(id) = request.id() { + response = response.with_id(id); + } if let Some(sender) = request.sender() { response = response.with_receiver(sender); } response } + +#[cfg(test)] +mod tests { + use super::error_response; + use mtp::codec::{CommunicationType, CommunicationValue}; + + #[test] + fn error_response_preserves_an_absent_request_id() { + let request = CommunicationValue::new(CommunicationType::GetChats) + .without_id() + .with_sender(42); + let response = error_response(&request, CommunicationType::ErrorInvalidData); + + assert_eq!(response.id(), None); + assert_eq!(response.receiver(), Some(42)); + } + + #[test] + fn error_response_copies_an_existing_request_id() { + let request = CommunicationValue::new(CommunicationType::GetChats) + .with_id(7) + .with_sender(42); + let response = error_response(&request, CommunicationType::ErrorInvalidData); + + assert_eq!(response.id(), Some(7)); + assert_eq!(response.receiver(), Some(42)); + } +} diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 4d7144f..4be17f6 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -10,15 +10,26 @@ use mtp::codec::{ use crate::relay::VerifiedRelayContext; +#[derive(Debug)] pub struct MessageMutation { pub sender_id: i64, pub partner_id: i64, pub send_time: i64, } -pub fn message_mutation(cv: &CommunicationValue) -> Result { - let sender_id = i64::try_from(cv.get_sender()) +fn required_sender_id(cv: &CommunicationValue) -> Result { + let sender = cv + .require_sender() .map_err(|_| error_response(cv, CommunicationType::ErrorInvalidData))?; + i64::try_from(sender).map_err(|_| error_response(cv, CommunicationType::ErrorInvalidData)) +} + +fn sender_wire_id(sender_id: i64) -> u64 { + u64::try_from(sender_id).expect("validated authenticated sender is non-negative") +} + +pub fn message_mutation(cv: &CommunicationValue) -> Result { + let sender_id = required_sender_id(cv)?; let partner_id = data_i64(cv, DataType::ChatPartnerId) .filter(|id| *id > 0) .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; @@ -115,7 +126,7 @@ pub fn apply_verified_relay_content( match content.message_type.as_str() { "MessageSend" => { - let message = relay_string(&content.content, DataType::Content, &context.type_map) + let message = relay_string(&content.content, DataType::AppContent, &context.type_map) .ok_or_else(|| "Relay MessageSend is missing Content".to_string())?; let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) .and_then(|value| i64::try_from(value).ok()) @@ -138,7 +149,7 @@ pub fn apply_verified_relay_content( Ok(()) } "MessageEdit" => { - let message = relay_string(&content.content, DataType::Content, &context.type_map) + let message = relay_string(&content.content, DataType::AppContent, &context.type_map) .ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?; let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) .and_then(|value| i64::try_from(value).ok()) @@ -206,7 +217,7 @@ pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { Ok(mutation) => mutation, Err(response) => return response, }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { + let Some(content) = cv.get_data(DataType::AppContent).as_str() else { return error_response(cv, CommunicationType::ErrorInvalidData); }; @@ -277,14 +288,17 @@ fn stored_message_fields( ) -> Vec<(DataType, DataValue)> { let mut fields = vec![ ( - DataType::MessageId, + DataType::AppMessageId, DataValue::SignedNumber(message.id as i128), ), ( DataType::SendTime, DataValue::SignedNumber(message.message_time as i128), ), - (DataType::Content, DataValue::Str(message.content.clone())), + ( + DataType::AppContent, + DataValue::Str(message.content.clone()), + ), ( DataType::MessageState, DataValue::Str(message.message_state.clone()), @@ -293,22 +307,22 @@ fn stored_message_fields( DataType::Height, DataValue::SignedNumber(message.height as i128), ), - ( - DataType::SenderId, - DataValue::UnsignedNumber(if message.sent_by_self { - storage_owner as u128 - } else { - partner_id as u128 - }), - ), ]; + let sender_id = if message.sent_by_self { + storage_owner + } else { + partner_id + }; + if let Ok(sender_id) = u128::try_from(sender_id) { + fields.push((DataType::SenderId, DataValue::UnsignedNumber(sender_id))); + } if message.edited { fields.push((DataType::Edited, DataValue::Bool(true))); } - if let Some(reply_to) = message.reply_to { + if let Some(reply_to) = message.reply_to.and_then(|id| u64::try_from(id).ok()) { fields.push(( DataType::ReplyId, - DataValue::UnsignedNumber(reply_to as u64 as u128), + DataValue::UnsignedNumber(u128::from(reply_to)), )); } if !message.reactions.is_empty() { @@ -345,7 +359,11 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { let Some(user_id) = data_string(cv, DataType::UserId) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; - if user_id != cv.get_sender().to_string() { + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; + if user_id != sender_id.to_string() { return error_response(cv, CommunicationType::ErrorNotFound); } let Some(chat_id) = data_string(cv, DataType::ChatId) else { @@ -358,8 +376,8 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { secret_id: data_string(cv, DataType::SecretId), }) { Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) @@ -380,7 +398,7 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { DataValue::Str(record.wrapping_scheme), ) .add_typed_default( - DataType::CreatedAt, + DataType::AppCreatedAt, DataValue::SignedNumber(record.created_at as i128), ) .add_typed_default( @@ -393,7 +411,10 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { } pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = cv.get_sender() as i64; + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; let app_identifier = cv .get_data(DataType::AppIdentifier) .as_str() @@ -415,12 +436,15 @@ pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { } CommunicationValue::new(CommunicationType::CreateApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) } pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = cv.get_sender() as i64; + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; let app_identifier = cv .get_data(DataType::AppIdentifier) .as_str() @@ -437,8 +461,8 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { } CommunicationValue::new(CommunicationType::DeleteApp) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) } fn contact_value( @@ -495,8 +519,8 @@ fn contact_ids_value(ids: impl IntoIterator) -> DataValue { #[cfg(test)] mod presence_tests { - use super::contact_ids_value; - use mtp::codec::DataValue; + use super::{contact_ids_value, handle_get_chats, message_mutation}; + use mtp::codec::{CommunicationType, CommunicationValue, DataValue}; #[test] fn contact_snapshot_is_sorted_and_deduplicated() { @@ -509,6 +533,26 @@ mod presence_tests { ]) ); } + + #[test] + fn message_mutation_rejects_a_missing_authenticated_sender() { + let request = CommunicationValue::new(CommunicationType::MessageEdit).with_id(11); + let response = message_mutation(&request).expect_err("missing sender must be rejected"); + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + assert_eq!(response.id(), Some(11)); + assert_eq!(response.receiver(), None); + } + + #[test] + fn read_handler_rejects_a_missing_authenticated_sender() { + let request = CommunicationValue::new(CommunicationType::GetChats).with_id(12); + let response = handle_get_chats(&request); + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + assert_eq!(response.id(), Some(12)); + assert_eq!(response.receiver(), None); + } } fn sync_error(cv: &CommunicationValue) -> CommunicationValue { @@ -523,7 +567,7 @@ fn sync_error(cv: &CommunicationValue) -> CommunicationValue { /// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; - let user_id = match i64::try_from(cv.get_sender()) { + let user_id = match required_sender_id(cv) { Ok(id) if id > 0 => id, _ => return sync_error(cv), }; @@ -578,8 +622,8 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .map(|message| stored_message_value(message, user_id, message.external_user)) .collect(); CommunicationValue::new(CommunicationType::ClientStateSync) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) + .with_request_id(cv) + .with_receiver(sender_wire_id(user_id)) .add_typed_default( DataType::SessionId, DataValue::SignedNumber(session_id as i128), @@ -603,6 +647,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { ), ) .add_typed_default(DataType::Messages, DataValue::Array(message_values)) + .add_typed_default( + DataType::Communities, + DataValue::Array(community_values(user_id)), + ) .add_typed_default( DataType::DeletedMessageIds, DataValue::Array( @@ -627,7 +675,7 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; - let user_id = match i64::try_from(cv.get_sender()) { + let user_id = match required_sender_id(cv) { Ok(id) if id > 0 => id, _ => return sync_error(cv), }; @@ -654,46 +702,50 @@ pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { } pub fn handle_message_state(cv: &CommunicationValue) { - let sender_id = &cv.get_sender(); - let receiver_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(id) => id, + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(_) => return, + }; + let receiver_id = match data_i64(cv, DataType::ChatPartnerId) { + Some(id) if id > 0 => id, _ => return, }; - let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() - }; + let timestamp_i64 = data_i64(cv, DataType::SendTime).unwrap_or_else(now_millis_i64); let _ = chat_files::change_message_state( timestamp_i64, - receiver_id as i64, - *sender_id as i64, + receiver_id, + sender_id, MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), ); } pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { - let my_id = cv.get_sender(); - let partner_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0); - let offset = cv.get_data(DataType::Offset).as_number().unwrap_or(0); - let amount = cv.get_data(DataType::Amount).as_number().unwrap_or(0); - let messages = chat_files::get_messages( - my_id as i64, - partner_id as i64, - offset as i64, - amount as i64, - ); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(partner_id) = data_i64(cv, DataType::UserId).filter(|id| *id > 0) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(offset) = data_i64(cv, DataType::Offset).filter(|offset| *offset >= 0) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(amount) = data_i64(cv, DataType::Amount).filter(|amount| *amount > 0) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let messages = chat_files::get_messages(my_id_i64, partner_id, offset, amount); let mut msg_array: Vec = Vec::new(); for m in &messages { - msg_array.push(stored_message_value(m, my_id as i64, partner_id as i64)); + msg_array.push(stored_message_value(m, my_id_i64, partner_id)); } CommunicationValue::new(CommunicationType::MessagesGet) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default(DataType::Messages, DataValue::Array(msg_array)) } @@ -703,7 +755,10 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { return error_response(cv, CommunicationType::ErrorInvalidData); }; let partner_id = data_i64(cv, DataType::ChatPartnerId); - let owner = cv.get_sender() as i64; + let owner = match required_sender_id(cv) { + Ok(owner) => owner, + Err(response) => return response, + }; let message = match chat_files::get_message(owner, send_time, partner_id) { Ok(Some(message)) => message, @@ -712,8 +767,8 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { }; let mut response = CommunicationValue::new(CommunicationType::MessageGet) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()); + .with_request_id(cv) + .with_receiver(u64::try_from(owner).expect("authenticated sender is non-negative")); for (data_type, value) in stored_message_fields(&message, owner, message.external_user) { response = response.add_typed_default(data_type, value); } @@ -721,8 +776,14 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { } pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { - let user_id = cv.get_sender(); - let users = chats_util::get_users(user_id as i64); + let user_id = match cv.require_sender() { + Ok(user_id) => user_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(user_id_i64) = i64::try_from(user_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let users = chats_util::get_users(user_id_i64); let mut user_array = Vec::new(); for user in users { let mut container = Vec::new(); @@ -739,27 +800,28 @@ pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { user_array.push(typed_container(container)); } CommunicationValue::new(CommunicationType::GetChats) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(user_id) .add_typed_default(DataType::UserIds, DataValue::Array(user_array)) } pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { - let user_id = cv.get_sender(); + let user_id = match cv.require_sender() { + Ok(user_id) => user_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(user_id_i64) = i64::try_from(user_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let session_id = match data_i64(cv, DataType::SessionId) { Some(id) if id > 0 => id, _ => return sync_error(cv), }; - let other_id = match cv.get_data(DataType::ChatPartnerId).as_number() { - Some(n) => n as i64, - None => cv - .get_data(DataType::ChatPartnerId) - .as_str() - .unwrap_or("0") - .parse() - .unwrap_or(0), + let other_id = match data_i64(cv, DataType::ChatPartnerId) { + Some(id) if id > 0 => id, + _ => return error_response(cv, CommunicationType::ErrorInvalidData), }; - let mut contact = get_user(user_id as i64, other_id) + let mut contact = get_user(user_id_i64, other_id) .unwrap_or(iota_storage::users::contact::Contact::new(other_id)); if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { @@ -767,75 +829,97 @@ pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { } contact.set_last_message_at(now_millis_i64()); - mod_user(user_id as i64, &contact); + mod_user(user_id_i64, &contact); CommunicationValue::new(CommunicationType::AddConversation) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(user_id) .add_typed_default( DataType::SessionId, DataValue::SignedNumber(session_id as i128), ) - .add_typed_default(DataType::UserIds, current_contact_ids(user_id as i64)) + .add_typed_default(DataType::UserIds, current_contact_ids(user_id_i64)) } pub fn handle_add_community(cv: &CommunicationValue) -> CommunicationValue { + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; + let Some(address) = cv.get_data(DataType::CommunityAddress).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(title) = cv.get_data(DataType::CommunityTitle).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Some(position) = cv.get_data(DataType::Position).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; CommunitiesUtil::add_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::CommunityTitle) - .as_str() - .unwrap() - .to_string(), - cv.get_data(DataType::Position) - .as_str() - .unwrap() - .to_string(), + sender_id, + address.to_string(), + title.to_string(), + position.to_string(), ); CommunicationValue::new(CommunicationType::AddCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) } pub fn handle_get_communities(cv: &CommunicationValue) -> CommunicationValue { - let mut comm_array = Vec::new(); - for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { - let mut container: Vec<(DataType, DataValue)> = Vec::new(); - container.push(( - DataType::CommunityAddress, - DataValue::Str(c.address.clone()), - )); - container.push((DataType::CommunityTitle, DataValue::Str(c.title.clone()))); - container.push((DataType::Position, DataValue::Str(c.position.clone()))); - comm_array.push(typed_container(container)); - } - + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; CommunicationValue::new(CommunicationType::GetCommunities) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default(DataType::Communities, DataValue::Array(comm_array)) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) + .add_typed_default( + DataType::Communities, + DataValue::Array(community_values(sender_id)), + ) +} + +fn community_values(storage_owner: i64) -> Vec { + CommunitiesUtil::get_communities(storage_owner) + .into_iter() + .map(|community| { + typed_container(vec![ + ( + DataType::CommunityAddress, + DataValue::Str(community.address), + ), + (DataType::CommunityTitle, DataValue::Str(community.title)), + (DataType::Position, DataValue::Str(community.position)), + ]) + }) + .collect() } pub fn handle_remove_community(cv: &CommunicationValue) -> CommunicationValue { - CommunitiesUtil::remove_community( - cv.get_sender() as i64, - cv.get_data(DataType::CommunityAddress) - .as_str() - .unwrap() - .to_string(), - ); + let sender_id = match required_sender_id(cv) { + Ok(sender_id) => sender_id, + Err(response) => return response, + }; + let Some(address) = cv.get_data(DataType::CommunityAddress).as_str() else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + CommunitiesUtil::remove_community(sender_id, address.to_string()); CommunicationValue::new(CommunicationType::RemoveCommunity) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) + .with_request_id(cv) + .with_receiver(sender_wire_id(sender_id)) } pub fn handle_global_settings_save(cv: &CommunicationValue) -> CommunicationValue { - let my_id = cv.get_sender(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -843,13 +927,13 @@ pub fn handle_global_settings_save(cv: &CommunicationValue) -> CommunicationValu ); }; - if settings::save_global(my_id as i64, settings_value).is_err() { + if settings::save_global(my_id_i64, settings_value).is_err() { return error_response(cv, CommunicationType::ErrorInvalidData); } let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) .with_receiver(my_id) - .with_id(cv.get_id()); + .with_request_id(cv); if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { response = response.add_typed_default( @@ -862,13 +946,19 @@ pub fn handle_global_settings_save(cv: &CommunicationValue) -> CommunicationValu } pub fn handle_global_settings_load(cv: &CommunicationValue) -> CommunicationValue { - let my_id = cv.get_sender(); - let Ok(settings_value) = settings::load_global(my_id as i64) else { + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let Ok(settings_value) = settings::load_global(my_id_i64) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; let Some(settings_value_str) = settings_value else { let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Path, @@ -886,7 +976,7 @@ pub fn handle_global_settings_load(cv: &CommunicationValue) -> CommunicationValu }; let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); @@ -904,10 +994,16 @@ pub fn handle_settings_save( cv: &CommunicationValue, _expected_session_id: i128, ) -> CommunicationValue { - let my_id = cv.get_sender(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -916,7 +1012,7 @@ pub fn handle_settings_save( }; if session_id == 0 || session_id > 1_000_000 { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -929,7 +1025,7 @@ pub fn handle_settings_save( }; let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -942,7 +1038,7 @@ pub fn handle_settings_save( }; let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -960,7 +1056,7 @@ pub fn handle_settings_save( || settings_name.contains("..") { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -975,21 +1071,17 @@ pub fn handle_settings_save( DataValue::SignedNumber(session_id as i128), ); } + let Ok(session_id_i64) = i64::try_from(session_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; - if settings::save( - my_id as i64, - session_id as i64, - settings_name, - settings_value, - ) - .is_err() - { + if settings::save(my_id_i64, session_id_i64, settings_name, settings_value).is_err() { return error_response(cv, CommunicationType::ErrorInvalidData); } CommunicationValue::new(CommunicationType::SettingsSave) .with_receiver(my_id) - .with_id(cv.get_id()) + .with_request_id(cv) .add_typed_default( DataType::SettingsName, DataValue::Str(settings_name.to_string()), @@ -1004,10 +1096,16 @@ pub fn handle_settings_load( cv: &CommunicationValue, _expected_session_id: i128, ) -> CommunicationValue { - let my_id = cv.get_sender(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1016,7 +1114,7 @@ pub fn handle_settings_load( }; if session_id == 0 || session_id > 1_000_000 { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1027,9 +1125,12 @@ pub fn handle_settings_load( DataValue::SignedNumber(session_id as i128), ); } + let Ok(session_id_i64) = i64::try_from(session_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1047,7 +1148,7 @@ pub fn handle_settings_load( || settings_name.contains("..") { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1063,12 +1164,12 @@ pub fn handle_settings_load( ); } - let Ok(settings_value) = settings::load(my_id as i64, session_id as i64, settings_name) else { + let Ok(settings_value) = settings::load(my_id_i64, session_id_i64, settings_name) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; let Some(settings_value_str) = settings_value else { return CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::SettingsName, @@ -1081,7 +1182,7 @@ pub fn handle_settings_load( }; CommunicationValue::new(CommunicationType::SettingsLoad) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) .add_typed_default( @@ -1098,10 +1199,16 @@ pub fn handle_settings_list( cv: &CommunicationValue, _expected_session_id: i128, ) -> CommunicationValue { - let my_id = cv.get_sender(); + let my_id = match cv.require_sender() { + Ok(my_id) => my_id, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let Ok(my_id_i64) = i64::try_from(my_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1110,7 +1217,7 @@ pub fn handle_settings_list( }; if session_id == 0 || session_id > 1_000_000 { return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default( DataType::Message, @@ -1121,13 +1228,16 @@ pub fn handle_settings_list( DataValue::SignedNumber(session_id as i128), ); } + let Ok(session_id_i64) = i64::try_from(session_id) else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; - let Ok(settings) = settings::list(my_id as i64, session_id as i64) else { + let Ok(settings) = settings::list(my_id_i64, session_id_i64) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; let settings_json = settings.into_iter().map(DataValue::Str).collect(); CommunicationValue::new(CommunicationType::SettingsList) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(my_id) .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) .add_typed_default( diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs index 298a0df..13a52c5 100644 --- a/iota-connection/src/relay.rs +++ b/iota-connection/src/relay.rs @@ -1,8 +1,9 @@ use iota_util::route_target::RouteTarget; use mtp::codec::{ - CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap, + CommunicationValue, ProtectionPolicy, RelayError, RelayOpenOptions, SignaturePolicy, TypeMap, VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, - open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id, + open_relay_content_with_limits_without_replay, open_relay_metadata_with_without_replay, + relay_metadata_claimed_signer_id_with_options, }; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::fmt; @@ -146,7 +147,13 @@ where return Err(RelayValidationError::OuterSenderNotAllowed); } - let claimed_signer = relay_metadata_claimed_signer_id(frame, &[keyring])?; + let open_options = RelayOpenOptions::new(RELAY_PROTECTION_POLICY); + let claimed_signer = relay_metadata_claimed_signer_id_with_options( + frame, + &[keyring], + open_options.decode_limits, + open_options.protected_limits, + )?; let signing_keys = resolve_signing_keys(claimed_signer).await?; if signing_keys.is_empty() { return Err(RelayValidationError::MissingSigningKeys(claimed_signer)); @@ -157,13 +164,12 @@ where .type_map() .cloned() .ok_or(RelayValidationError::MissingTypeMap)?; - let metadata = open_relay_metadata_with( + let metadata = open_relay_metadata_with_without_replay( frame, &[keyring], Some(claimed_signer), move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()), - RELAY_PROTECTION_POLICY, - None, + open_options, )?; let context = VerifiedRelayContext { @@ -186,12 +192,17 @@ pub fn open_verified_relay_content( keyrings: &[&Keyring], expected_recipient_id: u64, ) -> Result { - Ok(open_relay_content_with_keyrings( + Ok(open_relay_content_with_limits_without_replay( &relay.metadata, keyrings, &relay.signing_keys, Some(expected_recipient_id), - RELAY_PROTECTION_POLICY, + RelayOpenOptions { + policy: RELAY_PROTECTION_POLICY, + decode_limits: relay.metadata.decode_limits(), + encode_limits: relay.metadata.encode_limits(), + protected_limits: relay.metadata.protected_limits(), + }, )?) } diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 03250d9..f28cc45 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -13,17 +13,6 @@ iota-terms = { path = "../iota-terms" } iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } -omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } -web-ui = { path = "../web-ui" } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -dashmap = "6.1.0" -json = "*" -once_cell = "1.21.3" pnet = "0.35.0" -ratatui = "0.30.0" -reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 4eb0b7c..7512429 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -13,10 +13,10 @@ iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -dashmap = "6.1.0" libc = "0.2" sysinfo = "0.39.0" serde_yaml = "0.9" +serde_json = "1" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 7f9f1c7..a6a2718 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,10 +1,10 @@ use crate::log_buffer::LogBuffer; use crate::{DaemonRuntime, DaemonServices}; use iota_ipc::{ - CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode, - LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, - ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, - UserSummary, + CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, + IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, + OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, + TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; @@ -15,6 +15,37 @@ use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; +pub use iota_ipc::IpcRole; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PeerContext { + pub pid: i32, + pub uid: u32, + pub role: IpcRole, +} + +const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512; + +fn bounded_log_entries(mut entries: Vec) -> Vec { + entries.truncate(MAX_LOG_ENTRIES_PER_RESPONSE); + while !entries.is_empty() { + let response = DaemonMessage::Response(ResponseEnvelope { + request_id: u64::MAX, + result: ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { + entries: entries.clone(), + })), + }); + let fits = serde_json::to_vec(&response) + .map(|encoded| encoded.len() <= MAX_MESSAGE_SIZE) + .unwrap_or(false); + if fits { + return entries; + } + entries.remove(0); + } + entries +} + #[derive(Clone)] pub struct CommandRouter { runtime: Arc, @@ -35,8 +66,33 @@ impl CommandRouter { } } - pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { - log_command!("{:?}", request); + pub async fn route( + &self, + peer: &PeerContext, + request_id: u64, + request: LocalRequest, + ) -> ResponseEnvelope { + if !peer.role.allows(request.required_role()) { + log!( + "IPC authorization denied: pid={}, uid={}, role={:?}, request={:?}", + peer.pid, + peer.uid, + peer.role, + request + ); + return ResponseEnvelope { + request_id, + result: ResponseResult::Error(IpcErrorCode::Unauthorized), + }; + } + + log_command!( + "pid={} uid={} role={:?} request={:?}", + peer.pid, + peer.uid, + peer.role, + request + ); let result = self.execute(request).await; ResponseEnvelope { request_id, result } } @@ -284,7 +340,7 @@ impl CommandRouter { { return ResponseResult::Error(IpcErrorCode::Conflict); } - self.runtime.shutdown(match intent { + self.runtime.request_shutdown(match intent { ExitIntent::Stop => ShutdownReason::Stop, ExitIntent::Restart => ShutdownReason::Restart, }); @@ -298,13 +354,13 @@ impl CommandRouter { }, )), LocalRequest::RestartDaemon => { - self.runtime.shutdown(ShutdownReason::Restart); + self.runtime.request_shutdown(ShutdownReason::Restart); ResponseResult::Ok(ResponsePayload::Acknowledged { message: "Daemon restart requested".into(), }) } LocalRequest::StopDaemon => { - self.runtime.shutdown(ShutdownReason::Stop); + self.runtime.request_shutdown(ShutdownReason::Stop); ResponseResult::Ok(ResponsePayload::Acknowledged { message: "Daemon shutdown requested".into(), }) @@ -378,7 +434,7 @@ impl CommandRouter { LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { let entries = if let Ok(buf) = self.log_buffer.lock() { - buf.recent(limit) + bounded_log_entries(buf.recent(limit.min(MAX_LOG_ENTRIES_PER_RESPONSE))) } else { Vec::new() }; @@ -393,11 +449,10 @@ impl CommandRouter { Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), }, LocalRequest::ListCommunities => { - let iota_id = config_util::CONFIG - .load() - .iota_id - .map(|id| id as i64) - .unwrap_or(0); + let iota_id = config_util::CONFIG.load().iota_id; + let Ok(iota_id) = iota_id.map(i64::try_from).unwrap_or(Ok(0)) else { + return ResponseResult::Ok(ResponsePayload::Communities(Vec::new())); + }; let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); let summaries: Vec = stored @@ -412,3 +467,77 @@ impl CommandRouter { } } } + +#[cfg(test)] +mod tests { + use super::{IpcRole, LocalRequest, bounded_log_entries}; + use iota_ipc::{ExitIntent, LogEntry, SecretString}; + + #[test] + fn every_request_has_an_explicit_role_policy() { + let requests = [ + LocalRequest::GetStatus, + LocalRequest::ListTasks, + LocalRequest::ListUsers, + LocalRequest::CreateUser { + username: "alice".into(), + }, + LocalRequest::AttachUserFromTu { + credential: SecretString("credential".into()), + }, + LocalRequest::PurgeUserData { user_id: 1 }, + LocalRequest::ReleaseUser { user_id: 1 }, + LocalRequest::CompleteDeleteUser { + user_id: 1, + credential: None, + }, + LocalRequest::RemoveUser { user_id: 1 }, + LocalRequest::ReconnectOmikron, + LocalRequest::RotateIotaIdentity, + LocalRequest::RequestProcessExit { + intent: ExitIntent::Stop, + }, + LocalRequest::GetDaemonStatus, + LocalRequest::RestartDaemon, + LocalRequest::StopDaemon, + LocalRequest::GetConfig, + LocalRequest::SetConfig { + key: "port".into(), + value: "1984".into(), + }, + LocalRequest::ReloadConfig, + LocalRequest::GetOmikronStatus, + LocalRequest::ListComponents, + LocalRequest::GetUser { user_id: 1 }, + LocalRequest::ImportUser { + username: "alice".into(), + }, + LocalRequest::GetLogs { limit: 10 }, + LocalRequest::CheckUpdate, + LocalRequest::ListCommunities, + ]; + + assert_eq!(requests.len(), 25); + for request in requests { + let required = request.required_role(); + assert!(IpcRole::Admin.allows(required)); + assert_eq!( + IpcRole::Operate.allows(required), + required != IpcRole::Admin + ); + assert_eq!(IpcRole::Read.allows(required), required == IpcRole::Read); + } + } + + #[test] + fn log_responses_drop_entries_that_cannot_fit_one_ipc_frame() { + let entries = vec![LogEntry { + timestamp_ms: 0, + sender: "test".into(), + message: "x".repeat(2 * 1024 * 1024), + is_error: false, + }]; + + assert!(bounded_log_entries(entries).is_empty()); + } +} diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs index 4c7347c..634cbe2 100644 --- a/iota-daemon-lib/src/daemon_state.rs +++ b/iota-daemon-lib/src/daemon_state.rs @@ -55,7 +55,7 @@ impl From for iota_ipc::StartupPhase { /* This wrapper exposes daemon state as IPC-safe snapshots while preserving a * single owned state instance for all daemon subsystems. The cancellation token - * is the single lifecycle signal — all subsystems check it instead of a + * is the single lifecycle signal, and all subsystems check it instead of a * separate boolean. */ pub struct DaemonRuntime { pub state: Arc, @@ -131,12 +131,20 @@ impl DaemonRuntime { } pub fn shutdown(&self, reason: ShutdownReason) { + self.request_shutdown(reason); + self.begin_shutdown(); + } + + pub fn request_shutdown(&self, reason: ShutdownReason) { if self.shutdown_tx.borrow().is_none() { let _ = self.shutdown_tx.send(Some(reason)); - self.cancellation.cancel(); } } + pub fn begin_shutdown(&self) { + self.cancellation.cancel(); + } + pub fn shutdown_reason(&self) -> Option { self.shutdown_tx.borrow().clone() } diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index 97b5dd6..a472348 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,23 +1,26 @@ use crate::deployment::from_environment; use crate::log_buffer::LogBuffer; -use crate::{CommandRouter, DaemonRuntime, DaemonServices}; +use crate::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext}; use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, write_msg, }; use iota_logger::log; +use iota_storage::util::config_util; use std::io::Result; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; +use tokio::io::AsyncWriteExt; use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::{broadcast, mpsc, watch}; +use tokio::sync::{Semaphore, broadcast, mpsc, watch}; use tokio::time::timeout; use uuid::Uuid; /// Per-client outbound queue capacity. const CLIENT_CHANNEL_SIZE: usize = 256; +const MAX_CONFIGURED_IPC_CLIENTS: usize = 4096; /// Maximum handshake retries before giving up. const MAX_HANDSHAKE_RETRIES: u32 = 1; @@ -36,6 +39,20 @@ struct ClientSubscription { metric_interval_ms: u64, } +enum WriterCommand { + Message(DaemonMessage), + Flush { + complete: tokio::sync::oneshot::Sender<()>, + }, +} + +fn configured_client_limit() -> usize { + config_util::CONFIG + .load() + .max_ipc_clients + .clamp(1, MAX_CONFIGURED_IPC_CLIENTS) +} + pub struct IpcServer { listener: UnixListener, runtime: Arc, @@ -45,6 +62,7 @@ pub struct IpcServer { state_rx: watch::Receiver, instance_id: String, _instance_lock: File, + client_limit: Arc, } impl IpcServer { @@ -96,11 +114,18 @@ impl IpcServer { } remove_stale_socket(&path).await?; let listener = UnixListener::bind(&path)?; - let _ = tokio::fs::set_permissions( - &path, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - ) - .await; + if let Err(error) = + tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)).await + { + drop(listener); + let _ = tokio::fs::remove_file(&path).await; + return Err(error); + } + if let Err(error) = validate_manual_socket(&path).await { + drop(listener); + let _ = tokio::fs::remove_file(&path).await; + return Err(error); + } return Ok(Self { listener, runtime, @@ -110,6 +135,7 @@ impl IpcServer { state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: lock, + client_limit: Arc::new(Semaphore::new(configured_client_limit())), }); } }; @@ -122,12 +148,21 @@ impl IpcServer { state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: File::options().read(true).open("/dev/null")?, + client_limit: Arc::new(Semaphore::new(configured_client_limit())), }) } pub async fn serve(self) -> Result<()> { loop { let (stream, _addr) = self.listener.accept().await?; + let permit = match self.client_limit.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + eprintln!("IPC connection rejected: active client limit reached"); + drop(stream); + continue; + } + }; eprintln!("IPC client accepted"); let runtime = self.runtime.clone(); let services = self.services.clone(); @@ -136,6 +171,7 @@ impl IpcServer { let state_rx = self.state_rx.clone(); let instance_id = self.instance_id.clone(); tokio::spawn(async move { + let _permit = permit; if let Err(error) = handle_client( stream, runtime, @@ -232,6 +268,33 @@ async fn remove_stale_socket(path: &Path) -> Result<()> { } } +async fn validate_manual_socket(path: &Path) -> Result<()> { + let metadata = tokio::fs::symlink_metadata(path).await?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "bound IPC path is no longer a Unix socket", + )); + } + + let mode = metadata.permissions().mode() & 0o777; + if mode != 0o600 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("IPC socket has unexpected mode {mode:o}"), + )); + } + + let expected_uid = unsafe { libc::geteuid() } as u32; + if metadata.uid() != expected_uid { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "IPC socket ownership changed after bind", + )); + } + Ok(()) +} + #[derive(Clone, Debug)] struct PeerIdentity { pid: i32, @@ -274,6 +337,14 @@ fn peer_credentials(stream: &UnixStream) -> Result { } } +fn role_for_peer(_peer: &PeerIdentity) -> IpcRole { + // This deployment has one IPC listener. Its Unix socket permissions are + // the admission boundary: systemd grants access to root, the daemon, and + // members of iota-operators. Once a peer has passed that boundary, it is + // an administrator for the operator console protocol. + IpcRole::Admin +} + async fn handle_client( stream: UnixStream, runtime: Arc, @@ -283,16 +354,17 @@ async fn handle_client( mut state_rx: watch::Receiver, instance_id: String, ) -> Result<()> { - let peer = peer_credentials(&stream)?; - // Access control belongs to the Unix socket. The systemd socket grants - // iota-operators group access (0660); rejecting every UID other than the - // service account here would make that authorization ineffective. Manual - // sockets remain owner-only (0600) at bind time. + let peer_identity = peer_credentials(&stream)?; + let peer = PeerContext { + pid: peer_identity.pid, + uid: peer_identity.uid, + role: role_for_peer(&peer_identity), + }; let (mut reader, mut writer) = stream.into_split(); // A failed writer must stop the reader and any subsequent command work // for this client; otherwise the reader can remain parked forever. let session_cancellation = runtime.cancellation.child_token(); - let (directed_tx, directed_rx) = mpsc::channel::(CLIENT_CHANNEL_SIZE); + let (directed_tx, directed_rx) = mpsc::channel::(CLIENT_CHANNEL_SIZE); eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid); // --- Handshake --- @@ -343,7 +415,7 @@ async fn handle_client( break; } Ok(_) => { - // Unexpected first message — send error and close. + // Unexpected first message, send an error and close. return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "Expected Hello as first message", @@ -353,7 +425,7 @@ async fn handle_client( }, } } - let _version = negotiated_version.ok_or_else(|| { + let negotiated_version = negotiated_version.ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::Other, "Handshake failed after retries") })?; @@ -361,7 +433,7 @@ async fn handle_client( // --- Send initial state snapshot --- let initial = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(initial).await; + let _ = directed_tx.send(WriterCommand::Message(initial)).await; // --- Writer task: merge directed responses + shared log events --- let mut log_rx = log_tx.subscribe(); @@ -375,19 +447,29 @@ async fn handle_client( tokio::spawn(async move { let mut directed_rx = directed_rx; let mut last_metric_sent = tokio::time::Instant::now(); + let mut state_updates_open = true; loop { let metric_interval = sub_rx.borrow().metric_interval_ms; tokio::select! { + _ = session_cancellation.cancelled() => break, // Directed messages (responses to this client's requests) - msg = directed_rx.recv() => { - match msg { - Some(message) => { + command = directed_rx.recv() => { + match command { + Some(WriterCommand::Message(message)) => { if let Err(error) = write_client_message(&mut writer, &message).await { eprintln!("IPC client writer stopped while sending directed message: {error}"); session_cancellation.cancel(); break; } } + Some(WriterCommand::Flush { complete }) => { + if let Err(error) = writer.flush().await { + eprintln!("IPC client writer stopped while flushing: {error}"); + session_cancellation.cancel(); + break; + } + let _ = complete.send(()); + } None => break, } } @@ -435,12 +517,16 @@ async fn handle_client( break; } } - Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Closed) => { + session_cancellation.cancel(); + break; + } } } - changed = state_rx.changed() => { + changed = state_rx.changed(), if state_updates_open => { if changed.is_err() { - break; + state_updates_open = false; + continue; } let snapshot = state_rx.borrow().clone(); if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await { @@ -475,9 +561,14 @@ async fn handle_client( iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"), _ => None, }; - let response = if envelope.protocol_version < MIN_PROTOCOL_VERSION - || envelope.protocol_version > PROTOCOL_VERSION - { + let response = if envelope.protocol_version != negotiated_version { + log!( + "IPC protocol mismatch: pid={}, uid={}, negotiated={}, request={}", + peer.pid, + peer.uid, + negotiated_version, + envelope.protocol_version + ); iota_ipc::ResponseEnvelope { request_id: envelope.request_id, result: iota_ipc::ResponseResult::Error( @@ -485,21 +576,42 @@ async fn handle_client( ), } } else { - router.route(envelope.request_id, envelope.request).await + router + .route(&peer, envelope.request_id, envelope.request) + .await }; - let _ = directed_tx.send(DaemonMessage::Response(response)).await; - if let Some(reason) = shutdown_reason { + let should_shutdown = shutdown_reason.is_some() + && matches!(&response.result, iota_ipc::ResponseResult::Ok(_)); + let _ = directed_tx + .send(WriterCommand::Message(DaemonMessage::Response(response))) + .await; + if let Some(reason) = shutdown_reason.filter(|_| should_shutdown) { let _ = directed_tx - .send(DaemonMessage::LifecycleEvent( + .send(WriterCommand::Message(DaemonMessage::LifecycleEvent( iota_ipc::LifecycleEvent::Shutdown { reason: reason.into(), }, - )) + ))) .await; - // The request itself initiates daemon cancellation. Give - // the dedicated writer a chance to flush the response - // and lifecycle event before this session is torn down. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let (flush_tx, flush_rx) = tokio::sync::oneshot::channel(); + let _ = directed_tx + .send(WriterCommand::Flush { complete: flush_tx }) + .await; + timeout(CLIENT_IO_TIMEOUT, flush_rx) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "IPC shutdown response flush timed out", + ) + })? + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "IPC writer stopped before shutdown flush", + ) + })?; + runtime.begin_shutdown(); break; } } @@ -515,26 +627,51 @@ async fn handle_client( metric_interval_ms: interval, }); let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(snapshot).await; - let _ = directed_tx.send(DaemonMessage::Subscribed).await; + let _ = directed_tx.send(WriterCommand::Message(snapshot)).await; + let _ = directed_tx + .send(WriterCommand::Message(DaemonMessage::Subscribed)) + .await; } Ok(ClientMessage::Ping { seq }) => { - let _ = directed_tx.send(DaemonMessage::Pong { seq }).await; + let _ = directed_tx + .send(WriterCommand::Message(DaemonMessage::Pong { seq })) + .await; } Ok(ClientMessage::Hello { .. }) => { // Re-handshake on existing connection: treat as resubscribe let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(snapshot).await; + let _ = directed_tx.send(WriterCommand::Message(snapshot)).await; } Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(error) => { - writer_task.abort(); + session_cancellation.cancel(); + drop(directed_tx); + let mut writer_task = writer_task; + match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await { + Ok(_) => {} + Err(_) => { + writer_task.abort(); + let _ = writer_task.await; + } + } return Err(error); } } } + drop(directed_tx); session_cancellation.cancel(); - writer_task.abort(); + let mut writer_task = writer_task; + match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + eprintln!("IPC client writer task failed: {error}"); + } + Err(_) => { + eprintln!("IPC client writer did not stop before timeout"); + writer_task.abort(); + let _ = writer_task.await; + } + } log!( "IPC client disconnected (pid={}, uid={})", peer.pid, @@ -563,4 +700,46 @@ mod tests { .is_err() ); } + + #[tokio::test] + async fn manual_socket_validation_requires_owner_only_mode() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ipc.sock"); + let listener = StdUnixListener::bind(&path).expect("test socket binds"); + tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)) + .await + .expect("test socket permissions apply"); + + validate_manual_socket(&path) + .await + .expect("manual socket validation succeeds"); + drop(listener); + } + + #[tokio::test] + async fn manual_socket_validation_rejects_unexpected_mode() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ipc.sock"); + let listener = StdUnixListener::bind(&path).expect("test socket binds"); + tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o660)) + .await + .expect("test socket permissions apply"); + + let error = validate_manual_socket(&path) + .await + .expect_err("group-accessible manual socket must be rejected"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + drop(listener); + } + + #[test] + fn an_admitted_operator_peer_receives_administrator_role() { + let peer = PeerIdentity { + pid: 123, + uid: 1000, + _gid: 1000, + }; + + assert_eq!(role_for_peer(&peer), IpcRole::Admin); + } } diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 6ccd977..3afeff8 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -7,7 +7,7 @@ pub mod log_buffer; pub mod services; pub mod task_registry; -pub use command_router::CommandRouter; +pub use command_router::{CommandRouter, IpcRole, PeerContext}; pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase}; pub use ipc_server::IpcServer; pub use services::DaemonServices; diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 2bf8bd7..99a2aaa 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use iota_daemon_lib::log_buffer::LogBuffer; -use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; -use iota_ipc::{LocalRequest, ResponseResult}; +use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext}; +use iota_ipc::{IpcErrorCode, LocalRequest, ResponseResult}; use mtp::codec::CommunicationValue; use omikron_connector::{OmikronClient, OmikronError}; use std::sync::{ @@ -13,6 +13,22 @@ use std::time::Duration; struct FakeOmikron { reconnects: AtomicUsize, } + +fn admin_peer() -> PeerContext { + PeerContext { + pid: 1, + uid: 0, + role: IpcRole::Admin, + } +} + +fn read_peer() -> PeerContext { + PeerContext { + pid: 2, + uid: 1000, + role: IpcRole::Read, + } +} #[async_trait] impl OmikronClient for FakeOmikron { async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { @@ -55,7 +71,10 @@ async fn reconnect_uses_the_injected_client() { Arc::new(Mutex::new(LogBuffer::new(100))), ); assert!(matches!( - router.route(1, LocalRequest::ReconnectOmikron).await.result, + router + .route(&admin_peer(), 1, LocalRequest::ReconnectOmikron) + .await + .result, ResponseResult::Ok(_) )); assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); @@ -79,10 +98,44 @@ async fn identity_rotation_is_available_while_omikron_is_offline() { ); assert!(matches!( router - .route(1, LocalRequest::RotateIotaIdentity) + .route(&admin_peer(), 1, LocalRequest::RotateIotaIdentity) .await .result, ResponseResult::Ok(_) )); assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); } + +#[tokio::test] +async fn read_role_cannot_execute_an_administrative_request() { + let fake = Arc::new(FakeOmikron { + reconnects: AtomicUsize::new(0), + }); + let services = Arc::new(DaemonServices { + omikron: fake.clone(), + users: Default::default(), + config: Default::default(), + active: true, + }); + let router = CommandRouter::new( + Arc::new(DaemonRuntime::new()), + services, + Arc::new(Mutex::new(LogBuffer::new(100))), + ); + + assert!(matches!( + router + .route( + &read_peer(), + 9, + LocalRequest::SetConfig { + key: "port".into(), + value: "1984".into(), + }, + ) + .await + .result, + ResponseResult::Error(IpcErrorCode::Unauthorized) + )); + assert_eq!(fake.reconnects.load(Ordering::SeqCst), 0); +} diff --git a/iota-daemon-lib/tests/ipc_server.rs b/iota-daemon-lib/tests/ipc_server.rs new file mode 100644 index 0000000..7540ea4 --- /dev/null +++ b/iota-daemon-lib/tests/ipc_server.rs @@ -0,0 +1,252 @@ +use async_trait::async_trait; +use iota_daemon_lib::{DaemonRuntime, DaemonServices, IpcServer}; +use iota_ipc::{ + ClientMessage, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, PROTOCOL_VERSION, + RequestEnvelope, ResponseResult, read_msg, write_msg, +}; +use iota_storage::util::config_util::{self, IotaConfig}; +use mtp::codec::CommunicationValue; +use omikron_connector::{OmikronClient, OmikronError}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UnixStream; +use tokio::sync::{broadcast, watch}; + +struct ConfigRestore(Arc); + +impl Drop for ConfigRestore { + fn drop(&mut self) { + config_util::CONFIG.store(self.0.clone()); + } +} + +fn set_client_limit(limit: usize) -> ConfigRestore { + let previous = config_util::CONFIG.load_full(); + let mut config = (*previous).clone(); + config.max_ipc_clients = limit; + config_util::CONFIG.store(Arc::new(config)); + ConfigRestore(previous) +} + +async fn start_server( + path: &Path, + services: Arc, +) -> (Arc, tokio::task::JoinHandle<()>) { + let runtime = Arc::new(DaemonRuntime::new()); + let (log_tx, _) = broadcast::channel(32); + let log_buffer = Arc::new(std::sync::Mutex::new( + iota_daemon_lib::log_buffer::LogBuffer::new(32), + )); + let (_, state_rx) = watch::channel(runtime.snapshot()); + let server = IpcServer::bind( + path.to_owned(), + runtime.clone(), + services, + log_tx, + log_buffer, + state_rx, + ) + .await + .expect("IPC server binds"); + let task = tokio::spawn(async move { + let _ = server.serve().await; + }); + (runtime, task) +} + +async fn try_connect_and_await_hello(path: &Path) -> std::io::Result { + let mut stream = UnixStream::connect(path).await?; + write_msg( + &mut stream, + &ClientMessage::Hello { + supported_versions: vec![PROTOCOL_VERSION], + }, + ) + .await?; + let message: DaemonMessage = read_msg(&mut stream).await?; + if !matches!(message, DaemonMessage::HelloAck(_)) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "expected HelloAck", + )); + } + Ok(stream) +} + +async fn connect_and_await_hello(path: &Path) -> UnixStream { + try_connect_and_await_hello(path) + .await + .expect("IPC connection completes the Hello exchange") +} + +#[tokio::test] +async fn active_client_limit_rejects_excess_clients_and_releases_permits() { + let _config = set_client_limit(1); + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("ipc.sock"); + let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await; + + let first = connect_and_await_hello(&socket).await; + let mut rejected = UnixStream::connect(&socket) + .await + .expect("second connection reaches the Unix listener"); + let rejected_result = tokio::time::timeout( + Duration::from_secs(2), + read_msg::<_, DaemonMessage>(&mut rejected), + ) + .await + .expect("rejected client is closed promptly"); + assert!(rejected_result.is_err()); + + drop(first); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let _released = loop { + match try_connect_and_await_hello(&socket).await { + Ok(stream) => break stream, + Err(_error) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => panic!("client permit was not released: {error}"), + } + }; + server_task.abort(); + let _ = server_task.await; +} + +#[tokio::test] +async fn request_with_version_different_from_hello_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("ipc.sock"); + let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await; + let mut stream = connect_and_await_hello(&socket).await; + + write_msg( + &mut stream, + &ClientMessage::Request(RequestEnvelope { + request_id: 7, + protocol_version: PROTOCOL_VERSION + 1, + request: LocalRequest::GetStatus, + }), + ) + .await + .expect("request sends"); + + let response = loop { + match read_msg::<_, DaemonMessage>(&mut stream) + .await + .expect("daemon response arrives") + { + DaemonMessage::Response(response) => break response, + _ => continue, + } + }; + assert_eq!(response.request_id, 7); + assert!(matches!( + response.result, + ResponseResult::Error(IpcErrorCode::UnsupportedVersion) + )); + + drop(stream); + server_task.abort(); + let _ = server_task.await; +} + +struct TestOmikron; + +#[async_trait] +impl OmikronClient for TestOmikron { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + Ok(()) + } + + async fn await_response( + &self, + _: &CommunicationValue, + _: Duration, + ) -> Result { + Err(OmikronError::Disconnected("test client".into())) + } + + async fn reconnect(&self) -> Result<(), OmikronError> { + Ok(()) + } + + async fn rotate_identity(&self) -> Result<(), OmikronError> { + Ok(()) + } + + async fn is_connected(&self) -> bool { + true + } +} + +fn active_services() -> Arc { + Arc::new(DaemonServices { + omikron: Arc::new(TestOmikron), + users: Default::default(), + config: Default::default(), + active: true, + }) +} + +#[tokio::test] +async fn shutdown_delivers_response_and_lifecycle_event_before_eof() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("ipc.sock"); + let (runtime, server_task) = start_server(&socket, active_services()).await; + let mut stream = connect_and_await_hello(&socket).await; + + write_msg( + &mut stream, + &ClientMessage::Request(RequestEnvelope { + request_id: 8, + protocol_version: PROTOCOL_VERSION, + request: LocalRequest::RequestProcessExit { + intent: ExitIntent::Stop, + }, + }), + ) + .await + .expect("shutdown request sends"); + + let mut response_seen = false; + let mut lifecycle_seen = false; + for _ in 0..4 { + match tokio::time::timeout( + Duration::from_secs(2), + read_msg::<_, DaemonMessage>(&mut stream), + ) + .await + .expect("shutdown message arrives") + .expect("shutdown stream remains readable") + { + DaemonMessage::Response(response) => { + assert_eq!(response.request_id, 8); + assert!(matches!(response.result, ResponseResult::Ok(_))); + response_seen = true; + } + DaemonMessage::LifecycleEvent(iota_ipc::LifecycleEvent::Shutdown { .. }) => { + lifecycle_seen = true; + } + _ => {} + } + if response_seen && lifecycle_seen { + break; + } + } + + assert!(response_seen); + assert!(lifecycle_seen); + let eof = tokio::time::timeout( + Duration::from_secs(2), + read_msg::<_, DaemonMessage>(&mut stream), + ) + .await + .expect("shutdown connection closes after flush"); + assert!(eof.is_err()); + assert!(runtime.is_shutting_down()); + + server_task.abort(); + let _ = server_task.await; +} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 29de9e1..87a5636 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -7,7 +7,6 @@ edition = "2024" iota-daemon-lib = { path = "../iota-daemon-lib" } iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } @@ -15,4 +14,3 @@ iota-terms = { path = "../iota-terms" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index af695f2..acf72b1 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -5,13 +5,13 @@ pub mod transport; pub use protocol::{ ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, - ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LocalUserState, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, - RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, SecretString, StartupPhase, - StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, UpdateStatusResponse, - UserDetailResponse, UserSummary, + ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase, + LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, + OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, + SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, + UpdateStatusResponse, UserDetailResponse, UserSummary, }; -pub use transport::{read_msg, write_msg}; +pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; /// Current IPC protocol version. pub const PROTOCOL_VERSION: u16 = 2; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 01cd14e..4006583 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -97,6 +97,59 @@ pub enum LocalRequest { ListCommunities, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IpcRole { + Read, + Operate, + Admin, +} + +impl IpcRole { + pub fn allows(self, required: IpcRole) -> bool { + matches!( + (self, required), + (IpcRole::Admin, _) + | (IpcRole::Operate, IpcRole::Operate | IpcRole::Read) + | (IpcRole::Read, IpcRole::Read) + ) + } +} + +impl LocalRequest { + /// Return the minimum authenticated local role required to execute a + /// request. New request variants must be assigned explicitly here. + pub fn required_role(&self) -> IpcRole { + match self { + Self::GetStatus + | Self::ListTasks + | Self::ListUsers + | Self::GetDaemonStatus + | Self::GetOmikronStatus + | Self::ListComponents + | Self::GetUser { .. } + | Self::GetLogs { .. } + | Self::CheckUpdate + | Self::ListCommunities => IpcRole::Read, + + Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, + + Self::CreateUser { .. } + | Self::AttachUserFromTu { .. } + | Self::PurgeUserData { .. } + | Self::ReleaseUser { .. } + | Self::CompleteDeleteUser { .. } + | Self::RemoveUser { .. } + | Self::RotateIotaIdentity + | Self::RequestProcessExit { .. } + | Self::RestartDaemon + | Self::StopDaemon + | Self::GetConfig + | Self::SetConfig { .. } + | Self::ImportUser { .. } => IpcRole::Admin, + } + } +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ExitIntent { @@ -296,7 +349,9 @@ impl std::fmt::Display for IpcErrorCode { Self::Disconnected => "the daemon connection was lost", Self::Timeout => "the daemon did not respond in time", Self::Cancelled => "the daemon cancelled the request", - Self::Unauthorized => "the daemon denied this operation", + Self::Unauthorized => { + "the daemon denied this operation because the IPC account lacks the required role" + } Self::InternalFailure => "the daemon encountered an internal failure", }) } @@ -317,6 +372,11 @@ mod error_tests { .to_string() .contains("InternalFailure") ); + assert!( + IpcErrorCode::Unauthorized + .to_string() + .contains("required role") + ); } } diff --git a/iota-ipc/src/transport.rs b/iota-ipc/src/transport.rs index 2793451..6a4382f 100644 --- a/iota-ipc/src/transport.rs +++ b/iota-ipc/src/transport.rs @@ -3,7 +3,10 @@ use serde::de::DeserializeOwned; use std::io::{Error, ErrorKind, Result}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -const MAX_MESSAGE_SIZE: usize = 1024 * 1024; +/// Maximum encoded payload size for a single IPC frame. +/// +/// This is a wire-level contract shared by both sides of the connection. +pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024; /* Length-prefixing preserves message boundaries on a byte stream and bounds * allocations before JSON is deserialized. */ @@ -14,6 +17,12 @@ where { let payload = serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + if payload.len() > MAX_MESSAGE_SIZE { + return Err(Error::new( + ErrorKind::InvalidData, + "IPC message exceeds limit", + )); + } let len = u32::try_from(payload.len()) .map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?; writer.write_u32(len).await?; @@ -40,7 +49,7 @@ where #[cfg(test)] mod tests { - use super::{read_msg, write_msg}; + use super::{MAX_MESSAGE_SIZE, read_msg, write_msg}; use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope}; #[tokio::test] @@ -57,4 +66,31 @@ mod tests { let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds"); assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4)); } + + #[tokio::test] + async fn write_rejects_message_above_frame_limit() { + let (mut writer, _reader) = tokio::io::duplex(MAX_MESSAGE_SIZE + 16); + let message = "x".repeat(MAX_MESSAGE_SIZE + 1); + + let error = write_msg(&mut writer, &message) + .await + .expect_err("oversized payload must be rejected before framing"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("exceeds limit")); + } + + #[tokio::test] + async fn read_rejects_frame_above_limit_before_allocating_payload() { + let (mut writer, mut reader) = tokio::io::duplex(16); + tokio::io::AsyncWriteExt::write_u32(&mut writer, (MAX_MESSAGE_SIZE + 1) as u32) + .await + .expect("length prefix write succeeds"); + + let error = read_msg::<_, ClientMessage>(&mut reader) + .await + .expect_err("oversized frame must be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } } diff --git a/iota-process-manager/Cargo.toml b/iota-process-manager/Cargo.toml index c159ef8..6977698 100644 --- a/iota-process-manager/Cargo.toml +++ b/iota-process-manager/Cargo.toml @@ -6,3 +6,7 @@ edition = "2024" [dependencies] async-trait = "0.1" tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] } + +[dev-dependencies] +libc = "0.2" +tempfile = "3" diff --git a/iota-process-manager/src/lib.rs b/iota-process-manager/src/lib.rs index c0ebb0d..3512a90 100644 --- a/iota-process-manager/src/lib.rs +++ b/iota-process-manager/src/lib.rs @@ -167,26 +167,66 @@ pub async fn detect() -> Option> { mod systemd { use super::*; use std::{path::Path, process::Stdio}; - use tokio::{process::Command, time::timeout}; + use tokio::{io::AsyncRead, process::Command, time::timeout}; const SERVICE: &str = "iota-daemon.service"; const SOCKET: &str = "iota-daemon.socket"; const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"]; + const MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024; pub struct RealExecutor; - #[async_trait] - impl CommandExecutor for RealExecutor { - async fn output( + + async fn read_bounded(reader: R) -> std::io::Result> + where + R: AsyncRead + Unpin, + { + use tokio::io::AsyncReadExt; + + let mut output = Vec::new(); + reader + .take((MAX_COMMAND_OUTPUT_BYTES + 1) as u64) + .read_to_end(&mut output) + .await?; + if output.len() > MAX_COMMAND_OUTPUT_BYTES { + output.truncate(MAX_COMMAND_OUTPUT_BYTES); + } + Ok(output) + } + + async fn collect_output( + stdout: tokio::process::ChildStdout, + stderr: tokio::process::ChildStderr, + ) -> Result<(Vec, Vec), ProcessManagerError> { + let (stdout_result, stderr_result) = + tokio::join!(read_bounded(stdout), read_bounded(stderr)); + let stdout = stdout_result.map_err(|error| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + format!("stdout read failed: {error}"), + ) + })?; + let stderr = stderr_result.map_err(|error| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + format!("stderr read failed: {error}"), + ) + })?; + Ok((stdout, stderr)) + } + + impl RealExecutor { + async fn output_with_timeout( &self, program: &str, args: &[&str], + process_timeout: std::time::Duration, ) -> Result { - let child = Command::new(program) + let mut child = Command::new(program) .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(false) + .kill_on_drop(true) .spawn() .map_err(|e| { ProcessManagerError::new( @@ -194,28 +234,79 @@ mod systemd { format!("Could not run {program}: {e}"), ) })?; - let output = timeout(PROCESS_MANAGER_TIMEOUT, child.wait_with_output()) - .await - .map_err(|_| { - ProcessManagerError::new( + + let stdout = child.stdout.take().ok_or_else(|| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + "command stdout pipe was not created", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + "command stderr pipe was not created", + ) + })?; + let output_task = tokio::spawn(collect_output(stdout, stderr)); + + let status = match timeout(process_timeout, child.wait()).await { + Ok(result) => result.map_err(|e| { + ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string()) + })?, + Err(_) => { + // Keep the Child alive across the timeout. Explicitly + // terminate it and await wait() so the OS child is + // reaped before reporting the timeout. + let kill_error = child.start_kill().err(); + let wait_error = child.wait().await.err(); + output_task.abort(); + let _ = output_task.await; + + if let Some(error) = wait_error { + return Err(ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + format!("{program} timed out and could not be reaped: {error}"), + )); + } + let termination_detail = kill_error + .map(|error| format!("; termination request reported: {error}")) + .unwrap_or_default(); + return Err(ProcessManagerError::new( ProcessManagerErrorKind::TimedOut, format!( - "{program} timed out after {} seconds", - PROCESS_MANAGER_TIMEOUT.as_secs() + "{program} timed out after {} seconds{termination_detail}", + process_timeout.as_secs(), ), - ) - })? - .map_err(|e| { - ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string()) - })?; + )); + } + }; + + let (stdout, stderr) = output_task.await.map_err(|error| { + ProcessManagerError::new( + ProcessManagerErrorKind::CommandFailed, + format!("command output task failed: {error}"), + ) + })??; Ok(CommandOutput { - success: output.status.success(), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + success: status.success(), + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), }) } } + #[async_trait] + impl CommandExecutor for RealExecutor { + async fn output( + &self, + program: &str, + args: &[&str], + ) -> Result { + self.output_with_timeout(program, args, PROCESS_MANAGER_TIMEOUT) + .await + } + } + pub struct SystemdManager { executor: Arc, service: &'static str, @@ -461,6 +552,51 @@ mod systemd { assert!(call.contains(&"--no-pager".into())); assert!(call.contains(&"--no-ask-password".into())); } + + #[tokio::test] + async fn timed_out_real_child_is_terminated_and_reaped() { + use std::fs; + use std::time::Duration; + + let directory = tempfile::tempdir().unwrap(); + let pid_file = directory.path().join("child.pid"); + let script = format!( + "printf '%s' \"$$\" > '{}'; exec sleep 60", + pid_file.display() + ); + let executor = RealExecutor; + let task = tokio::spawn(async move { + executor + .output_with_timeout("sh", &["-c", &script], Duration::from_millis(50)) + .await + }); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let pid = loop { + if let Ok(contents) = fs::read_to_string(&pid_file) { + if let Ok(pid) = contents.parse::() { + break pid; + } + } + assert!(tokio::time::Instant::now() < deadline); + tokio::task::yield_now().await; + }; + + let result = task.await.unwrap(); + assert_eq!( + result.unwrap_err().kind(), + ProcessManagerErrorKind::TimedOut + ); + assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists()); + + let mut status = 0; + let wait_result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; + assert_eq!(wait_result, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } } } diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index 8c9ab8a..2192830 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -13,5 +13,3 @@ once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" sysinfo = "0.39.0" -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -serde = { version = "1", features = ["derive"] } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index caf6417..9f91c52 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -5,33 +5,16 @@ edition = "2024" [dependencies] iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -aes-gcm = "0.10.3" base64 = "0.22.1" -hex = "*" -hkdf = "0.12.4" json = "*" arc-swap = "1" once_cell = "1.21.3" r2d2 = "0.8" serde = { version = "1", features = ["derive"] } -serde_json = "1" serde_yaml = "0.9" thiserror = "2" rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" rusqlite = "0.40.0" -sha2 = "0.11.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 60b1cda..371cb1c 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -29,6 +29,8 @@ pub struct IotaConfig { pub private_key: Option, #[serde(default = "default_read_receipts_enabled")] pub read_receipts_enabled: bool, + #[serde(default = "default_max_ipc_clients")] + pub max_ipc_clients: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -87,6 +89,10 @@ const fn default_read_receipts_enabled() -> bool { true } +const fn default_max_ipc_clients() -> usize { + 64 +} + impl Default for IotaConfig { fn default() -> Self { Self { @@ -99,6 +105,7 @@ impl Default for IotaConfig { public_key: None, private_key: None, read_receipts_enabled: default_read_receipts_enabled(), + max_ipc_clients: default_max_ipc_clients(), } } } @@ -200,6 +207,14 @@ pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> { modify_config(|cfg| cfg.read_receipts_enabled = parsed); Ok(()) } + "max_ipc_clients" => { + let parsed: usize = value.parse().map_err(|_| "invalid max_ipc_clients")?; + if parsed == 0 { + return Err("max_ipc_clients must be greater than zero"); + } + modify_config(|cfg| cfg.max_ipc_clients = parsed); + Ok(()) + } "web.mode" => { let mode = match value { "disabled" => WebMode::Disabled, diff --git a/iota-terms/Cargo.toml b/iota-terms/Cargo.toml index 607dce1..fe051de 100644 --- a/iota-terms/Cargo.toml +++ b/iota-terms/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] -iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } json = "*" diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 2f6bf04..1fbbef9 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -4,31 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] -iota-logger = { path = "../iota-logger" } iota-paths = { path = "../iota-paths" } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -json = "*" -pnet = "0.35.0" -ratatui = "0.30.0" -reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.39.0" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -zip = "6.0.0" -aes-gcm = "0.10.3" -base64 = "0.22.1" -rand_core = { version = "0.6", features = ["getrandom", "std"] } sha2 = "0.11.0" -x448 = { version = "*" } -hkdf = "0.12.4" -once_cell = "1.21.3" hex = "*" serde = "1.0.228" tempfile = "3.27.0" anyhow = "1.0.102" -semver = "1.0.28" ed25519-dalek = "2.2.0" serde_json = "1.0" diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs index 9375eb4..a258d17 100644 --- a/iota-util/src/crypto_helper.rs +++ b/iota-util/src/crypto_helper.rs @@ -6,7 +6,10 @@ pub fn generate_keyring() -> Keyring { } pub fn keyring_to_base64(keyring: &Keyring) -> String { - STANDARD.encode(keyring.to_bytes()) + let bytes = keyring + .try_to_bytes() + .expect("keyring fields must fit the wire format"); + STANDARD.encode(bytes) } pub fn keyring_from_base64(s: &str) -> Option { @@ -15,7 +18,10 @@ pub fn keyring_from_base64(s: &str) -> Option { } pub fn public_key_bundle_to_base64(bundle: &PublicKeyBundle) -> String { - STANDARD.encode(bundle.as_bytes()) + let bytes = bundle + .try_as_bytes() + .expect("public key bundle fields must fit the wire format"); + STANDARD.encode(bytes) } pub fn public_key_bundle_from_base64(s: &str) -> Option { diff --git a/iota-util/src/mtp_compat.rs b/iota-util/src/mtp_compat.rs index 02aced4..adc47d3 100644 --- a/iota-util/src/mtp_compat.rs +++ b/iota-util/src/mtp_compat.rs @@ -1,28 +1,42 @@ use mtp::codec::{CommunicationValue, DataValue}; use mtp::type_map::DataTypeId; -/* - * Keep legacy control-plane handlers source-compatible while they migrate to - * MTP's explicit optional routing fields. Relay handlers must use sender() and - * receiver() directly so an absent outer sender cannot become an identity. - */ -pub trait CommunicationValueCompat { - fn get_id(&self) -> u32; - fn get_sender(&self) -> u64; - fn get_receiver(&self) -> u64; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MtpFieldError { + MissingId, + MissingSender, + MissingReceiver, } -impl CommunicationValueCompat for CommunicationValue { - fn get_id(&self) -> u32 { - self.id().unwrap_or_default() +impl std::fmt::Display for MtpFieldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::MissingId => "missing message id", + Self::MissingSender => "missing sender", + Self::MissingReceiver => "missing receiver", + }) + } +} + +impl std::error::Error for MtpFieldError {} + +pub trait RequiredCommunicationFields { + fn require_id(&self) -> Result; + fn require_sender(&self) -> Result; + fn require_receiver(&self) -> Result; +} + +impl RequiredCommunicationFields for CommunicationValue { + fn require_id(&self) -> Result { + self.id().ok_or(MtpFieldError::MissingId) } - fn get_sender(&self) -> u64 { - self.sender().unwrap_or_default() + fn require_sender(&self) -> Result { + self.sender().ok_or(MtpFieldError::MissingSender) } - fn get_receiver(&self) -> u64 { - self.receiver().unwrap_or_default() + fn require_receiver(&self) -> Result { + self.receiver().ok_or(MtpFieldError::MissingReceiver) } } @@ -65,3 +79,33 @@ impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> { self.and_then(DataValue::as_container) } } + +#[cfg(test)] +mod tests { + use super::{MtpFieldError, RequiredCommunicationFields}; + use mtp::codec::{CommunicationType, CommunicationValue}; + + #[test] + fn missing_routing_fields_are_reported_instead_of_defaulted() { + let message = CommunicationValue::new(CommunicationType::Success).without_id(); + + assert_eq!(message.require_id(), Err(MtpFieldError::MissingId)); + assert_eq!(message.require_sender(), Err(MtpFieldError::MissingSender)); + assert_eq!( + message.require_receiver(), + Err(MtpFieldError::MissingReceiver) + ); + } + + #[test] + fn present_routing_fields_are_returned_unchanged() { + let message = CommunicationValue::new(CommunicationType::Success) + .with_id(7) + .with_sender(8) + .with_receiver(9); + + assert_eq!(message.require_id(), Ok(7)); + assert_eq!(message.require_sender(), Ok(8)); + assert_eq!(message.require_receiver(), Ok(9)); + } +} diff --git a/iota/Cargo.toml b/iota/Cargo.toml index b05cfe8..1fc0858 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -13,7 +13,6 @@ iota-paths = { path = "../iota-paths" } iota-terms = { path = "../iota-terms" } iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" serde_yaml = "0.9" clap = { version = "4.5", features = ["derive"] } diff --git a/mtp-type-maps b/mtp-type-maps index 486541b..a297dcc 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e +Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643 diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 31796b0..c4773f7 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -14,7 +14,6 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "client", "crypto", "files", - "raw", ] } dashmap = "6.2.1" @@ -24,8 +23,4 @@ tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" -hex = "*" -rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.11.0" -x448 = { version = "*" } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 1e1f5c7..5a63c5a 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -9,12 +9,12 @@ use iota_util::crypto_util::{self}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; +use rand_core::RngCore; use std::env; +use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; -use std::sync::{ - Arc, LazyLock, - atomic::{AtomicU32, Ordering}, -}; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; @@ -38,6 +38,9 @@ use iota_util::route_target::RouteTarget; // ============================================================================ const IOTA_KEYRING_PATH: &str = "iota.mk"; +const IDENTITY_SECRET_ENV: &str = "IOTA_IDENTITY_SECRET"; +const IDENTITY_SECRET_FILE_ENV: &str = "IOTA_IDENTITY_SECRET_FILE"; +const SYSTEMD_IDENTITY_CREDENTIAL: &str = "iota-identity"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); /// Must be called by the daemon before any Omikron connection is attempted. @@ -60,7 +63,173 @@ const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); const MAX_CONCURRENT_HANDLERS: usize = 20; const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; -static NEXT_RELAY_FRAME_ID: AtomicU32 = AtomicU32::new(1); + +#[derive(Debug)] +pub enum IdentityError { + Storage(mtp::files::FileError), + Directory(std::io::Error), + Secret(String), + InvalidLegacyIdentity, + Verification(String), +} + +impl std::fmt::Display for IdentityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Storage(error) => write!(f, "identity storage error: {error}"), + Self::Directory(error) => write!(f, "unable to create identity directory: {error}"), + Self::Secret(error) => write!(f, "unable to load identity secret: {error}"), + Self::InvalidLegacyIdentity => f.write_str("legacy identity is invalid"), + Self::Verification(error) => { + write!(f, "persisted identity could not be verified: {error}") + } + } + } +} + +impl std::error::Error for IdentityError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ConnectionAttemptResult { + became_healthy: bool, +} + +fn jittered_reconnect_delay(delay: Duration) -> Duration { + let ceiling_ms = u64::try_from(MAX_RECONNECT_DELAY.as_millis()) + .expect("reconnect ceiling must fit in milliseconds"); + let base_ms = u64::try_from(delay.as_millis().min(u128::from(ceiling_ms))) + .expect("bounded reconnect delay must fit in milliseconds"); + let jitter_span = base_ms / 5; + if jitter_span == 0 { + return Duration::from_millis(base_ms); + } + + let mut rng = rand_core::OsRng; + let range = jitter_span.saturating_mul(2).saturating_add(1); + let offset = (rng.next_u64() % range) as i128 - jitter_span as i128; + let jittered = (base_ms as i128 + offset).clamp(0, i128::from(ceiling_ms)); + Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative")) +} + +fn wire_user_id(user_id: i64) -> u64 { + u64::try_from(user_id).expect("validated user ID is non-negative") +} + +fn load_identity_secret() -> Result, IdentityError> { + if let Some(path) = env::var_os(IDENTITY_SECRET_FILE_ENV) { + let path = PathBuf::from(path); + let mut secret = fs::read(&path) + .map_err(|error| IdentityError::Secret(format!("{}: {error}", path.display())))?; + while matches!(secret.last(), Some(b'\n' | b'\r')) { + secret.pop(); + } + if secret.is_empty() { + return Err(IdentityError::Secret(format!( + "{} is empty", + path.display() + ))); + } + return Ok(secret); + } + + if let Ok(secret) = env::var(IDENTITY_SECRET_ENV) { + if secret.is_empty() { + return Err(IdentityError::Secret(format!( + "{IDENTITY_SECRET_ENV} is empty" + ))); + } + return Ok(secret.into_bytes()); + } + + if let Ok(credentials_dir) = env::var("CREDENTIALS_DIRECTORY") { + let path = Path::new(&credentials_dir).join(SYSTEMD_IDENTITY_CREDENTIAL); + let mut secret = fs::read(&path) + .map_err(|error| IdentityError::Secret(format!("{}: {error}", path.display())))?; + while matches!(secret.last(), Some(b'\n' | b'\r')) { + secret.pop(); + } + if secret.is_empty() { + return Err(IdentityError::Secret(format!( + "{} is empty", + path.display() + ))); + } + return Ok(secret); + } + + Err(IdentityError::Secret(format!( + "set {IDENTITY_SECRET_FILE_ENV}, {IDENTITY_SECRET_ENV}, or a systemd identity credential" + ))) +} + +fn load_legacy_raw_keyring(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| IdentityError::Storage(error.into()))?; + if bytes.len() < 5 || bytes[..4] != *b"MTMK" || bytes[4] != 1 { + return Err(IdentityError::InvalidLegacyIdentity); + } + Keyring::from_bytes(&bytes[5..]).map_err(|_| IdentityError::InvalidLegacyIdentity) +} + +fn save_protected_keyring_verified( + keyring: &Keyring, + path: &Path, + passphrase: &[u8], +) -> Result<(), IdentityError> { + mtp::files::save_keyring(keyring, path, passphrase).map_err(IdentityError::Storage)?; + let persisted = mtp::files::load_keyring(path, passphrase).map_err(IdentityError::Storage)?; + let expected = keyring + .try_to_bytes() + .map_err(|error| IdentityError::Verification(error.to_string()))?; + let actual = persisted + .try_to_bytes() + .map_err(|error| IdentityError::Verification(error.to_string()))?; + if expected != actual { + return Err(IdentityError::Verification( + "persisted keyring differs from the requested identity".into(), + )); + } + Ok(()) +} + +fn load_or_migrate_keyring_at( + path: &Path, + legacy: Option, + passphrase: &[u8], +) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(IdentityError::Directory)?; + } + + match mtp::files::load_keyring(path, passphrase) { + Ok(keyring) => return Ok(keyring), + Err(mtp::files::FileError::Io(error)) if error.kind() == ErrorKind::NotFound => {} + Err(mtp::files::FileError::UnprotectedKeyring) => { + let keyring = load_legacy_raw_keyring(path)?; + save_protected_keyring_verified(&keyring, path, passphrase)?; + return Ok(keyring); + } + Err(error) => return Err(IdentityError::Storage(error)), + } + + let keyring = match legacy { + Some(encoded) => { + keyring_from_base64(&encoded).ok_or(IdentityError::InvalidLegacyIdentity)? + } + None => { + log!( + "No existing Iota identity found at {}; generating a new identity", + path.display() + ); + crypto_helper::generate_keyring() + } + }; + + save_protected_keyring_verified(&keyring, path, passphrase)?; + Ok(keyring) +} // ============================================================================ // Waiting Task System @@ -128,14 +297,6 @@ pub struct OmikronConnection { pub(crate) app: Arc>, } -fn ensure_relay_frame_id(frame: CommunicationValue) -> CommunicationValue { - if frame.id().is_some_and(|id| id != 0) { - return frame; - } - let id = NEXT_RELAY_FRAME_ID.fetch_add(1, Ordering::Relaxed).max(1); - frame.with_id(id) -} - impl OmikronConnection { pub fn new(active_tasks: Arc>, app: Arc>) -> Self { Self::with_cancellation(CancellationToken::new(), active_tasks, app) @@ -248,29 +409,29 @@ impl OmikronConnection { break; } - match self.clone().connect_once().await { - Ok(()) => { - if *self.reconnect_on_close.read().await { - log!("Connection lost, reconnecting in {:?}...", reconnect_delay); - } else { + let retry_reason = match self.clone().connect_once().await { + Ok(result) => { + if result.became_healthy { + reconnect_delay = RECONNECT_DELAY; + } + if !*self.reconnect_on_close.read().await { break; } + "Connection lost".to_string() } Err(e) => { if self.auth_failure.read().await.is_some() { log!("Authentication failed, stopping reconnection: {}", e); break; } - log!( - "Connection failed: {}, retrying in {:?}...", - e, - reconnect_delay - ); + format!("Connection failed: {e}") } - } + }; + let delay = jittered_reconnect_delay(reconnect_delay); + log!("{}, retrying in {:?}...", retry_reason, delay); tokio::select! { - _ = sleep(reconnect_delay) => {} + _ = sleep(delay) => {} _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { break; @@ -282,11 +443,16 @@ impl OmikronConnection { } } - async fn connect_once(self: Arc) -> Result<(), String> { + async fn connect_once(self: Arc) -> Result { self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); - let keyring = Arc::new(self.load_or_migrate_keyring().await); + let identity_secret = load_identity_secret().map_err(|error| error.to_string())?; + let keyring = Arc::new( + self.load_or_migrate_keyring(&identity_secret) + .await + .map_err(|error| format!("Iota identity initialization failed: {error}"))?, + ); *self.keyring.write().await = Some(keyring.clone()); let existing_iota_id = CONFIG.load().iota_id; @@ -298,29 +464,21 @@ impl OmikronConnection { log!("Connecting to Omikron at {}", addr_str); + let policy = Policy::default() + .with_send_mode(SendMode::SingleStreamPerMessage) + .with_timeouts( + Duration::from_millis(2_000), + Duration::from_millis(2_000), + Duration::from_millis(30_000), + ) + .with_keep_alive(Some(Duration::from_secs(6))) + .with_receiver_queue_capacity(1000) + .with_max_concurrent_stream_tasks(10) + .with_persistent_stream_retries(5, Duration::from_secs(5)); let client_config = ClientConfig::new(&addr_str) .with_description("iota") - .with_policy(Policy { - send_mode: SendMode::SingleStreamPerMessage, - max_message_size: 1_000_000_000, - handshake_max_message_size: 1_000_000_000, - close_frame_len: u32::MAX, - application_close_code: 0, - open_stream_timeout: Duration::from_millis(2_000), - write_timeout: Duration::from_millis(2_000), - accept_stream_timeout: Duration::from_millis(10_000), - read_timeout: Duration::from_millis(30_000), - keep_alive_interval: Some(Duration::from_secs(6)), - max_idle_timeout: None, - force_close_delay: Duration::from_millis(300), - receiver_queue_capacity: 1000, - max_concurrent_stream_tasks: 10, - persistent_stream_max_retries: 5, - persistent_stream_retry_backoff: Duration::from_secs(5), - max_frames_per_stream: None, - }) - .with_ping_interval(MAINTENANCE_INTERVAL) - .with_max_missed_pings(0); + .with_policy(policy) + .with_ping_interval(MAINTENANCE_INTERVAL); let connection = match Client::auth_connect_or_register( client_config, @@ -389,13 +547,9 @@ impl OmikronConnection { } match result { - Ok(()) => { - if *self.reconnect_on_close.read().await { - Err("Connection closed, will reconnect".to_string()) - } else { - Ok(()) - } - } + Ok(()) => Ok(ConnectionAttemptResult { + became_healthy: true, + }), Err(e) => Err(format!("Read loop error: {}", e)), } } @@ -404,41 +558,8 @@ impl OmikronConnection { // Identity (own Keyring, migrated from the legacy base64-in-config format) // ------------------------------------------------------------------------- - /* - * `iota.mk` is now the source of truth for this Iota's identity. A - * pre-existing base64 keyring in config.json (from before the MTP auth - * migration) is imported once so already-registered Iotas keep their - * identity, and mirrored back into config.json for older code paths - * that still read it directly. - */ - async fn load_or_migrate_keyring(&self) -> Keyring { - let path = identity_path(); - if let Ok(kr) = mtp::files::load_keyring_raw(path) { - return kr; - } - - let legacy = CONFIG.load().keyring.clone(); - let keyring = legacy - .and_then(|b64| keyring_from_base64(&b64)) - .unwrap_or_else(|| { - log!( - "WARNING: No existing keyring found. Neither {} nor config.json \ - contain a keyring; generating a new identity. If you already had \ - an Iota identity, restore {} from a backup to avoid losing access.", - path.display(), - path.display() - ); - crypto_helper::generate_keyring() - }); - - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = mtp::files::save_keyring_raw(&keyring, path) { - log!("Failed to persist {}: {}", path.display(), e); - } - - keyring + async fn load_or_migrate_keyring(&self, passphrase: &[u8]) -> Result { + load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone(), passphrase) } // ------------------------------------------------------------------------- @@ -503,17 +624,24 @@ impl OmikronConnection { }; let (host, port, public_key) = if let Some(endpoint) = discovered { + let discovered_key_bytes = endpoint.public_key.try_as_bytes().map_err(|error| { + format!("Failed to serialize discovered Omikron public key: {error}") + })?; match &cached_key { - Some(cached) if cached.as_bytes() != endpoint.public_key.as_bytes() => { - log!( - "Fetched Omikron public key differs from the cached {} - keeping the \ - cached key. Delete {} manually if this is an expected key rotation.", - OMIKRON_PUBLIC_KEY_PATH, - OMIKRON_PUBLIC_KEY_PATH - ); + Some(cached) => { + let cached_key_bytes = cached.try_as_bytes().map_err(|error| { + format!("Failed to serialize cached Omikron public key: {error}") + })?; + if cached_key_bytes != discovered_key_bytes { + log!( + "Fetched Omikron public key differs from the cached {} - keeping the \ + cached key. Delete {} manually if this is an expected key rotation.", + OMIKRON_PUBLIC_KEY_PATH, + OMIKRON_PUBLIC_KEY_PATH + ); + } (endpoint.host, endpoint.port, cached.clone()) } - Some(cached) => (endpoint.host, endpoint.port, cached.clone()), None => { if let Err(e) = mtp::files::save_public_key_bundle( &endpoint.public_key, @@ -563,7 +691,13 @@ impl OmikronConnection { }); continue; } - let msg_id = cv.get_id(); + let Some(msg_id) = cv.id() else { + let self_clone = self.clone(); + tokio::spawn(async move { + self_clone.handle_message_impl(cv).await; + }); + continue; + }; if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { if (task.task)(cv.clone()) { continue; @@ -613,7 +747,7 @@ impl OmikronConnection { } if let Some(ping) = connection.get_ping() { - let ping_ms = ping.as_millis() as i64; + let ping_ms = i64::try_from(ping.as_millis()).unwrap_or(i64::MAX); *self.last_ping.lock().await = ping_ms; self.app.lock().unwrap().push_ping_val(ping_ms as f64); } @@ -631,7 +765,10 @@ impl OmikronConnection { &self, signer_id: u64, ) -> Result, RelayValidationError> { - if let Some(user) = iota_storage::users::user_manager::get_user(signer_id as i64) { + let signer_id_i64 = i64::try_from(signer_id).map_err(|_| { + RelayValidationError::KeyLookup("signer ID exceeds local storage range".into()) + })?; + if let Some(user) = iota_storage::users::user_manager::get_user(signer_id_i64) { let key = iota_util::crypto_helper::public_key_bundle_from_base64(&user.public_key) .ok_or_else(|| { RelayValidationError::KeyLookup("stored user key is invalid".into()) @@ -641,7 +778,7 @@ impl OmikronConnection { let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, - DataValue::UnsignedNumber(signer_id as u128), + DataValue::UnsignedNumber(u128::from(signer_id)), ); let response = self .await_response(&request, Some(Duration::from_secs(10))) @@ -663,15 +800,19 @@ impl OmikronConnection { } pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { - if iota_storage::users::user_manager::get_user(user_id as i64).is_some() { + let user_id_i64 = i64::try_from(user_id) + .map_err(|_| "user ID exceeds local storage range".to_string())?; + if iota_storage::users::user_manager::get_user(user_id_i64).is_some() { return CONFIG .load() .iota_id .ok_or_else(|| "Iota identity is not configured".into()); } - let request = CommunicationValue::new(CommunicationType::GetUserData) - .add_typed_default(DataType::UserId, DataValue::UnsignedNumber(user_id as u128)); + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::UnsignedNumber(u128::from(user_id)), + ); let response = self .await_response(&request, Some(Duration::from_secs(10))) .await?; @@ -693,17 +834,19 @@ impl OmikronConnection { } async fn handle_relay(self: Arc, frame: CommunicationValue) { - let frame = ensure_relay_frame_id(frame); - let incoming_frame_id = frame.id(); + let Some(incoming_frame_id) = frame.id() else { + log!("Rejecting Relay without a message id"); + return; + }; let Some(local_iota_id) = CONFIG.load().iota_id else { log!("Rejecting Relay because this Iota has no registered identity"); - self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) + self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) .await; return; }; let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { log!("Rejecting Relay because the Iota keyring is unavailable"); - self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInternal) + self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) .await; return; }; @@ -725,24 +868,29 @@ impl OmikronConnection { Ok(value) => value, Err(error) => { log!("Relay metadata verification failed: {}", error); - self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) - .await; + self.send_relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ) + .await; return; } }; - let signer_is_local = - iota_storage::users::user_manager::get_user(verified.context.signer_id as i64) - .is_some(); - let recipient_is_local = - iota_storage::users::user_manager::get_user(verified.context.final_recipient_id as i64) - .is_some(); + let signer_is_local = i64::try_from(verified.context.signer_id) + .ok() + .and_then(iota_storage::users::user_manager::get_user) + .is_some(); + let recipient_is_local = i64::try_from(verified.context.final_recipient_id) + .ok() + .and_then(iota_storage::users::user_manager::get_user) + .is_some(); if !signer_is_local && !recipient_is_local { log!( "Rejecting Relay with no local origin or destination: signer {}, recipient {}", verified.context.signer_id, verified.context.final_recipient_id, ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) .await; return; } @@ -760,7 +908,7 @@ impl OmikronConnection { } }; let type_map_version = verified.context.type_map.version.to_string(); - let frame_id = frame.id().unwrap_or_default(); + let frame_id = incoming_frame_id; let reservation = match relay_replay::reserve( verified.context.signer_id, &verified.context.message_id, @@ -927,7 +1075,7 @@ impl OmikronConnection { RouteTarget::User(destination), &bytes, now_millis_i64(), - forwarded.id().unwrap_or_default(), + frame_id, &type_map_version, ) { log!("Relay could not be queued for client delivery: {}", error); @@ -1069,7 +1217,7 @@ impl OmikronConnection { } // ------------------------------------------------------------------------- - // Message Handling — Dispatch + // Message Handling - Dispatch // ------------------------------------------------------------------------- pub async fn handle_message(self: Arc, cv: CommunicationValue) { @@ -1096,7 +1244,10 @@ impl OmikronConnection { } } - let msg_id = cv.get_id(); + let Some(msg_id) = cv.id() else { + self.handle_message_impl(cv).await; + return; + }; if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { if (task.task)(cv.clone()) { @@ -1112,6 +1263,12 @@ impl OmikronConnection { self.handle_relay(cv).await; return; } + if cv.require_id().is_err() { + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } if matches!( iota_connection::relay::message_security_class(&cv), @@ -1191,7 +1348,7 @@ impl OmikronConnection { } let acknowledgement = CommunicationValue::new(CommunicationType::EraseHostedUserDataAck) - .with_id(cv.get_id()) + .with_request_id(cv) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); let _ = self.send_message(&acknowledgement).await; } @@ -1203,7 +1360,15 @@ impl OmikronConnection { } async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let app_identifier = cv .get_data(DataType::AppIdentifier) .as_str() @@ -1214,7 +1379,12 @@ impl OmikronConnection { .as_str() .unwrap_or("") .to_string(); - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; + let Some(user_id) = data_i64(cv, DataType::UserId).filter(|id| *id > 0) else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; let mut trusted = false; if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { @@ -1235,7 +1405,8 @@ impl OmikronConnection { if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) { - if let Ok(keyring) = mtp::files::load_keyring_raw(identity_path()) { + let keyring = self.keyring.read().await.as_ref().cloned(); + if let Some(keyring) = keyring { if let Ok(encrypted_challenge) = crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) { @@ -1243,7 +1414,7 @@ impl OmikronConnection { let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); let res = CommunicationValue::new(CommunicationType::AppChallenge) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id) .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) .add_typed_default( @@ -1259,18 +1430,26 @@ impl OmikronConnection { } let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id); let _ = self.send_message(&res).await; } async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { if expected_challenge == *response { let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id); let _ = self.send_message(&res).await; return; @@ -1278,13 +1457,21 @@ impl OmikronConnection { } } let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id); let _ = self.send_message(&res).await; } async fn handle_save_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let app_data = cv .get_data(DataType::AppData) .as_str() @@ -1297,13 +1484,21 @@ impl OmikronConnection { } let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id); let _ = self.send_message(&res).await; } async fn handle_load_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender(); + let sender_id = match cv.require_sender() { + Ok(sender_id) => sender_id, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + }; let mut app_data = String::new(); if let Some(session) = self.app_sessions.get(&sender_id) { @@ -1312,7 +1507,7 @@ impl OmikronConnection { } let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_id(cv.get_id()) + .with_request_id(cv) .with_receiver(sender_id) .add_typed_default(DataType::AppData, DataValue::Str(app_data)); let _ = self.send_message(&res).await; @@ -1353,9 +1548,9 @@ impl OmikronConnection { extra: Vec<(DataType, DataValue)>, ) -> CommunicationValue { let mut message = CommunicationValue::new(ty) - .with_id(request.get_id()) - .with_sender(mutation.sender_id as u64) - .with_receiver(mutation.partner_id as u64) + .with_request_id(request) + .with_sender(wire_user_id(mutation.sender_id)) + .with_receiver(wire_user_id(mutation.partner_id)) .add_typed_default( DataType::ChatPartnerId, DataValue::SignedNumber(mutation.sender_id as i128), @@ -1371,18 +1566,26 @@ impl OmikronConnection { } async fn persist_and_deliver_remote_edit(&self, cv: &CommunicationValue) { - let sender_id = match i64::try_from(cv.get_sender()) { - Ok(sender_id) => sender_id, - Err(_) => return, + let sender_id = match cv + .require_sender() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(sender_id) => sender_id, + None => return, }; - let receiver_id = match i64::try_from(cv.get_receiver()) { - Ok(receiver_id) if receiver_id > 0 => receiver_id, + let receiver_id = match cv + .require_receiver() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(receiver_id) if receiver_id > 0 => receiver_id, _ => return, }; let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { return; }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { + let Some(content) = cv.get_data(DataType::AppContent).as_str() else { return; }; if chat_files::apply_remote_edit(receiver_id, sender_id, send_time, sender_id, content) @@ -1393,12 +1596,20 @@ impl OmikronConnection { } async fn persist_and_deliver_remote_reaction(&self, cv: &CommunicationValue, add: bool) { - let sender_id = match i64::try_from(cv.get_sender()) { - Ok(sender_id) => sender_id, - Err(_) => return, + let sender_id = match cv + .require_sender() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(sender_id) => sender_id, + None => return, }; - let receiver_id = match i64::try_from(cv.get_receiver()) { - Ok(receiver_id) if receiver_id > 0 => receiver_id, + let receiver_id = match cv + .require_receiver() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(receiver_id) if receiver_id > 0 => receiver_id, _ => return, }; let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { @@ -1421,12 +1632,20 @@ impl OmikronConnection { } async fn persist_and_deliver_remote_delete(&self, cv: &CommunicationValue) { - let sender_id = match i64::try_from(cv.get_sender()) { - Ok(sender_id) => sender_id, - Err(_) => return, + let sender_id = match cv + .require_sender() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(sender_id) => sender_id, + None => return, }; - let receiver_id = match i64::try_from(cv.get_receiver()) { - Ok(receiver_id) if receiver_id > 0 => receiver_id, + let receiver_id = match cv + .require_receiver() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(receiver_id) if receiver_id > 0 => receiver_id, _ => return, }; let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { @@ -1449,7 +1668,7 @@ impl OmikronConnection { .await; return; }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { + let Some(content) = cv.get_data(DataType::AppContent).as_str() else { let _ = self .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) .await; @@ -1459,7 +1678,7 @@ impl OmikronConnection { CommunicationType::MessageEditLive, cv, &mutation, - vec![(DataType::Content, DataValue::Str(content.to_string()))], + vec![(DataType::AppContent, DataValue::Str(content.to_string()))], ); if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() && chat_files::apply_remote_edit( @@ -1558,9 +1777,13 @@ impl OmikronConnection { } async fn handle_message_delete_live(self: Arc, cv: &CommunicationValue) { - let sender_id = match i64::try_from(cv.get_sender()) { - Ok(sender_id) => sender_id, - Err(_) => return, + let sender_id = match cv + .require_sender() + .ok() + .and_then(|id| i64::try_from(id).ok()) + { + Some(sender_id) => sender_id, + None => return, }; if iota_storage::users::user_manager::get_user(sender_id).is_none() { self.persist_and_deliver_remote_delete(cv).await; @@ -1741,7 +1964,9 @@ impl OmikronConnection { timeout_duration: Option, ) -> Result { let (tx, rx) = oneshot::channel(); - let msg_id = cv.get_id(); + let msg_id = cv + .require_id() + .map_err(|error| format!("cannot await response without a message id: {error}"))?; WAITING_TASKS.insert( msg_id, @@ -1858,6 +2083,8 @@ impl OmikronConnection { /// recovery does not silently destroy the user's previous identity. pub async fn rotate_identity(self: &Arc) -> Result<(), OmikronError> { log!("Iota identity rotation requested"); + let identity_secret = + load_identity_secret().map_err(|error| OmikronError::Internal(error.to_string()))?; self.stop().await; let path = identity_path(); @@ -1877,7 +2104,10 @@ impl OmikronConnection { } let keyring = crypto_helper::generate_keyring(); - if let Some(parent) = path.parent() { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { std::fs::create_dir_all(parent).map_err(|error| { OmikronError::Internal(format!( "could not create identity directory {}: {error}", @@ -1885,7 +2115,7 @@ impl OmikronConnection { )) })?; } - mtp::files::save_keyring_raw(&keyring, path).map_err(|error| { + save_protected_keyring_verified(&keyring, path, &identity_secret).map_err(|error| { OmikronError::Internal(format!( "could not save new identity {}: {error}", path.display() @@ -2052,3 +2282,80 @@ impl OmikronClient for OmikronConnection { Self::is_connected(self).await } } + +#[cfg(test)] +mod identity_tests { + use super::*; + + fn test_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "iota-identity-{name}-{}-{}", + std::process::id(), + Uuid::new_v4() + )) + } + + #[test] + fn generated_identity_is_protected_and_survives_reload() { + let path = test_path("reload"); + let passphrase = b"test identity secret"; + let keyring = load_or_migrate_keyring_at(&path, None, passphrase).expect("identity saves"); + let reloaded = load_or_migrate_keyring_at(&path, None, passphrase).expect("identity loads"); + assert_eq!( + keyring.try_to_bytes().expect("keyring serializes"), + reloaded.try_to_bytes().expect("keyring serializes") + ); + assert!(mtp::files::load_keyring(&path, b"wrong secret").is_err()); + let _ = fs::remove_file(path); + } + + #[test] + fn corrupt_existing_identity_does_not_generate_a_replacement() { + let path = test_path("corrupt"); + fs::write(&path, b"not a keyring").expect("corrupt fixture writes"); + let error = load_or_migrate_keyring_at(&path, None, b"test identity secret") + .expect_err("corrupt identity must fail"); + assert!(matches!(error, IdentityError::Storage(_))); + let _ = fs::remove_file(path); + } + + #[test] + fn legacy_raw_identity_is_migrated_only_when_the_raw_format_is_valid() { + let path = test_path("legacy"); + let keyring = crypto_helper::generate_keyring(); + let mut raw = b"MTMK".to_vec(); + raw.push(1); + raw.extend_from_slice(&keyring.try_to_bytes().expect("keyring serializes")); + fs::write(&path, raw).expect("legacy fixture writes"); + + let migrated = load_or_migrate_keyring_at(&path, None, b"test identity secret") + .expect("legacy identity migrates"); + assert_eq!( + migrated.try_to_bytes().expect("keyring serializes"), + keyring.try_to_bytes().expect("keyring serializes") + ); + let _ = fs::remove_file(path); + } + + #[test] + fn identity_directory_failure_is_returned() { + let parent = test_path("parent-file"); + fs::write(&parent, b"not a directory").expect("parent fixture writes"); + let path = parent.join("iota.mk"); + let error = load_or_migrate_keyring_at(&path, None, b"test identity secret") + .expect_err("directory failure must be returned"); + assert!(matches!(error, IdentityError::Directory(_))); + let _ = fs::remove_file(parent); + } + + #[test] + fn reconnect_jitter_stays_bounded_by_the_exponential_delay_ceiling() { + for _ in 0..32 { + let delay = jittered_reconnect_delay(Duration::from_secs(5)); + assert!(delay >= Duration::from_secs(4)); + assert!(delay <= Duration::from_secs(6)); + } + + assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY); + } +} diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 3008120..60f8429 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -377,7 +377,7 @@ mod tests { use super::{CreateUserError, request_user_id, valid_username}; use crate::{OmikronClient, OmikronError}; use async_trait::async_trait; - use iota_util::mtp_compat::CommunicationValueCompat; + use iota_connection::message_common::CommunicationResponseExt; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::time::Duration; @@ -397,7 +397,7 @@ mod tests { _: Duration, ) -> Result { assert!(request.is_type(CommunicationType::GetRegister)); - Ok(self.response.clone().with_id(request.get_id())) + Ok(self.response.clone().with_request_id(request)) } async fn reconnect(&self) -> Result<(), OmikronError> { diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index a6933c3..1fb5337 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -4,57 +4,3 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" -tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index a5a2903..2c6340b 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -17,6 +17,7 @@ Environment=IOTA_SOCKET=/run/iota/iota.sock Environment=IOTA_DATA_DIR=/var/lib/iota Environment=IOTA_DEPLOYMENT_MODE=system_always_on Environment=IOTA_SUPERVISOR=systemd +LoadCredential=iota-identity:/etc/iota/iota-identity.secret # Exit code 75 = restart requested (daemon-specific convention) RestartPreventExitStatus=0 diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index ad49fba..c07501f 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -9,6 +9,5 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-s bytes = "1" http = "1" iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 988aac1..f6f6ff8 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -4,57 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -omikron-connector = { path = "../omikron-connector" } iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" From 8160f8d0cbfc149a6f5b0a8b710bfa1f5ccafcf6 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:22:59 +0200 Subject: [PATCH 114/119] [Fix] Stability --- Cargo.lock | 737 ++------------------ client/Cargo.toml | 47 -- client/src/client_connection.rs | 8 +- communities/Cargo.toml | 44 -- communities/src/community.rs | 42 +- communities/src/community_connection.rs | 23 +- communities/src/interactables/category.rs | 7 +- iota-auth/Cargo.toml | 50 -- iota-cli/Cargo.toml | 52 -- iota-cli/src/screens/terms_checker.rs | 12 +- iota-cli/src/screens/terms_updater.rs | 24 +- iota-connection/src/relay.rs | 10 +- iota-core/Cargo.toml | 11 - iota-core/src/consent_state.rs | 31 +- iota-daemon-lib/Cargo.toml | 3 +- iota-daemon/Cargo.toml | 2 - iota-daemon/src/main.rs | 17 +- iota-installer/src/lib.rs | 10 + iota-ipc/src/text_commands.rs | 10 + iota-logger/src/lib.rs | 29 +- iota-paths/src/lib.rs | 89 ++- iota-state/Cargo.toml | 4 +- iota-storage/Cargo.toml | 16 - iota-storage/src/util/config_util.rs | 4 +- iota-storage/src/util/db.rs | 48 +- iota-terms/Cargo.toml | 3 +- iota-terms/src/consent.rs | 7 +- iota-terms/src/doc.rs | 63 +- iota-terms/src/lib.rs | 2 +- iota-terms/src/terms_getter.rs | 115 ++- iota-updater/Cargo.toml | 18 - iota-util/Cargo.toml | 5 +- iota-util/src/atomic_file.rs | 158 +++++ iota-util/src/crypto_helper.rs | 10 +- iota-util/src/lib.rs | 1 + iota/Cargo.toml | 1 - iota/src/cli_args.rs | 42 ++ iota/src/main.rs | 62 +- omikron-connector/Cargo.toml | 4 - omikron-connector/src/omikron_connection.rs | 141 +++- other-iota/Cargo.toml | 54 -- web-server/Cargo.toml | 1 - web-ui/Cargo.toml | 44 -- web-ui/src/api.rs | 31 +- 44 files changed, 796 insertions(+), 1296 deletions(-) create mode 100644 iota-util/src/atomic_file.rs diff --git a/Cargo.lock b/Cargo.lock index 7405bb8..75af5ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,29 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-rt", - "bitflags 2.13.1", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] - [[package]] name = "actix-codec" version = "0.5.2" @@ -109,9 +86,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "c25da0441692de4ad67950cb7ed6c9ce4b669a6609525e547566c2e1ab4d695c" dependencies = [ "futures-core", "tokio", @@ -119,9 +96,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3716aae056e2f869b7b5cfd8a08fcf98890f8455bec61d69c7dff5d8576f9d2b" +checksum = "cc8dcb6fa613d47c3b764a7dc672b4b31f25751dcbcf542f7b16f3e1f700044c" dependencies = [ "actix-rt", "actix-service", @@ -175,9 +152,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.1" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58356675d8c86d2e720480645a0316808471a62d0073f6a3b98810a5e0ca0e73" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -217,24 +194,6 @@ dependencies = [ "url", ] -[[package]] -name = "actix-web-actors" -version = "4.3.1+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" -dependencies = [ - "actix", - "actix-codec", - "actix-http", - "actix-web", - "bytes", - "bytestring", - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "actix-web-codegen" version = "4.3.0" @@ -274,20 +233,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -678,9 +623,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -803,52 +748,14 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "client" version = "0.1.0" dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-auth", "iota-connection", "iota-logger", - "iota-state", "iota-storage", "iota-util", - "json", - "lazy_static", "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tokio", - "tokio-tungstenite", - "tungstenite", "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -1008,15 +915,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1082,15 +980,6 @@ dependencies = [ "phf", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "ctutils" version = "0.4.2" @@ -1145,9 +1034,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -1155,9 +1044,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", @@ -1168,9 +1057,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", @@ -1307,16 +1196,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.7" @@ -1392,22 +1271,11 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ed448-goldilocks" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" -dependencies = [ - "fiat-crypto 0.1.20", - "hex", - "subtle", -] - [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" @@ -1483,12 +1351,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fiat-crypto" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1553,21 +1415,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1720,16 +1567,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "glob" version = "0.3.4" @@ -1757,9 +1594,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1867,30 +1704,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64 0.22.1", - "bytes", - "headers-core", - "http 1.5.0", - "httpdate", - "mime", - "sha1 0.10.7", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http 1.5.0", -] - [[package]] name = "heck" version = "0.5.0" @@ -1903,15 +1716,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - [[package]] name = "hkdf" version = "0.13.0" @@ -2021,7 +1825,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.16", + "h2 0.4.18", "http 1.5.0", "http-body", "httparse", @@ -2167,9 +1971,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2276,114 +2080,34 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", - "tokio-util", ] [[package]] name = "iota-auth" version = "0.1.0" dependencies = [ - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", "json", - "lazy_static", - "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", - "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] name = "iota-cli" version = "0.1.0" dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", "chrono", "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", "iota-ipc", - "iota-logger", "iota-paths", - "iota-process-manager", "iota-state", - "iota-storage", "iota-terms", - "iota-util", - "json", - "lazy_static", - "mtp", - "omikron-connector", "once_cell", "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", "serde", - "serde_json", "serde_yaml", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tempfile", "tokio", - "tokio-tungstenite", "tokio-util", - "tungstenite", "unicode-width", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -2400,7 +2124,6 @@ dependencies = [ name = "iota-core" version = "0.1.0" dependencies = [ - "dashmap", "iota-cli", "iota-logger", "iota-paths", @@ -2409,17 +2132,9 @@ dependencies = [ "iota-terms", "iota-updater", "iota-util", - "json", - "mtp", - "omikron-connector", - "once_cell", "pnet", - "ratatui", - "reqwest", "tokio", - "tokio-util", "web-server", - "web-ui", ] [[package]] @@ -2430,13 +2145,11 @@ dependencies = [ "iota-ipc", "iota-logger", "iota-paths", - "iota-state", "iota-storage", "iota-terms", "iota-util", "omikron-connector", "tokio", - "tokio-util", "web-server", ] @@ -2445,7 +2158,6 @@ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ "async-trait", - "dashmap", "iota-ipc", "iota-logger", "iota-state", @@ -2515,9 +2227,7 @@ version = "0.1.0" dependencies = [ "dashmap", "json", - "mtp", "once_cell", - "serde", "sysinfo", "tokio", ] @@ -2526,77 +2236,44 @@ dependencies = [ name = "iota-storage" version = "0.1.0" dependencies = [ - "aes-gcm", "arc-swap", "base64 0.22.1", - "hex", - "hkdf 0.12.4", "iota-logger", "iota-paths", - "iota-state", "iota-util", "json", - "mtp", "once_cell", "r2d2", "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", "rusqlite", "serde", - "serde_json", "serde_yaml", - "sha2 0.11.0", - "sysinfo", "thiserror 2.0.20", "tokio", - "uuid", - "walkdir", - "x448", - "zip", ] [[package]] name = "iota-terms" version = "0.1.0" dependencies = [ - "iota-state", "iota-util", - "json", "reqwest", + "tokio", ] [[package]] name = "iota-updater" version = "0.1.0" dependencies = [ - "aes-gcm", "anyhow", - "base64 0.22.1", "ed25519-dalek 2.2.0", "hex", - "hkdf 0.12.4", - "iota-logger", "iota-paths", - "json", - "mtp", - "once_cell", - "pnet", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "semver", "serde", "serde_json", "sha2 0.11.0", - "sysinfo", "tempfile", "tokio", - "uuid", - "walkdir", - "x448", - "zip", ] [[package]] @@ -2609,6 +2286,7 @@ dependencies = [ "mtp", "reqwest", "sysinfo", + "tempfile", "tokio", "uuid", "walkdir", @@ -2768,9 +2446,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -2943,16 +2621,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3037,7 +2705,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "mtp-client", "mtp-codec", @@ -3053,7 +2721,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "mtp-codec", "mtp-common", @@ -3066,7 +2734,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "base64 0.23.1", "byteorder", @@ -3080,10 +2748,9 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "quinn", - "rustls", "thiserror 2.0.20", "wtransport", ] @@ -3091,14 +2758,14 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "argon2", "base64 0.22.1", "chacha20poly1305", "ed25519-dalek 3.0.0", "getrandom 0.4.3", - "hkdf 0.13.0", + "hkdf", "ml-dsa", "mlkem-tls", "rand 0.10.2", @@ -3114,7 +2781,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3125,7 +2792,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "mtp-codec", "mtp-common", @@ -3141,7 +2808,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "async-trait", "mtp-codec", @@ -3161,7 +2828,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "serde", "serde_yaml", @@ -3170,7 +2837,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" +source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" dependencies = [ "async-trait", "bytes", @@ -3195,23 +2862,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.29.0" @@ -3304,15 +2954,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3320,24 +2961,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "objc2", ] [[package]] @@ -3350,17 +2973,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-open-directory" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" -dependencies = [ - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - [[package]] name = "octets" version = "0.3.6" @@ -3383,7 +2995,6 @@ dependencies = [ "async-trait", "base64 0.22.1", "dashmap", - "hex", "iota-connection", "iota-logger", "iota-state", @@ -3391,14 +3002,11 @@ dependencies = [ "iota-util", "json", "mtp", - "rand 0.8.7", "rand_core 0.6.4", "reqwest", - "sha2 0.11.0", "tokio", "tokio-util", "uuid", - "x448", ] [[package]] @@ -3429,49 +3037,12 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "4.6.0" @@ -3484,53 +3055,6 @@ dependencies = [ [[package]] name = "other-iota" version = "0.1.0" -dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", - "iota-auth", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "lazy_static", - "mtp", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", - "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] [[package]] name = "palette" @@ -3728,26 +3252,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3882,18 +3386,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.15.0" @@ -4063,12 +3555,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" - [[package]] name = "rand_core" version = "0.6.4" @@ -4263,7 +3749,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.16", + "h2 0.4.18", "http 1.5.0", "http-body", "http-body-util", @@ -4443,9 +3929,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -4492,12 +3978,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -4662,7 +4142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.2", "sponge-cursor", ] @@ -4910,16 +4390,15 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.6" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", - "objc2-open-directory", "windows", ] @@ -4951,7 +4430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5158,16 +4637,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5189,20 +4658,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -5301,23 +4756,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http 1.5.0", - "httparse", - "log", - "native-tls", - "rand 0.10.2", - "sha1 0.11.0", - "thiserror 2.0.20", -] - [[package]] name = "typenum" version = "1.20.1" @@ -5330,12 +4768,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -5475,33 +4907,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "warp" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http 1.5.0", - "http-body", - "http-body-util", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project", - "scoped-tls", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tokio-util", - "tower-service", - "tracing", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5579,7 +4984,6 @@ dependencies = [ "bytes", "http 1.5.0", "iota-logger", - "iota-util", "mtp", "tokio", "tokio-util", @@ -5610,50 +5014,14 @@ name = "web-ui" version = "0.1.0" dependencies = [ "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64 0.22.1", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf 0.12.4", - "hyper", - "hyper-util", "iota-logger", "iota-state", "iota-storage", "iota-util", - "json", - "lazy_static", - "mtp", - "omikron-connector", - "once_cell", - "open", - "pnet", - "rand 0.8.7", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", "rustls", "rustls-pemfile", "serde_json", - "sha2 0.11.0", - "strum", - "strum_macros", - "sysinfo", "tokio", - "tokio-tungstenite", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", ] [[package]] @@ -6031,17 +5399,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "x448" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" -dependencies = [ - "ed448-goldilocks", - "hex", - "rand_core 0.5.1", -] - [[package]] name = "x509-parser" version = "0.18.1" @@ -6168,9 +5525,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -6179,9 +5536,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/client/Cargo.toml b/client/Cargo.toml index d63431f..8759d48 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -9,53 +9,6 @@ iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index a90df86..a207471 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -288,8 +288,8 @@ impl ClientConnection { if cv.is_type(CommunicationType::SettingsSave) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataType::SettingsName).as_str().unwrap(); - let settings_value = cv.get_data(DataType::Payload).as_str().unwrap(); + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { return }; + let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { return }; let _ = iota_storage::util::settings::save( my_id as i64, @@ -308,7 +308,7 @@ impl ClientConnection { if cv.is_type(CommunicationType::SettingsLoad) { let my_id = cv.get_sender(); - let settings_name = cv.get_data(DataType::SettingsName).as_string().unwrap(); + let Some(settings_name) = cv.get_data(DataType::SettingsName).as_string() else { return }; let settings_value_str = iota_storage::util::settings::load( my_id as i64, iota_storage::util::settings::GLOBAL_SESSION_ID, @@ -350,7 +350,7 @@ impl ClientConnection { return; }; - let encrypted_challenge = cv.get_data(DataType::Challenge).as_str().unwrap(); + let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { return }; let solved = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 89c4f57..36ef42e 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -5,56 +5,12 @@ edition = "2024" [dependencies] mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" x448 = { version = "*" } -zip = "6.0.0" diff --git a/communities/src/community.rs b/communities/src/community.rs index 69ba273..7b23247 100644 --- a/communities/src/community.rs +++ b/communities/src/community.rs @@ -40,7 +40,7 @@ impl Community { let mut buf = [0u8; 56]; let mut rng = OsRng; rng.fill_bytes(&mut buf); - let private_key = Secret::from_bytes(&buf).unwrap(); + let private_key = Secret::from(buf); let public_key = PublicKey::from(&private_key); Community { name: String::new(), @@ -58,7 +58,7 @@ impl Community { let mut buf = [0u8; 56]; let mut rng = OsRng; rng.fill_bytes(&mut buf); - let private_key = Secret::from_bytes(&buf).unwrap(); + let private_key = Secret::from(buf); let public_key = PublicKey::from(&private_key); let c = Community { name, @@ -125,7 +125,7 @@ impl Community { self.members.clone() } pub fn get_private_key(&self) -> Secret { - Secret::from_bytes(self.private_key.as_bytes()).unwrap() + Secret::from(*self.private_key.as_bytes()) } pub fn get_public_key(&self) -> &PublicKey { &self.public_key @@ -222,12 +222,14 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - let category: &Category = - interactable.as_any().downcast_ref::().unwrap(); + let Some(category) = interactable.as_any().downcast_ref::() else { + return CommunicationValue::new(CommunicationType::ErrorInternal); + }; // cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161) return category .get_child(path.to_string(), name.to_string()) - .unwrap() + .ok_or(CommunicationValue::new(CommunicationType::ErrorInternal)) + .unwrap_or_else(|error| return error) .run_function(cv.clone()) .await; } else { @@ -267,7 +269,7 @@ impl Community { let mut data = JsonValue::new_object(); let mut permissions = JsonValue::new_array(); - for perm in self.permissions.get(user).unwrap() { + for perm in self.permissions.get(user).into_iter().flatten() { if let Ok(_) = permissions.push(perm.to_string()) {} } @@ -287,10 +289,10 @@ impl Community { } pub async fn load(name: &String) -> Option> { let file_contents = file_util::load_file(&format!("communities/{}/", name), "config.json"); - let json_content = json::parse(&file_contents).unwrap(); + let json_content = json::parse(&file_contents).ok()?; let user_data = file_util::load_file(&format!("communities/{}/", name), "users.json"); - let user_json: JsonValue = json::parse(&user_data).unwrap(); + let user_json: JsonValue = json::parse(&user_data).ok()?; let mut users = Vec::new(); let mut permissions: HashMap> = HashMap::new(); @@ -318,25 +320,17 @@ pub async fn load(name: &String) -> Option> { }; let community = Community { - name: json_content["name"].as_str().unwrap().to_string(), + name: json_content["name"].as_str()?.to_string(), owner_id: Arc::new(RwLock::new(json_content["owner_id"].as_i64().unwrap_or(0))), members: users, roles, permissions, private_key: Secret::from_bytes( - &STANDARD - .decode(json_content["private_key"].as_str().unwrap()) - .unwrap(), - ) - .unwrap(), - public_key: PublicKey::from( - &Secret::from_bytes( - &STANDARD - .decode(json_content["private_key"].as_str().unwrap()) - .unwrap(), - ) - .unwrap(), - ), + &STANDARD.decode(json_content["private_key"].as_str()?).ok()?, + )?, + public_key: PublicKey::from(&Secret::from_bytes( + &STANDARD.decode(json_content["private_key"].as_str()?).ok()?, + )?), interactables: Arc::new(RwLock::new(Vec::new())), connections: Arc::new(RwLock::new(HashMap::new())), }; @@ -346,7 +340,7 @@ pub async fn load(name: &String) -> Option> { file_util::get_children(&format!("communities/{}/interactables/", name)); for file in interactable_files { if file.contains(".json") { - let name = file.split('.').next().unwrap().to_string(); + let Some(name) = file.split('.').next().map(str::to_string) else { continue }; let interactable: Box = registry::load(comarc.clone(), String::new(), name).await; comarc.add_interactable(Arc::new(interactable)).await; diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 685d9d8..b607a28 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -52,7 +52,9 @@ impl CommunityConnection { pub async fn send_message(&self, message: &CommunicationValue) { let mut sender = self.sender.write().await; // Access the SplitSink let message_text = Message::Text(Utf8Bytes::from(message.to_json().to_string())); - sender.send(message_text).await.unwrap(); // Send the message via the SplitSink + if let Err(error) = sender.send(message_text).await { + log::error!("failed to send community message: {error}"); + } } pub async fn get_community(&self) -> Option> { self.community.read().await.clone() @@ -94,14 +96,15 @@ impl CommunityConnection { } } async fn handle_function(&self, cv: CommunicationValue) { - let name = cv.get_data(DataType::Name).unwrap().as_str().unwrap(); - let path = cv.get_data(DataType::Path).unwrap().as_str().unwrap(); - let function = cv.get_data(DataType::Function).unwrap().as_str().unwrap(); + let Some(name) = cv.get_data(DataType::Name).as_str() else { return }; + let Some(path) = cv.get_data(DataType::Path).as_str() else { return }; + let Some(function) = cv.get_data(DataType::Function).as_str() else { return }; let result = self .get_community() .await - .unwrap() + .ok_or(()) + .unwrap_or_else(|_| return) .run_function(self.get_user_id().await, name, path, function, &cv) .await; @@ -364,13 +367,9 @@ impl CommunityConnection { pub async fn handle_close(self: Arc) { if self.is_identified().await { if self.get_user_id().await != 0 { - self.community - .read() - .await - .as_ref() - .unwrap() - .remove_connection(self.clone()) - .await; + if let Some(community) = self.community.read().await.as_ref() { + community.remove_connection(self.clone()).await; + } } } } diff --git a/communities/src/interactables/category.rs b/communities/src/interactables/category.rs index f2836ed..2896e8c 100644 --- a/communities/src/interactables/category.rs +++ b/communities/src/interactables/category.rs @@ -30,14 +30,13 @@ impl Category { .find(|child| child.get_name() == &name) .cloned() } else { - let sub_module = path.split("/").next().unwrap(); + let sub_module = path.split('/').next()?; let next = self .children .iter() - .find(|child| child.get_name() == sub_module) - .unwrap(); + .find(|child| child.get_name() == sub_module)?; if next.get_codec() == "category" { - let next_cat = next.as_any().downcast_ref::().unwrap(); + let next_cat = next.as_any().downcast_ref::()?; next_cat.get_child(path, name) } else { Some(next.clone()) diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index dc43347..f35935a 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -4,54 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } - -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" -tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 4489cdf..87b3954 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -5,76 +5,24 @@ edition = "2024" [features] legacy-commands = [ - "dep:iota-logger", - "dep:iota-storage", - "dep:iota-util", - "dep:mtp", - "dep:omikron-connector", ] [dependencies] -iota-logger = { path = "../iota-logger", optional = true } iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage", optional = true } iota-terms = { path = "../iota-terms" } -iota-util = { path = "../iota-util", optional = true } iota-ipc = { path = "../iota-ipc" } -iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } -omikron-connector = { path = "../omikron-connector", optional = true } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" chrono = "0.4.43" crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" once_cell = "1.21.3" open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" unicode-width = "0.2" [dev-dependencies] diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 628134f..885bd65 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -94,19 +94,19 @@ impl Screen for TermsCheckerScreen { height: content_height, }); let eula_text = if size.width < 70 { - "EULA ¹ (https://legal.tensamin.net/eula/)" + "EULA ¹ (https://legal.methanium.net/tensamin/eula)" } else { - "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" + "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)" }; let tos_text = if size.width < 72 { - "ToS ² (https://legal.tensamin.net/terms-of-service/)" + "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)" } else { - "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" + "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)" }; let pp_text = if size.width < 68 { - "PP ² (https://legal.tensamin.net/privacy-policy/)" + "PP ² (https://legal.methanium.net/tensamin/privacy-policy)" } else { - "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" + "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)" }; let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = if size.width > 143 { diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index 2dc502e..21e3247 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -201,7 +201,7 @@ impl Screen for TermsUpdaterScreen { if self.eula_future { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹³ (https://legal.tensamin.net/eula/newest/)", + "EULA ¹³ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -214,7 +214,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)", + "End User Licence Agreement ¹³ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -229,14 +229,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹ (https://legal.tensamin.net/eula/newest/)", + "EULA ¹ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, )); } else { text_lines.push(checkbox( - "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", + "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -250,7 +250,7 @@ impl Screen for TermsUpdaterScreen { if self.tos_future { if size.width < 80 { text_lines.push(checkbox( - "ToS ²³ (https://legal.tensamin.net/tos/newest/)", + "ToS ²³ (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -263,7 +263,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)", + "Terms of Service ²³ (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -278,14 +278,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "ToS ² (https://legal.tensamin.net/tos/newest/)", + "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, )); } else { text_lines.push(checkbox( - "Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", + "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -299,7 +299,7 @@ impl Screen for TermsUpdaterScreen { if self.pp_future { if size.width < 80 { text_lines.push(checkbox( - "PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", + "PP ²³ (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, @@ -312,7 +312,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)", + "Privacy Policy ²³ (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, @@ -327,14 +327,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "PP ² (https://legal.tensamin.net/privacy-policy/newest/)", + "PP ² (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, )); } else { text_lines.push(checkbox( - "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", + "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs index 298a0df..a75c5a5 100644 --- a/iota-connection/src/relay.rs +++ b/iota-connection/src/relay.rs @@ -1,8 +1,9 @@ use iota_util::route_target::RouteTarget; use mtp::codec::{ - CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap, + CommunicationValue, ProtectionPolicy, RelayError, RelayOpenOptions, SignaturePolicy, TypeMap, VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, - open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id, + open_relay_content_with_keyrings, open_relay_metadata_with_without_replay, + relay_metadata_claimed_signer_id, }; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::fmt; @@ -157,13 +158,12 @@ where .type_map() .cloned() .ok_or(RelayValidationError::MissingTypeMap)?; - let metadata = open_relay_metadata_with( + let metadata = open_relay_metadata_with_without_replay( frame, &[keyring], Some(claimed_signer), move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()), - RELAY_PROTECTION_POLICY, - None, + RelayOpenOptions::new(RELAY_PROTECTION_POLICY), )?; let context = VerifiedRelayContext { diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 03250d9..f28cc45 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -13,17 +13,6 @@ iota-terms = { path = "../iota-terms" } iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } -omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } -web-ui = { path = "../web-ui" } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -dashmap = "6.1.0" -json = "*" -once_cell = "1.21.3" pnet = "0.35.0" -ratatui = "0.30.0" -reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index de88a04..556ebe5 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -2,9 +2,8 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; -use iota_cli::screens::terms_updater::{TermsUpdaterScreen, UpdateDecision}; use iota_cli::ui::UI; -use iota_terms::{Doc, TermsType as Type, get_current_docs, get_newest_docs}; +use iota_terms::{Doc, TermsType as Type, get_current_docs}; use iota_util::file_util::{load_file, save_file}; use tokio::sync::oneshot; @@ -12,9 +11,11 @@ pub async fn check(ui: Arc) -> Result<(bool, bool), String> { let mut state = ConsentState::load_state(); ensure_initial_consent(ui.clone(), &mut state).await?; - // A mandatory document update is a hard bootstrap gate. In particular, - // refusing it must not allow service setup or daemon access to continue. - ensure_updates(ui, &mut state).await?; + /* + * The raw legal endpoint exposes only the current document. Restore this + * flow when it provides future versions that users can accept early. + */ + // ensure_updates(ui, &mut state).await?; state = state.sanitize(); state.save_state(); @@ -42,11 +43,9 @@ async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result return Ok(()); } - let docs = get_current_docs().await; - if docs.is_none() { - return Err("Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string()); - } - let (current_eula, current_tos, current_privacy) = docs.unwrap(); + let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| { + "Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string() + })?; let (tx, rx) = oneshot::channel(); @@ -73,6 +72,7 @@ async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result UserChoice::Deny => Ok(()), } } +/* async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { return Ok(()); @@ -115,6 +115,8 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), Str state.save_state(); Ok(()) } +*/ +/* fn apply_future_updates( state: &mut ConsentState, result: UserChoice, @@ -225,6 +227,7 @@ async fn get_updates() -> Option<( None } } +*/ #[derive(Debug, Clone)] pub struct ConsentState { @@ -299,7 +302,7 @@ impl ConsentState { if let Some(eula) = &self.eula { file_out.push_str(&format!("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.methanium.net/tensamin/eula\ \nEULA={}\ \nEULA-VERSION={}\ \nEULA-HASH={}\ @@ -309,7 +312,7 @@ impl ConsentState { && let Some(tos) = &self.tos { file_out.push_str(&format!("\ - \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.tensamin.net/tos/\ + \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.methanium.net/tensamin/terms-of-service\ \nTerms-of-Service={}\ \nTerms-of-Service-VERSION={}\ \nTerms-of-Service-HASH={}\ @@ -319,7 +322,7 @@ impl ConsentState { && let Some(pp) = &self.privacy { file_out.push_str(&format!("\ - \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy/\ + \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.methanium.net/tensamin/privacy-policy\ \nPrivacy-Policy={}\ \nPrivacy-Policy-VERSION={}\ \nPrivacy-Policy-HASH={}\ @@ -327,7 +330,7 @@ impl ConsentState { } } else { file_out.push_str("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.tensamin.net/eula/\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.methanium.net/tensamin/eula\ \nEULA=false\ "); } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 4eb0b7c..1aec94c 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -13,9 +13,8 @@ iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -dashmap = "6.1.0" libc = "0.2" -sysinfo = "0.39.0" +sysinfo = "0.38.0" serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 29de9e1..87a5636 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -7,7 +7,6 @@ edition = "2024" iota-daemon-lib = { path = "../iota-daemon-lib" } iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } @@ -15,4 +14,3 @@ iota-terms = { path = "../iota-terms" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index cfe0337..d2ff2c6 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -102,15 +102,18 @@ async fn main() -> ExitCode { let (state_tx, state_rx) = watch::channel(runtime.snapshot()); runtime.set_startup_phase(StartupPhase::LoadingUsers); - if tokio::task::spawn_blocking(user_manager::load_users_sync) - .await - .ok() - .and_then(Result::ok) - .is_none() - { + let storage_error = match iota_storage::util::db::verify_and_backup_database() { + Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync) + .await + .map_err(|error| format!("user storage task failed: {error}")) + .and_then(|result| result.map_err(|error| error.to_string())) + .err(), + Err(error) => Some(error.to_string()), + }; + if let Some(error) = storage_error { runtime.set_component_failed( iota_ipc::ComponentId::Storage, - "user storage failed to load".into(), + format!("user storage failed to load: {error}"), ); } else { runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None); diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 29724d6..0de2518 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -19,6 +19,10 @@ pub fn install_linux_bundle(bundle: &Path) -> Result<()> { install_linux_bundle_with_operator(bundle, None) } +pub fn bootstrap_linux_bundle(bundle: &Path, operator: Option<&str>) -> Result<()> { + install_linux_bundle_with_operator(bundle, operator) +} + pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> { if std::env::consts::OS != "linux" { bail!("Linux systemd bundles are not supported on this platform"); @@ -118,6 +122,12 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) ], )?; run("systemd-sysusers", &[])?; + for directory in ["/var/lib/iota", "/var/cache/iota", "/var/log/iota"] { + run( + "install", + &["-d", "-m", "0750", "-o", "iota", "-g", "iota", directory], + )?; + } if let Some(operator) = operator { run("usermod", &["-aG", "iota-operators", operator])?; } else { diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs index f8ee611..c6daa2b 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -15,6 +15,7 @@ pub const COMMANDS: &[&str] = &[ "config get", "config set ", "config reload", + "health", "components", "logs", "update check", @@ -84,6 +85,7 @@ pub fn parse(line: &str) -> Option { }), ["config", "reload"] => Some(LocalRequest::ReloadConfig), ["omikron", "status"] => Some(LocalRequest::GetOmikronStatus), + ["health"] => Some(LocalRequest::ListComponents), ["components"] => Some(LocalRequest::ListComponents), ["logs"] => Some(LocalRequest::GetLogs { limit: 100 }), ["update", "check"] => Some(LocalRequest::CheckUpdate), @@ -229,6 +231,14 @@ mod tests { )); } + #[test] + fn parses_health() { + assert!(matches!( + parse("health"), + Some(LocalRequest::ListComponents) + )); + } + #[test] fn parses_users_show() { let req = parse("users show 42").unwrap(); diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index d7d336a..b704910 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -355,7 +355,7 @@ fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) - let key_str = key.to_string(); match value { - DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), + DataValue::Str(s) => format!("{}=\"{}\"", key_str, abbreviate_string(&s)), DataValue::Container(inner) => { let inner_formatted = format_data_container(inner, version.clone()); @@ -386,7 +386,7 @@ fn format_array(arr: Vec, version: Version) -> String { let parts: Vec = arr .into_iter() .map(|value| match value { - DataValue::Str(s) => format!("\"{}\"", s), + DataValue::Str(s) => format!("\"{}\"", abbreviate_string(&s)), DataValue::Container(inner) => { let inner_formatted = format_data_container(inner, version.clone()); @@ -412,6 +412,31 @@ fn format_array(arr: Vec, version: Version) -> String { parts.join(", ") } +fn abbreviate_string(value: &str) -> String { + const EDGE_LENGTH: usize = 4; + + let chars: Vec = value.chars().collect(); + if chars.len() <= EDGE_LENGTH * 2 { + return value.to_string(); + } + + let prefix: String = chars.iter().take(EDGE_LENGTH).collect(); + let suffix: String = chars.iter().rev().take(EDGE_LENGTH).rev().collect(); + format!("{prefix}...{suffix}") +} + +#[cfg(test)] +mod tests { + use super::abbreviate_string; + + #[test] + fn abbreviates_only_strings_longer_than_eight_characters() { + assert_eq!(abbreviate_string("12345678"), "12345678"); + assert_eq!(abbreviate_string("123456789"), "1234...6789"); + assert_eq!(abbreviate_string("YWJjZGVmZ2hpag=="), "YWJj...ag=="); + } +} + #[macro_export] macro_rules! log_cv { ($kind:expr, $cv:expr) => { diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs index 9143ea1..0083012 100644 --- a/iota-paths/src/lib.rs +++ b/iota-paths/src/lib.rs @@ -70,20 +70,37 @@ pub struct IotaPaths { impl IotaPaths { pub fn resolve(scope: Scope) -> Result { let defaults = Defaults::for_scope(scope)?; - let config_dir = override_first(&["IOTA_CONFIG_DIR"])?.unwrap_or(defaults.config_dir); + let data_root = if scope == Scope::User { + absolute_env("IOTA_DATA_ROOT")? + } else { + None + }; + let config_dir = override_first(&["IOTA_CONFIG_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("config"))) + .unwrap_or(defaults.config_dir); // IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it // exactly like every other override; do not hide an invalid value. - let state_dir = - override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])?.unwrap_or(defaults.state_dir); - let cache_dir = override_first(&["IOTA_CACHE_DIR"])?.unwrap_or(defaults.cache_dir); - let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])?.or(defaults.runtime_dir); - let log_dir = override_first(&["IOTA_LOG_DIR"])?.unwrap_or(defaults.log_dir); + let state_dir = override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("state"))) + .unwrap_or(defaults.state_dir); + let cache_dir = override_first(&["IOTA_CACHE_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("cache"))) + .unwrap_or(defaults.cache_dir); + let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("runtime"))) + .or(defaults.runtime_dir); + let log_dir = override_first(&["IOTA_LOG_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("logs"))) + .unwrap_or(defaults.log_dir); let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])? + .or_else(|| data_root.as_ref().map(|root| root.join("web"))) .unwrap_or(defaults.asset_dir); - let install_root = override_first(&["IOTA_INSTALL_ROOT"])?.unwrap_or(defaults.install_root); + let install_root = override_first(&["IOTA_INSTALL_ROOT"])? + .or_else(|| data_root.as_ref().map(|root| root.join("bin"))) + .unwrap_or(defaults.install_root); let config_file = override_first(&["IOTA_CONFIG_FILE"])? .unwrap_or_else(|| config_dir.join("config.yaml")); - let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint)?; + let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint, data_root.as_deref())?; let runtime_dir = runtime_dir.or_else(|| match &ipc_endpoint { IpcEndpoint::UnixSocket(path) => path.parent().map(Path::to_path_buf), IpcEndpoint::WindowsPipe(_) => None, @@ -136,10 +153,20 @@ impl IotaPaths { &self.cache_dir, &self.log_dir, ] { - create_directory(directory, self.scope == Scope::User)?; + create_directory(directory, self.scope == Scope::User).map_err(|error| { + std::io::Error::new( + error.kind(), + format!("cannot prepare {}: {error}", directory.display()), + ) + })?; } if let Some(runtime) = &self.runtime_dir { - create_directory(runtime, self.scope == Scope::User)?; + create_directory(runtime, self.scope == Scope::User).map_err(|error| { + std::io::Error::new( + error.kind(), + format!("cannot prepare {}: {error}", runtime.display()), + ) + })?; } Ok(()) } @@ -337,12 +364,19 @@ fn override_first(names: &[&'static str]) -> Result, PathError> } Ok(None) } -fn resolve_ipc(scope: Scope, default: Option) -> Result { +fn resolve_ipc( + scope: Scope, + default: Option, + data_root: Option<&Path>, +) -> Result { #[cfg(unix)] { if let Some(path) = absolute_env("IOTA_SOCKET")? { return Ok(IpcEndpoint::UnixSocket(path)); } + if let Some(root) = data_root { + return Ok(IpcEndpoint::UnixSocket(root.join("runtime/iota.sock"))); + } if scope == Scope::User { return Err(PathError::MissingRequiredOverride("IOTA_SOCKET")); } @@ -451,9 +485,13 @@ pub fn daemon_endpoints() -> Vec { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, + }; static TEST_ID: AtomicU64 = AtomicU64::new(0); + static ENVIRONMENT: Mutex<()> = Mutex::new(()); #[test] fn system_layout_is_fhs() { let p = IotaPaths::resolve(Scope::System).unwrap(); @@ -501,4 +539,31 @@ mod tests { assert!(state.join("path-layout-v2.json").is_file()); let _ = std::fs::remove_dir_all(root); } + + #[test] + fn data_root_keeps_unmanaged_user_paths_together() { + let _guard = ENVIRONMENT.lock().unwrap(); + let root = std::env::temp_dir().join(format!( + "iota-data-root-test-{}-{}", + std::process::id(), + TEST_ID.fetch_add(1, Ordering::Relaxed) + )); + unsafe { + std::env::set_var("IOTA_DATA_ROOT", &root); + } + let paths = IotaPaths::resolve(Scope::User).unwrap(); + unsafe { + std::env::remove_var("IOTA_DATA_ROOT"); + } + + assert_eq!(paths.config_file, root.join("config/config.yaml")); + assert_eq!(paths.state_dir, root.join("state")); + assert_eq!(paths.cache_dir, root.join("cache")); + assert_eq!(paths.log_dir, root.join("logs")); + assert_eq!(paths.runtime_dir, Some(root.join("runtime"))); + assert_eq!( + paths.ipc_endpoint, + IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")) + ); + } } diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index 8c9ab8a..4db4406 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -12,6 +12,4 @@ dashmap = "6.1.0" once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" -sysinfo = "0.39.0" -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -serde = { version = "1", features = ["derive"] } +sysinfo = "0.38.0" diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index caf6417..a579f7f 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -5,33 +5,17 @@ edition = "2024" [dependencies] iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -aes-gcm = "0.10.3" base64 = "0.22.1" -hex = "*" -hkdf = "0.12.4" json = "*" arc-swap = "1" once_cell = "1.21.3" r2d2 = "0.8" serde = { version = "1", features = ["derive"] } -serde_json = "1" serde_yaml = "0.9" thiserror = "2" rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" rusqlite = "0.40.0" -sha2 = "0.11.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 60b1cda..62b65e9 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -149,10 +149,8 @@ pub fn save_config_to(path: &Path) { return; } } - let temporary = path.with_extension("yaml.tmp"); - if let Err(error) = fs::write(&temporary, yaml).and_then(|_| fs::rename(&temporary, path)) { + if let Err(error) = iota_util::atomic_file::replace(path, yaml.as_bytes(), 3) { eprintln!("Cannot save {}: {error}", path.display()); - let _ = fs::remove_file(temporary); } } } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 01ae3a5..46363cf 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -19,7 +19,7 @@ impl ManageConnection for SqliteManager { fn connect(&self) -> Result { let path = db_file_path(DB_NAME); let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; conn.busy_timeout(Duration::from_millis(250))?; Ok(conn) } @@ -55,6 +55,52 @@ where f(&conn) } +/* Verify the persistent database before the pool is initialized. A corrupt + * database is moved aside rather than opened again, preserving material for + * operator recovery while allowing the daemon to report the failed storage. */ +pub fn verify_and_backup_database() -> Result<(), StorageError> { + let storage_dir = iota_util::file_util::storage_directory(); + std::fs::create_dir_all(&storage_dir)?; + let path = storage_dir.join(format!("{DB_NAME}.sqlite3")); + if !path.exists() { + return Ok(()); + } + + let connection = Connection::open(&path)?; + connection.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; + connection.execute_batch("PRAGMA wal_checkpoint(FULL);")?; + let integrity: String = connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + drop(connection); + if integrity != "ok" { + let recovery = storage_dir.join("recovery"); + std::fs::create_dir_all(&recovery)?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + for suffix in ["", "-wal", "-shm"] { + let source = PathBuf::from(format!("{}{}", path.display(), suffix)); + if source.exists() { + let destination = recovery.join(format!("{DB_NAME}.sqlite3.{timestamp}{suffix}")); + std::fs::rename(source, destination)?; + } + } + return Err(StorageError::Other(format!( + "database integrity check failed ({integrity}); moved database files to {}", + recovery.display() + ))); + } + + let backup_dir = storage_dir.join("backups"); + std::fs::create_dir_all(&backup_dir)?; + let backup = backup_dir.join(format!("{DB_NAME}.sqlite3")); + let temporary = backup_dir.join(format!(".{DB_NAME}.sqlite3.tmp")); + std::fs::copy(&path, &temporary)?; + std::fs::File::open(&temporary)?.sync_all()?; + std::fs::rename(temporary, backup)?; + Ok(()) +} + fn db_file_path(db_name: &str) -> PathBuf { let storage_dir = iota_util::file_util::storage_directory(); // Creating storage belongs to initialization/connection setup, never to a diff --git a/iota-terms/Cargo.toml b/iota-terms/Cargo.toml index 607dce1..c53c3cf 100644 --- a/iota-terms/Cargo.toml +++ b/iota-terms/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } -json = "*" reqwest = "0.13.2" +tokio = { version = "1.50.0", features = ["macros"] } diff --git a/iota-terms/src/consent.rs b/iota-terms/src/consent.rs index ef968f9..601ab1f 100644 --- a/iota-terms/src/consent.rs +++ b/iota-terms/src/consent.rs @@ -40,7 +40,7 @@ impl ConsentRecord { } fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool { - matches!(value, Some((version, hash)) if version == &doc.get_version() && hash == &doc.get_hash()) + matches!(value, Some((_, hash)) if hash == &doc.get_hash()) } pub fn load(state_dir: &Path) -> ConsentRecord { @@ -82,8 +82,5 @@ pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> { text.push_str(&format!("{name}={version}:{hash}\n")); } } - let path = state_dir.join(FILE_NAME); - let temporary = state_dir.join(format!(".{FILE_NAME}.{}.tmp", std::process::id())); - fs::write(&temporary, text)?; - fs::rename(temporary, path) + iota_util::atomic_file::replace(&state_dir.join(FILE_NAME), text.as_bytes(), 3) } diff --git a/iota-terms/src/doc.rs b/iota-terms/src/doc.rs index 0c2e0d2..7510407 100644 --- a/iota-terms/src/doc.rs +++ b/iota-terms/src/doc.rs @@ -1,7 +1,5 @@ -use json::{JsonValue, object::Object}; - use crate::terms_getter::Type; -use iota_util::file_util::load_file; +use iota_util::crypto_helper::hex_hash; #[derive(Clone, Debug, PartialEq, Eq)] #[allow(unused)] @@ -23,15 +21,24 @@ impl Doc { } } + pub fn from_raw(doc_type: Type, content: String, timestamp: u64) -> Doc { + Doc::new( + timestamp.to_string(), + hex_hash(&content), + doc_type, + timestamp, + ) + } + pub fn equals_some(&self, other: &Option) -> bool { if let Some(other) = other { - self.get_version() == other.get_version() && self.get_hash() == other.get_hash() + self.equals(other) } else { false } } pub fn equals(&self, other: &Self) -> bool { - self.get_version() == other.get_version() && self.get_hash() == other.get_hash() + self.get_hash() == other.get_hash() } pub fn get_version(&self) -> String { @@ -41,34 +48,36 @@ impl Doc { self.hash.clone() } pub fn get_time(&self) -> u64 { - self.timestamp.clone() + self.timestamp } - pub fn get_content(&self) -> String { - load_file( - format!("docs/{}/", self.doc_type.to_str()).as_str(), - format!("{}.md", self.version).as_str(), - ) + #[cfg(test)] + fn timestamp(&self) -> u64 { + self.timestamp } +} - pub fn to_json(&self) -> JsonValue { - let mut json = JsonValue::new_object(); +#[cfg(test)] +mod tests { + use super::Doc; + use crate::terms_getter::Type; + use iota_util::crypto_helper::hex_hash; - let _ = json.insert("version", self.version.clone()); - let _ = json.insert("hash", self.hash.clone()); - let _ = json.insert("unix", self.timestamp.clone()); + #[test] + fn raw_documents_use_a_local_timestamp_and_content_hash() { + let content = "# EULA\n".to_owned(); + let document = Doc::from_raw(Type::EULA, content.clone(), 123); - json + assert_eq!(document.doc_type, Type::EULA); + assert_eq!(document.get_version(), "123"); + assert_eq!(document.timestamp(), 123); + assert_eq!(document.get_hash(), hex_hash(&content)); } - pub fn from_json(doc_type: Type, json: Object) -> Option { - let hash = json.get("hash")?.as_str()?.to_string(); - let version = json.get("version")?.as_str()?.to_string(); - let timestamp = json.get("unix")?.as_u64()?; - Some(Doc { - version, - hash, - doc_type, - timestamp, - }) + #[test] + fn matching_documents_ignore_the_fetch_timestamp() { + let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123); + let later = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 456); + + assert!(earlier.equals(&later)); } } diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index e878f9b..58e6de0 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -4,7 +4,7 @@ pub mod terms_getter; pub use terms_getter::Type as TermsType; pub use terms_getter::get_current_docs; pub use terms_getter::get_link; -pub use terms_getter::get_newest_docs; +// pub use terms_getter::get_newest_docs; pub use terms_getter::get_newest_link; pub use terms_getter::get_terms; diff --git a/iota-terms/src/terms_getter.rs b/iota-terms/src/terms_getter.rs index 65de9b1..9ccfffa 100755 --- a/iota-terms/src/terms_getter.rs +++ b/iota-terms/src/terms_getter.rs @@ -1,6 +1,5 @@ -use json::JsonValue::Object; - use crate::doc::Doc; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Type { @@ -13,8 +12,8 @@ impl Type { pub fn to_str(&self) -> &str { match self { Self::EULA => "eula", - Self::TOS => "tos", - Self::PP => "privacy", + Self::TOS => "terms-of-service", + Self::PP => "privacy-policy", } } pub fn to_string(&self) -> String { @@ -27,79 +26,73 @@ impl Type { } pub fn get_link(terms_type: Type) -> String { - format!("https://legal.tensamin.net/{}/", terms_type.to_str()) + format!( + "https://legal.methanium.net/tensamin/{}", + terms_type.to_str() + ) } + +/* + * The legal service exposes only its latest raw documents. Keep this helper + * for the dormant pre-emptive-acceptance UI until it has a source of future + * document versions again. + */ pub fn get_newest_link(terms_type: Type) -> String { - format!("https://legal.tensamin.net/{}/newest/", terms_type.to_str()) + get_link(terms_type) } pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { - let body = reqwest::get("https://legal.tensamin.net/api/current/") - .await - .ok()? - .text() - .await - .ok()?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + let (eula, tos, privacy) = tokio::join!( + get_terms(Type::EULA), + get_terms(Type::TOS), + get_terms(Type::PP), + ); - let json = json::parse(&body).ok()?; - - if let Object(eula) = &json["eula"] { - if let Object(tos) = &json["tos"] { - if let Object(pp) = &json["pp"] { - Some(( - Doc::from_json(Type::EULA, eula.clone())?, - Doc::from_json(Type::TOS, tos.clone())?, - Doc::from_json(Type::PP, pp.clone())?, - )) - } else { - None - } - } else { - None - } - } else { - None - } + Some(( + Doc::from_raw(Type::EULA, eula?, timestamp), + Doc::from_raw(Type::TOS, tos?, timestamp), + Doc::from_raw(Type::PP, privacy?, timestamp), + )) } +/* + * Future documents are unavailable from the raw endpoint. Restore this API + * with the pre-emptive-acceptance flow when the service provides them again. + * pub async fn get_newest_docs() -> Option<(Doc, Doc, Doc)> { - let body = reqwest::get("https://legal.tensamin.net/api/newest/") - .await - .ok()? - .text() - .await - .ok()?; - - let json = json::parse(&body).ok()?; - - if let Object(eula) = &json["eula"] { - if let Object(tos) = &json["tos"] { - if let Object(pp) = &json["pp"] { - Some(( - Doc::from_json(Type::EULA, eula.clone())?, - Doc::from_json(Type::TOS, tos.clone())?, - Doc::from_json(Type::PP, pp.clone())?, - )) - } else { - None - } - } else { - None - } - } else { - None - } + None } +*/ + pub async fn get_terms(terms_type: Type) -> Option { - let body = reqwest::get(format!( - "https://legal.tensamin.net/api/text/{}/", + reqwest::get(format!( + "https://legal.methanium.net/tensamin/{}/raw", terms_type.to_str() )) .await .ok()? .text() .await - .ok()?; + .ok() +} - Some(body) +#[cfg(test)] +mod tests { + use super::{Type, get_link}; + + #[test] + fn maps_document_types_to_tensamin_raw_document_names() { + assert_eq!(Type::EULA.to_str(), "eula"); + assert_eq!(Type::TOS.to_str(), "terms-of-service"); + assert_eq!(Type::PP.to_str(), "privacy-policy"); + } + + #[test] + fn links_to_the_tensamin_document_page() { + assert_eq!( + get_link(Type::TOS), + "https://legal.methanium.net/tensamin/terms-of-service" + ); + } } diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 2f6bf04..8467afd 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -4,31 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -iota-logger = { path = "../iota-logger" } iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -json = "*" -pnet = "0.35.0" -ratatui = "0.30.0" -reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.39.0" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -zip = "6.0.0" -aes-gcm = "0.10.3" -base64 = "0.22.1" -rand_core = { version = "0.6", features = ["getrandom", "std"] } sha2 = "0.11.0" -x448 = { version = "*" } -hkdf = "0.12.4" -once_cell = "1.21.3" hex = "*" serde = "1.0.228" tempfile = "3.27.0" anyhow = "1.0.102" -semver = "1.0.28" ed25519-dalek = "2.2.0" serde_json = "1.0" diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 49bfe51..676d02f 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -11,9 +11,12 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.39.0" +sysinfo = "0.38.0" uuid = { version = "*", features = ["v4"] } walkdir = "2.5.0" zip = "6.0.0" base64 = "0.22.1" hex = "*" + +[dev-dependencies] +tempfile = "3" diff --git a/iota-util/src/atomic_file.rs b/iota-util/src/atomic_file.rs new file mode 100644 index 0000000..9800a91 --- /dev/null +++ b/iota-util/src/atomic_file.rs @@ -0,0 +1,158 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); + +/* Persist small state files without exposing a partially written version after + * a crash. Backups give operators a local recovery point for keys and config. */ +pub fn replace(path: &Path, contents: &[u8], backup_limit: usize) -> io::Result<()> { + replace_with_mode(path, contents, backup_limit, false) +} + +pub fn replace_private(path: &Path, contents: &[u8], backup_limit: usize) -> io::Result<()> { + replace_with_mode(path, contents, backup_limit, true) +} + +fn replace_with_mode( + path: &Path, + contents: &[u8], + backup_limit: usize, + private: bool, +) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + if backup_limit > 0 && path.is_file() { + create_backup(path, backup_limit)?; + } + + let temporary = temporary_path(path)?; + let write_result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + set_private_permissions(&temporary, private)?; + file.write_all(contents)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + sync_directory(parent) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path, private: bool) -> io::Result<()> { + if private { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path, _private: bool) -> io::Result<()> { + Ok(()) +} + +fn create_backup(path: &Path, backup_limit: usize) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + ) + })?; + let name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "persistent file has no name") + })?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let id = TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); + let backup = parent.join(format!( + ".{}.backup-{timestamp}-{id}", + name.to_string_lossy() + )); + fs::copy(path, &backup)?; + File::open(&backup)?.sync_all()?; + sync_directory(parent)?; + + let prefix = format!(".{}.backup-", name.to_string_lossy()); + let mut backups = fs::read_dir(parent)? + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + .collect::>(); + backups.sort_by_key(|entry| entry.file_name()); + let obsolete = backups.len().saturating_sub(backup_limit); + for entry in backups.into_iter().take(obsolete) { + fs::remove_file(entry.path())?; + } + Ok(()) +} + +fn temporary_path(path: &Path) -> io::Result { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + ) + })?; + let name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "persistent file has no name") + })?; + let id = TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); + Ok(parent.join(format!( + ".{}.{}.{}.tmp", + name.to_string_lossy(), + std::process::id(), + id + ))) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> io::Result<()> { + File::open(path)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::replace; + + #[test] + fn replace_preserves_a_previous_version_as_a_backup() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state"); + replace(&path, b"first", 2).unwrap(); + replace(&path, b"second", 2).unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + let backups = std::fs::read_dir(directory.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .contains(".state.backup-") + }) + .count(); + assert_eq!(backups, 1); + } +} diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs index 9375eb4..339dd1f 100644 --- a/iota-util/src/crypto_helper.rs +++ b/iota-util/src/crypto_helper.rs @@ -6,7 +6,10 @@ pub fn generate_keyring() -> Keyring { } pub fn keyring_to_base64(keyring: &Keyring) -> String { - STANDARD.encode(keyring.to_bytes()) + keyring + .try_to_bytes() + .map(|bytes| STANDARD.encode(bytes)) + .unwrap_or_default() } pub fn keyring_from_base64(s: &str) -> Option { @@ -15,7 +18,10 @@ pub fn keyring_from_base64(s: &str) -> Option { } pub fn public_key_bundle_to_base64(bundle: &PublicKeyBundle) -> String { - STANDARD.encode(bundle.as_bytes()) + bundle + .try_as_bytes() + .map(|bytes| STANDARD.encode(bytes)) + .unwrap_or_default() } pub fn public_key_bundle_from_base64(s: &str) -> Option { diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 1fdc888..1b87352 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,3 +1,4 @@ +pub mod atomic_file; pub mod crypto_helper; pub mod crypto_util; pub mod file_util; diff --git a/iota/Cargo.toml b/iota/Cargo.toml index b05cfe8..1fc0858 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -13,7 +13,6 @@ iota-paths = { path = "../iota-paths" } iota-terms = { path = "../iota-terms" } iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" serde_yaml = "0.9" clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index b1447a0..ca914f8 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -93,6 +93,7 @@ enum CliCommand { yes: bool, }, Components, + Health, Logs { #[arg(long, default_value_t = 100)] limit: usize, @@ -209,6 +210,12 @@ enum DaemonAction { #[arg(long)] operator: Option, }, + Bootstrap { + #[arg(long)] + bundle: String, + #[arg(long)] + operator: Option, + }, } #[derive(Args, Debug)] struct UpdateArgs { @@ -276,6 +283,10 @@ pub enum Command { bundle: String, operator: Option, }, + Bootstrap { + bundle: String, + operator: Option, + }, Status, Tasks, UsersList, @@ -329,6 +340,7 @@ pub enum Command { confirmed: bool, }, Components, + Health, Logs { limit: usize, }, @@ -388,6 +400,7 @@ impl CliInvocation { Some(CliCommand::Status) => Command::Status, Some(CliCommand::Tasks) => Command::Tasks, Some(CliCommand::Components) => Command::Components, + Some(CliCommand::Health) => Command::Health, Some(CliCommand::Completions { shell }) => Command::Completions { shell }, Some(CliCommand::Man) => Command::ManPage, Some(CliCommand::Users(users)) => match users.action { @@ -463,6 +476,9 @@ impl CliInvocation { DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService, DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, + DaemonAction::Bootstrap { bundle, operator } => { + Command::Bootstrap { bundle, operator } + } }, }; Ok(Self { @@ -541,6 +557,12 @@ mod tests { assert_eq!(invocation.command, Command::UsersList); } + #[test] + fn parses_health() { + let invocation = CliInvocation::parse(["health".into()]).unwrap(); + assert_eq!(invocation.command, Command::Health); + } + #[test] fn parses_terminal_capability_overrides() { let invocation = @@ -599,6 +621,26 @@ mod tests { ); } + #[test] + fn parses_bootstrap_operator() { + let invocation = CliInvocation::parse([ + "daemon".into(), + "bootstrap".into(), + "--bundle".into(), + "release.zip".into(), + "--operator".into(), + "alice".into(), + ]) + .unwrap(); + assert_eq!( + invocation.command, + Command::Bootstrap { + bundle: "release.zip".into(), + operator: Some("alice".into()), + } + ); + } + #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); diff --git a/iota/src/main.rs b/iota/src/main.rs index d5c79d8..3746707 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -40,33 +40,6 @@ async fn run() -> Result<(), StartupError> { unicode, command, } = invocation; - let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? - .ipc_endpoint - { - iota_paths::IpcEndpoint::UnixSocket(path) => path, - iota_paths::IpcEndpoint::WindowsPipe(name) => { - return Err(StartupError::Other(format!( - "Windows IPC endpoint {name} is not supported by this client build" - ))); - } - }; - let system_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System) - .map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))? - .ipc_endpoint - { - iota_paths::IpcEndpoint::UnixSocket(path) => path, - iota_paths::IpcEndpoint::WindowsPipe(name) => { - return Err(StartupError::Other(format!( - "Windows IPC endpoint {name} is not supported by this client build" - ))); - } - }; - let endpoints = daemon_setup_flow::DaemonEndpoints { - local: local_endpoint, - system: system_endpoint, - }; - match command { Command::Help => { print_help(); @@ -91,7 +64,12 @@ async fn run() -> Result<(), StartupError> { ) .map_err(|error| StartupError::Other(format!("Installation failed: {error}"))) } + Command::Bootstrap { bundle, operator } => { + iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref()) + .map_err(|error| StartupError::Other(format!("Bootstrap failed: {error}"))) + } command => { + let endpoints = resolve_endpoints()?; if matches!( command, Command::DaemonEnable { .. } @@ -123,6 +101,32 @@ async fn run() -> Result<(), StartupError> { } } +fn resolve_endpoints() -> Result { + let local = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; + let system = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; + Ok(daemon_setup_flow::DaemonEndpoints { local, system }) +} + async fn run_startup_command(command: Command) -> Result<(), StartupError> { let manager = iota_process_manager::detect() .await @@ -406,6 +410,7 @@ fn print_help() { println!(" config get Show current configuration"); println!(" config set Set a configuration value"); println!(" config reload Reload configuration"); + println!(" health Show component health"); println!(" components Show component health"); println!(" logs [--limit N] Show recent log entries"); println!(" update check Check for updates"); @@ -422,6 +427,7 @@ fn print_help() { println!(" daemon restart-service Restart the daemon service"); println!(" daemon stop-service Stop the daemon service"); println!(" daemon install Install from a bundle"); + println!(" daemon bootstrap Install and enable a Linux systemd bundle"); println!(" help Show this help message"); println!(" completions Generate shell completions"); println!(" man Show the man page"); @@ -630,6 +636,7 @@ async fn run_command( Command::ConfigGet => LocalRequest::GetConfig, Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value }, Command::ConfigReload => LocalRequest::ReloadConfig, + Command::Health => LocalRequest::ListComponents, Command::Components => LocalRequest::ListComponents, Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::UpdateCheck => LocalRequest::CheckUpdate, @@ -657,6 +664,7 @@ async fn run_command( | Command::Completions { .. } | Command::ManPage | Command::Install { .. } + | Command::Bootstrap { .. } | Command::TermsStatus { .. } | Command::TermsShow { .. } | Command::TermsAccept { .. } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 31796b0..5902891 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -24,8 +24,4 @@ tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" -hex = "*" -rand = "0.8" rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.11.0" -x448 = { version = "*" } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 1e1f5c7..1aae831 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -39,11 +39,18 @@ use iota_util::route_target::RouteTarget; const IOTA_KEYRING_PATH: &str = "iota.mk"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Must be called by the daemon before any Omikron connection is attempted. -/// It keeps identity material independent from the working directory. +/* + * Keeps identity and pinned Omikron key files independent from the process + * working directory, so restarts use the same trusted material. + */ pub fn configure_identity_path(path: PathBuf) { + let key_path = path.parent().map(|parent| parent.join("omikron.mpkb")); let _ = IDENTITY_PATH.set(path); + if let Some(key_path) = key_path { + let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path); + } } fn identity_path() -> &'static Path { IDENTITY_PATH @@ -51,7 +58,51 @@ fn identity_path() -> &'static Path { .map(PathBuf::as_path) .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) } -const OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb"; +fn omikron_public_key_path() -> &'static Path { + OMIKRON_PUBLIC_KEY_PATH + .get() + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new("omikron.mpkb")) +} + +fn save_keyring(keyring: &Keyring, path: &Path) -> Result<(), String> { + let temporary = serialization_path(path)?; + mtp::files::save_keyring_raw(keyring, &temporary) + .map_err(|error| format!("serialize keyring: {error}"))?; + let bytes = + std::fs::read(&temporary).map_err(|error| format!("read serialized keyring: {error}")); + let _ = std::fs::remove_file(&temporary); + let bytes = bytes?; + iota_util::atomic_file::replace_private(path, &bytes, 3) + .map_err(|error| format!("write {}: {error}", path.display())) +} + +fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { + let temporary = serialization_path(path)?; + mtp::files::save_public_key_bundle(key, &temporary) + .map_err(|error| format!("serialize Omikron public key: {error}"))?; + let bytes = std::fs::read(&temporary) + .map_err(|error| format!("read serialized Omikron public key: {error}")); + let _ = std::fs::remove_file(&temporary); + let bytes = bytes?; + iota_util::atomic_file::replace(path, &bytes, 3) + .map_err(|error| format!("write {}: {error}", path.display())) +} + +fn serialization_path(path: &Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + let name = path + .file_name() + .ok_or_else(|| format!("{} has no file name", path.display()))?; + Ok(parent.join(format!( + ".{}.serialize-{}", + name.to_string_lossy(), + Uuid::new_v4() + ))) +} + const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); @@ -434,7 +485,7 @@ impl OmikronConnection { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - if let Err(e) = mtp::files::save_keyring_raw(&keyring, path) { + if let Err(e) = save_keyring(&keyring, path) { log!("Failed to persist {}: {}", path.display(), e); } @@ -469,21 +520,29 @@ impl OmikronConnection { let port: u16 = port_str .parse() .map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?; - let public_key = mtp::files::load_public_key_bundle(OMIKRON_PUBLIC_KEY_PATH) + let key_path = omikron_public_key_path(); + let public_key = mtp::files::load_public_key_bundle(key_path) .map_err(|e| { format!( - "Failed to load Omikron public key bundle from {}: {}. Obtain {} from the Omikron operator and place it in the working directory.", - OMIKRON_PUBLIC_KEY_PATH, e, OMIKRON_PUBLIC_KEY_PATH + "Failed to load Omikron public key bundle from {}: {}. Obtain {} from the Omikron operator and place it at that path.", + key_path.display(), e, key_path.display() ) })?; return Ok((host, port, public_key)); } - let cached_key = mtp::files::load_public_key_bundle(OMIKRON_PUBLIC_KEY_PATH).ok(); + let key_path = omikron_public_key_path(); + let cached_key = mtp::files::load_public_key_bundle(key_path).ok(); let cached_host_port = { let conf = CONFIG.load(); match (&conf.omikron_host, conf.omikron_port) { - (Some(host), Some(port)) => Some((host.clone(), port)), + (Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { + Some((host.clone(), port)) + } + (Some(_), Some(_)) => { + log!("Ignoring invalid cached Omikron endpoint in Iota configuration"); + None + } _ => None, } }; @@ -504,21 +563,35 @@ impl OmikronConnection { let (host, port, public_key) = if let Some(endpoint) = discovered { match &cached_key { - Some(cached) if cached.as_bytes() != endpoint.public_key.as_bytes() => { - log!( - "Fetched Omikron public key differs from the cached {} - keeping the \ - cached key. Delete {} manually if this is an expected key rotation.", - OMIKRON_PUBLIC_KEY_PATH, - OMIKRON_PUBLIC_KEY_PATH - ); - (endpoint.host, endpoint.port, cached.clone()) + Some(cached) => { + let keys_match = + match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { + (Ok(cached_bytes), Ok(discovered_bytes)) => { + cached_bytes == discovered_bytes + } + _ => false, + }; + if !keys_match { + log!( + "Fetched Omikron public key differs from the cached {} - keeping the \ + cached key. Delete {} manually if this is an expected key rotation.", + key_path.display(), + key_path.display() + ); + if let Some((cached_host, cached_port)) = &cached_host_port { + (cached_host.clone(), *cached_port, cached.clone()) + } else { + return Err(format!( + "Omega returned an Omikron key that differs from {} and no validated cached endpoint is available", + key_path.display() + )); + } + } else { + (endpoint.host, endpoint.port, cached.clone()) + } } - Some(cached) => (endpoint.host, endpoint.port, cached.clone()), None => { - if let Err(e) = mtp::files::save_public_key_bundle( - &endpoint.public_key, - OMIKRON_PUBLIC_KEY_PATH, - ) { + if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) { log!("Failed to cache Omikron public key: {}", e); } (endpoint.host, endpoint.port, endpoint.public_key) @@ -1885,7 +1958,7 @@ impl OmikronConnection { )) })?; } - mtp::files::save_keyring_raw(&keyring, path).map_err(|error| { + save_keyring(&keyring, path).map_err(|error| { OmikronError::Internal(format!( "could not save new identity {}: {error}", path.display() @@ -2052,3 +2125,25 @@ impl OmikronClient for OmikronConnection { Self::is_connected(self).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn durable_keyring_save_preserves_mtp_format() { + let directory = std::env::temp_dir().join(format!("iota-keyring-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join(IOTA_KEYRING_PATH); + let keyring = crypto_helper::generate_keyring(); + + save_keyring(&keyring, &path).unwrap(); + + let loaded = mtp::files::load_keyring_raw(&path).unwrap(); + assert_eq!( + keyring.try_to_bytes().unwrap(), + loaded.try_to_bytes().unwrap() + ); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index a6933c3..1fb5337 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -4,57 +4,3 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-auth = { path = "../iota-auth" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" -tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index ad49fba..c07501f 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -9,6 +9,5 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-s bytes = "1" http = "1" iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index 988aac1..f6f6ff8 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -4,57 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -omikron-connector = { path = "../omikron-connector" } iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } actix-web = { version = "4", features = ["rustls-0_23"] } -actix-web-actors = "4" -aes-gcm = "0.10.3" -async-trait = "0.1.89" -base64 = "0.22.1" -chrono = "0.4.43" -crossterm = "*" -dashmap = "6.1.0" -futures = "*" -futures-util = "*" -hex = "*" -hkdf = "0.12.4" -hyper = { version = "1.8.1", features = [ - "capi", - "client", - "full", - "http1", - "http2", - "nightly", - "server", -] } -hyper-util = { version = "*" } -json = "*" -lazy_static = "1.5.0" -once_cell = "1.21.3" -open = "5.3.3" -pnet = "0.35.0" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -ratatui = "0.30.0" -reqwest = "0.13.2" -rusqlite = "0.40.0" rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls-pemfile = "2.2.0" serde_json = "1.0.149" -sha2 = "0.11.0" -strum = "0.28.0" -strum_macros = "0.28.0" -sysinfo = "0.39.0" tokio = { version = "1.50.0", features = ["full"] } -tokio-tungstenite = { version = "*", features = ["native-tls"] } -tungstenite = "*" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -warp = "*" -x448 = { version = "*" } -zip = "6.0.0" diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index c5c3dab..0243a61 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -128,7 +128,10 @@ async fn users_remove( return forbidden(); } - let uuid = payload.get("uuid").and_then(|v| v.as_i64()).unwrap_or(0); + let uuid = match user_id(&payload) { + Ok(uuid) => uuid, + Err(response) => return response, + }; iota_storage::users::user_manager::remove_user(uuid); iota_storage::users::user_manager::save_users(); @@ -194,7 +197,14 @@ fn success() -> HttpResponse { } fn error() -> HttpResponse { - HttpResponse::Ok().json(json!({ "type": "error" })) + HttpResponse::BadRequest().json(json!({ "type": "error" })) +} + +fn user_id(payload: &Value) -> Result { + payload + .get("uuid") + .and_then(Value::as_i64) + .ok_or_else(error) } fn is_allowed(addr: SocketAddr, ssl: bool) -> bool { @@ -208,3 +218,20 @@ fn is_allowed_req(req: &HttpRequest, ssl: bool) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_response_is_bad_request() { + assert_eq!(error().status(), actix_web::http::StatusCode::BAD_REQUEST); + } + + #[test] + fn user_id_rejects_missing_or_non_integer_uuid() { + assert!(user_id(&json!({})).is_err()); + assert!(user_id(&json!({ "uuid": "0" })).is_err()); + assert_eq!(user_id(&json!({ "uuid": 0 })).ok(), Some(0)); + } +} From 29d46d1c954c340782adaf2314853900a5e52d2c Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:21:11 +0200 Subject: [PATCH 115/119] [Add] Replyjumps --- iota-connection/src/message_handlers.rs | 125 ++++++- iota-connection/src/relay.rs | 2 - iota-state/Cargo.toml | 4 - iota-storage/Cargo.toml | 4 - iota-storage/src/storage_error.rs | 2 + iota-storage/src/util/chat_files.rs | 385 ++++++++++++++++---- iota-storage/src/util/db.rs | 86 ++++- iota-storage/src/util/relay_replay.rs | 38 +- iota-updater/Cargo.toml | 4 - iota-util/src/crypto_helper.rs | 14 - mtp-type-maps | 2 +- omikron-connector/src/omikron_connection.rs | 224 +++++++----- 12 files changed, 650 insertions(+), 240 deletions(-) diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 4be17f6..1f247aa 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -115,6 +115,9 @@ fn validate_relay_identity( pub fn apply_verified_relay_content( context: &VerifiedRelayContext, content: &VerifiedRelayContent, + accepted_at: i64, + storage_owner: i64, + sent_by_self: bool, ) -> Result<(), String> { validate_relay_identity(context, &content.content)?; let sender_id = i64::try_from(context.signer_id) @@ -125,8 +128,48 @@ pub fn apply_verified_relay_content( .map_err(|_| "Relay creation time exceeds the local storage range".to_string())?; match content.message_type.as_str() { + "MessageState" => { + let partner_id = relay_number( + &content.content, + DataType::ChatPartnerId, + &context.type_map, + ) + .and_then(|value| i64::try_from(value).ok()) + .filter(|id| *id == recipient_id) + .ok_or_else(|| "Relay MessageState has an invalid ChatPartnerId".to_string())?; + let relay_message_id = relay_string( + &content.content, + DataType::RelayMessageId, + &context.type_map, + ) + .ok_or_else(|| "Relay MessageState is missing RelayMessageId".to_string())?; + let event_at = relay_number(&content.content, DataType::EventAt, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay MessageState is missing EventAt".to_string())?; + let state = relay_string( + &content.content, + DataType::MessageState, + &context.type_map, + ) + .map(MessageState::from_str) + .filter(|state| matches!(state, MessageState::Received | MessageState::Read)) + .ok_or_else(|| "Relay MessageState has an invalid state".to_string())?; + chat_files::record_message_receipt( + storage_owner, + recipient_id, + relay_message_id, + sender_id, + &context.message_id, + state, + event_at, + now_millis_i64(), + ) + .map_err(|error| error.to_string())?; + let _ = partner_id; + Ok(()) + } "MessageSend" => { - let message = relay_string(&content.content, DataType::AppContent, &context.type_map) + let message = relay_string(&content.content, DataType::Content, &context.type_map) .ok_or_else(|| "Relay MessageSend is missing Content".to_string())?; let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) .and_then(|value| i64::try_from(value).ok()) @@ -136,20 +179,32 @@ pub fn apply_verified_relay_content( .unwrap_or_default(); let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map) .and_then(|value| i64::try_from(value).ok()); - chat_files::add_message( - u128::try_from(send_time) - .map_err(|_| "Relay MessageSend has a negative SendTime".to_string())?, - false, - recipient_id, - sender_id, - message, + let relay_message_id = relay_string( + &content.content, + DataType::RelayMessageId, + &context.type_map, + ) + .ok_or_else(|| "Relay MessageSend is missing RelayMessageId".to_string())?; + chat_files::add_message(chat_files::NewMessage { + relay_signer_id: sender_id, + relay_message_id, + authored_at: created_at, + send_time, + storage_owner, + external_user: if sent_by_self { recipient_id } else { sender_id }, + sent_by_self, + content: message, height, reply_to, - ); + origin_iota_received_at: sent_by_self.then_some(accepted_at), + destination_iota_received_at: (!sent_by_self).then_some(accepted_at), + initial_state: MessageState::Sent, + }) + .map_err(|error| error.to_string())?; Ok(()) } "MessageEdit" => { - let message = relay_string(&content.content, DataType::AppContent, &context.type_map) + let message = relay_string(&content.content, DataType::Content, &context.type_map) .ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?; let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) .and_then(|value| i64::try_from(value).ok()) @@ -217,7 +272,7 @@ pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { Ok(mutation) => mutation, Err(response) => return response, }; - let Some(content) = cv.get_data(DataType::AppContent).as_str() else { + let Some(content) = cv.get_data(DataType::Content).as_str() else { return error_response(cv, CommunicationType::ErrorInvalidData); }; @@ -265,6 +320,9 @@ pub fn handle_message_reaction(cv: &CommunicationValue, add: bool) -> Communicat match result { Ok(()) => success_response(cv), + Err(iota_storage::storage_error::StorageError::ReactionLimitReached) => { + error_response(cv, CommunicationType::ErrorInvalidData) + } Err(_) => error_response(cv, CommunicationType::ErrorNotFound), } } @@ -288,7 +346,7 @@ fn stored_message_fields( ) -> Vec<(DataType, DataValue)> { let mut fields = vec![ ( - DataType::AppMessageId, + DataType::MessageId, DataValue::SignedNumber(message.id as i128), ), ( @@ -296,7 +354,7 @@ fn stored_message_fields( DataValue::SignedNumber(message.message_time as i128), ), ( - DataType::AppContent, + DataType::Content, DataValue::Str(message.content.clone()), ), ( @@ -316,6 +374,25 @@ fn stored_message_fields( if let Ok(sender_id) = u128::try_from(sender_id) { fields.push((DataType::SenderId, DataValue::UnsignedNumber(sender_id))); } + if let Some(relay_message_id) = &message.relay_message_id { + fields.push(( + DataType::RelayMessageId, + DataValue::Str(relay_message_id.clone()), + )); + } + for (data_type, timestamp) in [ + (DataType::AuthoredAt, message.authored_at), + (DataType::OriginIotaReceivedAt, message.origin_iota_received_at), + (DataType::DestinationIotaReceivedAt, message.destination_iota_received_at), + (DataType::ClientReceivedAt, message.client_received_at), + (DataType::ClientReceivedRecordedAt, message.client_received_recorded_at), + (DataType::ReadAt, message.read_at), + (DataType::ReadRecordedAt, message.read_recorded_at), + ] { + if let Some(timestamp) = timestamp { + fields.push((data_type, DataValue::SignedNumber(timestamp.into()))); + } + } if message.edited { fields.push((DataType::Edited, DataValue::Bool(true))); } @@ -398,7 +475,7 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { DataValue::Str(record.wrapping_scheme), ) .add_typed_default( - DataType::AppCreatedAt, + DataType::CreatedAt, DataValue::SignedNumber(record.created_at as i128), ) .add_typed_default( @@ -760,10 +837,18 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { Err(response) => return response, }; - let message = match chat_files::get_message(owner, send_time, partner_id) { - Ok(Some(message)) => message, - Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + let (message, offset) = match partner_id { + Some(partner_id) => match chat_files::get_message_with_offset(owner, partner_id, send_time) + { + Ok(Some((message, offset))) => (message, Some(offset)), + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }, + None => match chat_files::get_message(owner, send_time, None) { + Ok(Some(message)) => (message, None), + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }, }; let mut response = CommunicationValue::new(CommunicationType::MessageGet) @@ -772,6 +857,10 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { for (data_type, value) in stored_message_fields(&message, owner, message.external_user) { response = response.add_typed_default(data_type, value); } + if let Some(offset) = offset { + response = + response.add_typed_default(DataType::Offset, DataValue::SignedNumber(offset as i128)); + } response } diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs index f9ef1fe..161fb93 100644 --- a/iota-connection/src/relay.rs +++ b/iota-connection/src/relay.rs @@ -2,8 +2,6 @@ use iota_util::route_target::RouteTarget; use mtp::codec::{ CommunicationValue, ProtectionPolicy, RelayError, RelayOpenOptions, SignaturePolicy, TypeMap, VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, - open_relay_content_with_keyrings, open_relay_metadata_with_without_replay, - relay_metadata_claimed_signer_id, open_relay_content_with_limits_without_replay, open_relay_metadata_with_without_replay, relay_metadata_claimed_signer_id_with_options, }; diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml index e4acfc2..4db4406 100644 --- a/iota-state/Cargo.toml +++ b/iota-state/Cargo.toml @@ -12,8 +12,4 @@ dashmap = "6.1.0" once_cell = "1.21.3" tokio = { version = "1.50.0", features = ["full"] } json = "*" -<<<<<<< HEAD sysinfo = "0.38.0" -======= -sysinfo = "0.39.0" ->>>>>>> refs/remotes/origin/main diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index ac8783e..9f91c52 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -7,10 +7,6 @@ edition = "2024" iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } -<<<<<<< HEAD - -======= ->>>>>>> refs/remotes/origin/main base64 = "0.22.1" json = "*" arc-swap = "1" diff --git a/iota-storage/src/storage_error.rs b/iota-storage/src/storage_error.rs index 100bf08..86a0c53 100644 --- a/iota-storage/src/storage_error.rs +++ b/iota-storage/src/storage_error.rs @@ -8,6 +8,8 @@ pub enum StorageError { Pool(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), + #[error("message has reached the unique reaction limit")] + ReactionLimitReached, #[error("{0}")] Other(String), } diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index 024c302..76695e5 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -2,7 +2,9 @@ use crate::storage_error::StorageError; use crate::util::db; use crate::util::sync::{self, EntityType, Operation}; use iota_logger::log; -use rusqlite::params; +use rusqlite::{OptionalExtension, params}; + +pub const MAX_UNIQUE_REACTIONS_PER_MESSAGE: usize = 10; #[derive(PartialEq, Debug, Clone)] pub enum MessageState { @@ -48,7 +50,16 @@ impl MessageState { pub struct StoredMessage { pub id: i64, pub external_user: i64, + pub relay_signer_id: Option, + pub relay_message_id: Option, pub message_time: i64, + pub authored_at: Option, + pub origin_iota_received_at: Option, + pub destination_iota_received_at: Option, + pub client_received_at: Option, + pub client_received_recorded_at: Option, + pub read_at: Option, + pub read_recorded_at: Option, pub content: String, pub edited: bool, pub sent_by_self: bool, @@ -58,6 +69,22 @@ pub struct StoredMessage { pub reactions: Vec, } +pub struct NewMessage<'a> { + pub relay_signer_id: i64, + pub relay_message_id: &'a str, + pub authored_at: i64, + pub send_time: i64, + pub storage_owner: i64, + pub external_user: i64, + pub sent_by_self: bool, + pub content: &'a str, + pub height: i64, + pub reply_to: Option, + pub origin_iota_received_at: Option, + pub destination_iota_received_at: Option, + pub initial_state: MessageState, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct StoredReaction { pub reaction: String, @@ -149,7 +176,7 @@ fn update_message_content( let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_millis() as i64; let tx = conn.unchecked_transaction()?; @@ -351,8 +378,8 @@ pub fn add_reaction( user_id: i64, reaction: &str, ) -> Result<(), StorageError> { - db::with_db(|conn| { - let msg_id: i64 = conn.query_row( + db::with_immediate_transaction(|tx| { + let msg_id: i64 = tx.query_row( r#" SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 @@ -362,28 +389,46 @@ pub fn add_reaction( |row| row.get(0), )?; + let reaction_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM reactions WHERE message_id = ?1 AND reaction = ?2)", + params![msg_id, reaction], + |row| row.get(0), + )?; + if !reaction_exists { + let unique_reactions: i64 = tx.query_row( + "SELECT COUNT(DISTINCT reaction) FROM reactions WHERE message_id = ?1", + [msg_id], + |row| row.get(0), + )?; + if unique_reactions >= MAX_UNIQUE_REACTIONS_PER_MESSAGE as i64 { + return Err(StorageError::ReactionLimitReached); + } + } + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_millis() as i64; - let tx = conn.unchecked_transaction()?; - tx.execute( + let inserted = tx.execute( r#" INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) VALUES (?1, ?2, ?3, ?4) "#, params![msg_id, user_id, reaction, now], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) + if inserted > 0 { + sync::record_event( + tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + Ok(()) + } else { + Ok(()) + } }) } @@ -422,45 +467,46 @@ pub fn remove_reaction( }) } -pub fn add_message( - send_time: u128, - storage_owner_is_sender: bool, - storage_owner: i64, - external_user: i64, - message: &str, - height: i64, - reply_to: Option, -) { - let message_time = match i64::try_from(send_time) { - Ok(v) => v, - Err(_) => { - log!("Failed to store message: send_time out of range for i64 ({send_time})"); - return; - } - }; - - if let Err(e) = db::with_db(|conn| { +pub fn add_message(message: NewMessage<'_>) -> Result { + let NewMessage { + relay_signer_id, + relay_message_id, + authored_at, + send_time, + storage_owner, + external_user, + sent_by_self, + content, + height, + reply_to, + origin_iota_received_at, + destination_iota_received_at, + initial_state, + } = message; + let msg_id = db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( r#" INSERT INTO messages ( - storage_owner, external_user, message_time, content, - sent_by_self, message_state, height, reply_to - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + storage_owner, external_user, message_time, content, sent_by_self, + message_state, height, reply_to, relay_signer_id, relay_message_id, + authored_at, origin_iota_received_at, destination_iota_received_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) "#, params![ storage_owner, external_user, - message_time, - message, - if storage_owner_is_sender { - 1_i64 - } else { - 0_i64 - }, - MessageState::Sending.as_str(), + send_time, + content, + i64::from(sent_by_self), + initial_state.as_str(), height, reply_to, + relay_signer_id, + relay_message_id, + authored_at, + origin_iota_received_at, + destination_iota_received_at, ], )?; let msg_id = tx.last_insert_rowid(); @@ -472,15 +518,122 @@ pub fn add_message( Operation::Upsert, )?; tx.commit()?; - Ok(()) - }) { - log!("Failed to insert message into sqlite: {}", e); - return; - } + Ok(msg_id) + })?; let mut contact = crate::users::contact::Contact::new(external_user); - contact.set_last_message_at(message_time); + contact.set_last_message_at(send_time); crate::util::chats_util::mod_user(storage_owner, &contact); + Ok(msg_id) +} + +pub fn change_message_state_by_relay_id( + storage_owner: i64, + relay_signer_id: i64, + relay_message_id: &str, + new_state: MessageState, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + let Some((msg_id, current)) = tx + .query_row( + "SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_id, relay_message_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()? + else { + return Ok(()); + }; + let state = MessageState::from_str(¤t).upgrade(new_state).as_str(); + tx.execute("UPDATE messages SET message_state = ?1 WHERE id = ?2", params![state, msg_id])?; + sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?; + tx.commit()?; + Ok(()) + }) +} + +pub fn record_message_receipt( + storage_owner: i64, + target_signer_id: i64, + target_message_id: &str, + receipt_signer_id: i64, + receipt_message_id: &str, + receipt_type: MessageState, + event_at: i64, + recorded_at: i64, +) -> Result<(), StorageError> { + let receipt_type = match receipt_type { + MessageState::Received => "received", + MessageState::Read => "read", + _ => return Err(StorageError::Other("invalid message receipt state".into())), + }; + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + let Some((message_id, external_user)) = tx + .query_row( + "SELECT id, external_user FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", + params![storage_owner, target_signer_id, target_message_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()? + else { + return Err(StorageError::Other("message receipt target was not found".into())); + }; + if external_user != receipt_signer_id { + return Err(StorageError::Other("message receipt signer is not the chat partner".into())); + } + tx.execute( + "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at], + )?; + let (state_column, recorded_column) = if receipt_type == "read" { + ("read_at", "read_recorded_at") + } else { + ("client_received_at", "client_received_recorded_at") + }; + let state = MessageState::from_str( + &tx.query_row("SELECT message_state FROM messages WHERE id = ?1", [message_id], |row| row.get::<_, String>(0))?, + ) + .upgrade(MessageState::from_str(receipt_type)) + .as_str() + .to_string(); + tx.execute( + &format!("UPDATE messages SET {state_column} = COALESCE({state_column}, ?1), {recorded_column} = COALESCE({recorded_column}, ?2), message_state = ?3 WHERE id = ?4"), + params![event_at, recorded_at, state, message_id], + )?; + sync::record_event(&tx, storage_owner, EntityType::Message, message_id, Operation::Upsert)?; + tx.commit()?; + Ok(()) + }) +} + +pub fn record_destination_iota_received( + storage_owner: i64, + relay_signer_id: i64, + relay_message_id: &str, + accepted_at: i64, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + let Some(message_id) = tx + .query_row( + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_id, relay_message_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(()); + }; + tx.execute( + "UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", + params![accepted_at, message_id], + )?; + sync::record_event(&tx, storage_owner, EntityType::Message, message_id, Operation::Upsert)?; + tx.commit()?; + Ok(()) + }) } pub fn change_message_state( @@ -574,7 +727,15 @@ fn load_reactions( )) }) { for row in rows.flatten() { - map.entry(row.0).or_default().push(row.1); + let reactions = map.entry(row.0).or_default(); + if reactions + .iter() + .any(|stored: &StoredReaction| stored.reaction == row.1.reaction) + { + reactions.push(row.1); + } else if reactions.len() < MAX_UNIQUE_REACTIONS_PER_MESSAGE { + reactions.push(row.1); + } } } } @@ -594,7 +755,11 @@ pub fn get_messages( match db::with_db(|conn| { let mut stmt = conn.prepare( r#" - SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count + SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, + origin_iota_received_at, destination_iota_received_at, + client_received_at, client_received_recorded_at, read_at, + read_recorded_at, content, sent_by_self, message_state, height, + reply_to, edited_count FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 ORDER BY message_time DESC, id DESC @@ -608,13 +773,22 @@ pub fn get_messages( Ok(StoredMessage { id: row.get(0)?, external_user, - message_time: row.get(1)?, - content: row.get(2)?, - sent_by_self: row.get::<_, i64>(3)? != 0, - message_state: row.get(4)?, - height: row.get(5).unwrap_or(0), - reply_to: row.get(6).ok().flatten(), - edited: row.get::<_, i64>(7).unwrap_or(0) > 0, + relay_signer_id: row.get(1)?, + relay_message_id: row.get(2)?, + message_time: row.get(3)?, + authored_at: row.get(4)?, + origin_iota_received_at: row.get(5)?, + destination_iota_received_at: row.get(6)?, + client_received_at: row.get(7)?, + client_received_recorded_at: row.get(8)?, + read_at: row.get(9)?, + read_recorded_at: row.get(10)?, + content: row.get(11)?, + sent_by_self: row.get::<_, i64>(12)? != 0, + message_state: row.get(13)?, + height: row.get(14).unwrap_or(0), + reply_to: row.get(15).ok().flatten(), + edited: row.get::<_, i64>(16).unwrap_or(0) > 0, reactions: Vec::new(), }) }, @@ -652,7 +826,10 @@ pub fn get_message( db::with_db(|conn| { let mut stmt = conn.prepare( r#" - SELECT id, message_time, content, sent_by_self, message_state, height, + SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, + origin_iota_received_at, destination_iota_received_at, + client_received_at, client_received_recorded_at, read_at, + read_recorded_at, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 @@ -666,14 +843,23 @@ pub fn get_message( let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| { Ok(StoredMessage { id: row.get(0)?, - message_time: row.get(1)?, - content: row.get(2)?, - sent_by_self: row.get::<_, i64>(3)? != 0, - message_state: row.get(4)?, - height: row.get(5).unwrap_or(0), - reply_to: row.get(6).ok().flatten(), - edited: row.get::<_, i64>(7).unwrap_or(0) > 0, - external_user: row.get(8)?, + relay_signer_id: row.get(1)?, + relay_message_id: row.get(2)?, + message_time: row.get(3)?, + authored_at: row.get(4)?, + origin_iota_received_at: row.get(5)?, + destination_iota_received_at: row.get(6)?, + client_received_at: row.get(7)?, + client_received_recorded_at: row.get(8)?, + read_at: row.get(9)?, + read_recorded_at: row.get(10)?, + content: row.get(11)?, + sent_by_self: row.get::<_, i64>(12)? != 0, + message_state: row.get(13)?, + height: row.get(14).unwrap_or(0), + reply_to: row.get(15).ok().flatten(), + edited: row.get::<_, i64>(16).unwrap_or(0) > 0, + external_user: row.get(17)?, reactions: Vec::new(), }) })?; @@ -700,6 +886,40 @@ pub fn get_message( }) } +pub fn get_message_with_offset( + storage_owner: i64, + external_user: i64, + message_time: i64, +) -> Result, StorageError> { + let Some(message) = get_message(storage_owner, message_time, Some(external_user))? else { + return Ok(None); + }; + let offset = db::with_db(|conn| { + conn.query_row( + r#" + SELECT COUNT(*) + FROM messages + WHERE storage_owner = ?1 + AND external_user = ?2 + AND deleted_by_external = 0 + AND ( + message_time > ?3 + OR (message_time = ?3 AND id > ?4) + ) + "#, + params![ + storage_owner, + external_user, + message.message_time, + message.id + ], + |row| row.get(0), + ) + .map_err(StorageError::from) + })?; + Ok(Some((message, offset))) +} + pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { if ids.is_empty() { return Vec::new(); @@ -708,19 +928,28 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec(3)? != 0, - message_state: row.get(4)?, - height: row.get(5).unwrap_or(0), - reply_to: row.get(6).ok().flatten(), - edited: row.get::<_, i64>(7).unwrap_or(0) > 0, + relay_signer_id: row.get(1)?, + relay_message_id: row.get(2)?, + message_time: row.get(3)?, + authored_at: row.get(4)?, + origin_iota_received_at: row.get(5)?, + destination_iota_received_at: row.get(6)?, + client_received_at: row.get(7)?, + client_received_recorded_at: row.get(8)?, + read_at: row.get(9)?, + read_recorded_at: row.get(10)?, + content: row.get(11)?, + sent_by_self: row.get::<_, i64>(12)? != 0, + message_state: row.get(13)?, + height: row.get(14).unwrap_or(0), + reply_to: row.get(15).ok().flatten(), + edited: row.get::<_, i64>(16).unwrap_or(0) > 0, reactions: Vec::new(), }) })?; diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 46363cf..260c667 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1,6 +1,6 @@ use once_cell::sync::Lazy; use r2d2::ManageConnection; -use rusqlite::Connection; +use rusqlite::{Connection, Transaction}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -55,6 +55,17 @@ where f(&conn) } +pub fn with_immediate_transaction(f: F) -> Result +where + F: FnOnce(&Transaction<'_>) -> Result, +{ + let mut conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let value = f(&tx)?; + tx.commit()?; + Ok(value) +} + /* Verify the persistent database before the pool is initialized. A corrupt * database is moved aside rather than opened again, preserving material for * operator recovery while allowing the daemon to report the failed storage. */ @@ -388,6 +399,71 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 10 { + conn.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_messages_history + ON messages ( + storage_owner, + external_user, + deleted_by_external, + message_time DESC, + id DESC + ); + PRAGMA user_version = 10; + "#, + )?; + } + + if current_version < 11 { + for (column, definition) in [ + ("relay_signer_id", "relay_signer_id INTEGER"), + ("relay_message_id", "relay_message_id TEXT"), + ("authored_at", "authored_at INTEGER"), + ("origin_iota_received_at", "origin_iota_received_at INTEGER"), + ("destination_iota_received_at", "destination_iota_received_at INTEGER"), + ("client_received_at", "client_received_at INTEGER"), + ("client_received_recorded_at", "client_received_recorded_at INTEGER"), + ("read_at", "read_at INTEGER"), + ("read_recorded_at", "read_recorded_at INTEGER"), + ] { + add_column_if_missing(conn, column, definition)?; + } + for (column, definition) in [ + ("accepted_at", "accepted_at INTEGER"), + ("applied_at", "applied_at INTEGER"), + ("queued_at", "queued_at INTEGER"), + ("downstream_acked_at", "downstream_acked_at INTEGER"), + ("rejected_at", "rejected_at INTEGER"), + ] { + add_table_column_if_missing(conn, "relay_inbox", column, definition)?; + } + conn.execute_batch( + r#" + CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_relay_identity + ON messages (storage_owner, relay_signer_id, relay_message_id) + WHERE relay_message_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS message_receipts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + target_signer_id INTEGER NOT NULL, + target_message_id TEXT NOT NULL, + receipt_signer_id INTEGER NOT NULL, + receipt_message_id TEXT NOT NULL, + receipt_type TEXT NOT NULL CHECK (receipt_type IN ('received', 'read')), + event_at INTEGER NOT NULL, + recorded_at INTEGER NOT NULL, + UNIQUE(receipt_signer_id, receipt_message_id), + UNIQUE(storage_owner, target_signer_id, target_message_id, + receipt_signer_id, receipt_type) + ); + CREATE INDEX IF NOT EXISTS idx_message_receipts_target + ON message_receipts (storage_owner, target_signer_id, target_message_id); + PRAGMA user_version = 11; + "#, + )?; + } + Ok(()) } @@ -457,7 +533,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 9); + assert_eq!(version, 11); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -470,11 +546,13 @@ mod tests { #[test] fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> { let conn = Connection::open_in_memory()?; - conn.execute_batch("PRAGMA user_version = 5;")?; + conn.execute_batch( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, storage_owner INTEGER NOT NULL, external_user INTEGER NOT NULL, message_time INTEGER NOT NULL, content TEXT NOT NULL, sent_by_self INTEGER NOT NULL, message_state TEXT NOT NULL, height INTEGER NOT NULL DEFAULT 0, reply_to INTEGER, edited_count INTEGER NOT NULL DEFAULT 0, deleted_by_external INTEGER NOT NULL DEFAULT 0); PRAGMA user_version = 5;", + )?; run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 9); + assert_eq!(version, 11); for table in [ "sync_heads", "sync_events", diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs index bc3bda7..db3cf0e 100644 --- a/iota-storage/src/util/relay_replay.rs +++ b/iota-storage/src/util/relay_replay.rs @@ -12,6 +12,7 @@ pub fn reserve( signer_id: u64, message_id: &str, created_at: u64, + accepted_at: i64, destination_id: u64, frame: &[u8], frame_id: u32, @@ -26,11 +27,12 @@ pub fn reserve( db::with_db(|connection| { let inserted = connection.execute( - "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'received')", + "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'received')", params![ signer_id, message_id, created_at, + accepted_at, destination_id, frame, i64::from(frame_id), @@ -67,9 +69,13 @@ pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<() let destination_id = i64::try_from(destination_id) .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; db::with_db(|connection| { + let delivered_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; connection.execute( - "UPDATE relay_inbox SET state = 'delivered' WHERE destination_id = ?1 AND frame_id = ?2", - params![destination_id, i64::from(frame_id)], + "UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE destination_id = ?1 AND frame_id = ?2", + params![destination_id, i64::from(frame_id), delivered_at], )?; Ok(()) }) @@ -84,24 +90,36 @@ pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), S } let signer_id = i64::try_from(signer_id) .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; db::with_db(|connection| { + let column = match state { + "applied" => "applied_at", + "queued" => "queued_at", + "delivered" => "downstream_acked_at", + "rejected" => "rejected_at", + "received" => "accepted_at", + _ => return Err(StorageError::Other("invalid relay inbox state".into())), + }; connection.execute( - "UPDATE relay_inbox SET state = ?3 WHERE signer_id = ?1 AND message_id = ?2", - params![signer_id, message_id, state], + &format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_id = ?1 AND message_id = ?2"), + params![signer_id, message_id, state, timestamp], )?; Ok(()) }) } -pub fn prune_completed(before_created_at: i64) -> Result<(), StorageError> { +pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> { db::with_db(|connection| { connection.execute( - "DELETE FROM relay_inbox WHERE created_at < ?1 AND state IN ('delivered', 'rejected')", - params![before_created_at], + "DELETE FROM relay_inbox WHERE COALESCE(downstream_acked_at, rejected_at) < ?1 AND state IN ('delivered', 'rejected')", + params![before_terminal_at], )?; connection.execute( - "DELETE FROM relay_replay WHERE created_at < ?1", - params![before_created_at], + "DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id)", + [], )?; Ok(()) }) diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 9e19437..1fbbef9 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -5,10 +5,6 @@ edition = "2024" [dependencies] iota-paths = { path = "../iota-paths" } -<<<<<<< HEAD - -======= ->>>>>>> refs/remotes/origin/main tokio = { version = "1.50.0", features = ["full"] } sha2 = "0.11.0" hex = "*" diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs index 1931da7..339dd1f 100644 --- a/iota-util/src/crypto_helper.rs +++ b/iota-util/src/crypto_helper.rs @@ -6,17 +6,10 @@ pub fn generate_keyring() -> Keyring { } pub fn keyring_to_base64(keyring: &Keyring) -> String { -<<<<<<< HEAD keyring .try_to_bytes() .map(|bytes| STANDARD.encode(bytes)) .unwrap_or_default() -======= - let bytes = keyring - .try_to_bytes() - .expect("keyring fields must fit the wire format"); - STANDARD.encode(bytes) ->>>>>>> refs/remotes/origin/main } pub fn keyring_from_base64(s: &str) -> Option { @@ -25,17 +18,10 @@ pub fn keyring_from_base64(s: &str) -> Option { } pub fn public_key_bundle_to_base64(bundle: &PublicKeyBundle) -> String { -<<<<<<< HEAD bundle .try_as_bytes() .map(|bytes| STANDARD.encode(bytes)) .unwrap_or_default() -======= - let bytes = bundle - .try_as_bytes() - .expect("public key bundle fields must fit the wire format"); - STANDARD.encode(bytes) ->>>>>>> refs/remotes/origin/main } pub fn public_key_bundle_from_base64(s: &str) -> Option { diff --git a/mtp-type-maps b/mtp-type-maps index 486541b..f3c5037 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e +Subproject commit f3c5037b0a099ae5389486eec77471bd5addab4a diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 9599cb6..0a3a6a3 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -68,18 +68,6 @@ fn omikron_public_key_path() -> &'static Path { .unwrap_or_else(|| Path::new("omikron.mpkb")) } -fn save_keyring(keyring: &Keyring, path: &Path) -> Result<(), String> { - let temporary = serialization_path(path)?; - mtp::files::save_keyring_raw(keyring, &temporary) - .map_err(|error| format!("serialize keyring: {error}"))?; - let bytes = - std::fs::read(&temporary).map_err(|error| format!("read serialized keyring: {error}")); - let _ = std::fs::remove_file(&temporary); - let bytes = bytes?; - iota_util::atomic_file::replace_private(path, &bytes, 3) - .map_err(|error| format!("write {}: {error}", path.display())) -} - fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { let temporary = serialization_path(path)?; mtp::files::save_public_key_bundle(key, &temporary) @@ -609,46 +597,8 @@ impl OmikronConnection { // Identity (own Keyring, migrated from the legacy base64-in-config format) // ------------------------------------------------------------------------- -<<<<<<< HEAD - /* - * `iota.mk` is now the source of truth for this Iota's identity. A - * pre-existing base64 keyring in config.json (from before the MTP auth - * migration) is imported once so already-registered Iotas keep their - * identity, and mirrored back into config.json for older code paths - * that still read it directly. - */ - async fn load_or_migrate_keyring(&self) -> Keyring { - let path = identity_path(); - if let Ok(kr) = mtp::files::load_keyring_raw(path) { - return kr; - } - - let legacy = CONFIG.load().keyring.clone(); - let keyring = legacy - .and_then(|b64| keyring_from_base64(&b64)) - .unwrap_or_else(|| { - log!( - "WARNING: No existing keyring found. Neither {} nor config.json \ - contain a keyring; generating a new identity. If you already had \ - an Iota identity, restore {} from a backup to avoid losing access.", - path.display(), - path.display() - ); - crypto_helper::generate_keyring() - }); - - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = save_keyring(&keyring, path) { - log!("Failed to persist {}: {}", path.display(), e); - } - - keyring -======= async fn load_or_migrate_keyring(&self, passphrase: &[u8]) -> Result { load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone(), passphrase) ->>>>>>> refs/remotes/origin/main } // ------------------------------------------------------------------------- @@ -721,12 +671,8 @@ impl OmikronConnection { }; let (host, port, public_key) = if let Some(endpoint) = discovered { - let discovered_key_bytes = endpoint.public_key.try_as_bytes().map_err(|error| { - format!("Failed to serialize discovered Omikron public key: {error}") - })?; match &cached_key { Some(cached) => { -<<<<<<< HEAD let keys_match = match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { (Ok(cached_bytes), Ok(discovered_bytes)) => { @@ -752,20 +698,6 @@ impl OmikronConnection { } else { (endpoint.host, endpoint.port, cached.clone()) } -======= - let cached_key_bytes = cached.try_as_bytes().map_err(|error| { - format!("Failed to serialize cached Omikron public key: {error}") - })?; - if cached_key_bytes != discovered_key_bytes { - log!( - "Fetched Omikron public key differs from the cached {} - keeping the \ - cached key. Delete {} manually if this is an expected key rotation.", - OMIKRON_PUBLIC_KEY_PATH, - OMIKRON_PUBLIC_KEY_PATH - ); - } - (endpoint.host, endpoint.port, cached.clone()) ->>>>>>> refs/remotes/origin/main } None => { if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) { @@ -955,6 +887,30 @@ impl OmikronConnection { } } + async fn send_relay_success( + &self, + frame_id: Option, + iota_id: u64, + relay_message_id: &str, + accepted_at: i64, + ) { + let Some(frame_id) = frame_id else { return }; + let response = CommunicationValue::new(CommunicationType::Success) + .with_id(frame_id) + .add_typed_default(DataType::IotaId, DataValue::UnsignedNumber(iota_id.into())) + .add_typed_default( + DataType::RelayMessageId, + DataValue::Str(relay_message_id.to_string()), + ) + .add_typed_default( + DataType::RelayAcceptedAt, + DataValue::SignedNumber(accepted_at.into()), + ); + if let Err(error) = self.send_message(&response).await { + log!("Relay response could not be sent: {}", error); + } + } + async fn handle_relay(self: Arc, frame: CommunicationValue) { let Some(incoming_frame_id) = frame.id() else { log!("Rejecting Relay without a message id"); @@ -998,6 +954,7 @@ impl OmikronConnection { return; } }; + let accepted_at = now_millis_i64(); let signer_is_local = i64::try_from(verified.context.signer_id) .ok() .and_then(iota_storage::users::user_manager::get_user) @@ -1035,6 +992,7 @@ impl OmikronConnection { verified.context.signer_id, &verified.context.message_id, verified.context.created_at, + accepted_at, verified.context.final_recipient_id, &frame_bytes, frame_id, @@ -1082,6 +1040,43 @@ impl OmikronConnection { }; if signer_is_local && !recipient_is_local { + if !already_applied { + let content = match open_verified_relay_content( + &verified, + &[&keyring], + verified.context.signer_id, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay origin content verification failed: {}", error); + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + accepted_at, + i64::try_from(verified.context.signer_id).unwrap_or_default(), + true, + ) { + log!("Relay origin application failed: {}", error); + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + } let router = match self .hosting_iota_for_user(verified.context.final_recipient_id) .await @@ -1136,6 +1131,34 @@ impl OmikronConnection { .await { Ok(response) if response.is_type(CommunicationType::Success) => { + let returned_id = response + .get_data(DataType::RelayMessageId) + .as_str(); + let accepted_at = response + .get_data(DataType::RelayAcceptedAt) + .as_number() + .and_then(|value| i64::try_from(value).ok()); + if returned_id != Some(verified.context.message_id.as_str()) { + log!("Relay acknowledgement returned a different RelayMessageId"); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + if let Some(accepted_at) = accepted_at { + if let (Ok(owner), Ok(signer)) = ( + i64::try_from(verified.context.signer_id), + i64::try_from(verified.context.signer_id), + ) { + if let Err(error) = chat_files::record_destination_iota_received( + owner, + signer, + &verified.context.message_id, + accepted_at, + ) { + log!("Relay destination acknowledgement storage failed: {}", error); + } + } + } if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { log!( "Relay origin acknowledgement could not clear the queue: {}", @@ -1149,8 +1172,10 @@ impl OmikronConnection { ) { log!("Relay origin delivery state update failed: {}", error); } - self.send_relay_response(frame.id(), CommunicationType::Success) - .await; + let response = response.with_id(frame_id); + if let Err(error) = self.send_message(&response).await { + log!("Relay response could not be sent: {}", error); + } } Ok(response) => { log!("Relay origin route returned {}", response.get_type()); @@ -1234,7 +1259,24 @@ impl OmikronConnection { } }; if let Err(error) = - message_handlers::apply_verified_relay_content(&verified.context, &content) + message_handlers::apply_verified_relay_content( + &verified.context, + &content, + accepted_at, + match i64::try_from(destination) { + Ok(value) => value, + Err(_) => { + log!("Relay destination ID exceeds storage range"); + self.send_relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ) + .await; + return; + } + }, + false, + ) { log!("Relay application dispatch failed: {}", error); if let Err(queue_error) = @@ -1270,8 +1312,13 @@ impl OmikronConnection { ) { log!("Relay queue state update failed: {}", error); } - self.send_relay_response(frame.id(), CommunicationType::Success) - .await; + self.send_relay_success( + frame.id(), + local_iota_id, + &verified.context.message_id, + accepted_at, + ) + .await; if let Err(error) = self.send_message(&forwarded).await { log!("Relay delivery to local client failed: {}", error); } @@ -1707,7 +1754,7 @@ impl OmikronConnection { let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { return; }; - let Some(content) = cv.get_data(DataType::AppContent).as_str() else { + let Some(content) = cv.get_data(DataType::Content).as_str() else { return; }; if chat_files::apply_remote_edit(receiver_id, sender_id, send_time, sender_id, content) @@ -1790,7 +1837,7 @@ impl OmikronConnection { .await; return; }; - let Some(content) = cv.get_data(DataType::AppContent).as_str() else { + let Some(content) = cv.get_data(DataType::Content).as_str() else { let _ = self .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) .await; @@ -1800,7 +1847,7 @@ impl OmikronConnection { CommunicationType::MessageEditLive, cv, &mutation, - vec![(DataType::AppContent, DataValue::Str(content.to_string()))], + vec![(DataType::Content, DataValue::Str(content.to_string()))], ); if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() && chat_files::apply_remote_edit( @@ -2237,11 +2284,7 @@ impl OmikronConnection { )) })?; } -<<<<<<< HEAD - save_keyring(&keyring, path).map_err(|error| { -======= save_protected_keyring_verified(&keyring, path, &identity_secret).map_err(|error| { ->>>>>>> refs/remotes/origin/main OmikronError::Internal(format!( "could not save new identity {}: {error}", path.display() @@ -2410,29 +2453,9 @@ impl OmikronClient for OmikronConnection { } #[cfg(test)] -<<<<<<< HEAD mod tests { use super::*; - #[test] - fn durable_keyring_save_preserves_mtp_format() { - let directory = std::env::temp_dir().join(format!("iota-keyring-test-{}", Uuid::new_v4())); - std::fs::create_dir_all(&directory).unwrap(); - let path = directory.join(IOTA_KEYRING_PATH); - let keyring = crypto_helper::generate_keyring(); - - save_keyring(&keyring, &path).unwrap(); - - let loaded = mtp::files::load_keyring_raw(&path).unwrap(); - assert_eq!( - keyring.try_to_bytes().unwrap(), - loaded.try_to_bytes().unwrap() - ); - std::fs::remove_dir_all(directory).unwrap(); -======= -mod identity_tests { - use super::*; - fn test_path(name: &str) -> PathBuf { std::env::temp_dir().join(format!( "iota-identity-{name}-{}-{}", @@ -2503,6 +2526,5 @@ mod identity_tests { } assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY); ->>>>>>> refs/remotes/origin/main } } From 3bfec968485d82e329f1d53b03e1c96c0de7a2b7 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:00:44 +0200 Subject: [PATCH 116/119] [Add] timestamping --- iota-connection/src/message_handlers.rs | 3 + iota-storage/src/util/relay_replay.rs | 37 +++--- iota-storage/src/util/sync.rs | 2 +- omikron-connector/src/omikron_connection.rs | 137 +++++++++++++++----- 4 files changed, 133 insertions(+), 46 deletions(-) diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 1f247aa..d692be4 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -185,6 +185,9 @@ pub fn apply_verified_relay_content( &context.type_map, ) .ok_or_else(|| "Relay MessageSend is missing RelayMessageId".to_string())?; + if relay_message_id != context.message_id { + return Err("Relay MessageSend identity does not match its protected message ID".into()); + } chat_files::add_message(chat_files::NewMessage { relay_signer_id: sender_id, relay_message_id, diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs index db3cf0e..0c5bddc 100644 --- a/iota-storage/src/util/relay_replay.rs +++ b/iota-storage/src/util/relay_replay.rs @@ -81,13 +81,12 @@ pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<() }) } -pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), StorageError> { - if !matches!( - state, - "received" | "applied" | "queued" | "delivered" | "rejected" - ) { - return Err(StorageError::Other("invalid relay inbox state".into())); - } +fn mark_transition( + signer_id: u64, + message_id: &str, + state: &str, + column: &str, +) -> Result<(), StorageError> { let signer_id = i64::try_from(signer_id) .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; let timestamp = std::time::SystemTime::now() @@ -95,14 +94,6 @@ pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), S .unwrap_or_default() .as_millis() as i64; db::with_db(|connection| { - let column = match state { - "applied" => "applied_at", - "queued" => "queued_at", - "delivered" => "downstream_acked_at", - "rejected" => "rejected_at", - "received" => "accepted_at", - _ => return Err(StorageError::Other("invalid relay inbox state".into())), - }; connection.execute( &format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_id = ?1 AND message_id = ?2"), params![signer_id, message_id, state, timestamp], @@ -111,6 +102,22 @@ pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), S }) } +pub fn mark_applied(signer_id: u64, message_id: &str) -> Result<(), StorageError> { + mark_transition(signer_id, message_id, "applied", "applied_at") +} + +pub fn mark_queued(signer_id: u64, message_id: &str) -> Result<(), StorageError> { + mark_transition(signer_id, message_id, "queued", "queued_at") +} + +pub fn mark_downstream_acked(signer_id: u64, message_id: &str) -> Result<(), StorageError> { + mark_transition(signer_id, message_id, "delivered", "downstream_acked_at") +} + +pub fn mark_rejected(signer_id: u64, message_id: &str) -> Result<(), StorageError> { + mark_transition(signer_id, message_id, "rejected", "rejected_at") +} + pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> { db::with_db(|connection| { connection.execute( diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs index b84c94a..a358a1d 100644 --- a/iota-storage/src/util/sync.rs +++ b/iota-storage/src/util/sync.rs @@ -4,7 +4,7 @@ use crate::util::db; use rusqlite::{Transaction, params}; use std::collections::BTreeMap; -pub const CACHE_SCHEMA_VERSION: i64 = 1; +pub const CACHE_SCHEMA_VERSION: i64 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntityType { diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 0a3a6a3..0c37013 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -893,6 +893,7 @@ impl OmikronConnection { iota_id: u64, relay_message_id: &str, accepted_at: i64, + include_origin_timestamp: bool, ) { let Some(frame_id) = frame_id else { return }; let response = CommunicationValue::new(CommunicationType::Success) @@ -906,6 +907,19 @@ impl OmikronConnection { DataType::RelayAcceptedAt, DataValue::SignedNumber(accepted_at.into()), ); + let response = if include_origin_timestamp { + response + .add_typed_default( + DataType::OriginIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + .add_typed_default( + DataType::DestinationIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + } else { + response + }; if let Err(error) = self.send_message(&response).await { log!("Relay response could not be sent: {}", error); } @@ -1039,6 +1053,63 @@ impl OmikronConnection { relay_replay::RelayReservation::Existing { .. } => false, }; + /* A shared Iota owns both independent replicas before delivering to its + * local recipient. The destination path below writes the recipient copy. */ + if signer_is_local && recipient_is_local && !already_applied { + let content = match open_verified_relay_content( + &verified, + &[&keyring], + verified.context.signer_id, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay shared-Iota origin content verification failed: {}", error); + let _ = relay_replay::mark_rejected( + verified.context.signer_id, + &verified.context.message_id, + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let owner = match i64::try_from(verified.context.signer_id) { + Ok(value) => value, + Err(_) => { + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + accepted_at, + owner, + true, + ) { + log!("Relay shared-Iota origin application failed: {}", error); + let _ = relay_replay::mark_rejected( + verified.context.signer_id, + &verified.context.message_id, + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + if let Err(error) = chat_files::record_destination_iota_received( + owner, + owner, + &verified.context.message_id, + accepted_at, + ) { + log!("Relay shared-Iota destination timestamp storage failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + } + if signer_is_local && !recipient_is_local { if !already_applied { let content = match open_verified_relay_content( @@ -1049,10 +1120,9 @@ impl OmikronConnection { Ok(value) => value, Err(error) => { log!("Relay origin content verification failed: {}", error); - let _ = relay_replay::mark_state( + let _ = relay_replay::mark_rejected( verified.context.signer_id, &verified.context.message_id, - "rejected", ); self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; @@ -1067,10 +1137,9 @@ impl OmikronConnection { true, ) { log!("Relay origin application failed: {}", error); - let _ = relay_replay::mark_state( + let _ = relay_replay::mark_rejected( verified.context.signer_id, &verified.context.message_id, - "rejected", ); self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; @@ -1119,10 +1188,9 @@ impl OmikronConnection { .await; return; } - if let Err(error) = relay_replay::mark_state( + if let Err(error) = relay_replay::mark_queued( verified.context.signer_id, &verified.context.message_id, - "queued", ) { log!("Relay origin state update failed: {}", error); } @@ -1134,7 +1202,7 @@ impl OmikronConnection { let returned_id = response .get_data(DataType::RelayMessageId) .as_str(); - let accepted_at = response + let destination_accepted_at = response .get_data(DataType::RelayAcceptedAt) .as_number() .and_then(|value| i64::try_from(value).ok()); @@ -1144,19 +1212,23 @@ impl OmikronConnection { .await; return; } - if let Some(accepted_at) = accepted_at { - if let (Ok(owner), Ok(signer)) = ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.signer_id), + let Some(destination_accepted_at) = destination_accepted_at else { + log!("Relay acknowledgement is missing RelayAcceptedAt"); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + }; + if let (Ok(owner), Ok(signer)) = ( + i64::try_from(verified.context.signer_id), + i64::try_from(verified.context.signer_id), + ) { + if let Err(error) = chat_files::record_destination_iota_received( + owner, + signer, + &verified.context.message_id, + destination_accepted_at, ) { - if let Err(error) = chat_files::record_destination_iota_received( - owner, - signer, - &verified.context.message_id, - accepted_at, - ) { - log!("Relay destination acknowledgement storage failed: {}", error); - } + log!("Relay destination acknowledgement storage failed: {}", error); } } if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { @@ -1165,14 +1237,22 @@ impl OmikronConnection { error ); } - if let Err(error) = relay_replay::mark_state( + if let Err(error) = relay_replay::mark_downstream_acked( verified.context.signer_id, &verified.context.message_id, - "delivered", ) { log!("Relay origin delivery state update failed: {}", error); } - let response = response.with_id(frame_id); + let response = response + .add_typed_default( + DataType::OriginIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + .add_typed_default( + DataType::DestinationIotaReceivedAt, + DataValue::SignedNumber(destination_accepted_at.into()), + ) + .with_id(frame_id); if let Err(error) = self.send_message(&response).await { log!("Relay response could not be sent: {}", error); } @@ -1248,10 +1328,9 @@ impl OmikronConnection { queue_error ); } - let _ = relay_replay::mark_state( + let _ = relay_replay::mark_rejected( verified.context.signer_id, &verified.context.message_id, - "rejected", ); self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; @@ -1284,19 +1363,17 @@ impl OmikronConnection { { log!("Relay application queue cleanup failed: {}", queue_error); } - let _ = relay_replay::mark_state( + let _ = relay_replay::mark_rejected( verified.context.signer_id, &verified.context.message_id, - "rejected", ); self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; return; } - if let Err(error) = relay_replay::mark_state( + if let Err(error) = relay_replay::mark_applied( verified.context.signer_id, &verified.context.message_id, - "applied", ) { log!("Relay application state update failed: {}", error); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) @@ -1305,10 +1382,9 @@ impl OmikronConnection { } } - if let Err(error) = relay_replay::mark_state( + if let Err(error) = relay_replay::mark_queued( verified.context.signer_id, &verified.context.message_id, - "queued", ) { log!("Relay queue state update failed: {}", error); } @@ -1317,6 +1393,7 @@ impl OmikronConnection { local_iota_id, &verified.context.message_id, accepted_at, + signer_is_local, ) .await; if let Err(error) = self.send_message(&forwarded).await { From afc1832fb77e159e01f4ba06f88d01ce5b772b14 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:49:10 +0200 Subject: [PATCH 117/119] [Fix] Connectivity --- flake.nix | 9 - iota-connection/src/message_handlers.rs | 449 ++++++++++++- iota-connection/src/relay.rs | 22 + iota-storage/src/users/user_manager.rs | 4 + iota-storage/src/util/chats_util.rs | 11 + iota-storage/src/util/communities_util.rs | 32 +- iota-storage/src/util/db.rs | 48 +- iota-storage/src/util/mod.rs | 1 + iota-storage/src/util/sync.rs | 188 +++++- iota-storage/src/util/synced_settings.rs | 659 ++++++++++++++++++++ mtp-type-maps | 2 +- omikron-connector/Cargo.toml | 1 + omikron-connector/src/omikron_connection.rs | 223 +++---- systemd/iota-daemon.service | 1 - 14 files changed, 1462 insertions(+), 188 deletions(-) create mode 100644 iota-storage/src/util/synced_settings.rs diff --git a/flake.nix b/flake.nix index 5d64dc6..e2ca8a1 100644 --- a/flake.nix +++ b/flake.nix @@ -140,12 +140,6 @@ description = "Environment files to load for the Iota service."; }; - identitySecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Owner-readable file containing the passphrase for the protected Iota identity."; - }; - openFirewall = lib.mkOption { type = lib.types.bool; default = true; @@ -267,9 +261,6 @@ } // lib.optionalAttrs (cfg.environmentFiles != []) { EnvironmentFile = cfg.environmentFiles; - } - // lib.optionalAttrs (cfg.identitySecretFile != null) { - LoadCredential = "iota-identity:${cfg.identitySecretFile}"; }; }; diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index d692be4..ae7e392 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -1,9 +1,10 @@ use crate::message_common::*; use iota_storage::util::chat_files::{self, MessageState}; -use iota_storage::util::chats_util::{self, get_user, mod_user}; +use iota_storage::util::chats_util::{self, get_user, has_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::e2ee_storage::{self, ChatSecretQuery}; use iota_storage::util::settings; +use iota_storage::util::synced_settings::{self, SettingScope, SyncedSetting}; use mtp::codec::{ CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent, }; @@ -17,6 +18,18 @@ pub struct MessageMutation { pub send_time: i64, } +#[derive(Debug)] +pub struct SettingMutation { + pub response: CommunicationValue, + pub changed: Option, +} + +struct SettingLocator { + scope: SettingScope, + scope_key: String, + name: String, +} + fn required_sender_id(cv: &CommunicationValue) -> Result { let sender = cv .require_sender() @@ -435,6 +448,29 @@ fn stored_message_value( typed_container(stored_message_fields(message, storage_owner, partner_id)) } +fn synced_setting_value(setting: &SyncedSetting) -> DataValue { + typed_container(vec![ + ( + DataType::SettingId, + DataValue::SignedNumber(setting.id.into()), + ), + ( + DataType::SettingScope, + DataValue::Str(setting.scope.as_str().to_string()), + ), + ( + DataType::SettingTarget, + DataValue::Str(setting.scope_key.clone()), + ), + (DataType::SettingsName, DataValue::Str(setting.name.clone())), + (DataType::Payload, DataValue::Str(setting.payload.clone())), + ( + DataType::VersionNumber, + DataValue::SignedNumber(setting.revision.into()), + ), + ]) +} + pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { let Some(user_id) = data_string(cv, DataType::UserId) else { return error_response(cv, CommunicationType::ErrorInvalidData); @@ -671,32 +707,56 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { || !known_session || reported_version > head || schema != CACHE_SCHEMA_VERSION; - let (contacts, messages, deleted_messages, deleted_contacts, mode) = if full { - ( - chats_util::get_users(user_id), - chat_files::get_all_messages(user_id), - Vec::new(), - Vec::new(), - "full", - ) - } else { - match sync::delta(user_id, reported_version, head) { - Ok(delta) => ( - chats_util::get_users_by_ids(user_id, &delta.contact_upserts), - chat_files::get_messages_by_ids(user_id, &delta.message_upserts), - delta.deleted_message_ids, - delta.deleted_contact_ids, - "delta", - ), - Err(_) => ( + let (contacts, messages, settings, deleted_messages, deleted_contacts, deleted_settings, mode) = + if full { + let settings = match synced_settings::list(user_id) { + Ok(settings) => settings, + Err(_) => return sync_error(cv), + }; + ( chats_util::get_users(user_id), chat_files::get_all_messages(user_id), + settings, + Vec::new(), Vec::new(), Vec::new(), "full", - ), - } - }; + ) + } else { + match sync::delta(user_id, reported_version, head) { + Ok(delta) => { + let settings = + match synced_settings::list_by_ids(user_id, &delta.setting_upserts) { + Ok(settings) => settings, + Err(_) => return sync_error(cv), + }; + ( + chats_util::get_users_by_ids(user_id, &delta.contact_upserts), + chat_files::get_messages_by_ids(user_id, &delta.message_upserts), + settings, + delta.deleted_message_ids, + delta.deleted_contact_ids, + delta.deleted_setting_ids, + "delta", + ) + } + Err(_) => { + let settings = match synced_settings::list(user_id) { + Ok(settings) => settings, + Err(_) => return sync_error(cv), + }; + ( + chats_util::get_users(user_id), + chat_files::get_all_messages(user_id), + settings, + Vec::new(), + Vec::new(), + Vec::new(), + "full", + ) + } + } + }; let message_values = messages .iter() .map(|message| stored_message_value(message, user_id, message.external_user)) @@ -727,6 +787,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { ), ) .add_typed_default(DataType::Messages, DataValue::Array(message_values)) + .add_typed_default( + DataType::Settings, + DataValue::Array(settings.iter().map(synced_setting_value).collect()), + ) .add_typed_default( DataType::Communities, DataValue::Array(community_values(user_id)), @@ -749,6 +813,15 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .collect(), ), ) + .add_typed_default( + DataType::DeletedSettingIds, + DataValue::Array( + deleted_settings + .into_iter() + .map(|id| DataValue::SignedNumber(id as i128)) + .collect(), + ), + ) .add_typed_default(DataType::UserIds, current_contact_ids(user_id)) .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())) } @@ -995,7 +1068,9 @@ pub fn handle_remove_community(cv: &CommunicationValue) -> CommunicationValue { let Some(address) = cv.get_data(DataType::CommunityAddress).as_str() else { return error_response(cv, CommunicationType::ErrorInvalidData); }; - CommunitiesUtil::remove_community(sender_id, address.to_string()); + if CommunitiesUtil::remove_community(sender_id, address.to_string()).is_err() { + return error_response(cv, CommunicationType::ErrorInternal); + } CommunicationValue::new(CommunicationType::RemoveCommunity) .with_request_id(cv) .with_receiver(sender_wire_id(sender_id)) @@ -1337,3 +1412,331 @@ pub fn handle_settings_list( DataValue::SignedNumber(session_id as i128), ) } + +fn setting_response( + cv: &CommunicationValue, + response_type: CommunicationType, + setting: &SyncedSetting, +) -> CommunicationValue { + CommunicationValue::new(response_type) + .with_request_id(cv) + .with_receiver(sender_wire_id(setting.user_id)) + .add_typed_default( + DataType::SettingId, + DataValue::SignedNumber(setting.id.into()), + ) + .add_typed_default( + DataType::SettingScope, + DataValue::Str(setting.scope.as_str().to_string()), + ) + .add_typed_default( + DataType::SettingTarget, + DataValue::Str(setting.scope_key.clone()), + ) + .add_typed_default(DataType::SettingsName, DataValue::Str(setting.name.clone())) + .add_typed_default(DataType::Payload, DataValue::Str(setting.payload.clone())) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(setting.revision.into()), + ) +} + +fn setting_changed(setting: &SyncedSetting) -> CommunicationValue { + CommunicationValue::new(CommunicationType::SyncedSettingChanged) + .with_receiver(sender_wire_id(setting.user_id)) + .add_typed_default( + DataType::SettingId, + DataValue::SignedNumber(setting.id.into()), + ) + .add_typed_default( + DataType::SettingScope, + DataValue::Str(setting.scope.as_str().to_string()), + ) + .add_typed_default( + DataType::SettingTarget, + DataValue::Str(setting.scope_key.clone()), + ) + .add_typed_default(DataType::SettingsName, DataValue::Str(setting.name.clone())) + .add_typed_default(DataType::Payload, DataValue::Str(setting.payload.clone())) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(setting.revision.into()), + ) +} + +fn setting_deleted(user_id: i64, deleted: &synced_settings::DeletedSetting) -> CommunicationValue { + CommunicationValue::new(CommunicationType::SyncedSettingChanged) + .with_receiver(sender_wire_id(user_id)) + .add_typed_default( + DataType::DeletedSettingIds, + DataValue::Array(vec![DataValue::SignedNumber(deleted.id.into())]), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(deleted.revision.into()), + ) +} + +fn parse_setting_locator(cv: &CommunicationValue) -> Result { + let Some(scope_name) = cv.get_data(DataType::SettingScope).as_str() else { + return Err(error_response(cv, CommunicationType::ErrorInvalidData)); + }; + let Some(scope) = SettingScope::parse(scope_name) else { + return Err(error_response(cv, CommunicationType::ErrorInvalidData)); + }; + let Some(scope_key) = cv.get_data(DataType::SettingTarget).as_str() else { + return Err(error_response(cv, CommunicationType::ErrorInvalidData)); + }; + let Some(name) = cv.get_data(DataType::SettingsName).as_str() else { + return Err(error_response(cv, CommunicationType::ErrorInvalidData)); + }; + if !synced_settings::is_valid_name(name) { + return Err(error_response(cv, CommunicationType::ErrorInvalidData)); + } + match scope { + SettingScope::User if !scope_key.is_empty() => { + Err(error_response(cv, CommunicationType::ErrorInvalidData)) + } + SettingScope::Contact if !scope_key.parse::().is_ok_and(|id| id > 0) => { + Err(error_response(cv, CommunicationType::ErrorInvalidData)) + } + SettingScope::Community if scope_key.is_empty() => { + Err(error_response(cv, CommunicationType::ErrorInvalidData)) + } + _ => Ok(SettingLocator { + scope, + scope_key: scope_key.to_string(), + name: name.to_string(), + }), + } +} + +fn validate_setting_target( + user_id: i64, + locator: &SettingLocator, +) -> Result<(), CommunicationType> { + match locator.scope { + SettingScope::User => Ok(()), + SettingScope::Contact => { + let contact_id = locator + .scope_key + .parse::() + .map_err(|_| CommunicationType::ErrorInvalidData)?; + match has_user(user_id, contact_id) { + Ok(true) => Ok(()), + Ok(false) => Err(CommunicationType::ErrorInvalidData), + Err(_) => Err(CommunicationType::ErrorInternal), + } + } + SettingScope::Community => { + match CommunitiesUtil::has_community(user_id, &locator.scope_key) { + Ok(true) => Ok(()), + Ok(false) => Err(CommunicationType::ErrorInvalidData), + Err(_) => Err(CommunicationType::ErrorInternal), + } + } + } +} + +fn setting_mutation_error( + cv: &CommunicationValue, + error_type: CommunicationType, +) -> SettingMutation { + SettingMutation { + response: error_response(cv, error_type), + changed: None, + } +} + +pub fn handle_synced_setting_set(cv: &CommunicationValue) -> SettingMutation { + let user_id = match required_sender_id(cv) { + Ok(user_id) if user_id > 0 => user_id, + _ => return setting_mutation_error(cv, CommunicationType::ErrorInvalidData), + }; + let locator = match parse_setting_locator(cv) { + Ok(locator) => locator, + Err(response) => { + return SettingMutation { + response, + changed: None, + }; + } + }; + if let Err(error_type) = validate_setting_target(user_id, &locator) { + return setting_mutation_error(cv, error_type); + } + let Some(payload) = cv.get_data(DataType::Payload).as_str() else { + return setting_mutation_error(cv, CommunicationType::ErrorInvalidData); + }; + match synced_settings::set( + user_id, + locator.scope, + &locator.scope_key, + &locator.name, + payload, + ) { + Ok(setting) => SettingMutation { + response: setting_response(cv, CommunicationType::SyncedSettingSet, &setting), + changed: Some(setting_changed(&setting)), + }, + Err(_) => setting_mutation_error(cv, CommunicationType::ErrorInternal), + } +} + +pub fn handle_synced_setting_get(cv: &CommunicationValue) -> CommunicationValue { + let user_id = match required_sender_id(cv) { + Ok(user_id) if user_id > 0 => user_id, + _ => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + let locator = match parse_setting_locator(cv) { + Ok(locator) => locator, + Err(response) => return response, + }; + if let Err(error_type) = validate_setting_target(user_id, &locator) { + return error_response(cv, error_type); + } + match synced_settings::get(user_id, locator.scope, &locator.scope_key, &locator.name) { + Ok(Some(setting)) => setting_response(cv, CommunicationType::SyncedSettingGet, &setting), + Ok(None) => error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => error_response(cv, CommunicationType::ErrorInternal), + } +} + +pub fn handle_synced_setting_delete(cv: &CommunicationValue) -> SettingMutation { + let user_id = match required_sender_id(cv) { + Ok(user_id) if user_id > 0 => user_id, + _ => return setting_mutation_error(cv, CommunicationType::ErrorInvalidData), + }; + let locator = match parse_setting_locator(cv) { + Ok(locator) => locator, + Err(response) => { + return SettingMutation { + response, + changed: None, + }; + } + }; + if let Err(error_type) = validate_setting_target(user_id, &locator) { + return setting_mutation_error(cv, error_type); + } + match synced_settings::delete(user_id, locator.scope, &locator.scope_key, &locator.name) { + Ok(Some(deleted)) => SettingMutation { + response: CommunicationValue::new(CommunicationType::SyncedSettingDelete) + .with_request_id(cv) + .with_receiver(sender_wire_id(user_id)) + .add_typed_default( + DataType::SettingId, + DataValue::SignedNumber(deleted.id.into()), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(deleted.revision.into()), + ), + changed: deleted.changed.then(|| setting_deleted(user_id, &deleted)), + }, + Ok(None) => SettingMutation { + response: CommunicationValue::new(CommunicationType::SyncedSettingDelete) + .with_request_id(cv) + .with_receiver(sender_wire_id(user_id)), + changed: None, + }, + Err(_) => setting_mutation_error(cv, CommunicationType::ErrorInternal), + } +} + +pub fn handle_synced_settings_list(cv: &CommunicationValue) -> CommunicationValue { + let user_id = match required_sender_id(cv) { + Ok(user_id) if user_id > 0 => user_id, + _ => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + match synced_settings::list(user_id) { + Ok(settings) => CommunicationValue::new(CommunicationType::SyncedSettingsList) + .with_request_id(cv) + .with_receiver(sender_wire_id(user_id)) + .add_typed_default( + DataType::Settings, + DataValue::Array(settings.iter().map(synced_setting_value).collect()), + ), + Err(_) => error_response(cv, CommunicationType::ErrorInternal), + } +} + +#[cfg(test)] +mod synced_settings_tests { + use super::{handle_synced_setting_get, handle_synced_setting_set, parse_setting_locator}; + use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + + fn request() -> CommunicationValue { + CommunicationValue::new(CommunicationType::SyncedSettingSet) + .with_id(1) + .with_sender(7) + .add_typed_default(DataType::SettingScope, DataValue::Str("user".to_string())) + .add_typed_default(DataType::SettingTarget, DataValue::Str(String::new())) + .add_typed_default( + DataType::SettingsName, + DataValue::Str("notifications.enabled".to_string()), + ) + } + + #[test] + fn missing_sender_is_rejected_for_synced_settings() { + let response = handle_synced_setting_get(&request().without_sender()); + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + assert_eq!(response.id(), Some(1)); + assert_eq!(response.receiver(), None); + } + + #[test] + fn user_scope_rejects_a_non_empty_target() { + let request = + request().add_typed_default(DataType::SettingTarget, DataValue::Str("123".to_string())); + + let response = handle_synced_setting_set(&request).response; + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + } + + #[test] + fn contact_scope_rejects_a_malformed_target() { + let request = request() + .add_typed_default( + DataType::SettingScope, + DataValue::Str("contact".to_string()), + ) + .add_typed_default( + DataType::SettingTarget, + DataValue::Str("not-a-user".to_string()), + ); + + let response = handle_synced_setting_set(&request).response; + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + } + + #[test] + fn community_scope_requires_an_address() { + let request = request() + .add_typed_default( + DataType::SettingScope, + DataValue::Str("community".to_string()), + ) + .add_typed_default(DataType::SettingTarget, DataValue::Str(String::new())); + + let response = handle_synced_setting_set(&request).response; + + assert!(response.is_type(CommunicationType::ErrorInvalidData)); + } + + #[test] + fn invalid_setting_name_is_rejected() { + let request = request().add_typed_default( + DataType::SettingsName, + DataValue::Str("notifications..enabled".to_string()), + ); + + let response = parse_setting_locator(&request); + + assert!(response.is_err()); + } +} diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs index 161fb93..8dde6c3 100644 --- a/iota-connection/src/relay.rs +++ b/iota-connection/src/relay.rs @@ -53,6 +53,28 @@ pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClas } } +#[cfg(test)] +mod security_tests { + use super::{MessageSecurityClass, message_security_class}; + use mtp::codec::{CommunicationType, CommunicationValue}; + + #[test] + fn synchronized_setting_requests_are_authenticated_local_requests() { + for setting_type in [ + CommunicationType::SyncedSettingSet, + CommunicationType::SyncedSettingGet, + CommunicationType::SyncedSettingDelete, + CommunicationType::SyncedSettingsList, + CommunicationType::SyncedSettingChanged, + ] { + assert_eq!( + message_security_class(&CommunicationValue::new(setting_type)), + MessageSecurityClass::AuthenticatedLocalRequest + ); + } + } +} + #[derive(Debug, Clone)] pub struct UserIdentity { pub user_id: u64, diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index ed2294f..cad4a4e 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -276,6 +276,10 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage params![user_id], )?; tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM synced_settings WHERE user_id = ?1", + params![user_id], + )?; tx.execute( "DELETE FROM sync_events WHERE user_id = ?1", params![user_id], diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index 634b8f6..e5e158a 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -1,8 +1,19 @@ +use crate::storage_error::StorageError; use crate::users::contact::Contact; use crate::util::db; use crate::util::sync::{self, EntityType, Operation}; use rusqlite::params; +pub fn has_user(storage_owner: i64, user_id: i64) -> Result { + db::with_db(|conn| { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND user_id = ?2)", + params![storage_owner, user_id], + |row| row.get(0), + )?) + }) +} + pub fn mod_user(storage_owner: i64, contact: &Contact) { if let Err(e) = db::with_db(|conn| { let tx = conn.unchecked_transaction()?; diff --git a/iota-storage/src/util/communities_util.rs b/iota-storage/src/util/communities_util.rs index c560056..3b3be8e 100644 --- a/iota-storage/src/util/communities_util.rs +++ b/iota-storage/src/util/communities_util.rs @@ -1,4 +1,6 @@ +use crate::storage_error::StorageError; use crate::util::db; +use crate::util::synced_settings::{self, SettingScope}; use rusqlite::params; #[derive(Debug, Clone)] @@ -11,6 +13,16 @@ pub struct StoredCommunity { pub struct CommunitiesUtil; impl CommunitiesUtil { + pub fn has_community(storage_owner: i64, address: &str) -> Result { + db::with_db(|conn| { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM communities WHERE storage_owner = ?1 AND address = ?2)", + params![storage_owner, address], + |row| row.get(0), + )?) + }) + } + pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { if let Err(e) = db::with_db(|conn| { conn.execute( @@ -29,16 +41,22 @@ impl CommunitiesUtil { } } - pub fn remove_community(storage_owner: i64, community_address: String) { - if let Err(e) = db::with_db(|conn| { - conn.execute( + pub fn remove_community( + storage_owner: i64, + community_address: String, + ) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( "DELETE FROM communities WHERE storage_owner = ?1 AND address = ?2", params![storage_owner, community_address], )?; - Ok(()) - }) { - eprintln!("Failed to remove_community: {}", e); - } + synced_settings::delete_scope_in_tx( + tx, + storage_owner, + SettingScope::Community, + &community_address, + ) + }) } pub fn get_communities(storage_owner: i64) -> Vec { diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 260c667..ad540af 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -464,6 +464,31 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 12 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS synced_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + scope_type TEXT NOT NULL + CHECK (scope_type IN ('user', 'contact', 'community')), + scope_key TEXT NOT NULL, + name TEXT NOT NULL, + payload TEXT NOT NULL, + revision INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 + CHECK (deleted IN (0, 1)), + UNIQUE(user_id, scope_type, scope_key, name) + ); + CREATE INDEX IF NOT EXISTS idx_synced_settings_owner + ON synced_settings (user_id, deleted); + CREATE INDEX IF NOT EXISTS idx_synced_settings_scope + ON synced_settings (user_id, scope_type, scope_key, deleted); + PRAGMA user_version = 12; + "#, + )?; + } + Ok(()) } @@ -533,7 +558,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 11); + assert_eq!(version, 12); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -552,7 +577,7 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 11); + assert_eq!(version, 12); for table in [ "sync_heads", "sync_events", @@ -561,6 +586,7 @@ mod tests { "relay_replay", "pending_relays", "relay_inbox", + "synced_settings", ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -576,4 +602,22 @@ mod tests { } Ok(()) } + + #[test] + fn adds_synced_settings_to_a_version_eleven_schema() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch("PRAGMA user_version = 11;")?; + + run_migrations_on_connection(&conn)?; + + let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + assert_eq!(version, 12); + for column in ["id", "user_id", "scope_type", "scope_key", "name", "payload", "revision", "deleted"] { + let mut statement = + conn.prepare("SELECT 1 FROM pragma_table_info('synced_settings') WHERE name = ?1")?; + assert!(statement.exists([column])?); + } + + Ok(()) + } } diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 2a1eec6..0731077 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -8,3 +8,4 @@ pub mod relay_queue; pub mod relay_replay; pub mod settings; pub mod sync; +pub mod synced_settings; diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs index a358a1d..b9303d0 100644 --- a/iota-storage/src/util/sync.rs +++ b/iota-storage/src/util/sync.rs @@ -1,21 +1,23 @@ -//! Durable per-user state journal used by device cache synchronization. +/* Durable per-user state journal used by device cache synchronization. */ use crate::storage_error::StorageError; use crate::util::db; use rusqlite::{Transaction, params}; use std::collections::BTreeMap; -pub const CACHE_SCHEMA_VERSION: i64 = 2; +pub const CACHE_SCHEMA_VERSION: i64 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntityType { Message, Contact, + Setting, } impl EntityType { fn as_str(self) -> &'static str { match self { Self::Message => "message", Self::Contact => "contact", + Self::Setting => "setting", } } } @@ -40,6 +42,8 @@ pub struct Delta { pub deleted_message_ids: Vec, pub contact_upserts: Vec, pub deleted_contact_ids: Vec, + pub setting_upserts: Vec, + pub deleted_setting_ids: Vec, } pub fn now_millis() -> i64 { @@ -145,16 +149,174 @@ pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result out.deleted_message_ids.push(id), - ("message", _) => out.message_upserts.push(id), - ("contact", "delete") => out.deleted_contact_ids.push(id), - ("contact", _) => out.contact_upserts.push(id), - _ => {} - } - } - Ok(out) + Ok(reduce_events(final_events)) }) } + +fn reduce_events(events: BTreeMap<(String, i64), String>) -> Delta { + let mut out = Delta::default(); + for ((kind, id), operation) in events { + match (kind.as_str(), operation.as_str()) { + ("message", "delete") => out.deleted_message_ids.push(id), + ("message", _) => out.message_upserts.push(id), + ("contact", "delete") => out.deleted_contact_ids.push(id), + ("contact", _) => out.contact_upserts.push(id), + ("setting", "delete") => out.deleted_setting_ids.push(id), + ("setting", _) => out.setting_upserts.push(id), + _ => {} + } + } + out +} + +#[cfg(test)] +fn reduce_event_sequence(events: I) -> Delta +where + I: IntoIterator, +{ + let mut final_events = BTreeMap::new(); + for (kind, id, operation) in events { + final_events.insert((kind, id), operation); + } + reduce_events(final_events) +} + +#[cfg(test)] +mod tests { + use super::{EntityType, Operation, reduce_event_sequence, reduce_events}; + use rusqlite::Connection; + use std::collections::BTreeMap; + + #[test] + fn setting_upsert_is_included_in_delta() { + let mut events = BTreeMap::new(); + events.insert( + (EntityType::Setting.as_str().to_string(), 7), + Operation::Upsert.as_str().to_string(), + ); + + let delta = reduce_events(events); + + assert_eq!(delta.setting_upserts, vec![7]); + assert!(delta.deleted_setting_ids.is_empty()); + } + + #[test] + fn setting_delete_is_included_in_delta() { + let mut events = BTreeMap::new(); + events.insert( + (EntityType::Setting.as_str().to_string(), 7), + Operation::Delete.as_str().to_string(), + ); + + let delta = reduce_events(events); + + assert_eq!(delta.deleted_setting_ids, vec![7]); + assert!(delta.setting_upserts.is_empty()); + } + + #[test] + fn final_setting_operation_wins() { + let mut events = BTreeMap::new(); + events.insert( + (EntityType::Setting.as_str().to_string(), 7), + Operation::Upsert.as_str().to_string(), + ); + events.insert( + (EntityType::Setting.as_str().to_string(), 8), + Operation::Delete.as_str().to_string(), + ); + + let delta = reduce_events(events); + + assert_eq!(delta.setting_upserts, vec![7]); + assert_eq!(delta.deleted_setting_ids, vec![8]); + } + + #[test] + fn setting_upsert_then_delete_resolves_to_delete() { + let delta = reduce_event_sequence([ + ( + EntityType::Setting.as_str().to_string(), + 7, + Operation::Upsert.as_str().to_string(), + ), + ( + EntityType::Setting.as_str().to_string(), + 7, + Operation::Delete.as_str().to_string(), + ), + ]); + + assert_eq!(delta.deleted_setting_ids, vec![7]); + assert!(delta.setting_upserts.is_empty()); + } + + #[test] + fn setting_delete_then_upsert_resolves_to_upsert() { + let delta = reduce_event_sequence([ + ( + EntityType::Setting.as_str().to_string(), + 7, + Operation::Delete.as_str().to_string(), + ), + ( + EntityType::Setting.as_str().to_string(), + 7, + Operation::Upsert.as_str().to_string(), + ), + ]); + + assert_eq!(delta.setting_upserts, vec![7]); + assert!(delta.deleted_setting_ids.is_empty()); + } + + #[test] + fn setting_events_share_the_user_sync_head() { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch( + " + CREATE TABLE sync_heads (user_id INTEGER PRIMARY KEY, version INTEGER NOT NULL); + CREATE TABLE sync_events ( + user_id INTEGER NOT NULL, + version INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + operation TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, version) + ); + ", + ) + .unwrap(); + let transaction = connection.unchecked_transaction().unwrap(); + let message = super::record_event( + &transaction, + 1, + EntityType::Message, + 10, + Operation::Upsert, + ) + .unwrap(); + let setting = super::record_event( + &transaction, + 1, + EntityType::Setting, + 11, + Operation::Upsert, + ) + .unwrap(); + let contact = super::record_event( + &transaction, + 1, + EntityType::Contact, + 12, + Operation::Upsert, + ) + .unwrap(); + transaction.commit().unwrap(); + + assert_eq!((message, setting, contact), (1, 2, 3)); + } +} diff --git a/iota-storage/src/util/synced_settings.rs b/iota-storage/src/util/synced_settings.rs new file mode 100644 index 0000000..61c884c --- /dev/null +++ b/iota-storage/src/util/synced_settings.rs @@ -0,0 +1,659 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use crate::util::sync::{self, EntityType, Operation}; +use rusqlite::{params, Connection, OptionalExtension, Row, Transaction}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingScope { + User, + Contact, + Community, +} + +impl SettingScope { + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Contact => "contact", + Self::Community => "community", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "user" => Some(Self::User), + "contact" => Some(Self::Contact), + "community" => Some(Self::Community), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncedSetting { + pub id: i64, + pub user_id: i64, + pub scope: SettingScope, + pub scope_key: String, + pub name: String, + pub payload: String, + pub revision: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeletedSetting { + pub id: i64, + pub revision: i64, + pub changed: bool, +} + +pub fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character.is_alphanumeric() || "_-.".contains(character)) + && !name.contains("..") +} + +fn validate_locator(scope: SettingScope, scope_key: &str, name: &str) -> Result<(), StorageError> { + if !is_valid_name(name) { + return Err(StorageError::Other( + "invalid synchronized setting name".into(), + )); + } + + match scope { + SettingScope::User if !scope_key.is_empty() => Err(StorageError::Other( + "user settings must not have a target".into(), + )), + SettingScope::Contact => { + let valid_contact = scope_key.parse::().is_ok_and(|id| id > 0); + if valid_contact { + Ok(()) + } else { + Err(StorageError::Other("invalid contact setting target".into())) + } + } + SettingScope::Community if scope_key.is_empty() => Err(StorageError::Other( + "community settings require a target".into(), + )), + _ => Ok(()), + } +} + +fn normalized_scope_key(scope: SettingScope, scope_key: &str) -> Result { + match scope { + SettingScope::Contact => scope_key + .parse::() + .map(|id| id.to_string()) + .map_err(|_| StorageError::Other("invalid contact setting target".into())), + SettingScope::User | SettingScope::Community => Ok(scope_key.to_string()), + } +} + +fn setting_from_parts( + id: i64, + user_id: i64, + scope_type: String, + scope_key: String, + name: String, + payload: String, + revision: i64, +) -> Result { + let scope = SettingScope::parse(&scope_type) + .ok_or_else(|| StorageError::Other("database contains an invalid setting scope".into()))?; + Ok(SyncedSetting { + id, + user_id, + scope, + scope_key, + name, + payload, + revision, + }) +} + +fn row_parts(row: &Row<'_>) -> rusqlite::Result<(i64, i64, String, String, String, String, i64)> { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) +} + +fn load_setting_from_tx( + tx: &Transaction<'_>, + setting_id: i64, +) -> Result { + let parts = tx.query_row( + "SELECT id, user_id, scope_type, scope_key, name, payload, revision + FROM synced_settings + WHERE id = ?1 AND deleted = 0", + [setting_id], + row_parts, + )?; + setting_from_parts( + parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, + ) +} + +pub fn set( + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, + payload: &str, +) -> Result { + if user_id <= 0 { + return Err(StorageError::Other("invalid setting owner".into())); + } + validate_locator(scope, scope_key, name)?; + let scope_key = normalized_scope_key(scope, scope_key)?; + + db::with_immediate_transaction(|tx| set_in_tx(tx, user_id, scope, &scope_key, name, payload)) +} + +fn set_in_tx( + tx: &Transaction<'_>, + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, + payload: &str, +) -> Result { + tx.execute( + "INSERT INTO synced_settings + (user_id, scope_type, scope_key, name, payload, revision, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 0) + ON CONFLICT(user_id, scope_type, scope_key, name) DO NOTHING", + params![user_id, scope.as_str(), scope_key, name, payload], + )?; + let setting_id: i64 = tx.query_row( + "SELECT id FROM synced_settings + WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND name = ?4", + params![user_id, scope.as_str(), scope_key, name], + |row| row.get(0), + )?; + let revision = sync::record_event( + tx, + user_id, + EntityType::Setting, + setting_id, + Operation::Upsert, + )?; + tx.execute( + "UPDATE synced_settings + SET payload = ?2, deleted = 0, revision = ?3 + WHERE id = ?1", + params![setting_id, payload, revision], + )?; + load_setting_from_tx(tx, setting_id) +} + +pub fn get( + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, +) -> Result, StorageError> { + if user_id <= 0 { + return Err(StorageError::Other("invalid setting owner".into())); + } + validate_locator(scope, scope_key, name)?; + let scope_key = normalized_scope_key(scope, scope_key)?; + db::with_db(|conn| get_from_connection(conn, user_id, scope, &scope_key, name)) +} + +fn get_from_connection( + conn: &Connection, + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, +) -> Result, StorageError> { + let parts = conn + .query_row( + "SELECT id, user_id, scope_type, scope_key, name, payload, revision + FROM synced_settings + WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 + AND name = ?4 AND deleted = 0", + params![user_id, scope.as_str(), scope_key, name], + row_parts, + ) + .optional()?; + parts + .map(|parts| { + setting_from_parts( + parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, + ) + }) + .transpose() +} + +pub fn list(user_id: i64) -> Result, StorageError> { + if user_id <= 0 { + return Err(StorageError::Other("invalid setting owner".into())); + } + db::with_db(|conn| list_from_connection(conn, user_id)) +} + +fn list_from_connection( + conn: &Connection, + user_id: i64, +) -> Result, StorageError> { + let mut statement = conn.prepare( + "SELECT id, user_id, scope_type, scope_key, name, payload, revision + FROM synced_settings + WHERE user_id = ?1 AND deleted = 0 + ORDER BY id ASC", + )?; + let rows = statement.query_map([user_id], row_parts)?; + let mut settings = Vec::new(); + for row in rows { + let parts = row?; + settings.push(setting_from_parts( + parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, + )?); + } + Ok(settings) +} + +pub fn list_by_ids(user_id: i64, ids: &[i64]) -> Result, StorageError> { + if user_id <= 0 { + return Err(StorageError::Other("invalid setting owner".into())); + } + if ids.is_empty() { + return Ok(Vec::new()); + } + db::with_db(|conn| list_by_ids_from_connection(conn, user_id, ids)) +} + +fn list_by_ids_from_connection( + conn: &Connection, + user_id: i64, + ids: &[i64], +) -> Result, StorageError> { + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(", "); + let query = format!( + "SELECT id, user_id, scope_type, scope_key, name, payload, revision + FROM synced_settings + WHERE user_id = ? AND deleted = 0 AND id IN ({placeholders}) + ORDER BY id ASC" + ); + let mut values = Vec::with_capacity(ids.len() + 1); + values.push(user_id); + values.extend_from_slice(ids); + let mut statement = conn.prepare(&query)?; + let rows = statement.query_map(rusqlite::params_from_iter(values), row_parts)?; + let mut settings = Vec::new(); + for row in rows { + let parts = row?; + settings.push(setting_from_parts( + parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, + )?); + } + Ok(settings) +} + +pub fn delete( + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, +) -> Result, StorageError> { + if user_id <= 0 { + return Err(StorageError::Other("invalid setting owner".into())); + } + validate_locator(scope, scope_key, name)?; + let scope_key = normalized_scope_key(scope, scope_key)?; + db::with_immediate_transaction(|tx| delete_in_tx(tx, user_id, scope, &scope_key, name)) +} + +fn delete_in_tx( + tx: &Transaction<'_>, + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, +) -> Result, StorageError> { + let existing = tx + .query_row( + "SELECT id, revision, deleted FROM synced_settings + WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND name = ?4", + params![user_id, scope.as_str(), scope_key, name], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((setting_id, current_revision, deleted)) = existing else { + return Ok(None); + }; + if deleted != 0 { + return Ok(Some(DeletedSetting { + id: setting_id, + revision: current_revision, + changed: false, + })); + } + + let revision = sync::record_event( + tx, + user_id, + EntityType::Setting, + setting_id, + Operation::Delete, + )?; + tx.execute( + "UPDATE synced_settings SET deleted = 1, revision = ?2 WHERE id = ?1", + params![setting_id, revision], + )?; + Ok(Some(DeletedSetting { + id: setting_id, + revision, + changed: true, + })) +} + +pub(crate) fn delete_scope_in_tx( + tx: &Transaction<'_>, + user_id: i64, + scope: SettingScope, + scope_key: &str, +) -> Result<(), StorageError> { + let setting_ids = { + let mut statement = tx.prepare( + "SELECT id FROM synced_settings + WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND deleted = 0", + )?; + let rows = statement.query_map(params![user_id, scope.as_str(), scope_key], |row| { + row.get::<_, i64>(0) + })?; + rows.collect::, _>>()? + }; + + for setting_id in setting_ids { + let revision = sync::record_event( + tx, + user_id, + EntityType::Setting, + setting_id, + Operation::Delete, + )?; + tx.execute( + "UPDATE synced_settings SET deleted = 1, revision = ?2 WHERE id = ?1", + params![setting_id, revision], + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + delete_in_tx, get_from_connection, is_valid_name, list_by_ids_from_connection, + list_from_connection, set_in_tx, SettingScope, + }; + use rusqlite::Connection; + + fn connection() -> Connection { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch( + " + CREATE TABLE sync_heads ( + user_id INTEGER PRIMARY KEY, + version INTEGER NOT NULL + ); + CREATE TABLE sync_events ( + user_id INTEGER NOT NULL, + version INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + operation TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, version) + ); + CREATE TABLE synced_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + scope_type TEXT NOT NULL, + scope_key TEXT NOT NULL, + name TEXT NOT NULL, + payload TEXT NOT NULL, + revision INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, scope_type, scope_key, name) + ); + ", + ) + .unwrap(); + connection + } + + fn set( + connection: &mut Connection, + user_id: i64, + scope: SettingScope, + scope_key: &str, + name: &str, + payload: &str, + ) -> super::SyncedSetting { + let transaction = connection.transaction().unwrap(); + let setting = set_in_tx(&transaction, user_id, scope, scope_key, name, payload).unwrap(); + transaction.commit().unwrap(); + setting + } + + #[test] + fn parses_supported_setting_scopes() { + assert_eq!(SettingScope::parse("user"), Some(SettingScope::User)); + assert_eq!(SettingScope::parse("contact"), Some(SettingScope::Contact)); + assert_eq!( + SettingScope::parse("community"), + Some(SettingScope::Community) + ); + } + + #[test] + fn rejects_unknown_setting_scope() { + assert_eq!(SettingScope::parse("device"), None); + } + + #[test] + fn validates_setting_name_syntax() { + assert!(is_valid_name("notifications.enabled")); + assert!(!is_valid_name("notifications..enabled")); + assert!(!is_valid_name("")); + assert!(!is_valid_name("notifications/enabled")); + } + + #[test] + fn stores_and_loads_a_user_setting() { + let mut connection = connection(); + let stored = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "true", + ); + let loaded = get_from_connection( + &connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + ) + .unwrap(); + + assert_eq!(loaded, Some(stored)); + } + + #[test] + fn contact_and_community_targets_are_distinct() { + let mut connection = connection(); + set( + &mut connection, + 1, + SettingScope::Contact, + "123", + "notifications.enabled", + "false", + ); + set( + &mut connection, + 1, + SettingScope::Community, + "community-a", + "notifications.enabled", + "true", + ); + + assert_eq!(list_from_connection(&connection, 1).unwrap().len(), 2); + } + + #[test] + fn users_store_same_setting_independently() { + let mut connection = connection(); + set( + &mut connection, + 1, + SettingScope::User, + "", + "receipts.user_read", + "true", + ); + set( + &mut connection, + 2, + SettingScope::User, + "", + "receipts.user_read", + "false", + ); + + assert_eq!( + get_from_connection(&connection, 1, SettingScope::User, "", "receipts.user_read") + .unwrap() + .unwrap() + .payload, + "true" + ); + assert_eq!( + get_from_connection(&connection, 2, SettingScope::User, "", "receipts.user_read") + .unwrap() + .unwrap() + .payload, + "false" + ); + } + + #[test] + fn update_retains_id_and_advances_revision() { + let mut connection = connection(); + let first = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "true", + ); + let second = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "false", + ); + + assert_eq!(second.id, first.id); + assert!(second.revision > first.revision); + } + + #[test] + fn delete_tombstones_setting_and_records_delta_delete() { + let mut connection = connection(); + let stored = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "true", + ); + let transaction = connection.transaction().unwrap(); + let deleted = delete_in_tx( + &transaction, + 1, + SettingScope::User, + "", + "notifications.enabled", + ) + .unwrap() + .unwrap(); + transaction.commit().unwrap(); + let journal_operation: String = connection + .query_row( + "SELECT operation FROM sync_events WHERE entity_id = ?1 ORDER BY version DESC LIMIT 1", + [stored.id], + |row| row.get(0), + ) + .unwrap(); + + assert_eq!(deleted.id, stored.id); + assert!(deleted.changed); + assert_eq!(journal_operation, "delete"); + assert!(list_from_connection(&connection, 1).unwrap().is_empty()); + assert!(list_by_ids_from_connection(&connection, 1, &[stored.id]) + .unwrap() + .is_empty()); + } + + #[test] + fn setting_can_be_recreated_with_the_same_id() { + let mut connection = connection(); + let first = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "true", + ); + let transaction = connection.transaction().unwrap(); + delete_in_tx( + &transaction, + 1, + SettingScope::User, + "", + "notifications.enabled", + ) + .unwrap(); + transaction.commit().unwrap(); + let recreated = set( + &mut connection, + 1, + SettingScope::User, + "", + "notifications.enabled", + "false", + ); + + assert_eq!(recreated.id, first.id); + assert_eq!(recreated.payload, "false"); + } +} diff --git a/mtp-type-maps b/mtp-type-maps index f3c5037..f4e45aa 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit f3c5037b0a099ae5389486eec77471bd5addab4a +Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index c4773f7..5902891 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -14,6 +14,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "client", "crypto", "files", + "raw", ] } dashmap = "6.2.1" diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 0c37013..119c9a4 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -12,7 +12,6 @@ use mtp::crypto::{Keyring, PublicKeyBundle}; use rand_core::RngCore; use std::env; use std::fs; -use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -38,9 +37,6 @@ use iota_util::route_target::RouteTarget; // ============================================================================ const IOTA_KEYRING_PATH: &str = "iota.mk"; -const IDENTITY_SECRET_ENV: &str = "IOTA_IDENTITY_SECRET"; -const IDENTITY_SECRET_FILE_ENV: &str = "IOTA_IDENTITY_SECRET_FILE"; -const SYSTEMD_IDENTITY_CREDENTIAL: &str = "iota-identity"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -107,7 +103,6 @@ const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; pub enum IdentityError { Storage(mtp::files::FileError), Directory(std::io::Error), - Secret(String), InvalidLegacyIdentity, Verification(String), } @@ -117,7 +112,6 @@ impl std::fmt::Display for IdentityError { match self { Self::Storage(error) => write!(f, "identity storage error: {error}"), Self::Directory(error) => write!(f, "unable to create identity directory: {error}"), - Self::Secret(error) => write!(f, "unable to load identity secret: {error}"), Self::InvalidLegacyIdentity => f.write_str("legacy identity is invalid"), Self::Verification(error) => { write!(f, "persisted identity could not be verified: {error}") @@ -154,68 +148,13 @@ fn wire_user_id(user_id: i64) -> u64 { u64::try_from(user_id).expect("validated user ID is non-negative") } -fn load_identity_secret() -> Result, IdentityError> { - if let Some(path) = env::var_os(IDENTITY_SECRET_FILE_ENV) { - let path = PathBuf::from(path); - let mut secret = fs::read(&path) - .map_err(|error| IdentityError::Secret(format!("{}: {error}", path.display())))?; - while matches!(secret.last(), Some(b'\n' | b'\r')) { - secret.pop(); - } - if secret.is_empty() { - return Err(IdentityError::Secret(format!( - "{} is empty", - path.display() - ))); - } - return Ok(secret); - } - - if let Ok(secret) = env::var(IDENTITY_SECRET_ENV) { - if secret.is_empty() { - return Err(IdentityError::Secret(format!( - "{IDENTITY_SECRET_ENV} is empty" - ))); - } - return Ok(secret.into_bytes()); - } - - if let Ok(credentials_dir) = env::var("CREDENTIALS_DIRECTORY") { - let path = Path::new(&credentials_dir).join(SYSTEMD_IDENTITY_CREDENTIAL); - let mut secret = fs::read(&path) - .map_err(|error| IdentityError::Secret(format!("{}: {error}", path.display())))?; - while matches!(secret.last(), Some(b'\n' | b'\r')) { - secret.pop(); - } - if secret.is_empty() { - return Err(IdentityError::Secret(format!( - "{} is empty", - path.display() - ))); - } - return Ok(secret); - } - - Err(IdentityError::Secret(format!( - "set {IDENTITY_SECRET_FILE_ENV}, {IDENTITY_SECRET_ENV}, or a systemd identity credential" - ))) -} - -fn load_legacy_raw_keyring(path: &Path) -> Result { - let bytes = fs::read(path).map_err(|error| IdentityError::Storage(error.into()))?; - if bytes.len() < 5 || bytes[..4] != *b"MTMK" || bytes[4] != 1 { - return Err(IdentityError::InvalidLegacyIdentity); - } - Keyring::from_bytes(&bytes[5..]).map_err(|_| IdentityError::InvalidLegacyIdentity) -} - -fn save_protected_keyring_verified( - keyring: &Keyring, - path: &Path, - passphrase: &[u8], -) -> Result<(), IdentityError> { - mtp::files::save_keyring(keyring, path, passphrase).map_err(IdentityError::Storage)?; - let persisted = mtp::files::load_keyring(path, passphrase).map_err(IdentityError::Storage)?; +/* + * The identity is stored in the Iota state directory as raw keyring bytes so + * daemon restarts do not depend on a separately managed passphrase. + */ +fn save_keyring_verified(keyring: &Keyring, path: &Path) -> Result<(), IdentityError> { + mtp::files::save_keyring_raw(keyring, path).map_err(IdentityError::Storage)?; + let persisted = mtp::files::load_keyring_raw(path).map_err(IdentityError::Storage)?; let expected = keyring .try_to_bytes() .map_err(|error| IdentityError::Verification(error.to_string()))?; @@ -233,7 +172,6 @@ fn save_protected_keyring_verified( fn load_or_migrate_keyring_at( path: &Path, legacy: Option, - passphrase: &[u8], ) -> Result { if let Some(parent) = path .parent() @@ -242,14 +180,9 @@ fn load_or_migrate_keyring_at( fs::create_dir_all(parent).map_err(IdentityError::Directory)?; } - match mtp::files::load_keyring(path, passphrase) { + match mtp::files::load_keyring_raw(path) { Ok(keyring) => return Ok(keyring), - Err(mtp::files::FileError::Io(error)) if error.kind() == ErrorKind::NotFound => {} - Err(mtp::files::FileError::UnprotectedKeyring) => { - let keyring = load_legacy_raw_keyring(path)?; - save_protected_keyring_verified(&keyring, path, passphrase)?; - return Ok(keyring); - } + Err(mtp::files::FileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(IdentityError::Storage(error)), } @@ -266,7 +199,7 @@ fn load_or_migrate_keyring_at( } }; - save_protected_keyring_verified(&keyring, path, passphrase)?; + save_keyring_verified(&keyring, path)?; Ok(keyring) } @@ -486,9 +419,8 @@ impl OmikronConnection { self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); - let identity_secret = load_identity_secret().map_err(|error| error.to_string())?; let keyring = Arc::new( - self.load_or_migrate_keyring(&identity_secret) + self.load_or_migrate_keyring() .await .map_err(|error| format!("Iota identity initialization failed: {error}"))?, ); @@ -597,8 +529,8 @@ impl OmikronConnection { // Identity (own Keyring, migrated from the legacy base64-in-config format) // ------------------------------------------------------------------------- - async fn load_or_migrate_keyring(&self, passphrase: &[u8]) -> Result { - load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone(), passphrase) + async fn load_or_migrate_keyring(&self) -> Result { + load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone()) } // ------------------------------------------------------------------------- @@ -1063,7 +995,10 @@ impl OmikronConnection { ) { Ok(value) => value, Err(error) => { - log!("Relay shared-Iota origin content verification failed: {}", error); + log!( + "Relay shared-Iota origin content verification failed: {}", + error + ); let _ = relay_replay::mark_rejected( verified.context.signer_id, &verified.context.message_id, @@ -1103,7 +1038,10 @@ impl OmikronConnection { &verified.context.message_id, accepted_at, ) { - log!("Relay shared-Iota destination timestamp storage failed: {}", error); + log!( + "Relay shared-Iota destination timestamp storage failed: {}", + error + ); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) .await; return; @@ -1188,10 +1126,9 @@ impl OmikronConnection { .await; return; } - if let Err(error) = relay_replay::mark_queued( - verified.context.signer_id, - &verified.context.message_id, - ) { + if let Err(error) = + relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id) + { log!("Relay origin state update failed: {}", error); } match self @@ -1199,9 +1136,7 @@ impl OmikronConnection { .await { Ok(response) if response.is_type(CommunicationType::Success) => { - let returned_id = response - .get_data(DataType::RelayMessageId) - .as_str(); + let returned_id = response.get_data(DataType::RelayMessageId).as_str(); let destination_accepted_at = response .get_data(DataType::RelayAcceptedAt) .as_number() @@ -1228,7 +1163,10 @@ impl OmikronConnection { &verified.context.message_id, destination_accepted_at, ) { - log!("Relay destination acknowledgement storage failed: {}", error); + log!( + "Relay destination acknowledgement storage failed: {}", + error + ); } } if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { @@ -1337,26 +1275,21 @@ impl OmikronConnection { return; } }; - if let Err(error) = - message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - match i64::try_from(destination) { - Ok(value) => value, - Err(_) => { - log!("Relay destination ID exceeds storage range"); - self.send_relay_response( - frame.id(), - CommunicationType::ErrorInvalidData, - ) + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + accepted_at, + match i64::try_from(destination) { + Ok(value) => value, + Err(_) => { + log!("Relay destination ID exceeds storage range"); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; - return; - } - }, - false, - ) - { + return; + } + }, + false, + ) { log!("Relay application dispatch failed: {}", error); if let Err(queue_error) = relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) @@ -1371,10 +1304,9 @@ impl OmikronConnection { .await; return; } - if let Err(error) = relay_replay::mark_applied( - verified.context.signer_id, - &verified.context.message_id, - ) { + if let Err(error) = + relay_replay::mark_applied(verified.context.signer_id, &verified.context.message_id) + { log!("Relay application state update failed: {}", error); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) .await; @@ -1382,10 +1314,9 @@ impl OmikronConnection { } } - if let Err(error) = relay_replay::mark_queued( - verified.context.signer_id, - &verified.context.message_id, - ) { + if let Err(error) = + relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id) + { log!("Relay queue state update failed: {}", error); } self.send_relay_success( @@ -1564,6 +1495,10 @@ impl OmikronConnection { dispatch!(SettingsSave, handle_settings_save); dispatch!(SettingsLoad, handle_settings_load); dispatch!(SettingsList, handle_settings_list); + dispatch!(SyncedSettingSet, handle_synced_setting_set); + dispatch!(SyncedSettingGet, handle_synced_setting_get); + dispatch!(SyncedSettingDelete, handle_synced_setting_delete); + dispatch!(SyncedSettingsList, handle_synced_settings_list); dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); } @@ -2143,6 +2078,34 @@ impl OmikronConnection { .await; } + async fn handle_synced_setting_set(self: Arc, cv: &CommunicationValue) { + let mutation = message_handlers::handle_synced_setting_set(cv); + let _ = self.send_message(&mutation.response).await; + if let Some(changed) = mutation.changed { + let _ = self.send_message(&changed).await; + } + } + + async fn handle_synced_setting_get(self: Arc, cv: &CommunicationValue) { + let _ = self + .send_message(&message_handlers::handle_synced_setting_get(cv)) + .await; + } + + async fn handle_synced_setting_delete(self: Arc, cv: &CommunicationValue) { + let mutation = message_handlers::handle_synced_setting_delete(cv); + let _ = self.send_message(&mutation.response).await; + if let Some(changed) = mutation.changed { + let _ = self.send_message(&changed).await; + } + } + + async fn handle_synced_settings_list(self: Arc, cv: &CommunicationValue) { + let _ = self + .send_message(&message_handlers::handle_synced_settings_list(cv)) + .await; + } + // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- @@ -2329,8 +2292,6 @@ impl OmikronConnection { /// recovery does not silently destroy the user's previous identity. pub async fn rotate_identity(self: &Arc) -> Result<(), OmikronError> { log!("Iota identity rotation requested"); - let identity_secret = - load_identity_secret().map_err(|error| OmikronError::Internal(error.to_string()))?; self.stop().await; let path = identity_path(); @@ -2361,7 +2322,7 @@ impl OmikronConnection { )) })?; } - save_protected_keyring_verified(&keyring, path, &identity_secret).map_err(|error| { + save_keyring_verified(&keyring, path).map_err(|error| { OmikronError::Internal(format!( "could not save new identity {}: {error}", path.display() @@ -2542,16 +2503,15 @@ mod tests { } #[test] - fn generated_identity_is_protected_and_survives_reload() { + fn generated_identity_is_unprotected_and_survives_reload() { let path = test_path("reload"); - let passphrase = b"test identity secret"; - let keyring = load_or_migrate_keyring_at(&path, None, passphrase).expect("identity saves"); - let reloaded = load_or_migrate_keyring_at(&path, None, passphrase).expect("identity loads"); + let keyring = load_or_migrate_keyring_at(&path, None).expect("identity saves"); + let reloaded = load_or_migrate_keyring_at(&path, None).expect("identity loads"); assert_eq!( keyring.try_to_bytes().expect("keyring serializes"), reloaded.try_to_bytes().expect("keyring serializes") ); - assert!(mtp::files::load_keyring(&path, b"wrong secret").is_err()); + assert!(mtp::files::load_keyring_raw(&path).is_ok()); let _ = fs::remove_file(path); } @@ -2559,14 +2519,14 @@ mod tests { fn corrupt_existing_identity_does_not_generate_a_replacement() { let path = test_path("corrupt"); fs::write(&path, b"not a keyring").expect("corrupt fixture writes"); - let error = load_or_migrate_keyring_at(&path, None, b"test identity secret") - .expect_err("corrupt identity must fail"); + let error = + load_or_migrate_keyring_at(&path, None).expect_err("corrupt identity must fail"); assert!(matches!(error, IdentityError::Storage(_))); let _ = fs::remove_file(path); } #[test] - fn legacy_raw_identity_is_migrated_only_when_the_raw_format_is_valid() { + fn legacy_raw_identity_is_loaded_only_when_the_raw_format_is_valid() { let path = test_path("legacy"); let keyring = crypto_helper::generate_keyring(); let mut raw = b"MTMK".to_vec(); @@ -2574,8 +2534,7 @@ mod tests { raw.extend_from_slice(&keyring.try_to_bytes().expect("keyring serializes")); fs::write(&path, raw).expect("legacy fixture writes"); - let migrated = load_or_migrate_keyring_at(&path, None, b"test identity secret") - .expect("legacy identity migrates"); + let migrated = load_or_migrate_keyring_at(&path, None).expect("legacy identity loads"); assert_eq!( migrated.try_to_bytes().expect("keyring serializes"), keyring.try_to_bytes().expect("keyring serializes") @@ -2588,7 +2547,7 @@ mod tests { let parent = test_path("parent-file"); fs::write(&parent, b"not a directory").expect("parent fixture writes"); let path = parent.join("iota.mk"); - let error = load_or_migrate_keyring_at(&path, None, b"test identity secret") + let error = load_or_migrate_keyring_at(&path, None) .expect_err("directory failure must be returned"); assert!(matches!(error, IdentityError::Directory(_))); let _ = fs::remove_file(parent); diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index 2c6340b..a5a2903 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -17,7 +17,6 @@ Environment=IOTA_SOCKET=/run/iota/iota.sock Environment=IOTA_DATA_DIR=/var/lib/iota Environment=IOTA_DEPLOYMENT_MODE=system_always_on Environment=IOTA_SUPERVISOR=systemd -LoadCredential=iota-identity:/etc/iota/iota-identity.secret # Exit code 75 = restart requested (daemon-specific convention) RestartPreventExitStatus=0 From dd69b5bd97f3e790493e867e1b901c29037f4dc4 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:18:01 +0200 Subject: [PATCH 118/119] [Fix] Connections --- Cargo.lock | 3 + client/src/client_connection.rs | 10 +- iota-connection/src/message_handlers.rs | 186 ++++++++------ iota-daemon-lib/src/command_router.rs | 117 ++++++--- iota-storage/src/users/mod.rs | 5 +- iota-storage/src/users/pending_operations.rs | 173 +++++++++++++ iota-storage/src/users/user_manager.rs | 68 ++--- iota-storage/src/util/chat_files.rs | 43 +++- iota-storage/src/util/chats_util.rs | 51 ++-- iota-storage/src/util/db.rs | 60 ++++- iota-storage/src/util/sync.rs | 33 +-- iota-storage/src/util/synced_settings.rs | 14 +- iota-util/src/file_util.rs | 135 +++++++--- iota-util/src/tu.rs | 8 +- omikron-connector/src/omikron_connection.rs | 132 ++++++++-- omikron-connector/src/user_ops.rs | 256 ++++++++++++++++--- web-ui/Cargo.toml | 3 + web-ui/src/api.rs | 52 +++- web-ui/src/server.rs | 2 +- 19 files changed, 1010 insertions(+), 341 deletions(-) create mode 100644 iota-storage/src/users/pending_operations.rs diff --git a/Cargo.lock b/Cargo.lock index baa33b5..e506091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5017,7 +5017,10 @@ name = "web-ui" version = "0.1.0" dependencies = [ "actix-web", + "iota-cli", + "iota-ipc", "iota-logger", + "iota-paths", "iota-state", "iota-storage", "iota-util", diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 0dda93b..f52f6f1 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -280,12 +280,6 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::AddConversation) { - self.send_message(&message_handlers::handle_add_conversation(&cv)) - .await; - return; - } - if cv.is_type(CommunicationType::AddCommunity) { self.send_message(&message_handlers::handle_add_community(&cv)) .await; @@ -416,7 +410,9 @@ impl ClientConnection { return; }; - let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { return }; + let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { + return; + }; let solved = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index ae7e392..f912259 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -10,6 +10,7 @@ use mtp::codec::{ }; use crate::relay::VerifiedRelayContext; +use iota_storage::storage_error::StorageError; #[derive(Debug)] pub struct MessageMutation { @@ -61,6 +62,20 @@ pub fn success_response(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::Success) } +fn add_conversation_for_user( + user_id: i64, + other_id: i64, + name: Option<&str>, +) -> Result<(), StorageError> { + let mut contact = get_user(user_id, other_id)? + .unwrap_or_else(|| iota_storage::users::contact::Contact::new(other_id)); + if let Some(name) = name { + contact.user_name = Some(name.to_string()); + } + contact.set_last_message_at(now_millis_i64()); + mod_user(user_id, &contact) +} + fn relay_field<'a>( payload: &'a DataValue, data_type: DataType, @@ -142,14 +157,11 @@ pub fn apply_verified_relay_content( match content.message_type.as_str() { "MessageState" => { - let partner_id = relay_number( - &content.content, - DataType::ChatPartnerId, - &context.type_map, - ) - .and_then(|value| i64::try_from(value).ok()) - .filter(|id| *id == recipient_id) - .ok_or_else(|| "Relay MessageState has an invalid ChatPartnerId".to_string())?; + let partner_id = + relay_number(&content.content, DataType::ChatPartnerId, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .filter(|id| *id == recipient_id) + .ok_or_else(|| "Relay MessageState has an invalid ChatPartnerId".to_string())?; let relay_message_id = relay_string( &content.content, DataType::RelayMessageId, @@ -159,14 +171,10 @@ pub fn apply_verified_relay_content( let event_at = relay_number(&content.content, DataType::EventAt, &context.type_map) .and_then(|value| i64::try_from(value).ok()) .ok_or_else(|| "Relay MessageState is missing EventAt".to_string())?; - let state = relay_string( - &content.content, - DataType::MessageState, - &context.type_map, - ) - .map(MessageState::from_str) - .filter(|state| matches!(state, MessageState::Received | MessageState::Read)) - .ok_or_else(|| "Relay MessageState has an invalid state".to_string())?; + let state = relay_string(&content.content, DataType::MessageState, &context.type_map) + .map(MessageState::from_str) + .filter(|state| matches!(state, MessageState::Received | MessageState::Read)) + .ok_or_else(|| "Relay MessageState has an invalid state".to_string())?; chat_files::record_message_receipt( storage_owner, recipient_id, @@ -192,22 +200,28 @@ pub fn apply_verified_relay_content( .unwrap_or_default(); let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map) .and_then(|value| i64::try_from(value).ok()); - let relay_message_id = relay_string( + if relay_string( &content.content, DataType::RelayMessageId, &context.type_map, ) - .ok_or_else(|| "Relay MessageSend is missing RelayMessageId".to_string())?; - if relay_message_id != context.message_id { - return Err("Relay MessageSend identity does not match its protected message ID".into()); + .is_some_and(|relay_message_id| relay_message_id != context.message_id) + { + return Err( + "Relay MessageSend identity does not match its protected message ID".into(), + ); } chat_files::add_message(chat_files::NewMessage { relay_signer_id: sender_id, - relay_message_id, + relay_message_id: &context.message_id, authored_at: created_at, send_time, storage_owner, - external_user: if sent_by_self { recipient_id } else { sender_id }, + external_user: if sent_by_self { + recipient_id + } else { + sender_id + }, sent_by_self, content: message, height, @@ -256,7 +270,7 @@ pub fn apply_verified_relay_content( .ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?; let recipient = recipients .into_iter() - .find(|value| value.user_id == context.final_recipient_id.to_string()) + .find(|value| value.user_id == storage_owner.to_string()) .ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?; let chat_id = data_string(&frame, DataType::ChatId) .ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?; @@ -267,7 +281,7 @@ pub fn apply_verified_relay_content( let wrapping_scheme = data_string(&frame, DataType::WrappingScheme) .ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?; e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { - user_id: context.final_recipient_id.to_string(), + user_id: storage_owner.to_string(), chat_id, secret_id, version, @@ -279,6 +293,29 @@ pub fn apply_verified_relay_content( }) .map_err(|error| error.to_string()) } + "AddConversation" => { + let other_id = + relay_number(&content.content, DataType::ChatPartnerId, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .filter(|id| *id > 0) + .ok_or_else(|| { + "Relay AddConversation has an invalid ChatPartnerId".to_string() + })?; + let user_id = storage_owner; + if user_id <= 0 { + return Err("Relay AddConversation has an invalid storage owner".into()); + } + add_conversation_for_user( + user_id, + other_id, + relay_string( + &content.content, + DataType::ChatPartnerName, + &context.type_map, + ), + ) + .map_err(|error| format!("AddConversation persistence failed: {error}")) + } _ => Ok(()), } } @@ -369,10 +406,7 @@ fn stored_message_fields( DataType::SendTime, DataValue::SignedNumber(message.message_time as i128), ), - ( - DataType::Content, - DataValue::Str(message.content.clone()), - ), + (DataType::Content, DataValue::Str(message.content.clone())), ( DataType::MessageState, DataValue::Str(message.message_state.clone()), @@ -398,10 +432,19 @@ fn stored_message_fields( } for (data_type, timestamp) in [ (DataType::AuthoredAt, message.authored_at), - (DataType::OriginIotaReceivedAt, message.origin_iota_received_at), - (DataType::DestinationIotaReceivedAt, message.destination_iota_received_at), + ( + DataType::OriginIotaReceivedAt, + message.origin_iota_received_at, + ), + ( + DataType::DestinationIotaReceivedAt, + message.destination_iota_received_at, + ), (DataType::ClientReceivedAt, message.client_received_at), - (DataType::ClientReceivedRecordedAt, message.client_received_recorded_at), + ( + DataType::ClientReceivedRecordedAt, + message.client_received_recorded_at, + ), (DataType::ReadAt, message.read_at), (DataType::ReadRecordedAt, message.read_recorded_at), ] { @@ -543,7 +586,11 @@ pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { .to_string(); if !app_identifier.is_empty() && !app_public_key.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + let user = match iota_storage::users::user_manager::get_user(sender_id) { + Ok(user) => user, + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + if let Some(mut user) = user { if !user.trusted_apps.contains_key(&app_identifier) { user.trusted_apps.insert(app_identifier, app_public_key); iota_storage::users::user_manager::update_user(user); @@ -568,7 +615,11 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .to_string(); if !app_identifier.is_empty() { - if let Some(mut user) = iota_storage::users::user_manager::get_user(sender_id) { + let user = match iota_storage::users::user_manager::get_user(sender_id) { + Ok(user) => user, + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + if let Some(mut user) = user { if user.trusted_apps.contains_key(&app_identifier) { user.trusted_apps.remove(&app_identifier); iota_storage::users::user_manager::update_user(user); @@ -612,12 +663,12 @@ fn contact_value( typed_container(fields) } -fn current_contact_ids(user_id: i64) -> DataValue { - contact_ids_value( - chats_util::get_users(user_id) +fn current_contact_ids(user_id: i64) -> Result { + Ok(contact_ids_value( + chats_util::get_users(user_id)? .into_iter() .map(|contact| contact.user_id), - ) + )) } fn contact_ids_value(ids: impl IntoIterator) -> DataValue { @@ -714,7 +765,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { Err(_) => return sync_error(cv), }; ( - chats_util::get_users(user_id), + match chats_util::get_users(user_id) { + Ok(contacts) => contacts, + Err(_) => return sync_error(cv), + }, chat_files::get_all_messages(user_id), settings, Vec::new(), @@ -731,7 +785,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { Err(_) => return sync_error(cv), }; ( - chats_util::get_users_by_ids(user_id, &delta.contact_upserts), + match chats_util::get_users_by_ids(user_id, &delta.contact_upserts) { + Ok(contacts) => contacts, + Err(_) => return sync_error(cv), + }, chat_files::get_messages_by_ids(user_id, &delta.message_upserts), settings, delta.deleted_message_ids, @@ -746,7 +803,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { Err(_) => return sync_error(cv), }; ( - chats_util::get_users(user_id), + match chats_util::get_users(user_id) { + Ok(contacts) => contacts, + Err(_) => return sync_error(cv), + }, chat_files::get_all_messages(user_id), settings, Vec::new(), @@ -761,6 +821,10 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .iter() .map(|message| stored_message_value(message, user_id, message.external_user)) .collect(); + let contact_ids = match current_contact_ids(user_id) { + Ok(contact_ids) => contact_ids, + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; CommunicationValue::new(CommunicationType::ClientStateSync) .with_request_id(cv) .with_receiver(sender_wire_id(user_id)) @@ -822,7 +886,7 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { .collect(), ), ) - .add_typed_default(DataType::UserIds, current_contact_ids(user_id)) + .add_typed_default(DataType::UserIds, contact_ids) .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())) } @@ -948,7 +1012,10 @@ pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { let Ok(user_id_i64) = i64::try_from(user_id) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; - let users = chats_util::get_users(user_id_i64); + let users = match chats_util::get_users(user_id_i64) { + Ok(users) => users, + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; let mut user_array = Vec::new(); for user in users { let mut container = Vec::new(); @@ -970,41 +1037,6 @@ pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { .add_typed_default(DataType::UserIds, DataValue::Array(user_array)) } -pub fn handle_add_conversation(cv: &CommunicationValue) -> CommunicationValue { - let user_id = match cv.require_sender() { - Ok(user_id) => user_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(user_id_i64) = i64::try_from(user_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let session_id = match data_i64(cv, DataType::SessionId) { - Some(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let other_id = match data_i64(cv, DataType::ChatPartnerId) { - Some(id) if id > 0 => id, - _ => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let mut contact = get_user(user_id_i64, other_id) - .unwrap_or(iota_storage::users::contact::Contact::new(other_id)); - - if let Some(name) = cv.get_data(DataType::ChatPartnerName).as_str() { - contact.user_name = Some(name.to_string()); - } - - contact.set_last_message_at(now_millis_i64()); - mod_user(user_id_i64, &contact); - CommunicationValue::new(CommunicationType::AddConversation) - .with_request_id(cv) - .with_receiver(user_id) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) - .add_typed_default(DataType::UserIds, current_contact_ids(user_id_i64)) -} - pub fn handle_add_community(cv: &CommunicationValue) -> CommunicationValue { let sender_id = match required_sender_id(cv) { Ok(sender_id) => sender_id, diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index a6a2718..2e3c3e0 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -7,11 +7,14 @@ use iota_ipc::{ TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; use iota_logger::{log, log_command}; +use iota_storage::users::pending_operations::{ + self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, +}; use iota_storage::users::user_manager; use iota_storage::util::config_util::{self}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -26,6 +29,13 @@ pub struct PeerContext { const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512; +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + fn bounded_log_entries(mut entries: Vec) -> Vec { entries.truncate(MAX_LOG_ENTRIES_PER_RESPONSE); while !entries.is_empty() { @@ -152,32 +162,38 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_residency() + let users = user_manager::get_residency() .into_iter() - .map(|user| UserSummary { - credential_present: user.state == user_manager::LocalUserState::Managed - && user_manager::get_user(user.user_id).is_some_and(|profile| { - iota_util::file_util::read_user_credential_with_legacy( - user.user_id, - &profile.username, - ) - .ok() - .flatten() - .is_some() - }), - user_id: user.user_id, - username: user.username, - state: match user.state { - user_manager::LocalUserState::Managed => { - iota_ipc::LocalUserState::Managed - } - user_manager::LocalUserState::Released => { - iota_ipc::LocalUserState::Released - } - }, - data_present: user.data_present, + .map(|user| { + let profile = user_manager::get_user(user.user_id)?; + Ok(UserSummary { + credential_present: user.state == user_manager::LocalUserState::Managed + && profile.is_some_and(|profile| { + iota_util::file_util::read_user_credential_with_legacy( + user.user_id, + &profile.username, + ) + .ok() + .flatten() + .is_some() + }), + user_id: user.user_id, + username: user.username, + state: match user.state { + user_manager::LocalUserState::Managed => { + iota_ipc::LocalUserState::Managed + } + user_manager::LocalUserState::Released => { + iota_ipc::LocalUserState::Released + } + }, + data_present: user.data_present, + }) }) - .collect(); + .collect::, iota_storage::storage_error::StorageError>>(); + let Ok(users) = users else { + return ResponseResult::Error(IpcErrorCode::StorageFailure); + }; ResponseResult::Ok(ResponsePayload::Users(users)) } LocalRequest::CreateUser { username } => { @@ -206,6 +222,9 @@ impl CommandRouter { omikron_connector::user_ops::CreateUserError::RemoteRejected => { ResponseResult::Error(IpcErrorCode::Conflict) } + omikron_connector::user_ops::CreateUserError::LocalFinalizationPending { .. } => { + ResponseResult::Error(IpcErrorCode::StorageFailure) + } omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => { ResponseResult::Error(IpcErrorCode::StorageFailure) } @@ -247,7 +266,8 @@ impl CommandRouter { let contents = match credential { Some(value) => Ok(value.0), None => user_manager::get_user(user_id) - .ok_or(()) + .map_err(|_| ()) + .and_then(|user| user.ok_or(())) .and_then(|user| { iota_util::file_util::read_user_credential_with_legacy( user_id, @@ -278,8 +298,27 @@ impl CommandRouter { } LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::ReleaseUser { user_id } => { - if user_manager::get_user(user_id).is_none() { + let user = match user_manager::get_user(user_id) { + Ok(user) => user, + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let Some(user) = user else { return ResponseResult::Error(IpcErrorCode::NotFound); + }; + if pending_operations::upsert(&PendingUserOperation { + user_id, + operation: PendingUserOperationKind::Release, + username: user.username, + public_key: None, + private_key_hash: None, + reset_token: None, + registration_token: None, + phase: PendingUserOperationPhase::Prepared, + created_at: now_millis(), + }) + .is_err() + { + return ResponseResult::Error(IpcErrorCode::StorageFailure); } let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); @@ -291,11 +330,14 @@ impl CommandRouter { { Ok(response) if response.is_type(CommunicationType::Success) => { match user_manager::release_user(user_id) { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!( - "Released user {user_id}; hosted data was retained" - ), - }), + Ok(()) if pending_operations::remove(user_id).is_ok() => { + ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Released user {user_id}; hosted data was retained" + ), + }) + } + Ok(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), Err(error) => { log!( "Remote release succeeded but local cleanup failed for {user_id}: {error}" @@ -305,9 +347,13 @@ impl CommandRouter { } } Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => { + let _ = pending_operations::remove(user_id); ResponseResult::Error(IpcErrorCode::Unauthorized) } - Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict), + Ok(_) => { + let _ = pending_operations::remove(user_id); + ResponseResult::Error(IpcErrorCode::Conflict) + } Err(omikron_connector::OmikronError::Timeout(_)) => { ResponseResult::Error(IpcErrorCode::Timeout) } @@ -406,7 +452,7 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Components(components)) } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Some(user) => { + Ok(Some(user)) => { let credential_present = iota_util::file_util::read_user_credential_with_legacy( user_id, @@ -429,7 +475,8 @@ impl CommandRouter { credential_present, })) } - None => ResponseResult::Error(IpcErrorCode::NotFound), + Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure), }, LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { diff --git a/iota-storage/src/users/mod.rs b/iota-storage/src/users/mod.rs index cd4fa45..dad90f9 100644 --- a/iota-storage/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,3 +1,4 @@ -pub mod contact; -pub mod user_manager; +pub mod contact; +pub mod pending_operations; +pub mod user_manager; pub mod user_profile; diff --git a/iota-storage/src/users/pending_operations.rs b/iota-storage/src/users/pending_operations.rs new file mode 100644 index 0000000..4993f17 --- /dev/null +++ b/iota-storage/src/users/pending_operations.rs @@ -0,0 +1,173 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::params; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PendingUserOperationKind { + Create, + Attach, + Release, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PendingUserOperationPhase { + Prepared, + CredentialWritten, + RemoteCommitted, + LocalCommitted, +} + +impl PendingUserOperationPhase { + fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::CredentialWritten => "credential_written", + Self::RemoteCommitted => "remote_committed", + Self::LocalCommitted => "local_committed", + } + } + + fn parse(value: &str) -> Result { + match value { + "prepared" => Ok(Self::Prepared), + "credential_written" => Ok(Self::CredentialWritten), + "remote_committed" => Ok(Self::RemoteCommitted), + "local_committed" => Ok(Self::LocalCommitted), + _ => Err(StorageError::Other( + "unknown pending user operation phase".into(), + )), + } + } +} + +impl PendingUserOperationKind { + fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Attach => "attach", + Self::Release => "release", + } + } + + fn parse(value: &str) -> Result { + match value { + "create" => Ok(Self::Create), + "attach" => Ok(Self::Attach), + "release" => Ok(Self::Release), + _ => Err(StorageError::Other("unknown pending user operation".into())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingUserOperation { + pub user_id: i64, + pub operation: PendingUserOperationKind, + pub username: String, + pub public_key: Option, + pub private_key_hash: Option, + pub reset_token: Option, + pub registration_token: Option, + pub phase: PendingUserOperationPhase, + pub created_at: i64, +} + +pub fn upsert(operation: &PendingUserOperation) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( + r#" + INSERT INTO pending_user_operations ( + user_id, operation, username, public_key, private_key_hash, + reset_token, registration_token, phase, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(user_id) DO UPDATE SET + operation = excluded.operation, + username = excluded.username, + public_key = excluded.public_key, + private_key_hash = excluded.private_key_hash, + reset_token = excluded.reset_token, + registration_token = excluded.registration_token, + phase = excluded.phase, + created_at = excluded.created_at + "#, + params![ + operation.user_id, + operation.operation.as_str(), + operation.username, + operation.public_key, + operation.private_key_hash, + operation.reset_token, + operation.registration_token, + operation.phase.as_str(), + operation.created_at, + ], + )?; + Ok(()) + }) +} + +pub fn get_all() -> Result, StorageError> { + db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT user_id, operation, username, public_key, private_key_hash, reset_token, registration_token, phase, created_at FROM pending_user_operations ORDER BY created_at", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + })?; + rows.map(|row| { + let ( + user_id, + operation, + username, + public_key, + private_key_hash, + reset_token, + registration_token, + phase, + created_at, + ) = row?; + Ok(PendingUserOperation { + user_id, + operation: PendingUserOperationKind::parse(&operation)?, + username, + public_key, + private_key_hash, + reset_token, + registration_token, + phase: PendingUserOperationPhase::parse(&phase)?, + created_at, + }) + }) + .collect() + }) +} + +pub fn update_phase(user_id: i64, phase: PendingUserOperationPhase) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( + "UPDATE pending_user_operations SET phase = ?1 WHERE user_id = ?2", + params![phase.as_str(), user_id], + )?; + Ok(()) + }) +} + +pub fn remove(user_id: i64) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( + "DELETE FROM pending_user_operations WHERE user_id = ?1", + [user_id], + )?; + Ok(()) + }) +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index cad4a4e..90068f4 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -32,8 +32,8 @@ pub fn add_user(user: UserProfile) { } pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { - db::with_db(|conn| { - conn.execute( + db::with_immediate_transaction(|tx| { + tx.execute( r#" INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) @@ -56,7 +56,7 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora )?; for (app_id, app_secret) in &user.trusted_apps { - conn.execute( + tx.execute( r#" INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) VALUES (?1, ?2, ?3) @@ -64,7 +64,7 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora params![user.user_id, app_id, app_secret], )?; } - conn.execute( + tx.execute( r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#, @@ -93,7 +93,7 @@ pub fn get_user_by_username(username: &str) -> Option { private_key_hash: r.get(3)?, created_at: r.get(5)?, reset_token: r.get(4)?, - trusted_apps: load_trusted_apps(user_id), + trusted_apps: std::collections::HashMap::new(), }) }, ) { @@ -110,8 +110,8 @@ pub fn get_user_by_username(username: &str) -> Option { } } -pub fn get_user(user_id: i64) -> Option { - match db::with_db(|conn| { +pub fn get_user(user_id: i64) -> Result, crate::storage_error::StorageError> { + let user = db::with_db(|conn| { match conn.query_row( "SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name FROM users WHERE user_id = ?1 LIMIT 1", params![user_id], @@ -125,7 +125,7 @@ pub fn get_user(user_id: i64) -> Option { private_key_hash: r.get(3)?, created_at: r.get(5)?, reset_token: r.get(4)?, - trusted_apps: load_trusted_apps(user_id), + trusted_apps: std::collections::HashMap::new(), }) }, ) { @@ -133,13 +133,12 @@ pub fn get_user(user_id: i64) -> Option { Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), } - }) { - Ok(opt) => opt, - Err(e) => { - eprintln!("Error querying user: {}", e); - None - } - } + })?; + user.map(|mut user| { + user.trusted_apps = load_trusted_apps(user_id)?; + Ok(user) + }) + .transpose() } pub fn get_users() -> Vec { @@ -177,7 +176,7 @@ pub fn get_users() -> Vec { for row in rows { match row { Ok(mut user) => { - user.trusted_apps = load_trusted_apps(user.user_id); + user.trusted_apps = load_trusted_apps(user.user_id)?; out.push(user); } Err(e) => eprintln!("Failed to read user row: {}", e), @@ -193,8 +192,10 @@ pub fn get_users() -> Vec { } } -fn load_trusted_apps(user_id: i64) -> std::collections::HashMap { - match db::with_db(|conn| { +fn load_trusted_apps( + user_id: i64, +) -> Result, crate::storage_error::StorageError> { + db::with_db(|conn| { let mut stmt = conn.prepare("SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1")?; let rows = stmt.query_map(params![user_id], |r| { @@ -203,18 +204,11 @@ fn load_trusted_apps(user_id: i64) -> std::collections::HashMap let mut map = std::collections::HashMap::new(); for row in rows { - if let Ok((k, v)) = row { - map.insert(k, v); - } + let (key, value) = row?; + map.insert(key, value); } Ok(map) - }) { - Ok(m) => m, - Err(e) => { - eprintln!("Failed to load trusted apps: {}", e); - std::collections::HashMap::new() - } - } + }) } pub fn remove_user(user_id: i64) { @@ -233,9 +227,11 @@ pub fn remove_user(user_id: i64) { /// Remove only local management authority. Hosted content is intentionally /// retained and is indexed as released for a later purge operation. pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { - let username = get_user(user_id).map(|user| user.username).ok_or_else(|| { - crate::storage_error::StorageError::Other("managed user was not found".into()) - })?; + let username = get_user(user_id)? + .map(|user| user.username) + .ok_or_else(|| { + crate::storage_error::StorageError::Other("managed user was not found".into()) + })?; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( @@ -252,7 +248,7 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr tx.commit()?; Ok(()) })?; - remove_user_credential(user_id) + remove_user_credential(user_id, Some(&username)) .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } @@ -312,6 +308,12 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage /// Complete local erasure is idempotent and is the target for a durable /// Omega-hosted erasure request after account deletion. pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + let username = get_user(user_id)?.map(|user| user.username).or_else(|| { + get_residency() + .into_iter() + .find(|entry| entry.user_id == user_id) + .map(|entry| entry.username) + }); purge_user_data(user_id)?; db::with_db(|conn| { conn.execute( @@ -325,7 +327,7 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor )?; Ok(()) })?; - remove_user_credential(user_id) + remove_user_credential(user_id, username.as_deref()) .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index 76695e5..94aefb7 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -523,7 +523,7 @@ pub fn add_message(message: NewMessage<'_>) -> Result { let mut contact = crate::users::contact::Contact::new(external_user); contact.set_last_message_at(send_time); - crate::util::chats_util::mod_user(storage_owner, &contact); + crate::util::chats_util::mod_user(storage_owner, &contact)?; Ok(msg_id) } @@ -546,8 +546,17 @@ pub fn change_message_state_by_relay_id( return Ok(()); }; let state = MessageState::from_str(¤t).upgrade(new_state).as_str(); - tx.execute("UPDATE messages SET message_state = ?1 WHERE id = ?2", params![state, msg_id])?; - sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?; + tx.execute( + "UPDATE messages SET message_state = ?1 WHERE id = ?2", + params![state, msg_id], + )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; tx.commit()?; Ok(()) }) @@ -581,7 +590,9 @@ pub fn record_message_receipt( return Err(StorageError::Other("message receipt target was not found".into())); }; if external_user != receipt_signer_id { - return Err(StorageError::Other("message receipt signer is not the chat partner".into())); + return Err(StorageError::Other( + "message receipt signer is not the chat partner".into(), + )); } tx.execute( "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", @@ -592,9 +603,11 @@ pub fn record_message_receipt( } else { ("client_received_at", "client_received_recorded_at") }; - let state = MessageState::from_str( - &tx.query_row("SELECT message_state FROM messages WHERE id = ?1", [message_id], |row| row.get::<_, String>(0))?, - ) + let state = MessageState::from_str(&tx.query_row( + "SELECT message_state FROM messages WHERE id = ?1", + [message_id], + |row| row.get::<_, String>(0), + )?) .upgrade(MessageState::from_str(receipt_type)) .as_str() .to_string(); @@ -602,7 +615,13 @@ pub fn record_message_receipt( &format!("UPDATE messages SET {state_column} = COALESCE({state_column}, ?1), {recorded_column} = COALESCE({recorded_column}, ?2), message_state = ?3 WHERE id = ?4"), params![event_at, recorded_at, state, message_id], )?; - sync::record_event(&tx, storage_owner, EntityType::Message, message_id, Operation::Upsert)?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + message_id, + Operation::Upsert, + )?; tx.commit()?; Ok(()) }) @@ -630,7 +649,13 @@ pub fn record_destination_iota_received( "UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![accepted_at, message_id], )?; - sync::record_event(&tx, storage_owner, EntityType::Message, message_id, Operation::Upsert)?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + message_id, + Operation::Upsert, + )?; tx.commit()?; Ok(()) }) diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index e5e158a..1fd3cb0 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -14,9 +14,8 @@ pub fn has_user(storage_owner: i64, user_id: i64) -> Result }) } -pub fn mod_user(storage_owner: i64, contact: &Contact) { - if let Err(e) = db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; +pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { tx.execute( r#" INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) @@ -28,37 +27,34 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) { params![ storage_owner, contact.user_id, - contact.user_name.clone(), + contact.user_name, contact.last_message_at, ], )?; sync::record_event( - &tx, + tx, storage_owner, EntityType::Contact, contact.user_id, Operation::Upsert, )?; - tx.commit()?; Ok(()) - }) { - eprintln!("Failed to mod_user: {}", e); - } + }) } -pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { +pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Result, StorageError> { if ids.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let wanted: std::collections::HashSet = ids.iter().copied().collect(); - get_users(storage_owner) + Ok(get_users(storage_owner)? .into_iter() .filter(|contact| wanted.contains(&contact.user_id)) - .collect() + .collect()) } -pub fn get_user(storage_owner: i64, user_id: i64) -> Option { - match db::with_db(|conn| { +pub fn get_user(storage_owner: i64, user_id: i64) -> Result, StorageError> { + db::with_db(|conn| { match conn.query_row( r#" SELECT user_id, user_name, last_message_at @@ -79,17 +75,11 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Option { Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), } - }) { - Ok(opt) => opt, - Err(e) => { - eprintln!("Error querying user in get_user: {}", e); - None - } - } + }) } -pub fn get_users(storage_owner: i64) -> Vec { - match db::with_db(|conn| { +pub fn get_users(storage_owner: i64) -> Result, StorageError> { + db::with_db(|conn| { let mut stmt = conn.prepare( r#" SELECT user_id, user_name, last_message_at @@ -112,17 +102,8 @@ pub fn get_users(storage_owner: i64) -> Vec { let mut out = Vec::new(); for row in rows { - match row { - Ok(contact) => out.push(contact), - Err(e) => eprintln!("Failed to read contact row: {}", e), - } + out.push(row?); } Ok(out) - }) { - Ok(v) => v, - Err(e) => { - eprintln!("Failed to query contacts in get_users: {}", e); - Vec::new() - } - } + }) } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index ad540af..3e69a6f 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -421,9 +421,15 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { ("relay_message_id", "relay_message_id TEXT"), ("authored_at", "authored_at INTEGER"), ("origin_iota_received_at", "origin_iota_received_at INTEGER"), - ("destination_iota_received_at", "destination_iota_received_at INTEGER"), + ( + "destination_iota_received_at", + "destination_iota_received_at INTEGER", + ), ("client_received_at", "client_received_at INTEGER"), - ("client_received_recorded_at", "client_received_recorded_at INTEGER"), + ( + "client_received_recorded_at", + "client_received_recorded_at INTEGER", + ), ("read_at", "read_at INTEGER"), ("read_recorded_at", "read_recorded_at INTEGER"), ] { @@ -489,6 +495,38 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 13 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS pending_user_operations ( + user_id INTEGER PRIMARY KEY, + operation TEXT NOT NULL + CHECK (operation IN ('create', 'attach', 'release')), + username TEXT NOT NULL, + public_key TEXT, + private_key_hash TEXT, + reset_token TEXT, + registration_token TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pending_user_operations_operation + ON pending_user_operations (operation, created_at); + PRAGMA user_version = 13; + "#, + )?; + } + + if current_version < 14 { + conn.execute_batch( + r#" + ALTER TABLE pending_user_operations + ADD COLUMN phase TEXT NOT NULL DEFAULT 'prepared' + CHECK (phase IN ('prepared', 'credential_written', 'remote_committed', 'local_committed')); + PRAGMA user_version = 14; + "#, + )?; + } + Ok(()) } @@ -558,7 +596,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 12); + assert_eq!(version, 13); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -577,7 +615,7 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 12); + assert_eq!(version, 13); for table in [ "sync_heads", "sync_events", @@ -587,6 +625,7 @@ mod tests { "pending_relays", "relay_inbox", "synced_settings", + "pending_user_operations", ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -611,8 +650,17 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 12); - for column in ["id", "user_id", "scope_type", "scope_key", "name", "payload", "revision", "deleted"] { + assert_eq!(version, 13); + for column in [ + "id", + "user_id", + "scope_type", + "scope_key", + "name", + "payload", + "revision", + "deleted", + ] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('synced_settings') WHERE name = ?1")?; assert!(statement.exists([column])?); diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs index b9303d0..8ef8eb2 100644 --- a/iota-storage/src/util/sync.rs +++ b/iota-storage/src/util/sync.rs @@ -291,30 +291,15 @@ mod tests { ) .unwrap(); let transaction = connection.unchecked_transaction().unwrap(); - let message = super::record_event( - &transaction, - 1, - EntityType::Message, - 10, - Operation::Upsert, - ) - .unwrap(); - let setting = super::record_event( - &transaction, - 1, - EntityType::Setting, - 11, - Operation::Upsert, - ) - .unwrap(); - let contact = super::record_event( - &transaction, - 1, - EntityType::Contact, - 12, - Operation::Upsert, - ) - .unwrap(); + let message = + super::record_event(&transaction, 1, EntityType::Message, 10, Operation::Upsert) + .unwrap(); + let setting = + super::record_event(&transaction, 1, EntityType::Setting, 11, Operation::Upsert) + .unwrap(); + let contact = + super::record_event(&transaction, 1, EntityType::Contact, 12, Operation::Upsert) + .unwrap(); transaction.commit().unwrap(); assert_eq!((message, setting, contact), (1, 2, 3)); diff --git a/iota-storage/src/util/synced_settings.rs b/iota-storage/src/util/synced_settings.rs index 61c884c..ef7c574 100644 --- a/iota-storage/src/util/synced_settings.rs +++ b/iota-storage/src/util/synced_settings.rs @@ -1,7 +1,7 @@ use crate::storage_error::StorageError; use crate::util::db; use crate::util::sync::{self, EntityType, Operation}; -use rusqlite::{params, Connection, OptionalExtension, Row, Transaction}; +use rusqlite::{Connection, OptionalExtension, Row, Transaction, params}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingScope { @@ -401,8 +401,8 @@ pub(crate) fn delete_scope_in_tx( #[cfg(test)] mod tests { use super::{ - delete_in_tx, get_from_connection, is_valid_name, list_by_ids_from_connection, - list_from_connection, set_in_tx, SettingScope, + SettingScope, delete_in_tx, get_from_connection, is_valid_name, + list_by_ids_from_connection, list_from_connection, set_in_tx, }; use rusqlite::Connection; @@ -618,9 +618,11 @@ mod tests { assert!(deleted.changed); assert_eq!(journal_operation, "delete"); assert!(list_from_connection(&connection, 1).unwrap().is_empty()); - assert!(list_by_ids_from_connection(&connection, 1, &[stored.id]) - .unwrap() - .is_empty()); + assert!( + list_by_ids_from_connection(&connection, 1, &[stored.id]) + .unwrap() + .is_empty() + ); } #[test] diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index b70f7cc..0232148 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -43,14 +43,35 @@ pub fn delete_user_directory(user_id: i64) -> io::Result<()> { fs::remove_dir_all(user_dir) } -pub fn credential_path(user_id: i64) -> PathBuf { - storage_directory() - .join("credentials") - .join(format!("{user_id}.tu")) +fn credential_filename(username: &str) -> io::Result { + if username.is_empty() + || username.chars().any(char::is_control) + || username.contains(['/', '\\']) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe credential owner name", + )); + } + Ok(format!("{username}.tu")) } -pub fn read_user_credential(user_id: i64) -> io::Result> { - let path = credential_path(user_id); +pub fn credential_path(username: &str) -> io::Result { + credential_path_in(&storage_directory(), username) +} + +fn credential_path_in(root: &Path, username: &str) -> io::Result { + Ok(root + .join("credentials") + .join(credential_filename(username)?)) +} + +fn legacy_credential_path(user_id: i64) -> io::Result { + storage_file("credentials", format!("{user_id}.tu")) +} + +pub fn read_user_credential(username: &str) -> io::Result> { + let path = credential_path(username)?; match fs::read_to_string(path) { Ok(value) => Ok(Some(value)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), @@ -58,55 +79,66 @@ pub fn read_user_credential(user_id: i64) -> io::Result> { } } -/// Resolve a credential by immutable account id. A valid legacy -/// `.tu` is migrated atomically the first time it is encountered. +/* Resolve a credential by account id while using the owner's name for the + * canonical filename. Older ID-based and root-level files are migrated when + * they are encountered. */ pub fn read_user_credential_with_legacy( user_id: i64, username: &str, ) -> io::Result> { - if let Some(credential) = read_user_credential(user_id)? { + let canonical_path = credential_path(username)?; + if let Some(credential) = read_user_credential(username)? { return Ok(Some(credential)); } - let legacy = storage_file("", format!("{username}.tu"))?; - let credential = match fs::read_to_string(&legacy) { - Ok(value) => value, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error), - }; - let parsed = crate::tu::TuCredential::parse(&credential) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - if parsed.user_id != user_id { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "legacy credential user id mismatch", - )); + let legacy_paths = [ + legacy_credential_path(user_id)?, + storage_file("", credential_filename(username)?)?, + ]; + for legacy_path in legacy_paths { + let credential = match fs::read_to_string(&legacy_path) { + Ok(value) => value, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + let parsed = crate::tu::TuCredential::parse(&credential) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if parsed.user_id != user_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "legacy credential user id mismatch", + )); + } + write_user_credential(username, &parsed.to_canonical_string())?; + if legacy_path != canonical_path { + fs::remove_file(legacy_path)?; + } + return Ok(Some(parsed.to_canonical_string())); } - write_user_credential(user_id, &parsed.to_canonical_string())?; - fs::remove_file(legacy)?; - Ok(Some(parsed.to_canonical_string())) + Ok(None) } -pub fn write_user_credential(user_id: i64, credential: &str) -> io::Result<()> { - let path = credential_path(user_id); - let parent = path.parent().expect("credential path has parent"); - fs::create_dir_all(parent)?; - let temporary = parent.join(format!(".{user_id}.tu.tmp")); - fs::write(&temporary, credential)?; - if let Err(error) = fs::rename(&temporary, &path) { - let _ = fs::remove_file(&temporary); - return Err(error); +pub fn write_user_credential(username: &str, credential: &str) -> io::Result<()> { + let path = credential_path(username)?; + crate::atomic_file::replace_private(&path, credential.as_bytes(), 0) +} + +pub fn remove_user_credential(user_id: i64, username: Option<&str>) -> io::Result<()> { + let mut paths = vec![legacy_credential_path(user_id)?]; + if let Some(username) = username { + paths.push(credential_path(username)?); + paths.push(storage_file("", credential_filename(username)?)?); + } + + for path in paths { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } } Ok(()) } -pub fn remove_user_credential(user_id: i64) -> io::Result<()> { - match fs::remove_file(credential_path(user_id)) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - pub fn load_file_buf(path: &str, name: &str) -> io::Result> { let file_path = storage_file(path, name)?; @@ -455,3 +487,22 @@ pub async fn download_and_extract_zip(url: &str, as_name: &str) { println!("Downloaded and extracted ZIP file successfully."); } } + +#[cfg(test)] +mod tests { + use super::credential_path_in; + use std::path::Path; + + #[test] + fn credential_path_uses_owner_name() { + let path = credential_path_in(Path::new("/tmp/iota"), "alice").unwrap(); + assert!(path.ends_with("credentials/alice.tu")); + assert!(!path.ends_with("credentials/42.tu")); + } + + #[test] + fn credential_path_rejects_unsafe_owner_name() { + assert!(credential_path_in(Path::new("/tmp/iota"), "../alice").is_err()); + assert!(credential_path_in(Path::new("/tmp/iota"), "alice/bob").is_err()); + } +} diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs index 159bea6..3d05508 100644 --- a/iota-util/src/tu.rs +++ b/iota-util/src/tu.rs @@ -1,8 +1,6 @@ -//! Strict parsing and storage-independent handling of user credentials. -//! -//! A `.tu` file is deliberately identified by the account id embedded in its -//! contents. Its filename is presentation data owned by the CLI, never an -//! account authority. +/* Strict parsing and storage-independent handling of user credentials. A + * `.tu` file is identified by the account ID in its contents, while storage + * names the file after its owner's username. */ use crate::crypto_helper::{keyring_from_base64, keyring_to_base64}; use mtp::crypto::{Keyring, PublicKeyBundle}; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 119c9a4..5513efa 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -754,7 +754,9 @@ impl OmikronConnection { let signer_id_i64 = i64::try_from(signer_id).map_err(|_| { RelayValidationError::KeyLookup("signer ID exceeds local storage range".into()) })?; - if let Some(user) = iota_storage::users::user_manager::get_user(signer_id_i64) { + let local_user = iota_storage::users::user_manager::get_user(signer_id_i64) + .map_err(|error| RelayValidationError::KeyLookup(error.to_string()))?; + if let Some(user) = local_user { let key = iota_util::crypto_helper::public_key_bundle_from_base64(&user.public_key) .ok_or_else(|| { RelayValidationError::KeyLookup("stored user key is invalid".into()) @@ -788,7 +790,10 @@ impl OmikronConnection { pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { let user_id_i64 = i64::try_from(user_id) .map_err(|_| "user ID exceeds local storage range".to_string())?; - if iota_storage::users::user_manager::get_user(user_id_i64).is_some() { + if iota_storage::users::user_manager::get_user(user_id_i64) + .map_err(|error| error.to_string())? + .is_some() + { return CONFIG .load() .iota_id @@ -901,14 +906,54 @@ impl OmikronConnection { } }; let accepted_at = now_millis_i64(); - let signer_is_local = i64::try_from(verified.context.signer_id) - .ok() - .and_then(iota_storage::users::user_manager::get_user) - .is_some(); - let recipient_is_local = i64::try_from(verified.context.final_recipient_id) - .ok() - .and_then(iota_storage::users::user_manager::get_user) - .is_some(); + let signer_id = match i64::try_from(verified.context.signer_id) { + Ok(id) => id, + Err(_) => { + self.send_relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ) + .await; + return; + } + }; + let recipient_id = match i64::try_from(verified.context.final_recipient_id) { + Ok(id) => id, + Err(_) => { + self.send_relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ) + .await; + return; + } + }; + let signer_is_local = match iota_storage::users::user_manager::get_user(signer_id) { + Ok(user) => user.is_some(), + Err(error) => { + log!( + "Relay locality lookup failed for signer {}: {}", + signer_id, + error + ); + self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) + .await; + return; + } + }; + let recipient_is_local = match iota_storage::users::user_manager::get_user(recipient_id) { + Ok(user) => user.is_some(), + Err(error) => { + log!( + "Relay locality lookup failed for recipient {}: {}", + recipient_id, + error + ); + self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) + .await; + return; + } + }; if !signer_is_local && !recipient_is_local { log!( "Rejecting Relay with no local origin or destination: signer {}, recipient {}", @@ -991,7 +1036,7 @@ impl OmikronConnection { let content = match open_verified_relay_content( &verified, &[&keyring], - verified.context.signer_id, + verified.context.final_recipient_id, ) { Ok(value) => value, Err(error) => { @@ -1486,7 +1531,6 @@ impl OmikronConnection { dispatch!(MessageGet, handle_message_get); dispatch!(MessagesGet, handle_messages_get); dispatch!(GetChats, handle_get_chats); - dispatch!(AddConversation, handle_add_conversation); dispatch!(AddCommunity, handle_add_community); dispatch!(GetCommunities, handle_get_communities); dispatch!(RemoveCommunity, handle_remove_community); @@ -1568,7 +1612,16 @@ impl OmikronConnection { }; let mut trusted = false; - if let Some(user) = iota_storage::users::user_manager::get_user(user_id) { + let user = match iota_storage::users::user_manager::get_user(user_id) { + Ok(user) => user, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + if let Some(user) = user { if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { if pub_k == &app_public_key { trusted = true; @@ -1861,7 +1914,17 @@ impl OmikronConnection { &mutation, vec![(DataType::Content, DataValue::Str(content.to_string()))], ); - if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() + let partner_is_local = + match iota_storage::users::user_manager::get_user(mutation.partner_id) { + Ok(user) => user.is_some(), + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + if partner_is_local && chat_files::apply_remote_edit( mutation.partner_id, mutation.sender_id, @@ -1923,7 +1986,17 @@ impl OmikronConnection { (DataType::Accepted, DataValue::Bool(add)), ], ); - if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() { + let partner_is_local = + match iota_storage::users::user_manager::get_user(mutation.partner_id) { + Ok(user) => user.is_some(), + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + if partner_is_local { let result = if add { chat_files::add_reaction( mutation.partner_id, @@ -1966,7 +2039,16 @@ impl OmikronConnection { Some(sender_id) => sender_id, None => return, }; - if iota_storage::users::user_manager::get_user(sender_id).is_none() { + let sender_is_local = match iota_storage::users::user_manager::get_user(sender_id) { + Ok(user) => user.is_some(), + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + if !sender_is_local { self.persist_and_deliver_remote_delete(cv).await; return; } @@ -1988,7 +2070,17 @@ impl OmikronConnection { &mutation, Vec::new(), ); - if iota_storage::users::user_manager::get_user(mutation.partner_id).is_some() + let partner_is_local = + match iota_storage::users::user_manager::get_user(mutation.partner_id) { + Ok(user) => user.is_some(), + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + if partner_is_local && chat_files::apply_remote_delete( mutation.partner_id, mutation.sender_id, @@ -2024,12 +2116,6 @@ impl OmikronConnection { .await; } - async fn handle_add_conversation(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_add_conversation(cv)) - .await; - } - async fn handle_add_community(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_add_community(cv)) diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 60f8429..b140358 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,16 +1,19 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; +use iota_storage::users::pending_operations::{ + self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, +}; use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_profile::UserProfile; use iota_storage::util::config_util::CONFIG; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::write_user_credential; +use iota_util::file_util::{remove_user_credential, write_user_credential}; use iota_util::mtp_compat::OptionalDataValueExt; use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; use rand_core::{OsRng, RngCore}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::OmikronClient; use crate::omega_discovery; @@ -21,6 +24,7 @@ pub enum CreateUserError { Transport(crate::OmikronError), InvalidResponse, RemoteRejected, + LocalFinalizationPending { user_id: i64 }, LocalPersistence(String), } @@ -165,15 +169,6 @@ pub async fn attach_user_from_tu( let credential = TuCredential::parse(contents) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; let (username, public_key) = inspect_credential_account(connection, &credential).await?; - credential_proof( - connection, - &credential, - CommunicationType::AttachUserBegin, - CommunicationType::AttachUserChallenge, - CommunicationType::AttachUserComplete, - b"tensamin:user-attach:v1\0", - ) - .await?; let profile = UserProfile::new( credential.user_id, username, @@ -182,10 +177,40 @@ pub async fn attach_user_from_tu( hex_hash(contents), String::new(), ); - write_user_credential(profile.user_id, &credential.to_canonical_string()) + write_user_credential(&profile.username, &credential.to_canonical_string()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + pending_operations::upsert(&PendingUserOperation { + user_id: profile.user_id, + operation: PendingUserOperationKind::Attach, + username: profile.username.clone(), + public_key: Some(profile.public_key.clone()), + private_key_hash: Some(profile.private_key_hash.clone()), + reset_token: Some(profile.reset_token.clone()), + registration_token: None, + phase: PendingUserOperationPhase::Prepared, + created_at: now_millis(), + }) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + if let Err(error) = credential_proof( + connection, + &credential, + CommunicationType::AttachUserBegin, + CommunicationType::AttachUserChallenge, + CommunicationType::AttachUserComplete, + b"tensamin:user-attach:v1\0", + ) + .await + { + if matches!(error, LifecycleUserError::RemoteRejected) { + let _ = pending_operations::remove(profile.user_id); + let _ = remove_user_credential(profile.user_id, Some(&profile.username)); + } + return Err(error); + } try_add_user(profile.clone()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + pending_operations::remove(profile.user_id) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; Ok(profile) } @@ -219,6 +244,83 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { let Ok(local_iota_id) = configured_iota_id() else { return; }; + let pending = match pending_operations::get_all() { + Ok(pending) => pending, + Err(error) => { + log!("Pending user operation reconciliation could not read storage: {error}"); + return; + } + }; + for operation in pending { + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(operation.user_id.into()), + ); + let response = connection + .await_response(&request, Duration::from_secs(10)) + .await + .ok(); + let remote_iota_id = response.as_ref().and_then(|response| { + response + .get_data(DataType::IotaId) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()) + }); + let remote_matches = response.as_ref().is_some_and(|response| { + response.is_type(CommunicationType::GetUserData) + && remote_iota_id == Some(local_iota_id) + && response.get_data(DataType::Username).as_str() == Some(&operation.username) + && response.get_data(DataType::PublicKey).as_str() + == operation.public_key.as_deref() + }); + let completion_retried = matches!(operation.operation, PendingUserOperationKind::Create) + && matches!( + operation.phase, + PendingUserOperationPhase::Prepared | PendingUserOperationPhase::CredentialWritten + ) + && !remote_matches + && complete_pending_create(connection, &operation).await; + match operation.operation { + PendingUserOperationKind::Create | PendingUserOperationKind::Attach + if remote_matches || completion_retried => + { + let credential_present = iota_util::file_util::read_user_credential_with_legacy( + operation.user_id, + &operation.username, + ) + .ok() + .flatten() + .is_some(); + if !credential_present { + log!( + "Pending user {} has no credential; leaving it unresolved", + operation.user_id + ); + continue; + } + let Some(public_key) = operation.public_key else { + continue; + }; + let profile = UserProfile::new( + operation.user_id, + operation.username, + None, + public_key, + operation.private_key_hash.unwrap_or_default(), + operation.reset_token.unwrap_or_default(), + ); + if try_add_user(profile).is_ok() { + let _ = pending_operations::remove(operation.user_id); + } + } + PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => { + if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { + let _ = pending_operations::remove(operation.user_id); + } + } + _ => {} + } + } for user in iota_storage::users::user_manager::get_users() { let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, @@ -240,11 +342,86 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { } } +/* + * Retry completion only while the locally persisted operation still owns a + * valid registration lease. Omega treats an exact repeat as idempotent, which + * repairs an interrupted request without allocating another user ID. + */ +async fn complete_pending_create( + connection: &dyn OmikronClient, + operation: &PendingUserOperation, +) -> bool { + let Some(public_key) = operation.public_key.as_ref() else { + return false; + }; + let Some(reset_token) = operation.reset_token.as_ref() else { + return false; + }; + let Some(registration_token) = operation.registration_token.as_ref() else { + return false; + }; + let request = CommunicationValue::new(CommunicationType::CompleteRegisterUser) + .add_typed_default( + DataType::UserId, + DataValue::SignedNumber(operation.user_id.into()), + ) + .add_typed_default( + DataType::Username, + DataValue::Str(operation.username.clone()), + ) + .add_typed_default(DataType::PublicKey, DataValue::Str(public_key.clone())) + .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token.clone())) + .add_typed_default( + DataType::RegisterId, + DataValue::Str(registration_token.clone()), + ); + match connection + .await_response(&request, Duration::from_secs(20)) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = pending_operations::update_phase( + operation.user_id, + PendingUserOperationPhase::RemoteCommitted, + ) { + log!( + "Pending user {} completed remotely but could not update its phase: {error}", + operation.user_id + ); + } + true + } + Ok(response) => { + log!( + "Pending user {} registration retry was rejected with {}", + operation.user_id, + response.get_type() + ); + false + } + Err(error) => { + log!( + "Pending user {} registration retry failed: {error}", + operation.user_id + ); + false + } + } +} + fn valid_username(username: &str) -> bool { !username.is_empty() - && username.chars().count() <= 15 - && !username.chars().any(char::is_control) - && !username.contains(['/', '\\']) + && username.len() <= 15 + && username + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 } async fn request_user_id(connection: &dyn OmikronClient) -> Result<(i64, String), CreateUserError> { @@ -318,6 +495,28 @@ pub async fn create_user( private_key_hash, reset_token.clone(), ); + let credential = format!( + "{}@{}::{}", + user_id, + omega_discovery::omega_host(), + keyring_b64 + ); + pending_operations::upsert(&PendingUserOperation { + user_id, + operation: PendingUserOperationKind::Create, + username: user_profile.username.clone(), + public_key: Some(user_profile.public_key.clone()), + private_key_hash: Some(user_profile.private_key_hash.clone()), + reset_token: Some(user_profile.reset_token.clone()), + registration_token: Some(registration_token.clone()), + phase: PendingUserOperationPhase::Prepared, + created_at: now_millis(), + }) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; + write_user_credential(username, &credential) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; + pending_operations::update_phase(user_id, PendingUserOperationPhase::CredentialWritten) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) @@ -337,6 +536,8 @@ pub async fn create_user( Ok(response) => { log_cv!(PrintType::Omega, response); if !response.is_type(CommunicationType::Success) { + let _ = pending_operations::remove(user_id); + let _ = remove_user_credential(user_id, Some(username)); return Err(CreateUserError::RemoteRejected); } } @@ -355,20 +556,15 @@ pub async fn create_user( } } } - log!("Created User"); - write_user_credential( - user_id, - &format!( - "{}@{}::{}", - user_id, - omega_discovery::omega_host(), - keyring_b64 - ), - ) - .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - + pending_operations::update_phase(user_id, PendingUserOperationPhase::RemoteCommitted) + .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; try_add_user(user_profile.clone()) - .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; + .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; + pending_operations::update_phase(user_id, PendingUserOperationPhase::LocalCommitted) + .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; + pending_operations::remove(user_id) + .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; + log!("Created User"); Ok(user_profile) } @@ -416,10 +612,12 @@ mod tests { #[test] fn validates_usernames_before_remote_registration() { assert!(valid_username("alice")); - assert!(valid_username("fifteen_char_ok")); + assert!(valid_username("abc123def456ghi")); assert!(!valid_username("")); assert!(!valid_username("sixteen_chars_bad")); assert!(!valid_username("path/name")); + assert!(!valid_username("upperCase")); + assert!(!valid_username("underscore_name")); assert!(!valid_username("line\nbreak")); } diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml index f6f6ff8..679a1f2 100644 --- a/web-ui/Cargo.toml +++ b/web-ui/Cargo.toml @@ -8,6 +8,9 @@ iota-storage = { path = "../iota-storage" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } iota-logger = { path = "../iota-logger" } +iota-cli = { path = "../iota-cli" } +iota-ipc = { path = "../iota-ipc" } +iota-paths = { path = "../iota-paths" } actix-web = { version = "4", features = ["rustls-0_23"] } rustls = { version = "0.23.37", features = ["aws-lc-rs"] } diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 0243a61..4359c17 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -1,5 +1,6 @@ -use crate::server::is_local_network; use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use iota_ipc::{IpcErrorCode, LocalRequest, ResponsePayload, ResponseResult}; +use iota_paths::{Scope, socket_path}; use iota_state::DaemonState; use iota_storage::util::config_util::{CONFIG, modify_config}; use serde_json::{Value, json}; @@ -153,11 +154,27 @@ async fn users_add( _ => return error(), }; - // The legacy web API is intentionally quarantined until it can use the - // daemon's authenticated command/service boundary. It must not create a - // second connector or mutate daemon storage directly. - let _ = username; - error() + let client = match iota_cli::ipc_client::IpcClient::connect(socket_path(Scope::User)).await { + Ok(client) => client, + Err(_) => return HttpResponse::ServiceUnavailable().json(json!({ "status": "not_ready" })), + }; + match client + .send_request(LocalRequest::CreateUser { + username: username.to_string(), + }) + .await + { + Ok(ResponseResult::Ok(ResponsePayload::UserCreated { user_id, username })) => { + HttpResponse::Created().json(json!({ + "uuid": user_id, + "username": username, + "has_tu": true, + })) + } + Ok(ResponseResult::Ok(_)) => HttpResponse::Created().json(json!({ "status": "created" })), + Ok(ResponseResult::Error(code)) => ipc_error_response(code), + Err(_) => HttpResponse::GatewayTimeout().json(json!({ "status": "timeout" })), + } } async fn shutdown( @@ -200,6 +217,26 @@ fn error() -> HttpResponse { HttpResponse::BadRequest().json(json!({ "type": "error" })) } +fn ipc_error_response(code: IpcErrorCode) -> HttpResponse { + let status = match code { + IpcErrorCode::InvalidRequest => actix_web::http::StatusCode::BAD_REQUEST, + IpcErrorCode::Conflict => actix_web::http::StatusCode::CONFLICT, + IpcErrorCode::NotReady | IpcErrorCode::OmikronUnavailable => { + actix_web::http::StatusCode::SERVICE_UNAVAILABLE + } + IpcErrorCode::Timeout => actix_web::http::StatusCode::GATEWAY_TIMEOUT, + IpcErrorCode::Unauthorized => actix_web::http::StatusCode::FORBIDDEN, + IpcErrorCode::StorageFailure | IpcErrorCode::InternalFailure => { + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR + } + IpcErrorCode::NotFound => actix_web::http::StatusCode::NOT_FOUND, + IpcErrorCode::UnsupportedVersion | IpcErrorCode::Disconnected | IpcErrorCode::Cancelled => { + actix_web::http::StatusCode::SERVICE_UNAVAILABLE + } + }; + HttpResponse::build(status).json(json!({ "status": code.to_string() })) +} + fn user_id(payload: &Value) -> Result { payload .get("uuid") @@ -208,7 +245,8 @@ fn user_id(payload: &Value) -> Result { } fn is_allowed(addr: SocketAddr, ssl: bool) -> bool { - is_local_network(addr.ip()) || ssl + let _ = ssl; + addr.ip().is_loopback() } fn is_allowed_req(req: &HttpRequest, ssl: bool) -> bool { diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index c73f0f8..f799b23 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -19,7 +19,7 @@ use tokio::sync::oneshot; pub async fn start(port: u16, state: Arc) -> bool { let (tx, rx) = oneshot::channel::(); - let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string()); + let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1".to_string()); let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS"); let server_state = state.clone(); From ec3f5e6a6eedb40a7f4d91aa4f987c00bf601cea Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:28:17 +0200 Subject: [PATCH 119/119] [Fix] Durability --- client/src/client_connection.rs | 5 - iota-connection/src/message_handlers.rs | 54 ++++---- iota-storage/src/users/contact.rs | 15 +-- iota-storage/src/users/user_profile.rs | 28 ++++- iota-storage/src/util/chat_files.rs | 130 ++++++++++++++------ iota-storage/src/util/chats_util.rs | 72 ++++++----- iota-storage/src/util/db.rs | 58 ++++++++- omikron-connector/src/omikron_connection.rs | 50 +++++++- omikron-connector/src/user_ops.rs | 25 +++- 9 files changed, 318 insertions(+), 119 deletions(-) diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index f52f6f1..a43c308 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -226,11 +226,6 @@ impl ClientConnection { // Direct messages // // ************************************************ // - if cv.is_type(CommunicationType::MessageState) { - message_handlers::handle_message_state(&cv); - return; - } - if cv.is_type(CommunicationType::MessageEdit) { self.send_message(&message_handlers::handle_message_edit(&cv)) .await; diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index f912259..7da9a1d 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -72,7 +72,6 @@ fn add_conversation_for_user( if let Some(name) = name { contact.user_name = Some(name.to_string()); } - contact.set_last_message_at(now_millis_i64()); mod_user(user_id, &contact) } @@ -228,7 +227,11 @@ pub fn apply_verified_relay_content( reply_to, origin_iota_received_at: sent_by_self.then_some(accepted_at), destination_iota_received_at: (!sent_by_self).then_some(accepted_at), - initial_state: MessageState::Sent, + initial_state: if sent_by_self { + MessageState::Sending + } else { + MessageState::Sent + }, }) .map_err(|error| error.to_string())?; Ok(()) @@ -430,6 +433,12 @@ fn stored_message_fields( DataValue::Str(relay_message_id.clone()), )); } + if let Some(relay_signer_id) = message.relay_signer_id { + fields.push(( + DataType::SenderId, + DataValue::SignedNumber(relay_signer_id.into()), + )); + } for (data_type, timestamp) in [ (DataType::AuthoredAt, message.authored_at), ( @@ -452,6 +461,15 @@ fn stored_message_fields( fields.push((data_type, DataValue::SignedNumber(timestamp.into()))); } } + if let Some(failed_at) = message.delivery_failed_at { + fields.push(( + DataType::UpdatedAt, + DataValue::SignedNumber(failed_at.into()), + )); + } + if let Some(failure) = &message.delivery_failure { + fields.push((DataType::ErrorType, DataValue::Str(failure.clone()))); + } if message.edited { fields.push((DataType::Edited, DataValue::Bool(true))); } @@ -644,6 +662,12 @@ fn contact_value( if let Some(name) = &contact.user_name { fields.push((DataType::Username, DataValue::Str(name.clone()))); } + if contact.created_at > 0 { + fields.push(( + DataType::CreatedAt, + DataValue::SignedNumber(contact.created_at.into()), + )); + } if let Some(last_message_at) = contact.last_message_at { fields.push(( DataType::LastMessageAt, @@ -918,26 +942,6 @@ pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { ) } -pub fn handle_message_state(cv: &CommunicationValue) { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(_) => return, - }; - let receiver_id = match data_i64(cv, DataType::ChatPartnerId) { - Some(id) if id > 0 => id, - _ => return, - }; - - let timestamp_i64 = data_i64(cv, DataType::SendTime).unwrap_or_else(now_millis_i64); - - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id, - sender_id, - MessageState::from_str(cv.get_data(DataType::MessageState).as_str().unwrap_or("")), - ); -} - pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { let my_id = match cv.require_sender() { Ok(my_id) => my_id, @@ -1026,6 +1030,12 @@ pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { if let Some(name) = user.user_name { container.push((DataType::Username, DataValue::Str(name))); } + if user.created_at > 0 { + container.push(( + DataType::CreatedAt, + DataValue::SignedNumber(user.created_at.into()), + )); + } if let Some(ts) = user.last_message_at { container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); } diff --git a/iota-storage/src/users/contact.rs b/iota-storage/src/users/contact.rs index 50d224d..4d2ca43 100644 --- a/iota-storage/src/users/contact.rs +++ b/iota-storage/src/users/contact.rs @@ -1,31 +1,32 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - #[derive(Debug, Clone)] pub struct Contact { pub user_id: i64, pub user_name: Option, + pub created_at: i64, pub last_message_at: Option, } impl Default for Contact { fn default() -> Self { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64; Contact { user_id: 0, user_name: None, - last_message_at: Some(now), + created_at: 0, + last_message_at: None, } } } impl Contact { pub fn new(user_id: i64) -> Self { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; Contact { user_id: user_id, user_name: None, + created_at, last_message_at: None, } } diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index 5f2abd5..f1aa2a3 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -27,6 +27,29 @@ impl UserProfile { public_key: String, private_key_hash: String, reset_token: String, + ) -> Self { + Self::new_with_created_at( + user_id, + username, + display_name, + public_key, + private_key_hash, + reset_token, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + ) + } + + pub fn new_with_created_at( + user_id: i64, + username: String, + display_name: Option, + public_key: String, + private_key_hash: String, + reset_token: String, + created_at: i64, ) -> Self { Self { user_id, @@ -34,10 +57,7 @@ impl UserProfile { display_name, public_key, private_key_hash, - created_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64, + created_at, reset_token, trusted_apps: std::collections::HashMap::new(), } diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index 94aefb7..051557b 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -60,6 +60,8 @@ pub struct StoredMessage { pub client_received_recorded_at: Option, pub read_at: Option, pub read_recorded_at: Option, + pub delivery_failed_at: Option, + pub delivery_failure: Option, pub content: String, pub edited: bool, pub sent_by_self: bool, @@ -483,7 +485,7 @@ pub fn add_message(message: NewMessage<'_>) -> Result { destination_iota_received_at, initial_state, } = message; - let msg_id = db::with_db(|conn| { + db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( r#" @@ -517,14 +519,16 @@ pub fn add_message(message: NewMessage<'_>) -> Result { msg_id, Operation::Upsert, )?; + let mut contact = crate::users::contact::Contact::new(external_user); + contact.set_last_message_at( + destination_iota_received_at + .or(origin_iota_received_at) + .unwrap_or(authored_at), + ); + crate::util::chats_util::upsert_contact(&tx, storage_owner, &contact)?; tx.commit()?; Ok(msg_id) - })?; - - let mut contact = crate::users::contact::Contact::new(external_user); - contact.set_last_message_at(send_time); - crate::util::chats_util::mod_user(storage_owner, &contact)?; - Ok(msg_id) + }) } pub fn change_message_state_by_relay_id( @@ -579,11 +583,11 @@ pub fn record_message_receipt( }; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; - let Some((message_id, external_user)) = tx + let Some((message_id, external_user, authored_at)) = tx .query_row( - "SELECT id, external_user FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", + "SELECT id, external_user, authored_at FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", params![storage_owner, target_signer_id, target_message_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?)), ) .optional()? else { @@ -594,6 +598,13 @@ pub fn record_message_receipt( "message receipt signer is not the chat partner".into(), )); } + if event_at > recorded_at.saturating_add(5 * 60 * 1000) + || authored_at.is_some_and(|authored_at| event_at < authored_at) + { + return Err(StorageError::Other( + "message receipt event time is outside the accepted clock range".into(), + )); + } tx.execute( "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at], @@ -646,7 +657,7 @@ pub fn record_destination_iota_received( return Ok(()); }; tx.execute( - "UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", + "UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![accepted_at, message_id], )?; sync::record_event( @@ -661,6 +672,41 @@ pub fn record_destination_iota_received( }) } +pub fn record_delivery_failure( + storage_owner: i64, + relay_signer_id: i64, + relay_message_id: &str, + failure: &str, + failed_at: i64, +) -> Result<(), StorageError> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + let Some(message_id) = tx + .query_row( + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_id, relay_message_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(()); + }; + tx.execute( + "UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3 AND destination_iota_received_at IS NULL", + params![failed_at, failure, message_id], + )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + message_id, + Operation::Upsert, + )?; + tx.commit()?; + Ok(()) + }) +} + pub fn change_message_state( timestamp: i64, storage_owner: i64, @@ -783,11 +829,11 @@ pub fn get_messages( SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, - read_recorded_at, content, sent_by_self, message_state, height, + read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 - ORDER BY message_time DESC, id DESC + ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC LIMIT ?3 OFFSET ?4 "#, )?; @@ -808,12 +854,14 @@ pub fn get_messages( client_received_recorded_at: row.get(8)?, read_at: row.get(9)?, read_recorded_at: row.get(10)?, - content: row.get(11)?, - sent_by_self: row.get::<_, i64>(12)? != 0, - message_state: row.get(13)?, - height: row.get(14).unwrap_or(0), - reply_to: row.get(15).ok().flatten(), - edited: row.get::<_, i64>(16).unwrap_or(0) > 0, + delivery_failed_at: row.get(11)?, + delivery_failure: row.get(12)?, + content: row.get(13)?, + sent_by_self: row.get::<_, i64>(14)? != 0, + message_state: row.get(15)?, + height: row.get(16).unwrap_or(0), + reply_to: row.get(17).ok().flatten(), + edited: row.get::<_, i64>(18).unwrap_or(0) > 0, reactions: Vec::new(), }) }, @@ -854,7 +902,7 @@ pub fn get_message( SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, - read_recorded_at, content, sent_by_self, message_state, height, + read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 @@ -878,13 +926,15 @@ pub fn get_message( client_received_recorded_at: row.get(8)?, read_at: row.get(9)?, read_recorded_at: row.get(10)?, - content: row.get(11)?, - sent_by_self: row.get::<_, i64>(12)? != 0, - message_state: row.get(13)?, - height: row.get(14).unwrap_or(0), - reply_to: row.get(15).ok().flatten(), - edited: row.get::<_, i64>(16).unwrap_or(0) > 0, - external_user: row.get(17)?, + delivery_failed_at: row.get(11)?, + delivery_failure: row.get(12)?, + content: row.get(13)?, + sent_by_self: row.get::<_, i64>(14)? != 0, + message_state: row.get(15)?, + height: row.get(16).unwrap_or(0), + reply_to: row.get(17).ok().flatten(), + edited: row.get::<_, i64>(18).unwrap_or(0) > 0, + external_user: row.get(19)?, reactions: Vec::new(), }) })?; @@ -928,14 +978,16 @@ pub fn get_message_with_offset( AND external_user = ?2 AND deleted_by_external = 0 AND ( - message_time > ?3 - OR (message_time = ?3 AND id > ?4) + COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) > + COALESCE(?3, ?4) + OR (COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) = + COALESCE(?3, ?4) AND id > ?4) ) "#, params![ storage_owner, external_user, - message.message_time, + message.destination_iota_received_at.or(message.origin_iota_received_at).or(message.authored_at), message.id ], |row| row.get(0), @@ -953,9 +1005,9 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec Vec(12)? != 0, - message_state: row.get(13)?, - height: row.get(14).unwrap_or(0), - reply_to: row.get(15).ok().flatten(), - edited: row.get::<_, i64>(16).unwrap_or(0) > 0, + delivery_failed_at: row.get(11)?, + delivery_failure: row.get(12)?, + content: row.get(13)?, + sent_by_self: row.get::<_, i64>(14)? != 0, + message_state: row.get(15)?, + height: row.get(16).unwrap_or(0), + reply_to: row.get(17).ok().flatten(), + edited: row.get::<_, i64>(18).unwrap_or(0) > 0, reactions: Vec::new(), }) })?; diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index 1fd3cb0..3eb4043 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -4,6 +4,42 @@ use crate::util::db; use crate::util::sync::{self, EntityType, Operation}; use rusqlite::params; +pub(crate) fn upsert_contact( + tx: &rusqlite::Transaction<'_>, + storage_owner: i64, + contact: &Contact, +) -> Result<(), StorageError> { + tx.execute( + r#" + INSERT INTO contacts (storage_owner, user_id, user_name, created_at, last_message_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(storage_owner, user_id) DO UPDATE SET + user_name = COALESCE(excluded.user_name, contacts.user_name), + created_at = MIN(contacts.created_at, excluded.created_at), + last_message_at = CASE + WHEN excluded.last_message_at IS NULL THEN contacts.last_message_at + WHEN contacts.last_message_at IS NULL THEN excluded.last_message_at + ELSE MAX(contacts.last_message_at, excluded.last_message_at) + END + "#, + params![ + storage_owner, + contact.user_id, + contact.user_name, + contact.created_at, + contact.last_message_at, + ], + )?; + sync::record_event( + tx, + storage_owner, + EntityType::Contact, + contact.user_id, + Operation::Upsert, + )?; + Ok(()) +} + pub fn has_user(storage_owner: i64, user_id: i64) -> Result { db::with_db(|conn| { Ok(conn.query_row( @@ -15,31 +51,7 @@ pub fn has_user(storage_owner: i64, user_id: i64) -> Result } pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - r#" - INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(storage_owner, user_id) DO UPDATE SET - user_name = excluded.user_name, - last_message_at = excluded.last_message_at - "#, - params![ - storage_owner, - contact.user_id, - contact.user_name, - contact.last_message_at, - ], - )?; - sync::record_event( - tx, - storage_owner, - EntityType::Contact, - contact.user_id, - Operation::Upsert, - )?; - Ok(()) - }) + db::with_immediate_transaction(|tx| upsert_contact(tx, storage_owner, contact)) } pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Result, StorageError> { @@ -57,7 +69,7 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Result, Sto db::with_db(|conn| { match conn.query_row( r#" - SELECT user_id, user_name, last_message_at + SELECT user_id, user_name, created_at, last_message_at FROM contacts WHERE storage_owner = ?1 AND user_id = ?2 LIMIT 1 @@ -67,7 +79,8 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Result, Sto Ok(Contact { user_id: r.get(0)?, user_name: r.get(1)?, - last_message_at: r.get(2)?, + created_at: r.get(2)?, + last_message_at: r.get(3)?, }) }, ) { @@ -82,7 +95,7 @@ pub fn get_users(storage_owner: i64) -> Result, StorageError> { db::with_db(|conn| { let mut stmt = conn.prepare( r#" - SELECT user_id, user_name, last_message_at + SELECT user_id, user_name, created_at, last_message_at FROM contacts WHERE storage_owner = ?1 ORDER BY @@ -96,7 +109,8 @@ pub fn get_users(storage_owner: i64) -> Result, StorageError> { Ok(Contact { user_id: r.get(0)?, user_name: r.get(1)?, - last_message_at: r.get(2)?, + created_at: r.get(2)?, + last_message_at: r.get(3)?, }) })?; diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 3e69a6f..e8cbf6e 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -180,6 +180,7 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { storage_owner INTEGER NOT NULL, user_id INTEGER NOT NULL, user_name TEXT, + created_at INTEGER NOT NULL, last_message_at INTEGER, UNIQUE(storage_owner, user_id) ); @@ -527,6 +528,57 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 15 { + let messages_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", + [], + |row| row.get(0), + )?; + if messages_exist { + conn.execute_batch( + r#" + DROP INDEX IF EXISTS idx_messages_history; + CREATE INDEX IF NOT EXISTS idx_messages_history_accepted + ON messages ( + storage_owner, + external_user, + deleted_by_external, + destination_iota_received_at DESC, + origin_iota_received_at DESC, + authored_at DESC, + id DESC + ); + "#, + )?; + } + conn.pragma_update(None, "user_version", 15)?; + } + + if current_version < 16 { + let messages_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", + [], + |row| row.get(0), + )?; + if messages_exist { + add_column_if_missing(conn, "delivery_failed_at", "delivery_failed_at INTEGER")?; + add_column_if_missing(conn, "delivery_failure", "delivery_failure TEXT")?; + } + let contacts_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'contacts')", + [], + |row| row.get(0), + )?; + if contacts_exist { + add_table_column_if_missing(conn, "contacts", "created_at", "created_at INTEGER")?; + conn.execute( + "UPDATE contacts SET created_at = COALESCE(created_at, last_message_at, 0)", + [], + )?; + } + conn.pragma_update(None, "user_version", 16)?; + } + Ok(()) } @@ -596,7 +648,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 13); + assert_eq!(version, 16); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -615,7 +667,7 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 13); + assert_eq!(version, 16); for table in [ "sync_heads", "sync_events", @@ -650,7 +702,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 13); + assert_eq!(version, 16); for column in [ "id", "user_id", diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 5513efa..adb18ee 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -40,6 +40,21 @@ const IOTA_KEYRING_PATH: &str = "iota.mk"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) { + let Ok(storage_owner) = i64::try_from(signer_id) else { + return; + }; + if let Err(error) = chat_files::record_delivery_failure( + storage_owner, + storage_owner, + relay_message_id, + failure, + now_millis_i64(), + ) { + log!("Relay delivery failure storage failed: {error}"); + } +} + /* * Keeps identity and pinned Omikron key files independent from the process * working directory, so restarts use the same trusted material. @@ -1136,6 +1151,11 @@ impl OmikronConnection { Ok(destination_iota) => destination_iota, Err(error) => { log!("Relay origin route lookup failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "destination_iota_not_found", + ); self.send_relay_response(frame.id(), CommunicationType::ErrorNoIota) .await; return; @@ -1145,6 +1165,11 @@ impl OmikronConnection { Ok(value) => value, Err(error) => { log!("Relay origin forwarding validation failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "forwarding_validation_failed", + ); self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) .await; return; @@ -1154,6 +1179,11 @@ impl OmikronConnection { Ok(bytes) => bytes, Err(error) => { log!("Relay origin retry could not be serialized: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "serialization_failed", + ); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) .await; return; @@ -1167,6 +1197,11 @@ impl OmikronConnection { &type_map_version, ) { log!("Relay origin retry queue failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "queue_failed", + ); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) .await; return; @@ -1242,6 +1277,11 @@ impl OmikronConnection { } Ok(response) => { log!("Relay origin route returned {}", response.get_type()); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "destination_rejected", + ); self.send_relay_response( frame.id(), response @@ -1252,6 +1292,11 @@ impl OmikronConnection { } Err(error) => { log!("Relay origin forwarding failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + &verified.context.message_id, + "destination_unreachable", + ); self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) .await; } @@ -1521,7 +1566,6 @@ impl OmikronConnection { dispatch!(DeleteApp, handle_delete_app); dispatch!(ClientConnected, handle_client_connected); dispatch!(ClientStateAck, handle_client_state_ack); - dispatch!(MessageState, handle_message_state); dispatch!(MessageEdit, handle_message_edit); dispatch!(MessageEditLive, handle_message_edit_live); dispatch!(MessageReactionAdd, handle_message_reaction_add); @@ -1771,10 +1815,6 @@ impl OmikronConnection { .await; } - async fn handle_message_state(self: Arc, cv: &CommunicationValue) { - message_handlers::handle_message_state(cv); - } - fn mutation_live_message( ty: CommunicationType, request: &CommunicationValue, diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index b140358..5c33416 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -84,7 +84,7 @@ fn sign_lifecycle_payload( async fn inspect_credential_account( connection: &dyn OmikronClient, credential: &TuCredential, -) -> Result<(String, String), LifecycleUserError> { +) -> Result<(String, String, i64), LifecycleUserError> { if credential.omega_host != omega_discovery::omega_host() { return Err(LifecycleUserError::OmegaHostMismatch); } @@ -108,10 +108,16 @@ async fn inspect_credential_account( .as_str() .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; + let created_at = response + .get_data(DataType::CreatedAt) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or(LifecycleUserError::RemoteRejected)?; if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { return Err(LifecycleUserError::RemoteRejected); } - Ok((username, public_key)) + Ok((username, public_key, created_at)) } async fn credential_proof( @@ -168,17 +174,17 @@ pub async fn attach_user_from_tu( ) -> Result { let credential = TuCredential::parse(contents) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - let (username, public_key) = inspect_credential_account(connection, &credential).await?; - let profile = UserProfile::new( + let (username, public_key, created_at) = + inspect_credential_account(connection, &credential).await?; + let profile = UserProfile::new_with_created_at( credential.user_id, username, None, public_key, hex_hash(contents), String::new(), + created_at, ); - write_user_credential(&profile.username, &credential.to_canonical_string()) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; pending_operations::upsert(&PendingUserOperation { user_id: profile.user_id, operation: PendingUserOperationKind::Attach, @@ -191,6 +197,13 @@ pub async fn attach_user_from_tu( created_at: now_millis(), }) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + write_user_credential(&profile.username, &credential.to_canonical_string()) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + pending_operations::update_phase( + profile.user_id, + PendingUserOperationPhase::CredentialWritten, + ) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; if let Err(error) = credential_proof( connection, &credential,