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

View file

@ -1,7 +1,7 @@
# Omega # Omega
The Omega is Tensamin's central Server. It maintains the centralized user Registry & manages Omikron useage. 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 ## 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> { pub async fn create_new_iota(public_key: PublicKeyBundle) -> Result<IotaId> {
for _ in 0..16 { 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); let iota_id = IotaId::from(id.0);
match register_complete_iota(iota_id, public_key.clone()).await { match register_complete_iota(iota_id, public_key.clone()).await {
Ok(()) => return Ok(iota_id), 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; pub const MAX_PROTOCOL_ID: i64 = (1_i64 << 48) - 1;
const ID_ALLOCATION_ATTEMPTS: usize = 16; const ID_ALLOCATION_ATTEMPTS: usize = 16;
pub async fn get_register_id() -> Result<UserId> { pub fn generate_protocol_id() -> UserId {
use std::time::{SystemTime, UNIX_EPOCH}; loop {
let value = rand::random::<u64>() & ((1_u64 << 48) - 1);
let timestamp = SystemTime::now() if value != 0 {
.duration_since(UNIX_EPOCH) return UserId::from(value as i64);
.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));
} }
// 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 { 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 { 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 token = uuid::Uuid::new_v4().to_string();
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO registration_leases (token, user_id, iota_id, request_id, expires_at) \ "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(id.0)
.bind(iota_id.0) .bind(iota_id.0)
.bind(request_id) .bind(request_id)
.execute(&pool().await?) .execute(&database)
.await; .await;
match result { match result {
Ok(_) => return Ok((id, token)), 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(iota_id.0)
.bind(request_id) .bind(request_id)
.fetch_optional(&pool().await?) .fetch_optional(&database)
.await?; .await?;
if let Some(existing) = existing { if let Some(existing) = existing {
let current: i8 = existing.get("current"); let current: i8 = existing.get("current");
@ -172,7 +179,7 @@ fn normalized_ids(ids: &[i64]) -> Vec<i64> {
ids ids
} }
fn append_in_clause(query: &mut QueryBuilder<'_, MySql>, ids: &[i64]) { fn append_in_clause(query: &mut QueryBuilder<MySql>, ids: &[i64]) {
query.push("("); query.push("(");
for (index, id) in ids.iter().enumerate() { for (index, id) in ids.iter().enumerate() {
if index > 0 { if index > 0 {
@ -183,7 +190,7 @@ fn append_in_clause(query: &mut QueryBuilder<'_, MySql>, ids: &[i64]) {
query.push(")"); 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 query
.build_query_as::<UserRow>() .build_query_as::<UserRow>()
.fetch_all(&pool().await?) .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<()> { 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 = ?") sqlx::query("UPDATE users SET iota_id = ? WHERE id = ?")
.bind(value.map(|id| id.0)) .bind(value.map(|id| id.0))
.bind(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?) .execute(&pool().await?)
.await?; .await?;
Ok(()) 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 /// Delete the central identity while retaining a durable instruction for the
/// last hosting Iota. The pending row is intentionally independent of users: /// last hosting Iota. The pending row is intentionally independent of users:
/// it must outlive the account row. /// it must outlive the account row.
@ -488,6 +533,7 @@ pub async fn register_complete_user(
} }
}; };
result?; result?;
enqueue_iota_snapshot(&mut transaction, iota_id.0).await?;
sqlx::query("UPDATE registration_leases SET completed_at = UTC_TIMESTAMP() WHERE token = ?") sqlx::query("UPDATE registration_leases SET completed_at = UTC_TIMESTAMP() WHERE token = ?")
.bind(&registration_token) .bind(&registration_token)
.execute(&mut *transaction) .execute(&mut *transaction)
@ -498,19 +544,20 @@ pub async fn register_complete_user(
fn valid_username(username: &str) -> bool { fn valid_username(username: &str) -> bool {
!username.is_empty() !username.is_empty()
&& username.chars().count() <= 15 && username.len() <= 15
&& !username.chars().any(char::is_control) && username
&& !username.contains(['/', '\\']) .bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[tokio::test]
async fn generated_registration_ids_fit_the_mtp_wire_range() { async fn generated_registration_ids_fit_the_mtp_wire_range() {
for _ in 0..128 { 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(0));
assert!(valid_protocol_id(MAX_PROTOCOL_ID)); assert!(valid_protocol_id(MAX_PROTOCOL_ID));

View file

@ -1,7 +1,7 @@
use crate::error::{IdentityError, Result}; use crate::error::{IdentityError, Result};
use mtp::crypto::{Keyring, PublicKeyBundle}; use mtp::crypto::{Keyring, PublicKeyBundle};
use mtp::files::{ 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::{ use std::{
fs, fs,
@ -22,25 +22,20 @@ pub struct OmegaIdentity {
static PUBLIC_BUNDLE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); static PUBLIC_BUNDLE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
impl OmegaIdentity { impl OmegaIdentity {
pub fn load_or_create(passphrase: &[u8]) -> Result<Self> { pub fn load_or_create() -> Result<Self> {
Self::load_or_create_at( Self::load_or_create_at(Path::new(KEYRING_PATH), Path::new(PUBLIC_KEY_PATH))
Path::new(KEYRING_PATH),
Path::new(PUBLIC_KEY_PATH),
passphrase,
)
} }
pub(crate) fn load_or_create_at( pub(crate) fn load_or_create_at(
keyring_path: impl AsRef<Path>, keyring_path: impl AsRef<Path>,
public_key_path: impl AsRef<Path>, public_key_path: impl AsRef<Path>,
passphrase: &[u8],
) -> Result<Self> { ) -> Result<Self> {
let keyring_path = keyring_path.as_ref(); let keyring_path = keyring_path.as_ref();
let public_key_path = public_key_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, Ok(keyring) => keyring,
Err(FileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { 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) => { Err(error) => {
return Err(IdentityError::Storage { return Err(IdentityError::Storage {
@ -72,13 +67,11 @@ impl OmegaIdentity {
Ok(identity) 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(); let keyring = Keyring::generate();
save_keyring(&keyring, keyring_path, passphrase).map_err(|error| { save_keyring_raw(&keyring, keyring_path).map_err(|error| IdentityError::Storage {
IdentityError::Storage { path: keyring_path.to_path_buf(),
path: keyring_path.to_path_buf(), source: error,
source: error,
}
})?; })?;
Self::persist_public_bundle(&keyring.public_key_bundle(), public_key_path)?; Self::persist_public_bundle(&keyring.public_key_bundle(), public_key_path)?;
Ok(Self { Ok(Self {
@ -188,9 +181,7 @@ mod tests {
let directory = test_directory(); let directory = test_directory();
let keyring_path = directory.join("omega.mk"); let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb"); 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)
let first = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("create identity"); .expect("create identity");
let first_bundle = first.public_key_bundle().try_as_bytes().expect("bundle"); 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!(&keyring_bytes[..4], b"MTMK");
assert_eq!(&bundle_bytes[..4], b"MPKB"); assert_eq!(&bundle_bytes[..4], b"MPKB");
let restarted = let restarted = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase) .expect("reload identity");
.expect("reload identity");
assert_eq!( assert_eq!(
restarted restarted
.public_key_bundle() .public_key_bundle()
@ -227,8 +217,7 @@ mod tests {
let public_key_path = directory.join("omega.mpkb"); let public_key_path = directory.join("omega.mpkb");
fs::write(&keyring_path, b"not-a-keyring").expect("write invalid keyring"); fs::write(&keyring_path, b"not-a-keyring").expect("write invalid keyring");
let result = let result = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path);
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, b"test-passphrase");
assert!(result.is_err()); assert!(result.is_err());
assert!(!public_key_path.exists()); assert!(!public_key_path.exists());
@ -240,16 +229,12 @@ mod tests {
let directory = test_directory(); let directory = test_directory();
let keyring_path = directory.join("omega.mk"); let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb"); let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase"; OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path).expect("create identity");
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("create identity");
let original_keyring = fs::read(&keyring_path).expect("read keyring"); let original_keyring = fs::read(&keyring_path).expect("read keyring");
fs::remove_file(&public_key_path).expect("remove bundle"); fs::remove_file(&public_key_path).expect("remove bundle");
let repaired = let repaired = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase) .expect("repair bundle");
.expect("repair bundle");
assert_eq!( assert_eq!(
fs::read(&keyring_path).expect("read keyring"), fs::read(&keyring_path).expect("read keyring"),
@ -271,16 +256,13 @@ mod tests {
let directory = test_directory(); let directory = test_directory();
let keyring_path = directory.join("omega.mk"); let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb"); let public_key_path = directory.join("omega.mpkb");
let passphrase = b"test-passphrase"; OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path).expect("create identity");
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, passphrase)
.expect("create identity");
let other_keyring = Keyring::generate(); let other_keyring = Keyring::generate();
save_public_key_bundle(&other_keyring.public_key_bundle(), &public_key_path) save_public_key_bundle(&other_keyring.public_key_bundle(), &public_key_path)
.expect("save mismatched bundle"); .expect("save mismatched bundle");
assert!(matches!( 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( Err(crate::OmegaError::Identity(
IdentityError::PublicBundleMismatch { .. } IdentityError::PublicBundleMismatch { .. }
)) ))
@ -290,24 +272,25 @@ mod tests {
} }
#[test] #[test]
fn wrong_passphrase_does_not_replace_existing_keyring() { fn existing_raw_keyring_is_reloaded_without_a_passphrase() {
let directory = test_directory(); let directory = test_directory();
let keyring_path = directory.join("omega.mk"); let keyring_path = directory.join("omega.mk");
let public_key_path = directory.join("omega.mpkb"); 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"); .expect("create identity");
let original_keyring = fs::read(&keyring_path).expect("read keyring"); let original_keyring = fs::read(&keyring_path).expect("read keyring");
assert!( let reloaded = OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path)
OmegaIdentity::load_or_create_at(&keyring_path, &public_key_path, b"wrong-passphrase") .expect("reload identity");
.is_err()
);
assert_eq!( assert_eq!(
fs::read(&keyring_path).expect("read keyring"), fs::read(&keyring_path).expect("read keyring"),
original_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"); 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::db::initialize;
use crate::state::OmegaState; use crate::state::OmegaState;
use crate::transport::omikron_connection; use crate::transport::omikron_connection;
use crate::transport::omikron_manager;
use crate::util::file_util::get_directory; use crate::util::file_util::get_directory;
use crate::util::logger::PrintType; use crate::util::logger::PrintType;
use crate::util::logger::startup; use crate::util::logger::startup;
@ -25,7 +26,6 @@ use std::env;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use tokio::time::interval; use tokio::time::interval;
use zeroize::Zeroizing;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -38,18 +38,6 @@ async fn main() {
log_in!("Incoming messages"); log_in!("Incoming messages");
log_out!("Outgoing 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() { let config = match OmegaConfig::from_env() {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
@ -61,14 +49,13 @@ async fn main() {
log!("[FATAL] Omega rate-limit configuration was initialized more than once"); log!("[FATAL] Omega rate-limit configuration was initialized more than once");
return; return;
} }
let identity = match identity::OmegaIdentity::load_or_create(identity_secret.as_bytes()) { let identity = match identity::OmegaIdentity::load_or_create() {
Ok(identity) => identity, Ok(identity) => identity,
Err(error) => { Err(error) => {
log!("[FATAL] Omega identity initialization failed: {}", error); log!("[FATAL] Omega identity initialization failed: {}", error);
return; return;
} }
}; };
drop(identity_secret);
let state = OmegaState::new(identity, config); let state = OmegaState::new(identity, config);
log!("Started"); 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") let port: u16 = env::var("PORT")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
@ -114,4 +102,5 @@ async fn main() {
} }
rate_limit_cleanup.abort(); rate_limit_cleanup.abort();
short_link_cleanup.abort(); short_link_cleanup.abort();
snapshot_outbox_worker.abort();
} }

View file

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

View file

@ -1,6 +1,8 @@
use http::StatusCode; use http::StatusCode;
use mtp::webserver::HttpResponse; use mtp::webserver::HttpResponse;
use crate::server::with_cors;
pub fn index_handler(response: HttpResponse) -> HttpResponse { pub fn index_handler(response: HttpResponse) -> HttpResponse {
let documentation = r#" let documentation = r#"
Omega API Server 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. All other routes will return this documentation.
"#; "#;
response with_cors(
.status(StatusCode::OK) response
.header("content-type", "text/plain; charset=utf-8") .status(StatusCode::OK)
.body(documentation) .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 short_link;
pub mod validation; pub mod validation;
pub mod web; 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::identity::OmegaIdentity;
use crate::server::{api, index::index_handler}; use crate::server::{api, index::index_handler};
use http::{Method, StatusCode};
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig}; use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
use std::{future::Future, pin::Pin, sync::Arc}; 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("/api/get/user/{id}", api_pattern_handler(identity.clone()))?
.route_pattern("/direct/{short}", api_pattern_handler(identity))? .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, message_type,
mtp::codec::CommunicationType::GetUserData mtp::codec::CommunicationType::GetUserData
| mtp::codec::CommunicationType::ChangeUserData | mtp::codec::CommunicationType::ChangeUserData
| mtp::codec::CommunicationType::ChangeIotaData
| mtp::codec::CommunicationType::DeleteUser | mtp::codec::CommunicationType::DeleteUser
| mtp::codec::CommunicationType::AttachUserBegin | mtp::codec::CommunicationType::AttachUserBegin
| mtp::codec::CommunicationType::AttachUserComplete | mtp::codec::CommunicationType::AttachUserComplete
@ -147,7 +146,6 @@ mod tests {
for message_type in [ for message_type in [
CommunicationType::GetUserData, CommunicationType::GetUserData,
CommunicationType::ChangeUserData, CommunicationType::ChangeUserData,
CommunicationType::ChangeIotaData,
CommunicationType::DeleteUser, CommunicationType::DeleteUser,
CommunicationType::AttachUserBegin, CommunicationType::AttachUserBegin,
CommunicationType::AttachUserComplete, CommunicationType::AttachUserComplete,

View file

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

View file

@ -562,6 +562,15 @@ pub async fn sync_status(
&affected_iota_ids.iter().copied().collect::<Vec<_>>(), &affected_iota_ids.iter().copied().collect::<Vec<_>>(),
) )
.await?; .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<_>>(); let user_ids = users.iter().map(|user| user.id.0).collect::<Vec<_>>();
apply_preferences( apply_preferences(
&state, &state,

View file

@ -135,6 +135,17 @@ pub async fn complete_user(
.await .await
{ {
Ok(()) => { 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 connection
.send( .send(
&CommunicationValue::new(CommunicationType::Success) &CommunicationValue::new(CommunicationType::Success)

View file

@ -264,49 +264,3 @@ pub async fn change_user(
) -> OmikronResult<()> { ) -> OmikronResult<()> {
update_user(connection, value).await 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 request_id = value.id();
let result = crate::transport::relay_router::route_from_omikron(id, value).await; let result = crate::transport::relay_router::route_from_omikron(id, value).await;
let response = match &result { let response = match &result {
Ok(()) => request_id.map(|request_id| { Ok(response) => request_id.map(|request_id| response.clone().with_id(request_id)),
CommunicationValue::new(CommunicationType::Success).with_id(request_id)
}),
Err(error) => { Err(error) => {
log_err!(id, PrintType::Omega, "Relay routing failed: {}", error); log_err!(id, PrintType::Omega, "Relay routing failed: {}", error);
request_id.map(|request_id| { request_id.map(|request_id| {
@ -417,7 +415,9 @@ impl OmikronConnection {
send_error 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() { match value.get_comm_type_enum() {
Some(CommunicationType::ShortenLink) => { Some(CommunicationType::ShortenLink) => {
@ -449,9 +449,6 @@ impl OmikronConnection {
Some(CommunicationType::ChangeUserData) => { Some(CommunicationType::ChangeUserData) => {
crate::transport::handlers::user_data::change_user(self, value).await 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) => { Some(CommunicationType::GetRegister) => {
crate::transport::handlers::register::get_register(self, value).await 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)] #[cfg(test)]
mod tests { mod tests {
use super::{DispatchClass, OmikronConnection}; use super::{DispatchClass, OmikronConnection, parse_bind_address};
use mtp::codec::{CommunicationType, CommunicationValue}; use mtp::codec::{CommunicationType, CommunicationValue};
use std::net::{IpAddr, Ipv4Addr};
#[test] #[test]
fn relay_dispatch_is_not_on_the_ordered_state_lane() { 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] #[test]
fn presence_lifecycle_dispatch_is_ordered() { fn presence_lifecycle_dispatch_is_ordered() {
let value = CommunicationValue::new(CommunicationType::UserConnected); let value = CommunicationValue::new(CommunicationType::UserConnected);
@ -806,4 +812,12 @@ mod tests {
&CommunicationValue::new(CommunicationType::GetUserData).with_id(1) &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 once_cell::sync::Lazy;
use rand::prelude::IteratorRandom; use rand::prelude::IteratorRandom;
use std::sync::Arc; 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>>> = pub static OMIKRON_CONNECTIONS: Lazy<DashMap<i64, Arc<OmikronConnection>>> =
Lazy::new(DashMap::new); 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()) { if let Some(old) = OMIKRON_CONNECTIONS.insert(id, conn.clone()) {
old.close().await; old.close().await;
} }
let _ = flush_iota_snapshot_outbox().await;
} }
pub async fn remove_omikron(omikron_id: i64, connection: &Arc<OmikronConnection>) -> bool { pub async fn remove_omikron(omikron_id: i64, connection: &Arc<OmikronConnection>) -> bool {
@ -63,12 +67,18 @@ pub async fn get_all_connections()
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
for user in users { for user in users {
for route in state.presence.routes_for_user(user.id.0) { if let Some(iota_id) = user.iota_id {
if let Some(iotas) = result.get_mut(&route.omikron_id) for omikron_id in state
&& let Some(iota_id) = user.iota_id .presence
&& let Some(users) = iotas.get_mut(&iota_id.0) .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. * Publish each Iota membership snapshot to every live relay route. Each
pub async fn publish_iota_user_snapshot(iota_id: i64) { * Omikron keeps a local authorization index, so sending only a primary route
let Some(omikron_id) = get_iota_primary_omikron_connection(iota_id) else { * leaves the remaining relays stale after registration or migration.
return; */
}; pub async fn publish_iota_user_snapshot(iota_id: i64) -> OmikronResult<()> {
let Some(connection) = get_connected_omikron(omikron_id) else { let state = get_state().ok_or(crate::error::OmegaError::NotConnected)?;
return; let omikron_ids = state
}; .presence
let Ok(users) = user_repo::get_users_by_iota_id(crate::models::IotaId::from(iota_id)).await .iota_connections(iota_id)
else { .ok_or(crate::error::OmegaError::NotConnected)?;
return; let users = user_repo::get_users_by_iota_id(crate::models::IotaId::from(iota_id)).await?;
};
let user_ids = users let user_ids = users
.into_iter() .into_iter()
.map(|user| DataValue::SignedNumber(user.id.0.into())) .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) let snapshot = CommunicationValue::new(CommunicationType::IotaUserData)
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into())) .add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into()))
.add_typed_default(DataType::UserIds, DataValue::Array(user_ids)); .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) { 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( pub async fn route_from_omikron(
source_omikron_id: i64, source_omikron_id: i64,
frame: CommunicationValue, frame: CommunicationValue,
) -> Result<(), RelayRouteError> { ) -> Result<CommunicationValue, RelayRouteError> {
let frame = ensure_relay_frame_id(frame); let frame = ensure_relay_frame_id(frame);
if !frame.is_type(CommunicationType::Relay) { if !frame.is_type(CommunicationType::Relay) {
return Err(RelayRouteError::Relay(RelayError::NotRelay)); return Err(RelayRouteError::Relay(RelayError::NotRelay));
@ -154,7 +154,7 @@ pub async fn route_from_omikron(
RelayRouteError::Send(error.to_string()) RelayRouteError::Send(error.to_string())
})?; })?;
if response.is_type(CommunicationType::Success) { if response.is_type(CommunicationType::Success) {
Ok(()) Ok(response)
} else { } else {
Err(RelayRouteError::Send(format!( Err(RelayRouteError::Send(format!(
"destination Omikron rejected the Relay with {}", "destination Omikron rejected the Relay with {}",