[Fix] Stability

This commit is contained in:
Alex 2026-07-27 20:37:33 +02:00
commit 14df716cf1
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
19 changed files with 483 additions and 405 deletions

View file

@ -1,2 +1,2 @@
[env] [env]
MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true } MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "mtp-type-maps"]
path = mtp-type-maps
url = ssh://git@git.methanium.net/tensamin/mtp-type-maps

38
Cargo.lock generated
View file

@ -198,7 +198,7 @@ dependencies = [
"foldhash", "foldhash",
"futures-core", "futures-core",
"futures-util", "futures-util",
"impl-more 0.3.2", "impl-more 0.3.5",
"itoa", "itoa",
"language-tags", "language-tags",
"log", "log",
@ -651,9 +651,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.3.0" version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -1327,9 +1327,9 @@ dependencies = [
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
@ -2136,9 +2136,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]] [[package]]
name = "impl-more" name = "impl-more"
version = "0.3.2" version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021" checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
@ -2956,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp" name = "mtp"
version = "0.2.0" 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 = [ dependencies = [
"mtp-client", "mtp-client",
"mtp-codec", "mtp-codec",
@ -2972,7 +2972,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-client" name = "mtp-client"
version = "0.2.0" 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 = [ dependencies = [
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
@ -2985,7 +2985,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-codec" name = "mtp-codec"
version = "0.2.0" 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 = [ dependencies = [
"base64", "base64",
"byteorder", "byteorder",
@ -2998,7 +2998,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-common" name = "mtp-common"
version = "0.2.0" 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 = [ dependencies = [
"quinn", "quinn",
"rustls", "rustls",
@ -3009,7 +3009,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-crypto" name = "mtp-crypto"
version = "0.2.0" 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 = [ dependencies = [
"base64", "base64",
"chacha20poly1305", "chacha20poly1305",
@ -3031,7 +3031,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-files" name = "mtp-files"
version = "0.2.0" 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 = [ dependencies = [
"mtp-crypto", "mtp-crypto",
"rand 0.10.2", "rand 0.10.2",
@ -3042,7 +3042,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-host" name = "mtp-host"
version = "0.2.0" 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 = [ dependencies = [
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
@ -3057,7 +3057,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-transport" name = "mtp-transport"
version = "0.2.0" 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 = [ dependencies = [
"async-trait", "async-trait",
"mtp-codec", "mtp-codec",
@ -3075,7 +3075,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-type-map" name = "mtp-type-map"
version = "0.2.0" 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 = [ dependencies = [
"serde", "serde",
"serde_yaml", "serde_yaml",
@ -3084,7 +3084,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-webserver" name = "mtp-webserver"
version = "0.2.0" 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 = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@ -4263,9 +4263,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.0" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [ dependencies = [
"web-time", "web-time",
"zeroize", "zeroize",

View file

@ -594,7 +594,7 @@ impl IpcClient {
return Err(error); 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(Ok(result)) => Ok(result),
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
Err(_) => { Err(_) => {

View file

@ -55,6 +55,17 @@ impl OverviewScreen {
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(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(""));
lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); lines.push(Line::from(Span::styled("Daemon", theme.text.heading)));

View file

@ -8,7 +8,7 @@ use iota_ipc::{
}; };
use iota_logger::{log, log_command}; use iota_logger::{log, log_command};
use iota_storage::users::user_manager; 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 mtp::codec::{CommunicationType, CommunicationValue};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@ -52,10 +52,7 @@ impl CommandRouter {
} }
let needs_omikron = matches!( let needs_omikron = matches!(
request, request,
LocalRequest::CreateUser { .. } LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. }
| LocalRequest::RemoveUser { .. }
| LocalRequest::ReconnectOmikron
| LocalRequest::RotateIotaIdentity
); );
if needs_omikron && !self.services.omikron.is_connected().await { if needs_omikron && !self.services.omikron.is_connected().await {
return ResponseResult::Error( return ResponseResult::Error(
@ -112,11 +109,33 @@ impl CommandRouter {
) )
.await .await
{ {
(Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated {
user_id: user.user_id, user_id: user.user_id,
username: user.username, 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 } => { LocalRequest::RemoveUser { user_id } => {
@ -139,16 +158,14 @@ impl CommandRouter {
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
}, },
LocalRequest::RotateIotaIdentity => { LocalRequest::RotateIotaIdentity => {
modify_config(|config| { match self.services.omikron.rotate_identity().await {
config.public_key = None;
config.private_key = None;
config.iota_id = None;
});
match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { 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 } => { LocalRequest::RequestProcessExit { intent } => {

View file

@ -61,6 +61,11 @@ impl OmikronClient for InactiveOmikron {
"terms have not been accepted".into(), "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 { async fn is_connected(&self) -> bool {
false false
} }

View file

@ -29,8 +29,12 @@ impl OmikronClient for FakeOmikron {
self.reconnects.fetch_add(1, Ordering::SeqCst); self.reconnects.fetch_add(1, Ordering::SeqCst);
Ok(()) Ok(())
} }
async fn rotate_identity(&self) -> Result<(), OmikronError> {
self.reconnects.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn is_connected(&self) -> bool { async fn is_connected(&self) -> bool {
true false
} }
} }
@ -43,6 +47,7 @@ async fn reconnect_uses_the_injected_client() {
omikron: fake.clone(), omikron: fake.clone(),
users: Default::default(), users: Default::default(),
config: Default::default(), config: Default::default(),
active: true,
}); });
let router = CommandRouter::new( let router = CommandRouter::new(
Arc::new(DaemonRuntime::new()), Arc::new(DaemonRuntime::new()),
@ -55,3 +60,29 @@ async fn reconnect_uses_the_injected_client() {
)); ));
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); 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);
}

View file

@ -140,19 +140,75 @@ async fn main() -> ExitCode {
); );
connection connection
} }
Err(omikron_connector::OmikronStartupError::Authentication) => { Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
runtime.set_component_failed( runtime.set_component_failed(
iota_ipc::ComponentId::Omikron, 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)) => { Err(omikron_connector::OmikronStartupError::Construction(error)) => {
eprintln!("Cannot construct Omikron connection: {error}"); eprintln!("Cannot construct Omikron connection: {error}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
let omikron_health = omikron.clone();
let services = DaemonServices::new(omikron); 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( let ipc_server = match IpcServer::bind(
socket.clone(), socket.clone(),
runtime.clone(), runtime.clone(),

View file

@ -171,5 +171,3 @@ fn run(program: &str, args: &[&str]) -> Result<()> {
bail!("{program} failed; run the installer as root") bail!("{program} failed; run the installer as root")
} }
} }

View file

@ -98,19 +98,19 @@ pub fn startup_with_log_dir(log_dir: Option<std::path::PathBuf>) {
format!("{} ", msg.prefix) format!("{} ", msg.prefix)
}; };
let line1 = format!( let line = format!(
"{} {}{}", "{} {} {}{}",
fixed_box(&msg.timestamp_ms.to_string(), 13), fixed_box(&msg.timestamp_ms.to_string(), 13),
timestamp,
prefix, prefix,
resolved_message resolved_message
); );
let line2 = format!(" {}", timestamp);
if let Some(file) = file.as_mut() { 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 { let entry = UiLogEntry {
timestamp_ms: msg.timestamp_ms, timestamp_ms: msg.timestamp_ms,
@ -329,8 +329,11 @@ pub fn format_cv(cv: &CommunicationValue) -> String {
parts.push(format!("> {}", receiver)); parts.push(format!("> {}", receiver));
} }
let comm_type = cv.get_type().to_string(); let comm_type = cv
parts.push(format!("{}", comm_type)); .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(); let data = cv.data();

View file

@ -7,7 +7,13 @@ use rand_core::{OsRng, RngCore};
use rusqlite::params; use rusqlite::params;
pub fn add_user(user: UserProfile) { 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( conn.execute(
r#" r#"
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) 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(()) Ok(())
}) { })
eprintln!("Failed to add_user: {}", e);
}
} }
pub fn update_user(user: UserProfile) { pub fn update_user(user: UserProfile) {

View file

@ -239,5 +239,3 @@ fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Pending
created_at: row.get(8)?, created_at: row.get(8)?,
}) })
} }

View file

@ -105,41 +105,28 @@ pub fn load_file_vec(path: &str, name: &str) -> Result<Vec<u8>, std::io::Error>
} }
pub fn save_file(path: &str, name: &str, value: &str) { pub fn save_file(path: &str, name: &str, value: &str) {
let Ok(file_path) = storage_file(path, name) else { if let Err(error) = try_save_file(path, name, value) {
eprintln!("[IMPORTANT] Refusing unsafe storage path"); eprintln!("[IMPORTANT] Couldn't save file: {error}");
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;
}
} }
}
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. // Write to a temp file first, then atomically rename to prevent partial writes.
let tmp_name = format!(".{}.tmp", name); let tmp_name = format!(".{}.tmp", name);
let tmp_path = dir.join(&tmp_name); let tmp_path = dir.join(&tmp_name);
if let Err(e) = fs::write(&tmp_path, value) { fs::write(&tmp_path, value)?;
println!( if let Err(error) = fs::rename(&tmp_path, &file_path) {
"[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); let _ = fs::remove_file(&tmp_path);
return Err(error);
} }
Ok(())
} }
pub fn get_children(path: &str) -> Vec<String> { pub fn get_children(path: &str) -> Vec<String> {

1
mtp-type-maps Submodule

@ -0,0 +1 @@
Subproject commit 594646ac39d986f0787aa614a99d580035a67318

View file

@ -27,7 +27,9 @@ pub enum OmikronStartupError {
InitialConnectionTimeout { InitialConnectionTimeout {
connection: std::sync::Arc<crate::omikron_connection::OmikronConnection>, connection: std::sync::Arc<crate::omikron_connection::OmikronConnection>,
}, },
Authentication, Authentication {
connection: std::sync::Arc<crate::omikron_connection::OmikronConnection>,
},
} }
#[async_trait] #[async_trait]
@ -39,5 +41,9 @@ pub trait OmikronClient: Send + Sync {
timeout: Duration, timeout: Duration,
) -> Result<CommunicationValue, OmikronError>; ) -> Result<CommunicationValue, OmikronError>;
async fn reconnect(&self) -> Result<(), OmikronError>; 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; async fn is_connected(&self) -> bool;
} }

View file

@ -13,7 +13,7 @@ use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, LazyLock}; 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::sync::{Mutex, RwLock, Semaphore, oneshot, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::sleep; use tokio::time::sleep;
@ -221,6 +221,11 @@ impl OmikronConnection {
let _ = self.state_watch_tx.send(new_state); let _ = self.state_watch_tx.send(new_state);
} }
/// Subscribe to connection transitions for daemon health reporting.
pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {
self.state_watch_tx.subscribe()
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Connection Management // Connection Management
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -1880,10 +1885,11 @@ impl OmikronConnection {
let reason = response_cv let reason = response_cv
.get_data(DataType::Message) .get_data(DataType::Message)
.as_str() .as_str()
.or_else(|| response_cv.get_data(DataType::ErrorType).as_str())
.unwrap_or("connection error") .unwrap_or("connection error")
.to_string(); .to_string();
Err(format!( Err(format!(
"Request failed due to disconnect (msg_id={}, reason={})", "Request rejected (msg_id={}, reason={})",
msg_id, reason msg_id, reason
)) ))
} else { } else {
@ -1955,6 +1961,75 @@ impl OmikronConnection {
self.stop().await; self.stop().await;
self.connect().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<Self>) -> 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 { match conn.await_connection(Some(CONNECTION_TIMEOUT)).await {
Ok(()) => Ok(conn), Ok(()) => Ok(conn),
Err(_) if conn.has_auth_failure().await => { Err(_) if conn.has_auth_failure().await => {
Err(crate::client::OmikronStartupError::Authentication) Err(crate::client::OmikronStartupError::Authentication { connection: conn })
} }
Err(_) => { Err(_) => {
Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn }) Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn })
@ -2027,6 +2102,8 @@ impl OmikronClient for OmikronConnection {
.map_err(|error| { .map_err(|error| {
if error.contains("timed out") { if error.contains("timed out") {
OmikronError::Timeout(error) OmikronError::Timeout(error)
} else if error.starts_with("Request rejected") {
OmikronError::Internal(error)
} else { } else {
OmikronError::Disconnected(error) OmikronError::Disconnected(error)
} }
@ -2057,6 +2134,29 @@ impl OmikronClient for OmikronConnection {
Ok(()) 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 { async fn is_connected(&self) -> bool {
Self::is_connected(self).await Self::is_connected(self).await
} }

View file

@ -1,9 +1,9 @@
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use iota_logger::{PrintType, log, log_cv, log_t}; 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_storage::users::user_profile::UserProfile;
use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; 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 mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use std::time::Duration; use std::time::Duration;
@ -11,34 +11,75 @@ use std::time::Duration;
use crate::OmikronClient; use crate::OmikronClient;
use crate::omega_discovery; 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( pub async fn create_user(
connection: &dyn OmikronClient, connection: &dyn OmikronClient,
username: &str, username: &str,
) -> (Option<UserProfile>, Option<String>) { ) -> Result<UserProfile, CreateUserError> {
let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); if !valid_username(username) {
return Err(CreateUserError::InvalidUsername);
let response_communication_value = match connection }
.await_response(&register_communication_value, Duration::from_secs(20)) let (user_id, registration_token) = request_user_id(connection).await?;
.await log!("User creation: Omega allocated user ID {user_id}");
{
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);
}
};
let keyring = crypto_helper::generate_keyring(); let keyring = crypto_helper::generate_keyring();
let pub_key_bundle = keyring.public_key_bundle(); let pub_key_bundle = keyring.public_key_bundle();
let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); 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 reset_token = STANDARD.encode(&bytes);
let user_profile = UserProfile::new( let user_profile = UserProfile::new(
user_id as i64, user_id,
username.to_string(), username.to_string(),
None, None,
public_key_bundle_to_base64(&pub_key_bundle), public_key_bundle_to_base64(&pub_key_bundle),
@ -59,30 +100,43 @@ pub async fn create_user(
); );
let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) 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::Username, DataValue::Str(username.to_string()))
.add_typed_default( .add_typed_default(
DataType::PublicKey, DataType::PublicKey,
DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), 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 let response_communication_value = connection
.await_response(&communication_value, Duration::from_secs(20)) .await_response(&communication_value, Duration::from_secs(20))
.await; .await;
if let Ok(response) = response_communication_value { match response_communication_value {
log_cv!(PrintType::Omega, response); Ok(response) => {
if !response.is_type(CommunicationType::Success) { log_cv!(PrintType::Omega, response);
return (None, None); 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"); log!("Created User");
save_file( try_save_file(
"", "",
&format!("{}.tu", username), &format!("{}.tu", username),
&format!( &format!(
@ -91,9 +145,91 @@ pub async fn create_user(
omega_discovery::omega_host(), omega_discovery::omega_host(),
keyring_b64 keyring_b64
), ),
); )
.map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;
add_user(user_profile.clone()); try_add_user(user_profile.clone())
save_users(); .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;
(Some(user_profile), Some(keyring_b64)) 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<CommunicationValue, OmikronError> {
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)
));
}
} }

View file

@ -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