Compare commits

..
Author SHA1 Message Date
Alex Emmet
8e709513c0
[Fix] Connections 2026-08-30 19:18:03 +02:00
Alex Emmet
ed10028808
[Fix] Connectivity 2026-08-29 13:09:06 +02:00
Alex Emmet
809ebfb4fb
[Add] Replyjumps 2026-08-29 13:09:06 +02:00
Alex Emmet
cf9607b15e
[Fix] Stability 2026-08-29 13:09:06 +02:00
21 changed files with 426 additions and 656 deletions

550
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,28 +7,28 @@ edition = "2024"
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
"crypto",
"files",
"raw",
"web-server",
] }
ansi_term = "0.12.1"
base64 = "0.22.1"
base64 = "0.23.1"
bytes = "1"
dashmap = "6.2.1"
dotenv = "0.15.0"
http = "1"
once_cell = "1.21.4"
rand = "0.10.2"
rustls = { version = "0.23.42", default-features = false, features = [
rustls = { version = "0.23.43", default-features = false, features = [
"std",
"tls12",
"aws-lc-rs",
"prefer-post-quantum",
] }
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio", "migrate"] }
sqlx = { version = "0.9.0", features = ["mysql", "runtime-tokio", "migrate"] }
tokio = { version = "*", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["rt"] }
uuid = { version = "1.24.0", features = ["v4", "v7"] }
thiserror = "2.0.19"
uuid = { version = "1.26.0", features = ["v4", "v7"] }
thiserror = "2.0.20"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
zeroize = "1.9"

View file

@ -1,7 +1,7 @@
# Omega
The Omega is Tensamin's central Server. It maintains the centralized user Registry & manages Omikron useage.
Omega requires the non-empty `OMEGA_IDENTITY_SECRET` environment variable at startup. On first start it creates the protected private identity at `./omega.mk` and the matching public bundle at `./omega.mpkb`. Existing identity files are loaded fail-closed, so a malformed keyring or mismatched public bundle stops startup. A missing public bundle is rebuilt from a valid private keyring without generating a new identity.
On first start Omega creates an unencrypted private keyring at `./omega.mk` and the matching public bundle at `./omega.mpkb`. Existing identity files are loaded fail-closed, so a malformed keyring or mismatched public bundle stops startup. A missing public bundle is rebuilt from a valid private keyring without generating a new identity.
## MTP routing contract

View file

@ -0,0 +1,4 @@
CREATE TABLE iota_snapshot_outbox (
iota_id BIGINT NOT NULL PRIMARY KEY,
updated_at DATETIME NOT NULL
);

@ -1 +1 @@
Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e
Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c

View file

@ -22,7 +22,7 @@ pub async fn get_iota_by_id(id: IotaId) -> Result<Iota> {
pub async fn create_new_iota(public_key: PublicKeyBundle) -> Result<IotaId> {
for _ in 0..16 {
let id = crate::db::user_repo::get_register_id().await?;
let id = crate::db::user_repo::generate_protocol_id();
let iota_id = IotaId::from(id.0);
match register_complete_iota(iota_id, public_key.clone()).await {
Ok(()) => return Ok(iota_id),

View file

@ -11,22 +11,13 @@ use std::collections::HashMap;
pub const MAX_PROTOCOL_ID: i64 = (1_i64 << 48) - 1;
const ID_ALLOCATION_ATTEMPTS: usize = 16;
pub async fn get_register_id() -> Result<UserId> {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ts = timestamp as i64;
if (1..=MAX_PROTOCOL_ID).contains(&ts) {
return Ok(UserId::from(ts));
pub fn generate_protocol_id() -> UserId {
loop {
let value = rand::random::<u64>() & ((1_u64 << 48) - 1);
if value != 0 {
return UserId::from(value as i64);
}
}
// Fall back to random if the timestamp is outside the 48-bit range.
let id = (rand::random::<u64>() & ((1_u64 << 48) - 1)) as i64;
Ok(UserId::from(id.max(1)))
}
pub fn valid_protocol_id(id: i64) -> bool {
@ -42,8 +33,24 @@ pub async fn allocate_registration(iota_id: IotaId, request_id: u32) -> Result<(
));
}
let database = pool().await?;
sqlx::query(
"UPDATE registration_leases SET request_id = NULL \
WHERE request_id IS NOT NULL AND expires_at < UTC_TIMESTAMP()",
)
.execute(&database)
.await?;
for _ in 0..ID_ALLOCATION_ATTEMPTS {
let id = get_register_id().await?;
let id = generate_protocol_id();
let user_exists = sqlx::query("SELECT 1 FROM users WHERE id = ? LIMIT 1")
.bind(id.0)
.fetch_optional(&database)
.await?
.is_some();
if user_exists {
continue;
}
let token = uuid::Uuid::new_v4().to_string();
let result = sqlx::query(
"INSERT INTO registration_leases (token, user_id, iota_id, request_id, expires_at) \
@ -53,7 +60,7 @@ pub async fn allocate_registration(iota_id: IotaId, request_id: u32) -> Result<(
.bind(id.0)
.bind(iota_id.0)
.bind(request_id)
.execute(&pool().await?)
.execute(&database)
.await;
match result {
Ok(_) => return Ok((id, token)),
@ -64,7 +71,7 @@ pub async fn allocate_registration(iota_id: IotaId, request_id: u32) -> Result<(
)
.bind(iota_id.0)
.bind(request_id)
.fetch_optional(&pool().await?)
.fetch_optional(&database)
.await?;
if let Some(existing) = existing {
let current: i8 = existing.get("current");
@ -172,7 +179,7 @@ fn normalized_ids(ids: &[i64]) -> Vec<i64> {
ids
}
fn append_in_clause(query: &mut QueryBuilder<'_, MySql>, ids: &[i64]) {
fn append_in_clause(query: &mut QueryBuilder<MySql>, ids: &[i64]) {
query.push("(");
for (index, id) in ids.iter().enumerate() {
if index > 0 {
@ -183,7 +190,7 @@ fn append_in_clause(query: &mut QueryBuilder<'_, MySql>, ids: &[i64]) {
query.push(")");
}
async fn fetch_users(mut query: QueryBuilder<'_, MySql>) -> Result<Vec<User>> {
async fn fetch_users(mut query: QueryBuilder<MySql>) -> Result<Vec<User>> {
query
.build_query_as::<UserRow>()
.fetch_all(&pool().await?)
@ -336,21 +343,59 @@ pub async fn change_status(id: UserId, value: String) -> Result<()> {
}
pub async fn change_iota_id(id: UserId, value: Option<IotaId>) -> Result<()> {
let mut transaction = pool().await?.begin().await?;
let previous = sqlx::query("SELECT iota_id FROM users WHERE id = ? FOR UPDATE")
.bind(id.0)
.fetch_optional(&mut *transaction)
.await?
.ok_or(OmegaError::NotFound)?;
let previous_iota_id: Option<i64> = previous.get("iota_id");
sqlx::query("UPDATE users SET iota_id = ? WHERE id = ?")
.bind(value.map(|id| id.0))
.bind(id.0)
.execute(&mut *transaction)
.await?;
if let Some(iota_id) = previous_iota_id {
enqueue_iota_snapshot(&mut transaction, iota_id).await?;
}
if let Some(iota_id) = value {
enqueue_iota_snapshot(&mut transaction, iota_id.0).await?;
}
transaction.commit().await?;
Ok(())
}
async fn enqueue_iota_snapshot(
transaction: &mut sqlx::Transaction<'_, MySql>,
iota_id: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO iota_snapshot_outbox (iota_id, updated_at) VALUES (?, UTC_TIMESTAMP()) \
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)",
)
.bind(iota_id)
.execute(&mut **transaction)
.await?;
Ok(())
}
pub async fn pending_iota_snapshots() -> Result<Vec<IotaId>> {
let rows = sqlx::query("SELECT iota_id FROM iota_snapshot_outbox ORDER BY updated_at")
.fetch_all(&pool().await?)
.await?;
Ok(rows
.into_iter()
.map(|row| IotaId::from(row.get::<i64, _>("iota_id")))
.collect())
}
pub async fn complete_iota_snapshot(iota_id: IotaId) -> Result<()> {
sqlx::query("DELETE FROM iota_snapshot_outbox WHERE iota_id = ?")
.bind(iota_id.0)
.execute(&pool().await?)
.await?;
Ok(())
}
pub async fn change_token(id: UserId, value: String) -> Result<()> {
update(
id,
"UPDATE users SET token = ? WHERE id = ?",
value.into_bytes(),
)
.await
}
/// Delete the central identity while retaining a durable instruction for the
/// last hosting Iota. The pending row is intentionally independent of users:
/// it must outlive the account row.
@ -488,6 +533,7 @@ pub async fn register_complete_user(
}
};
result?;
enqueue_iota_snapshot(&mut transaction, iota_id.0).await?;
sqlx::query("UPDATE registration_leases SET completed_at = UTC_TIMESTAMP() WHERE token = ?")
.bind(&registration_token)
.execute(&mut *transaction)
@ -498,19 +544,20 @@ pub async fn register_complete_user(
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())
}
#[cfg(test)]
mod tests {
use super::{MAX_PROTOCOL_ID, get_register_id, valid_protocol_id};
use super::{MAX_PROTOCOL_ID, generate_protocol_id, valid_protocol_id};
#[tokio::test]
async fn generated_registration_ids_fit_the_mtp_wire_range() {
for _ in 0..128 {
assert!(valid_protocol_id(get_register_id().await.unwrap().0));
assert!(valid_protocol_id(generate_protocol_id().0));
}
assert!(!valid_protocol_id(0));
assert!(valid_protocol_id(MAX_PROTOCOL_ID));

View file

@ -1,7 +1,7 @@
use crate::error::{IdentityError, Result};
use mtp::crypto::{Keyring, PublicKeyBundle};
use mtp::files::{
FileError, load_keyring, load_public_key_bundle, save_keyring, save_public_key_bundle,
FileError, load_keyring_raw, load_public_key_bundle, save_keyring_raw, save_public_key_bundle,
};
use std::{
fs,
@ -22,25 +22,20 @@ pub struct OmegaIdentity {
static PUBLIC_BUNDLE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
impl OmegaIdentity {
pub fn load_or_create(passphrase: &[u8]) -> Result<Self> {
Self::load_or_create_at(
Path::new(KEYRING_PATH),
Path::new(PUBLIC_KEY_PATH),
passphrase,
)
pub fn load_or_create() -> Result<Self> {
Self::load_or_create_at(Path::new(KEYRING_PATH), Path::new(PUBLIC_KEY_PATH))
}
pub(crate) fn load_or_create_at(
keyring_path: impl AsRef<Path>,
public_key_path: impl AsRef<Path>,
passphrase: &[u8],
) -> Result<Self> {
let keyring_path = keyring_path.as_ref();
let public_key_path = public_key_path.as_ref();
let keyring = match load_keyring(keyring_path, passphrase) {
let keyring = match load_keyring_raw(keyring_path) {
Ok(keyring) => keyring,
Err(FileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
return Self::create_at(keyring_path, public_key_path, passphrase);
return Self::create_at(keyring_path, public_key_path);
}
Err(error) => {
return Err(IdentityError::Storage {
@ -72,13 +67,11 @@ impl OmegaIdentity {
Ok(identity)
}
fn create_at(keyring_path: &Path, public_key_path: &Path, passphrase: &[u8]) -> Result<Self> {
fn create_at(keyring_path: &Path, public_key_path: &Path) -> Result<Self> {
let keyring = Keyring::generate();
save_keyring(&keyring, keyring_path, passphrase).map_err(|error| {
IdentityError::Storage {
path: keyring_path.to_path_buf(),
source: error,
}
save_keyring_raw(&keyring, keyring_path).map_err(|error| IdentityError::Storage {
path: keyring_path.to_path_buf(),
source: error,
})?;
Self::persist_public_bundle(&keyring.public_key_bundle(), public_key_path)?;
Ok(Self {
@ -188,9 +181,7 @@ mod tests {
let directory = test_directory();
let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase";
let first = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
let first = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
.expect("create identity");
let first_bundle = first.public_key_bundle().try_as_bytes().expect("bundle");
@ -199,9 +190,8 @@ mod tests {
assert_eq!(&keyring_bytes[..4], b"MTMK");
assert_eq!(&bundle_bytes[..4], b"MPKB");
let restarted =
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("reload identity");
let restarted = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
.expect("reload identity");
assert_eq!(
restarted
.public_key_bundle()
@ -227,8 +217,7 @@ mod tests {
let public_key_path = directory.join("omega.mpkb");
fs::write(&keyring_path, b"not-a-keyring").expect("write invalid keyring");
let result =
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, b"test-passphrase");
let result = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path);
assert!(result.is_err());
assert!(!public_key_path.exists());
@ -240,16 +229,12 @@ mod tests {
let directory = test_directory();
let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase";
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("create identity");
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path).expect("create identity");
let original_keyring = fs::read(&keyring_path).expect("read keyring");
fs::remove_file(&public_key_path).expect("remove bundle");
let repaired =
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("repair bundle");
let repaired = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
.expect("repair bundle");
assert_eq!(
fs::read(&keyring_path).expect("read keyring"),
@ -271,16 +256,13 @@ mod tests {
let directory = test_directory();
let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase";
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("create identity");
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path).expect("create identity");
let other_keyring = Keyring::generate();
save_public_key_bundle(&other_keyring.public_key_bundle(), &public_key_path)
.expect("save mismatched bundle");
assert!(matches!(
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase),
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path),
Err(crate::OmegaError::Identity(
IdentityError::PublicBundleMismatch { .. }
))
@ -290,24 +272,25 @@ mod tests {
}
#[test]
fn wrong_passphrase_does_not_replace_existing_keyring() {
fn existing_raw_keyring_is_reloaded_without_a_passphrase() {
let directory = test_directory();
let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase";
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
let first = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
.expect("create identity");
let original_keyring = fs::read(&keyring_path).expect("read keyring");
assert!(
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, b"wrong-passphrase")
.is_err()
);
let reloaded = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
.expect("reload identity");
assert_eq!(
fs::read(&keyring_path).expect("read keyring"),
original_keyring
);
assert_eq!(
reloaded.public_key_bundle().try_as_bytes().expect("bundle"),
first.public_key_bundle().try_as_bytes().expect("bundle")
);
fs::remove_dir_all(directory).expect("remove test directory");
}

View file

@ -15,6 +15,7 @@ pub use error::{OmegaError, Result};
use crate::db::initialize;
use crate::state::OmegaState;
use crate::transport::omikron_connection;
use crate::transport::omikron_manager;
use crate::util::file_util::get_directory;
use crate::util::logger::PrintType;
use crate::util::logger::startup;
@ -25,7 +26,6 @@ use std::env;
use std::path::Path;
use std::time::Duration;
use tokio::time::interval;
use zeroize::Zeroizing;
#[tokio::main]
async fn main() {
@ -38,18 +38,6 @@ async fn main() {
log_in!("Incoming messages");
log_out!("Outgoing messages");
let identity_secret = match env::var("OMEGA_IDENTITY_SECRET") {
Ok(secret) if !secret.is_empty() => secret,
Ok(_) => {
log!("[FATAL] OMEGA_IDENTITY_SECRET must not be empty");
return;
}
Err(error) => {
log!("[FATAL] Unable to load OMEGA_IDENTITY_SECRET: {}", error);
return;
}
};
let identity_secret = Zeroizing::new(identity_secret);
let config = match OmegaConfig::from_env() {
Ok(config) => config,
Err(error) => {
@ -61,14 +49,13 @@ async fn main() {
log!("[FATAL] Omega rate-limit configuration was initialized more than once");
return;
}
let identity = match identity::OmegaIdentity::load_or_create(identity_secret.as_bytes()) {
let identity = match identity::OmegaIdentity::load_or_create() {
Ok(identity) => identity,
Err(error) => {
log!("[FATAL] Omega identity initialization failed: {}", error);
return;
}
};
drop(identity_secret);
let state = OmegaState::new(identity, config);
log!("Started");
@ -97,6 +84,7 @@ async fn main() {
}
}
});
let snapshot_outbox_worker = omikron_manager::spawn_iota_snapshot_outbox_worker();
let port: u16 = env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
@ -114,4 +102,5 @@ async fn main() {
}
rate_limit_cleanup.abort();
short_link_cleanup.abort();
snapshot_outbox_worker.abort();
}

View file

@ -13,6 +13,7 @@ use crate::models::UserId;
use crate::server::{
middleware,
validation::{parse_positive_id, validate_non_empty},
with_cors,
};
use crate::transport::omikron_manager::{
get_all_connections, get_connected_omikron, get_iota_primary_omikron_connection,
@ -193,39 +194,34 @@ pub async fn handle(
let method = request.method;
let path = request.uri.path().to_string();
if method != Method::OPTIONS && !middleware::allow(request.remote_addr.ip(), &path) {
return response
.status(StatusCode::TOO_MANY_REQUESTS)
.header("access-control-allow-origin", crate::config::cors_origin())
.body(json(&StatusResponse {
return with_cors(response.status(StatusCode::TOO_MANY_REQUESTS).body(json(
&StatusResponse {
status: "error_rate_limited",
}));
},
)));
}
if method == Method::OPTIONS {
return response
.status(StatusCode::OK)
.header("access-control-allow-origin", crate::config::cors_origin())
.header("access-control-allow-methods", "GET, POST, OPTIONS")
.header("access-control-allow-headers", "*");
return with_cors(response.status(StatusCode::NO_CONTENT));
}
let path_parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
if let ["api", "download", "iota_frontend"] = path_parts.as_slice() {
let file_path = format!("{}/downloads/iota_frontend.zip", get_directory());
return match std::fs::read(file_path) {
Ok(bytes) => response
.status(StatusCode::OK)
.header("access-control-allow-origin", crate::config::cors_origin())
.header("content-type", "application/zip")
.header(
"content-disposition",
"attachment; filename=\"iota_frontend.zip\"",
)
.body(Bytes::from(bytes)),
Err(_) => response
.status(StatusCode::NOT_FOUND)
.header("access-control-allow-origin", crate::config::cors_origin())
.body(json(&StatusResponse {
Ok(bytes) => with_cors(
response
.status(StatusCode::OK)
.header("content-type", "application/zip")
.header(
"content-disposition",
"attachment; filename=\"iota_frontend.zip\"",
)
.body(Bytes::from(bytes)),
),
Err(_) => with_cors(response.status(StatusCode::NOT_FOUND).body(json(
&StatusResponse {
status: "error_not_found",
})),
},
))),
};
}
if let ["direct", short @ ..] = path_parts.as_slice() {
@ -233,19 +229,16 @@ pub async fn handle(
let location = crate::server::short_link::get_short_link(&short)
.await
.unwrap_or_else(|_| "https://tensamin.net".to_string());
return response
.status(StatusCode::TEMPORARY_REDIRECT)
.header("location", &location);
return with_cors(
response
.status(StatusCode::TEMPORARY_REDIRECT)
.header("location", &location),
);
}
let (status, body) = route(&path_parts, &identity)
.await
.unwrap_or_else(|error| (error.status_code(), error_body(&error)));
response
.status(status)
.header("access-control-allow-origin", crate::config::cors_origin())
.header("access-control-allow-headers", "*")
.header("access-control-allow-methods", "GET, POST, OPTIONS")
.body(body)
with_cors(response.status(status).body(body))
}
pub async fn handle_pattern(

View file

@ -1,6 +1,8 @@
use http::StatusCode;
use mtp::webserver::HttpResponse;
use crate::server::with_cors;
pub fn index_handler(response: HttpResponse) -> HttpResponse {
let documentation = r#"
Omega API Server
@ -33,8 +35,10 @@ live in-memory state; it is empty after an Omega restart until Omikrons sync.
All other routes will return this documentation.
"#;
response
.status(StatusCode::OK)
.header("content-type", "text/plain; charset=utf-8")
.body(documentation)
with_cors(
response
.status(StatusCode::OK)
.header("content-type", "text/plain; charset=utf-8")
.body(documentation),
)
}

View file

@ -4,3 +4,29 @@ pub mod middleware;
pub mod short_link;
pub mod validation;
pub mod web;
use mtp::webserver::HttpResponse;
pub(crate) fn with_cors(response: HttpResponse) -> HttpResponse {
response
.header("access-control-allow-origin", crate::config::cors_origin())
.header("access-control-allow-methods", "GET, POST, OPTIONS")
.header("access-control-allow-headers", "*")
}
#[cfg(test)]
mod tests {
use super::with_cors;
use mtp::webserver::HttpResponse;
#[test]
fn web_responses_allow_all_origins() {
let response = with_cors(HttpResponse::default());
assert_eq!(response.headers["access-control-allow-origin"], "*");
assert_eq!(
response.headers["access-control-allow-methods"],
"GET, POST, OPTIONS"
);
assert_eq!(response.headers["access-control-allow-headers"], "*");
}
}

View file

@ -1,5 +1,6 @@
use crate::identity::OmegaIdentity;
use crate::server::{api, index::index_handler};
use http::{Method, StatusCode};
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
use std::{future::Future, pin::Pin, sync::Arc};
@ -43,5 +44,11 @@ pub fn build_web_config(
)?
.route_pattern("/api/get/user/{id}", api_pattern_handler(identity.clone()))?
.route_pattern("/direct/{short}", api_pattern_handler(identity))?
.fallback(|_request, response| async move { index_handler(response) })
.fallback(|request, response| async move {
if request.method == Method::OPTIONS {
crate::server::with_cors(response.status(StatusCode::NO_CONTENT))
} else {
index_handler(response)
}
})
}

View file

@ -73,7 +73,6 @@ pub(crate) fn validate_dispatch_fields(value: &CommunicationValue) -> OmikronRes
message_type,
mtp::codec::CommunicationType::GetUserData
| mtp::codec::CommunicationType::ChangeUserData
| mtp::codec::CommunicationType::ChangeIotaData
| mtp::codec::CommunicationType::DeleteUser
| mtp::codec::CommunicationType::AttachUserBegin
| mtp::codec::CommunicationType::AttachUserComplete
@ -147,7 +146,6 @@ mod tests {
for message_type in [
CommunicationType::GetUserData,
CommunicationType::ChangeUserData,
CommunicationType::ChangeIotaData,
CommunicationType::DeleteUser,
CommunicationType::AttachUserBegin,
CommunicationType::AttachUserComplete,

View file

@ -84,7 +84,7 @@ pub async fn release_from_iota(
match user_repo::change_iota_id(user.id, None).await {
Ok(()) => {
if let Some(iota) = previous_iota {
crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await;
let _ = crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await;
}
connection
.send(
@ -225,9 +225,9 @@ pub async fn attach_complete(
match user_repo::change_iota_id(user.id, Some(IotaId::from(requester))).await {
Ok(()) => {
if let Some(iota) = previous_iota.filter(|id| id.0 != requester) {
crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await;
let _ = crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await;
}
crate::transport::omikron_manager::publish_iota_user_snapshot(requester).await;
let _ = crate::transport::omikron_manager::publish_iota_user_snapshot(requester).await;
connection
.send(
&CommunicationValue::new(CommunicationType::Success)
@ -252,7 +252,8 @@ async fn complete_delete(
Ok(iota_id) => {
let cleanup_pending = iota_id.is_some();
if let Some(iota_id) = iota_id {
crate::transport::omikron_manager::publish_iota_user_snapshot(iota_id.0).await;
let _ =
crate::transport::omikron_manager::publish_iota_user_snapshot(iota_id.0).await;
crate::transport::omikron_manager::deliver_pending_erasures(iota_id.0).await;
}
connection

View file

@ -562,6 +562,15 @@ pub async fn sync_status(
&affected_iota_ids.iter().copied().collect::<Vec<_>>(),
)
.await?;
if sessions.iter().any(|(user_id, _, iota_id)| {
!users
.iter()
.any(|user| user.id.0 == *user_id && user.iota_id == Some(IotaId::from(*iota_id)))
}) {
return connection
.send_error_response(request_id, CommunicationType::ErrorInvalidData)
.await;
}
let user_ids = users.iter().map(|user| user.id.0).collect::<Vec<_>>();
apply_preferences(
&state,

View file

@ -135,6 +135,17 @@ pub async fn complete_user(
.await
{
Ok(()) => {
if let Err(error) =
crate::transport::omikron_manager::publish_iota_user_snapshot(iota_id).await
{
crate::log_in!(
crate::util::logger::PrintType::General,
"Could not publish Iota snapshot after registering user {} on Iota {}: {}",
user_id,
iota_id,
error
);
}
connection
.send(
&CommunicationValue::new(CommunicationType::Success)

View file

@ -264,49 +264,3 @@ pub async fn change_user(
) -> OmikronResult<()> {
update_user(connection, value).await
}
pub async fn change_iota(
connection: Arc<OmikronConnection>,
value: CommunicationValue,
) -> OmikronResult<()> {
let request_id = value.require_id()?;
let reset_data = value.get_data(DataType::ResetToken);
let Some(reset) = reset_data.as_str() else {
return connection
.send_error_response(value.require_id()?, CommunicationType::ErrorInvalidData)
.await;
};
let new_token_data = value.get_data(DataType::NewToken);
let Some(new_token) = new_token_data.as_str() else {
return connection
.send_error_response(value.require_id()?, CommunicationType::ErrorInvalidData)
.await;
};
let user_id = UserId::from(value.require_sender_i64()?);
let user = match user_repo::get_by_user_id(user_id).await {
Ok(user) => user,
Err(_) => {
return connection
.send_error_response(request_id, CommunicationType::ErrorNotFound)
.await;
}
};
if user.token != reset {
return connection
.send_error_response(request_id, CommunicationType::ErrorInvalidChallenge)
.await;
}
let result =
match user_repo::change_iota_id(user_id, Some(IotaId::from(value.require_sender_i64()?)))
.await
{
Ok(()) => user_repo::change_token(user_id, new_token.to_owned()).await,
Err(error) => Err(error),
};
let response = match result {
Ok(()) => CommunicationValue::new(CommunicationType::Success),
Err(error) => CommunicationValue::new(CommunicationType::ErrorInternal)
.add_typed_default(DataType::ErrorType, DataValue::Str(error.to_string())),
};
connection.send(&response.with_id(request_id)).await
}

View file

@ -394,9 +394,7 @@ impl OmikronConnection {
let request_id = value.id();
let result = crate::transport::relay_router::route_from_omikron(id, value).await;
let response = match &result {
Ok(()) => request_id.map(|request_id| {
CommunicationValue::new(CommunicationType::Success).with_id(request_id)
}),
Ok(response) => request_id.map(|request_id| response.clone().with_id(request_id)),
Err(error) => {
log_err!(id, PrintType::Omega, "Relay routing failed: {}", error);
request_id.map(|request_id| {
@ -417,7 +415,9 @@ impl OmikronConnection {
send_error
);
}
return result.map_err(|error| crate::error::OmegaError::Transport(error.to_string()));
return result
.map(|_| ())
.map_err(|error| crate::error::OmegaError::Transport(error.to_string()));
}
match value.get_comm_type_enum() {
Some(CommunicationType::ShortenLink) => {
@ -449,9 +449,6 @@ impl OmikronConnection {
Some(CommunicationType::ChangeUserData) => {
crate::transport::handlers::user_data::change_user(self, value).await
}
Some(CommunicationType::ChangeIotaData) => {
crate::transport::handlers::user_data::change_iota(self, value).await
}
Some(CommunicationType::GetRegister) => {
crate::transport::handlers::register::get_register(self, value).await
}
@ -764,8 +761,9 @@ pub async fn start(port: u16, state: Arc<OmegaState>) -> Result<(), Box<dyn std:
#[cfg(test)]
mod tests {
use super::{DispatchClass, OmikronConnection};
use super::{DispatchClass, OmikronConnection, parse_bind_address};
use mtp::codec::{CommunicationType, CommunicationValue};
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn relay_dispatch_is_not_on_the_ordered_state_lane() {
@ -776,6 +774,14 @@ mod tests {
);
}
#[test]
fn parses_configured_bind_address() {
assert_eq!(
parse_bind_address(Some("10.200.2.0")),
Ok(IpAddr::V4(Ipv4Addr::new(10, 200, 2, 0)))
);
}
#[test]
fn presence_lifecycle_dispatch_is_ordered() {
let value = CommunicationValue::new(CommunicationType::UserConnected);
@ -806,4 +812,12 @@ mod tests {
&CommunicationValue::new(CommunicationType::GetUserData).with_id(1)
));
}
#[test]
fn defaults_bind_address_to_all_interfaces() {
assert_eq!(
parse_bind_address(None),
Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
);
}
}

View file

@ -7,6 +7,9 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use once_cell::sync::Lazy;
use rand::prelude::IteratorRandom;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::interval;
pub static OMIKRON_CONNECTIONS: Lazy<DashMap<i64, Arc<OmikronConnection>>> =
Lazy::new(DashMap::new);
@ -23,6 +26,7 @@ pub async fn add_omikron(conn: Arc<OmikronConnection>) {
if let Some(old) = OMIKRON_CONNECTIONS.insert(id, conn.clone()) {
old.close().await;
}
let _ = flush_iota_snapshot_outbox().await;
}
pub async fn remove_omikron(omikron_id: i64, connection: &Arc<OmikronConnection>) -> bool {
@ -63,12 +67,18 @@ pub async fn get_all_connections()
.await
.map_err(|_| ())?;
for user in users {
for route in state.presence.routes_for_user(user.id.0) {
if let Some(iotas) = result.get_mut(&route.omikron_id)
&& let Some(iota_id) = user.iota_id
&& let Some(users) = iotas.get_mut(&iota_id.0)
if let Some(iota_id) = user.iota_id {
for omikron_id in state
.presence
.iota_connections(iota_id.0)
.unwrap_or_default()
{
users.push(user.id.0);
if let Some(users) = result
.get_mut(&omikron_id)
.and_then(|iotas| iotas.get_mut(&iota_id.0))
{
users.push(user.id.0);
}
}
}
}
@ -115,19 +125,18 @@ pub async fn send_to_user(user_id: i64, cv: &CommunicationValue) {
}
}
/// Publish the authoritative membership list after an attach, migration, or
/// release. Omikron replaces its full local index from this snapshot.
pub async fn publish_iota_user_snapshot(iota_id: i64) {
let Some(omikron_id) = get_iota_primary_omikron_connection(iota_id) else {
return;
};
let Some(connection) = get_connected_omikron(omikron_id) else {
return;
};
let Ok(users) = user_repo::get_users_by_iota_id(crate::models::IotaId::from(iota_id)).await
else {
return;
};
/*
* Publish each Iota membership snapshot to every live relay route. Each
* Omikron keeps a local authorization index, so sending only a primary route
* leaves the remaining relays stale after registration or migration.
*/
pub async fn publish_iota_user_snapshot(iota_id: i64) -> OmikronResult<()> {
let state = get_state().ok_or(crate::error::OmegaError::NotConnected)?;
let omikron_ids = state
.presence
.iota_connections(iota_id)
.ok_or(crate::error::OmegaError::NotConnected)?;
let users = user_repo::get_users_by_iota_id(crate::models::IotaId::from(iota_id)).await?;
let user_ids = users
.into_iter()
.map(|user| DataValue::SignedNumber(user.id.0.into()))
@ -135,7 +144,54 @@ pub async fn publish_iota_user_snapshot(iota_id: i64) {
let snapshot = CommunicationValue::new(CommunicationType::IotaUserData)
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into()))
.add_typed_default(DataType::UserIds, DataValue::Array(user_ids));
let _ = connection.send(&snapshot).await;
for omikron_id in omikron_ids {
let connection =
get_connected_omikron(omikron_id).ok_or(crate::error::OmegaError::NotConnected)?;
connection.send(&snapshot).await?;
}
Ok(())
}
pub async fn flush_iota_snapshot_outbox() -> OmikronResult<()> {
for iota_id in user_repo::pending_iota_snapshots().await? {
match publish_iota_user_snapshot(iota_id.0).await {
Ok(()) => {
if let Err(error) = user_repo::complete_iota_snapshot(iota_id).await {
crate::log_in!(
crate::util::logger::PrintType::General,
"Could not complete Iota snapshot outbox entry for {}: {}",
iota_id.0,
error
);
}
}
Err(error) => {
crate::log_in!(
crate::util::logger::PrintType::General,
"Could not publish Iota snapshot for {}: {}",
iota_id.0,
error
);
}
}
}
Ok(())
}
pub fn spawn_iota_snapshot_outbox_worker() -> JoinHandle<()> {
tokio::spawn(async {
let mut retry = interval(Duration::from_secs(30));
loop {
retry.tick().await;
if let Err(error) = flush_iota_snapshot_outbox().await {
crate::log_in!(
crate::util::logger::PrintType::General,
"Could not load Iota snapshot outbox: {}",
error
);
}
}
})
}
pub async fn deliver_pending_erasures(iota_id: i64) {

View file

@ -82,7 +82,7 @@ pub fn error_response_type(error: &RelayRouteError) -> CommunicationType {
pub async fn route_from_omikron(
source_omikron_id: i64,
frame: CommunicationValue,
) -> Result<(), RelayRouteError> {
) -> Result<CommunicationValue, RelayRouteError> {
let frame = ensure_relay_frame_id(frame);
if !frame.is_type(CommunicationType::Relay) {
return Err(RelayRouteError::Relay(RelayError::NotRelay));
@ -154,7 +154,7 @@ pub async fn route_from_omikron(
RelayRouteError::Send(error.to_string())
})?;
if response.is_type(CommunicationType::Success) {
Ok(())
Ok(response)
} else {
Err(RelayRouteError::Send(format!(
"destination Omikron rejected the Relay with {}",