[WIP] User Invites

This commit is contained in:
Alex Emmet 2026-09-10 23:14:25 +02:00
commit 7164f4671d
No known key found for this signature in database
24 changed files with 2829 additions and 170 deletions

View file

@ -18,6 +18,18 @@ on:
description: "Release description" description: "Release description"
required: true required: true
type: string type: string
release_sequence:
description: "Monotonic sequence allocated for this update channel"
required: true
type: string
expires_at:
description: "Signed manifest expiry in RFC 3339 format"
required: true
type: string
concurrency:
group: iota-release-${{ inputs.release_type }}
cancel-in-progress: false
jobs: jobs:
build: build:
@ -88,8 +100,11 @@ jobs:
CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }} CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }}
CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }} CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }}
RELEASE_TYPE: ${{ inputs.release_type }} RELEASE_TYPE: ${{ inputs.release_type }}
RELEASE_SEQUENCE: ${{ inputs.release_sequence }}
EXPIRES_AT: ${{ inputs.expires_at }}
SERVER_URL: ${{ forgejo.server_url }} SERVER_URL: ${{ forgejo.server_url }}
REPO: ${{ forgejo.repository }} REPO: ${{ forgejo.repository }}
TOKEN: ${{ forgejo.token }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }} IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
run: | run: |
set -eu set -eu
@ -103,17 +118,45 @@ jobs:
UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}" UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}"
UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}" UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}"
export UPDATE_CHANNEL_BASE_URL
nix-shell -p curl jq --run '
set -eu
CHANNEL_STATUS="$(curl -L -sS -w "%{http_code}" -o previous-channel-manifest.json \
-H "Authorization: token $TOKEN" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME")"
case "$CHANNEL_STATUS" in
200)
jq -e \
--argjson proposed "$RELEASE_SEQUENCE" \
--arg version "$TAG" \
"(.release_sequence < \$proposed) or (.release_sequence == \$proposed and .product_version == \$version)" \
previous-channel-manifest.json >/dev/null || {
echo "release sequence must increase, or identify the same release during a refresh"
exit 1
}
;;
404) ;;
*)
echo "could not read current channel manifest: HTTP $CHANNEL_STATUS"
exit 1
;;
esac
'
PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export ARCH PUBLISHED_AT RELEASE_TYPE TAG UPDATE_BASE_URL UPDATE_MANIFEST_NAME export ARCH EXPIRES_AT PUBLISHED_AT RELEASE_SEQUENCE RELEASE_TYPE TAG UPDATE_BASE_URL UPDATE_MANIFEST_NAME
nix-shell -p coreutils jq --run 'bash scripts/build-update-manifest.sh dist/bin "$TAG" "$RELEASE_TYPE" "$PUBLISHED_AT" linux "$ARCH" "$UPDATE_BASE_URL" "dist/$UPDATE_MANIFEST_NAME"' nix-shell -p coreutils jq --run 'bash scripts/build-update-manifest.sh dist/bin "$TAG" "$RELEASE_TYPE" "$RELEASE_SEQUENCE" "$PUBLISHED_AT" "$EXPIRES_AT" linux "$ARCH" "$UPDATE_BASE_URL" "dist/$UPDATE_MANIFEST_NAME"'
UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")" UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")"
bash scripts/build-release-bundle.sh \ export UPDATE_PUBLIC_KEY
nix-shell -p jq zip --run 'bash scripts/build-release-bundle.sh \
dist/bin \ dist/bin \
"$TAG" \ "$TAG" \
"dist/$UPDATE_MANIFEST_NAME" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \ "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \
"$UPDATE_PUBLIC_KEY" \ "$UPDATE_PUBLIC_KEY" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \ "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \
"dist/$ASSET_NAME" "$RELEASE_TYPE" \
primary \
"dist/$ASSET_NAME"'
result/bin/iota-bundle "dist/$ASSET_NAME" result/bin/iota-bundle "dist/$ASSET_NAME"
- name: Create release and upload release assets - name: Create release and upload release assets

1
Cargo.lock generated
View file

@ -2290,6 +2290,7 @@ name = "iota-updater"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"ed25519-dalek 2.2.0", "ed25519-dalek 2.2.0",
"fs2", "fs2",
"hex", "hex",

View file

@ -484,6 +484,38 @@ impl IpcClient {
.join("\n") .join("\n")
} }
} }
ResponsePayload::InvitationCreated(invitation) => format!(
"Created {:?} invitation {}\nToken: {}\nExpires: {}{}",
invitation.authority,
invitation.invitation_id,
invitation.raw_token.0,
invitation.expires_at,
invitation
.short_url
.as_ref()
.map(|url| format!("\nLink: {url}"))
.unwrap_or_default(),
),
ResponsePayload::Invitations(invitations) => {
if invitations.is_empty() {
"No invitations.".into()
} else {
invitations
.iter()
.map(|invitation| {
format!(
"{} ({:?}, {:?})",
invitation.invitation_id, invitation.authority, invitation.state
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
ResponsePayload::InvitationUpdated(invitation) => format!(
"Invitation {} is {:?}",
invitation.invitation_id, invitation.state
),
ResponsePayload::UserCreated { user_id, username } => { ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id) format!("Created user {} ({})", username, user_id)
} }
@ -588,6 +620,7 @@ impl IpcClient {
iota_ipc::IpcErrorCode::OmikronUnavailable => { iota_ipc::IpcErrorCode::OmikronUnavailable => {
"Omikron is unavailable; try reconnecting." "Omikron is unavailable; try reconnecting."
} }
iota_ipc::IpcErrorCode::Unsupported => "The daemon does not implement this operation.",
iota_ipc::IpcErrorCode::UnsupportedVersion => { iota_ipc::IpcErrorCode::UnsupportedVersion => {
"CLI and daemon versions are incompatible." "CLI and daemon versions are incompatible."
} }

View file

@ -25,6 +25,7 @@ pub enum AddUserPhase {
pub struct AddUserFlow { pub struct AddUserFlow {
pub phase: AddUserPhase, pub phase: AddUserPhase,
pub methods: MenuState<AddUserMethod>, pub methods: MenuState<AddUserMethod>,
pub invitation_authorities: MenuState<iota_ipc::InvitationAuthority>,
pub username: TextInput, pub username: TextInput,
pub import_path: TextInput, pub import_path: TextInput,
pub credential: Option<iota_ipc::SecretString>, pub credential: Option<iota_ipc::SecretString>,
@ -39,7 +40,7 @@ impl AddUserFlow {
methods: MenuState::new(vec![ methods: MenuState::new(vec![
MenuItem { MenuItem {
label: "Share an invitation".into(), label: "Share an invitation".into(),
description: Some("Not supported by the connected daemon".into()), description: Some("Create a single-use onboarding credential".into()),
value: AddUserMethod::Invitation, value: AddUserMethod::Invitation,
enabled: invitation_supported, enabled: invitation_supported,
disabled_reason: Some("Not supported by the connected daemon".into()), disabled_reason: Some("Not supported by the connected daemon".into()),
@ -60,6 +61,22 @@ impl AddUserFlow {
.then(|| "TU inspection is not supported by the connected daemon".into()), .then(|| "TU inspection is not supported by the connected daemon".into()),
}, },
]), ]),
invitation_authorities: MenuState::new(vec![
MenuItem {
label: "Omega".into(),
description: Some("Central invitation authority".into()),
value: iota_ipc::InvitationAuthority::Omega,
enabled: true,
disabled_reason: None,
},
MenuItem {
label: "This Iota".into(),
description: Some("Local invitation authority".into()),
value: iota_ipc::InvitationAuthority::Iota,
enabled: false,
disabled_reason: Some("Local invitations are not implemented yet".into()),
},
]),
username: TextInput::new("Username"), username: TextInput::new("Username"),
import_path: TextInput::new("TU path"), import_path: TextInput::new("TU path"),
credential: None, credential: None,

View file

@ -399,6 +399,42 @@ impl UsersScreen {
}), }),
} }
} }
fn start_invitation(&mut self, authority: iota_ipc::InvitationAuthority) -> InteractionResult {
self.pending = true;
self.message = Some("Creating invitation…".into());
self.overlay = None;
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::CreateInvitation {
authority,
lifetime_seconds: 7 * 24 * 60 * 60,
password: None,
label: None,
})
.await
{
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::InvitationCreated(invitation),
)) => Ok(format!(
"Invitation {} created. Token: {} Link: {}",
invitation.invitation_id,
invitation.raw_token.0,
invitation.short_url.as_deref().unwrap_or("unavailable")
)),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Create invitation failed: {error}"))
}
Ok(_) => Err(
"Create invitation failed: daemon returned an unexpected response.".into(),
),
Err(error) => Err(format!("Create invitation failed: {error}")),
};
UiEvent::App(AppEvent::UserOperationFinished(result))
}),
}
}
fn refresh(&self) -> InteractionResult { fn refresh(&self) -> InteractionResult {
let ipc = self.ipc.clone(); let ipc = self.ipc.clone();
InteractionResult::AppTask { InteractionResult::AppTask {
@ -649,6 +685,33 @@ impl UsersScreen {
inner, inner,
); );
} }
AddUserPhase::ConfigureInvitation => {
let lines = flow
.invitation_authorities
.items()
.iter()
.enumerate()
.map(|(index, item)| {
Line::from(format!(
"{} {}{}",
if flow.invitation_authorities.selected_index() == Some(index) {
">"
} else {
" "
},
item.label,
item.disabled_reason
.as_ref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default()
))
})
.chain(std::iter::once(Line::from(
"\nEnter creates a single-use invitation valid for one week.",
)))
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), inner);
}
AddUserPhase::InspectingTu => { AddUserPhase::InspectingTu => {
frame.render_widget(Paragraph::new("Inspecting credential…"), inner); frame.render_widget(Paragraph::new("Inspecting credential…"), inner);
} }
@ -991,6 +1054,29 @@ impl Screen for UsersScreen {
InteractionResult::Handled InteractionResult::Handled
} }
}, },
AddUserPhase::ConfigureInvitation => {
if flow.invitation_authorities.handle_key(key.code) {
return InteractionResult::Handled;
}
match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ChooseMethod;
InteractionResult::Handled
}
KeyCode::Enter => {
let authority = flow
.invitation_authorities
.selected_item()
.map(|item| item.value);
if let Some(authority) = authority {
self.start_invitation(authority)
} else {
InteractionResult::Handled
}
}
_ => InteractionResult::Handled,
}
}
AddUserPhase::ReviewImport => match key.code { AddUserPhase::ReviewImport => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
flow.phase = AddUserPhase::ConfigureImport; flow.phase = AddUserPhase::ConfigureImport;

View file

@ -2,10 +2,11 @@ use crate::log_buffer::LogBuffer;
use crate::{DaemonRuntime, DaemonServices}; use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ use iota_ipc::{
CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent,
IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, InvitationAuthority, InvitationCreated, InvitationState, InvitationSummary, IpcErrorCode,
OmikronStatusResponse, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, OmikronStatusResponse,
StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse,
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, UserOperationKind,
UserOperationSummary, UserReconcileResult, UserSummary,
}; };
use iota_logger::{log, log_command}; use iota_logger::{log, log_command};
use iota_storage::users::pending_operations::{ use iota_storage::users::pending_operations::{
@ -13,6 +14,7 @@ use iota_storage::users::pending_operations::{
}; };
use iota_storage::users::user_manager; use iota_storage::users::user_manager;
use iota_storage::util::config_util::{self}; use iota_storage::util::config_util::{self};
use iota_util::mtp_compat::OptionalDataValueExt;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -149,6 +151,10 @@ impl CommandRouter {
| LocalRequest::ReconcileUser { .. } | LocalRequest::ReconcileUser { .. }
| LocalRequest::ReleaseUser { .. } | LocalRequest::ReleaseUser { .. }
| LocalRequest::CompleteDeleteUser { .. } | LocalRequest::CompleteDeleteUser { .. }
| LocalRequest::CreateInvitation {
authority: InvitationAuthority::Omega,
..
}
); );
if needs_omikron && !self.services.omikron.is_connected().await { if needs_omikron && !self.services.omikron.is_connected().await {
return ResponseResult::Error( return ResponseResult::Error(
@ -240,6 +246,269 @@ impl CommandRouter {
}; };
ResponseResult::Ok(ResponsePayload::Users(users)) ResponseResult::Ok(ResponsePayload::Users(users))
} }
LocalRequest::CreateInvitation {
authority,
lifetime_seconds,
password,
label,
} => {
if authority == InvitationAuthority::Iota {
return ResponseResult::Error(IpcErrorCode::Unsupported);
}
if lifetime_seconds == 0 || lifetime_seconds > 7 * 24 * 60 * 60 {
return ResponseResult::Error(IpcErrorCode::InvalidRequest);
}
let password_protected = password.is_some();
let mut request = CommunicationValue::new(CommunicationType::CreateUserInvitation)
.add_typed_default(
DataType::InvitationAuthority,
DataValue::Str("omega".into()),
)
.add_typed_default(
DataType::InvitationLifetimeSeconds,
DataValue::SignedNumber(lifetime_seconds.into()),
);
if let Some(password) = password {
request = request.add_typed_default(
DataType::InvitationPassword,
DataValue::Str(password.0),
);
}
if let Some(label) = label.as_ref() {
request = request.add_typed_default(
DataType::InvitationLabel,
DataValue::Str(label.clone()),
);
}
let response = match self
.services
.omikron
.await_response(&request, Duration::from_secs(20))
.await
{
Ok(response) => response,
Err(omikron_connector::OmikronError::Timeout(_)) => {
return ResponseResult::Error(IpcErrorCode::Timeout);
}
Err(_) => return ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
};
let invitation_id = response
.get_data(DataType::InvitationId)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok());
let raw_token = response
.get_data(DataType::InvitationToken)
.as_str()
.map(str::to_owned);
let expires_at = response
.get_data(DataType::InvitationExpiresAt)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok());
let created_at = response
.get_data(DataType::InvitationCreatedAt)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok());
let remote_revision = response
.get_data(DataType::InvitationRevision)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok())
.unwrap_or(1);
let short_url = response
.get_data(DataType::Link)
.as_str()
.map(str::to_owned);
let Some((invitation_id, raw_token, created_at, expires_at)) = invitation_id
.zip(raw_token)
.zip(created_at)
.zip(expires_at)
.map(|(((id, token), created), expires)| (id, token, created, expires))
else {
return ResponseResult::Error(IpcErrorCode::InternalFailure);
};
let summary = iota_storage::users::invitations::InvitationSummary {
invitation_id,
authority: iota_storage::users::invitations::InvitationAuthority::Omega,
label,
password_protected,
created_at,
expires_at: Some(expires_at),
state: iota_storage::users::invitations::InvitationState::Pending,
remote_revision,
redeemed_user_id: None,
redeemed_at: None,
revoked_at: None,
pending_action: None,
pending_action_at: None,
last_synced_at: Some(now_millis()),
local_provisioned_user_id: None,
local_provisioned_at: None,
};
if iota_storage::users::invitations::insert(&summary, None, now_millis()).is_err() {
return ResponseResult::Error(IpcErrorCode::StorageFailure);
}
ResponseResult::Ok(ResponsePayload::InvitationCreated(InvitationCreated {
authority,
invitation_id,
raw_token: iota_ipc::SecretString(raw_token),
expires_at,
short_url,
}))
}
LocalRequest::ListInvitations { authority } => {
if authority != Some(InvitationAuthority::Iota)
&& self.services.omikron.is_connected().await
{
match self.services.omikron.sync_omega_invitations().await {
Ok(()) => {}
Err(omikron_connector::OmikronError::Storage(_)) => {
return ResponseResult::Error(IpcErrorCode::StorageFailure);
}
Err(omikron_connector::OmikronError::Timeout(_)) => {
return ResponseResult::Error(IpcErrorCode::Timeout);
}
Err(omikron_connector::OmikronError::Disconnected(_)) => {
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
}
Err(_) => return ResponseResult::Error(IpcErrorCode::InternalFailure),
}
}
if iota_storage::users::invitations::expire_pending(now_millis()).is_err() {
return ResponseResult::Error(IpcErrorCode::StorageFailure);
}
let invitations = match iota_storage::users::invitations::list() {
Ok(invitations) => invitations,
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let mut summaries = Vec::new();
for invitation in invitations {
let invitation_authority = match invitation.authority {
iota_storage::users::invitations::InvitationAuthority::Omega => {
InvitationAuthority::Omega
}
iota_storage::users::invitations::InvitationAuthority::Iota => {
InvitationAuthority::Iota
}
};
if authority.is_some_and(|filter| filter != invitation_authority) {
continue;
}
let display_state = if invitation.pending_action
== Some(iota_storage::users::invitations::PendingAction::Revoke)
{
InvitationState::RevocationPending
} else if invitation.authority
== iota_storage::users::invitations::InvitationAuthority::Omega
&& invitation.state
== iota_storage::users::invitations::InvitationState::Pending
&& invitation
.expires_at
.is_some_and(|expires_at| expires_at <= now_millis())
{
InvitationState::Expired
} else {
match invitation.state {
iota_storage::users::invitations::InvitationState::Pending => {
InvitationState::Pending
}
iota_storage::users::invitations::InvitationState::Provisioning => {
InvitationState::Provisioning
}
iota_storage::users::invitations::InvitationState::Redeemed => {
InvitationState::Redeemed
}
iota_storage::users::invitations::InvitationState::Revoked => {
InvitationState::Revoked
}
iota_storage::users::invitations::InvitationState::Expired => {
InvitationState::Expired
}
}
};
summaries.push(InvitationSummary {
authority: invitation_authority,
invitation_id: invitation.invitation_id,
label: invitation.label,
password_protected: invitation.password_protected,
created_at: invitation.created_at,
expires_at: invitation.expires_at,
state: display_state,
redeemed_user_id: invitation.redeemed_user_id,
local_provisioned_user_id: invitation.local_provisioned_user_id,
local_provisioned_at: invitation.local_provisioned_at,
});
}
ResponseResult::Ok(ResponsePayload::Invitations(summaries))
}
LocalRequest::RevokeInvitation {
authority,
invitation_id,
} => {
if authority == InvitationAuthority::Iota {
return ResponseResult::Error(IpcErrorCode::Unsupported);
}
let changed = match iota_storage::users::invitations::mark_revoke_pending(
invitation_id,
now_millis(),
) {
Ok(changed) => changed,
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
if !changed {
return ResponseResult::Error(IpcErrorCode::Conflict);
}
if self.services.omikron.is_connected().await {
self.services
.omikron
.flush_pending_invitation_actions()
.await;
}
let invitation =
match iota_storage::users::invitations::list()
.ok()
.and_then(|items| {
items
.into_iter()
.find(|item| item.invitation_id == invitation_id)
}) {
Some(invitation) => invitation,
None => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let state = if invitation.pending_action
== Some(iota_storage::users::invitations::PendingAction::Revoke)
{
InvitationState::RevocationPending
} else {
match invitation.state {
iota_storage::users::invitations::InvitationState::Pending => {
InvitationState::Pending
}
iota_storage::users::invitations::InvitationState::Provisioning => {
InvitationState::Provisioning
}
iota_storage::users::invitations::InvitationState::Redeemed => {
InvitationState::Redeemed
}
iota_storage::users::invitations::InvitationState::Revoked => {
InvitationState::Revoked
}
iota_storage::users::invitations::InvitationState::Expired => {
InvitationState::Expired
}
}
};
ResponseResult::Ok(ResponsePayload::InvitationUpdated(InvitationSummary {
authority: InvitationAuthority::Omega,
invitation_id,
label: invitation.label,
password_protected: invitation.password_protected,
created_at: invitation.created_at,
expires_at: invitation.expires_at,
state,
redeemed_user_id: invitation.redeemed_user_id,
local_provisioned_user_id: invitation.local_provisioned_user_id,
local_provisioned_at: invitation.local_provisioned_at,
}))
}
LocalRequest::CreateUser { username } => { LocalRequest::CreateUser { username } => {
match omikron_connector::user_ops::create_user( match omikron_connector::user_ops::create_user(
self.services.omikron.as_ref(), self.services.omikron.as_ref(),

View file

@ -22,8 +22,6 @@ use uuid::Uuid;
const CLIENT_CHANNEL_SIZE: usize = 256; const CLIENT_CHANNEL_SIZE: usize = 256;
const MAX_CONFIGURED_IPC_CLIENTS: usize = 4096; const MAX_CONFIGURED_IPC_CLIENTS: usize = 4096;
/// Maximum handshake retries before giving up.
const MAX_HANDSHAKE_RETRIES: u32 = 1;
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Minimum metric subscription interval to prevent excessive update rates. /// Minimum metric subscription interval to prevent excessive update rates.
@ -368,9 +366,7 @@ async fn handle_client(
eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid); eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid);
// --- Handshake --- // --- Handshake ---
let mut negotiated_version: Option<u16> = None; let negotiated_version = match timeout(
for _ in 0..MAX_HANDSHAKE_RETRIES {
match timeout(
std::time::Duration::from_secs(15), std::time::Duration::from_secs(15),
read_msg::<_, ClientMessage>(&mut reader), read_msg::<_, ClientMessage>(&mut reader),
) )
@ -395,7 +391,6 @@ async fn handle_client(
"No compatible IPC protocol version", "No compatible IPC protocol version",
) )
})?; })?;
negotiated_version = Some(version);
let ack = DaemonMessage::HelloAck(HelloAck { let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version, protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(), daemon_version: env!("CARGO_PKG_VERSION").to_string(),
@ -408,6 +403,7 @@ async fn handle_client(
"user_management_v2".into(), "user_management_v2".into(),
"tu_inspection_v1".into(), "tu_inspection_v1".into(),
"credential_export_v1".into(), "credential_export_v1".into(),
"user_invitations_v1".into(),
], ],
lifecycle: *runtime.lifecycle.borrow(), lifecycle: *runtime.lifecycle.borrow(),
health: runtime.overall_health(), health: runtime.overall_health(),
@ -419,7 +415,7 @@ async fn handle_client(
"IPC handshake acknowledged (pid={}, uid={})", "IPC handshake acknowledged (pid={}, uid={})",
peer.pid, peer.uid peer.pid, peer.uid
); );
break; version
} }
Ok(_) => { Ok(_) => {
// Unexpected first message, send an error and close. // Unexpected first message, send an error and close.
@ -430,11 +426,7 @@ async fn handle_client(
} }
Err(e) => return Err(e), Err(e) => return Err(e),
}, },
} };
}
let negotiated_version = negotiated_version.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "Handshake failed after retries")
})?;
log!("IPC client connected (pid={}, uid={})", peer.pid, peer.uid); log!("IPC client connected (pid={}, uid={})", peer.pid, peer.uid);

View file

@ -183,6 +183,41 @@ fn product_version(staging: &Path) -> Result<String> {
.with_context(|| format!("read bundle manifest {}", manifest_path.display()))?; .with_context(|| format!("read bundle manifest {}", manifest_path.display()))?;
let manifest: serde_json::Value = serde_json::from_str(&contents) let manifest: serde_json::Value = serde_json::from_str(&contents)
.with_context(|| format!("parse bundle manifest {}", manifest_path.display()))?; .with_context(|| format!("parse bundle manifest {}", manifest_path.display()))?;
for name in [
"product_version",
"channel",
"published_at",
"expires_at",
"release_signing_key_id",
] {
manifest
.get(name)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.with_context(|| format!("bundle manifest {name} must be a non-empty string"))?;
}
for name in [
"release_sequence",
"minimum_data_schema",
"supported_ipc_min",
"supported_ipc_max",
] {
let value = manifest
.get(name)
.and_then(|value| value.as_u64())
.with_context(|| format!("bundle manifest {name} must be an unsigned integer"))?;
if name == "release_sequence" && value == 0 {
bail!("bundle manifest release_sequence must be greater than zero");
}
}
manifest
.get("artifacts")
.and_then(|value| value.as_array())
.context("bundle manifest artifacts must be an array")?;
manifest
.get("rollback_compatible")
.and_then(|value| value.as_bool())
.context("bundle manifest rollback_compatible must be a boolean")?;
let version = manifest let version = manifest
.get("product_version") .get("product_version")
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
@ -218,6 +253,8 @@ fn validate_update_environment(staging: &Path) -> Result<()> {
"IOTA_UPDATE_MANIFEST", "IOTA_UPDATE_MANIFEST",
"IOTA_UPDATE_PUBLIC_KEY", "IOTA_UPDATE_PUBLIC_KEY",
"IOTA_UPDATE_SIGNATURE", "IOTA_UPDATE_SIGNATURE",
"IOTA_UPDATE_CHANNEL",
"IOTA_UPDATE_SIGNING_KEY_ID",
] { ] {
let value = entries let value = entries
.get(name) .get(name)
@ -227,7 +264,7 @@ fn validate_update_environment(staging: &Path) -> Result<()> {
bail!("updater environment variable {name} contains whitespace"); bail!("updater environment variable {name} contains whitespace");
} }
} }
if entries.len() != 3 { if entries.len() != 5 {
bail!("updater environment contains unexpected variables"); bail!("updater environment contains unexpected variables");
} }
let public_key = entries let public_key = entries
@ -240,6 +277,27 @@ fn validate_update_environment(staging: &Path) -> Result<()> {
{ {
bail!("IOTA_UPDATE_PUBLIC_KEY must be 32-byte hex"); bail!("IOTA_UPDATE_PUBLIC_KEY must be 32-byte hex");
} }
let manifest: serde_json::Value = serde_json::from_slice(
&fs::read(staging.join("manifest.json")).context("read bundle manifest")?,
)
.context("parse bundle manifest")?;
for (environment_name, manifest_name) in [
("IOTA_UPDATE_CHANNEL", "channel"),
("IOTA_UPDATE_SIGNING_KEY_ID", "release_signing_key_id"),
] {
let environment_value = entries
.get(environment_name)
.with_context(|| format!("updater environment is missing {environment_name}"))?;
let manifest_value = manifest
.get(manifest_name)
.and_then(|value| value.as_str())
.with_context(|| format!("bundle manifest is missing {manifest_name}"))?;
if *environment_value != manifest_value {
bail!(
"updater environment {environment_name} does not match bundle manifest {manifest_name}"
);
}
}
Ok(()) Ok(())
} }
@ -279,11 +337,26 @@ mod tests {
.start_file(name, SimpleFileOptions::default()) .start_file(name, SimpleFileOptions::default())
.unwrap(); .unwrap();
if name == "manifest.json" { if name == "manifest.json" {
write!(archive, "{{\"product_version\":\"{product_version}\"}}").unwrap(); let manifest = serde_json::json!({
"product_version": product_version,
"channel": "stable",
"release_sequence": 1,
"published_at": "2026-09-10T00:00:00Z",
"expires_at": "2026-10-10T00:00:00Z",
"minimum_data_schema": 1,
"supported_ipc_min": 2,
"supported_ipc_max": 4,
"artifacts": [],
"release_signing_key_id": "primary",
"rollback_compatible": true
});
archive
.write_all(&serde_json::to_vec(&manifest).unwrap())
.unwrap();
} else if name == "systemd/update.env" { } else if name == "systemd/update.env" {
archive archive
.write_all( .write_all(
b"IOTA_UPDATE_MANIFEST=https://example.invalid/manifest.json\nIOTA_UPDATE_PUBLIC_KEY=0707070707070707070707070707070707070707070707070707070707070707\nIOTA_UPDATE_SIGNATURE=https://example.invalid/manifest.json.sig\n", b"IOTA_UPDATE_MANIFEST=https://example.invalid/manifest.json\nIOTA_UPDATE_PUBLIC_KEY=0707070707070707070707070707070707070707070707070707070707070707\nIOTA_UPDATE_SIGNATURE=https://example.invalid/manifest.json.sig\nIOTA_UPDATE_CHANNEL=stable\nIOTA_UPDATE_SIGNING_KEY_ID=primary\n",
) )
.unwrap(); .unwrap();
} else { } else {
@ -354,4 +427,18 @@ mod tests {
assert!(error.to_string().contains("IOTA_UPDATE_PUBLIC_KEY")); assert!(error.to_string().contains("IOTA_UPDATE_PUBLIC_KEY"));
} }
#[test]
fn rejects_legacy_bundle_manifest_without_sequence_baseline() {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("manifest.json"),
br#"{"product_version":"1.0.0"}"#,
)
.unwrap();
let error = product_version(directory.path()).unwrap_err();
assert!(error.to_string().contains("channel"));
}
} }

View file

@ -5,8 +5,9 @@ pub mod transport;
pub use protocol::{ pub use protocol::{
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse, ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse,
DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, DeploymentMode, ExitIntent, HealthStatus, HelloAck, InvitationAuthority, InvitationCreated,
LifecyclePhase, LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, InvitationState, InvitationSummary, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase,
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload, OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload,
ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind,
TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
@ -15,6 +16,6 @@ pub use protocol::{
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
/// Current IPC protocol version. /// Current IPC protocol version.
pub const PROTOCOL_VERSION: u16 = 4; pub const PROTOCOL_VERSION: u16 = 5;
/// Minimum protocol version this daemon understands. /// Minimum protocol version this daemon understands.
pub const MIN_PROTOCOL_VERSION: u16 = 2; pub const MIN_PROTOCOL_VERSION: u16 = 2;

View file

@ -45,6 +45,19 @@ pub enum LocalRequest {
GetStatus, GetStatus,
ListTasks, ListTasks,
ListUsers, ListUsers,
CreateInvitation {
authority: InvitationAuthority,
lifetime_seconds: u64,
password: Option<SecretString>,
label: Option<String>,
},
ListInvitations {
authority: Option<InvitationAuthority>,
},
RevokeInvitation {
authority: InvitationAuthority,
invitation_id: i64,
},
CreateUser { CreateUser {
username: String, username: String,
}, },
@ -148,6 +161,7 @@ impl LocalRequest {
Self::GetStatus Self::GetStatus
| Self::ListTasks | Self::ListTasks
| Self::ListUsers | Self::ListUsers
| Self::ListInvitations { .. }
| Self::GetDaemonStatus | Self::GetDaemonStatus
| Self::GetOmikronStatus | Self::GetOmikronStatus
| Self::ListComponents | Self::ListComponents
@ -160,6 +174,8 @@ impl LocalRequest {
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
Self::CreateUser { .. } Self::CreateUser { .. }
| Self::CreateInvitation { .. }
| Self::RevokeInvitation { .. }
| Self::InspectTuCredential { .. } | Self::InspectTuCredential { .. }
| Self::AttachUserFromTu { .. } | Self::AttachUserFromTu { .. }
| Self::ReconcileUser { .. } | Self::ReconcileUser { .. }
@ -249,6 +265,9 @@ pub enum ResponsePayload {
Status(StatusResponse), Status(StatusResponse),
Tasks(Vec<TaskSummary>), Tasks(Vec<TaskSummary>),
Users(Vec<UserSummary>), Users(Vec<UserSummary>),
InvitationCreated(InvitationCreated),
Invitations(Vec<InvitationSummary>),
InvitationUpdated(InvitationSummary),
UserCreated { UserCreated {
user_id: i64, user_id: i64,
username: String, username: String,
@ -327,6 +346,49 @@ pub struct CommunitySummary {
pub title: String, pub title: String,
} }
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvitationAuthority {
Omega,
Iota,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvitationState {
Pending,
RevocationPending,
Provisioning,
Redeemed,
Revoked,
Expired,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvitationCreated {
pub authority: InvitationAuthority,
pub invitation_id: i64,
pub raw_token: SecretString,
pub expires_at: i64,
pub short_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvitationSummary {
pub authority: InvitationAuthority,
pub invitation_id: i64,
pub label: Option<String>,
pub password_protected: bool,
pub created_at: i64,
pub expires_at: Option<i64>,
pub state: InvitationState,
pub redeemed_user_id: Option<i64>,
#[serde(default)]
pub local_provisioned_user_id: Option<i64>,
#[serde(default)]
pub local_provisioned_at: Option<i64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StatusResponse { pub struct StatusResponse {
pub phase: String, pub phase: String,
@ -427,6 +489,7 @@ pub enum IpcErrorCode {
Conflict, Conflict,
StorageFailure, StorageFailure,
OmikronUnavailable, OmikronUnavailable,
Unsupported,
UnsupportedVersion, UnsupportedVersion,
NotReady, NotReady,
Disconnected, Disconnected,
@ -444,6 +507,7 @@ impl std::fmt::Display for IpcErrorCode {
Self::Conflict => "the request conflicts with current daemon state", Self::Conflict => "the request conflicts with current daemon state",
Self::StorageFailure => "the daemon could not access local storage", Self::StorageFailure => "the daemon could not access local storage",
Self::OmikronUnavailable => "Omikron is unavailable", Self::OmikronUnavailable => "Omikron is unavailable",
Self::Unsupported => "the requested operation is not implemented",
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible", Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
Self::NotReady => "the daemon is not ready yet", Self::NotReady => "the daemon is not ready yet",
Self::Disconnected => "the daemon connection was lost", Self::Disconnected => "the daemon connection was lost",

View file

@ -6,7 +6,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version}; use mtp::codec::{CommunicationValue, DataTypeId, DataValue, TypeMap, Version};
use ratatui::style::Color; use ratatui::style::Color;
use iota_state::{UNIQUE, UiLogEntry}; use iota_state::{UNIQUE, UiLogEntry};
@ -354,6 +354,10 @@ fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) -
.map(|(key, value)| { .map(|(key, value)| {
let key_str = key.to_string(); let key_str = key.to_string();
if is_secret_data_type(key) {
return format!("{}=<redacted>", key_str);
}
match value { match value {
DataValue::Str(s) => format!("{}=\"{}\"", key_str, abbreviate_string(&s)), DataValue::Str(s) => format!("{}=\"{}\"", key_str, abbreviate_string(&s)),
@ -382,6 +386,13 @@ fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) -
parts.join(", ") parts.join(", ")
} }
fn is_secret_data_type(key: DataTypeId) -> bool {
matches!(
TypeMap::latest().data_type_name(key.0),
Some("InvitationToken" | "InvitationPassword" | "ResetToken" | "RegisterId" | "NewToken")
)
}
fn format_array(arr: Vec<DataValue>, version: Version) -> String { fn format_array(arr: Vec<DataValue>, version: Version) -> String {
let parts: Vec<String> = arr let parts: Vec<String> = arr
.into_iter() .into_iter()
@ -427,7 +438,8 @@ fn abbreviate_string(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::abbreviate_string; use super::{abbreviate_string, format_cv};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
#[test] #[test]
fn abbreviates_only_strings_longer_than_eight_characters() { fn abbreviates_only_strings_longer_than_eight_characters() {
@ -435,6 +447,24 @@ mod tests {
assert_eq!(abbreviate_string("123456789"), "1234...6789"); assert_eq!(abbreviate_string("123456789"), "1234...6789");
assert_eq!(abbreviate_string("YWJjZGVmZ2hpag=="), "YWJj...ag=="); assert_eq!(abbreviate_string("YWJjZGVmZ2hpag=="), "YWJj...ag==");
} }
#[test]
fn redacts_invitation_and_account_credentials() {
let reset_token = DataType::ResetToken.try_to_id(&TypeMap::latest()).unwrap();
let value = CommunicationValue::new(CommunicationType::RedeemUserInvitation)
.add_typed_default(DataType::InvitationToken, DataValue::Str("short123".into()))
.add_typed_default(
DataType::Invitations,
DataValue::Array(vec![DataValue::Container(vec![(
reset_token,
DataValue::Str("reset-secret".into()),
)])]),
);
let formatted = format_cv(&value);
assert!(!formatted.contains("short123"));
assert!(!formatted.contains("reset-secret"));
assert_eq!(formatted.matches("<redacted>").count(), 2);
}
} }
#[macro_export] #[macro_export]

View file

@ -1,9 +1,58 @@
use crate::{storage_error::StorageError, util::db}; use crate::{storage_error::StorageError, users::user_profile::UserProfile, util::db};
use rusqlite::params; use rusqlite::{OptionalExtension, params};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InvitationAuthority {
Omega,
Iota,
}
impl InvitationAuthority {
pub fn as_str(self) -> &'static str {
match self {
Self::Omega => "omega",
Self::Iota => "iota",
}
}
pub fn parse(value: &str) -> Result<Self, StorageError> {
match value {
"omega" => Ok(Self::Omega),
"iota" => Ok(Self::Iota),
_ => Err(StorageError::Other("unknown invitation authority".into())),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PendingAction {
Revoke,
}
impl PendingAction {
fn parse(value: &str) -> Result<Self, StorageError> {
match value {
"revoke" => Ok(Self::Revoke),
_ => Err(StorageError::Other(
"unknown pending invitation action".into(),
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProvisioningResult {
Created,
AlreadyApplied,
RevocationPending,
MissingInvitation,
Conflict,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InvitationState { pub enum InvitationState {
Pending, Pending,
Provisioning,
Redeemed, Redeemed,
Revoked, Revoked,
Expired, Expired,
@ -13,6 +62,7 @@ impl InvitationState {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { match self {
Self::Pending => "pending", Self::Pending => "pending",
Self::Provisioning => "provisioning",
Self::Redeemed => "redeemed", Self::Redeemed => "redeemed",
Self::Revoked => "revoked", Self::Revoked => "revoked",
Self::Expired => "expired", Self::Expired => "expired",
@ -22,6 +72,7 @@ impl InvitationState {
fn parse(value: &str) -> Result<Self, StorageError> { fn parse(value: &str) -> Result<Self, StorageError> {
match value { match value {
"pending" => Ok(Self::Pending), "pending" => Ok(Self::Pending),
"provisioning" => Ok(Self::Provisioning),
"redeemed" => Ok(Self::Redeemed), "redeemed" => Ok(Self::Redeemed),
"revoked" => Ok(Self::Revoked), "revoked" => Ok(Self::Revoked),
"expired" => Ok(Self::Expired), "expired" => Ok(Self::Expired),
@ -32,52 +83,361 @@ impl InvitationState {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvitationSummary { pub struct InvitationSummary {
pub invitation_id: String, pub invitation_id: i64,
pub authority: InvitationAuthority,
pub label: Option<String>,
pub password_protected: bool,
pub created_at: i64, pub created_at: i64,
pub expires_at: Option<i64>, pub expires_at: Option<i64>,
pub state: InvitationState, pub state: InvitationState,
pub remote_revision: i64,
pub redeemed_user_id: Option<i64>, pub redeemed_user_id: Option<i64>,
pub redeemed_at: Option<i64>, pub redeemed_at: Option<i64>,
pub revoked_at: Option<i64>, pub revoked_at: Option<i64>,
pub pending_action: Option<PendingAction>,
pub pending_action_at: Option<i64>,
pub last_synced_at: Option<i64>,
pub local_provisioned_user_id: Option<i64>,
pub local_provisioned_at: Option<i64>,
} }
pub fn insert(summary: &InvitationSummary, token_hash: &[u8]) -> Result<(), StorageError> { pub fn insert(
summary: &InvitationSummary,
token_hash: Option<&[u8]>,
synced_at: i64,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| insert_in_tx(tx, summary, token_hash, synced_at))
}
pub fn merge_omega_snapshot(
invitations: &[InvitationSummary],
synced_at: i64,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| { db::with_immediate_transaction(|tx| {
tx.execute( for invitation in invitations {
"INSERT INTO user_invitations (invitation_id, token_hash, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", insert_in_tx(tx, invitation, None, synced_at)?;
params![summary.invitation_id, token_hash, summary.created_at, summary.expires_at, summary.state.as_str(), summary.redeemed_user_id, summary.redeemed_at, summary.revoked_at], }
)?;
Ok(()) Ok(())
}) })
} }
fn insert_in_tx(
tx: &rusqlite::Transaction<'_>,
summary: &InvitationSummary,
token_hash: Option<&[u8]>,
synced_at: i64,
) -> Result<(), StorageError> {
tx.execute(
"INSERT INTO user_invitations (invitation_id, authority, token_hash, label, password_protected, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at, pending_action, pending_action_at, remote_revision, last_synced_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, NULL, ?12, ?13) ON CONFLICT(invitation_id) DO UPDATE SET authority = excluded.authority, token_hash = excluded.token_hash, label = excluded.label, password_protected = excluded.password_protected, expires_at = excluded.expires_at, state = excluded.state, redeemed_user_id = excluded.redeemed_user_id, redeemed_at = excluded.redeemed_at, revoked_at = excluded.revoked_at, remote_revision = excluded.remote_revision, last_synced_at = excluded.last_synced_at WHERE excluded.remote_revision >= user_invitations.remote_revision",
params![summary.invitation_id, summary.authority.as_str(), token_hash, summary.label, summary.password_protected, summary.created_at, summary.expires_at, summary.state.as_str(), summary.redeemed_user_id, summary.redeemed_at, summary.revoked_at, summary.remote_revision, synced_at],
)?;
Ok(())
}
pub fn list() -> Result<Vec<InvitationSummary>, StorageError> { pub fn list() -> Result<Vec<InvitationSummary>, StorageError> {
db::with_db(|conn| { db::with_db(|conn| {
let mut statement = conn.prepare("SELECT invitation_id, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at FROM user_invitations ORDER BY created_at DESC")?; let mut statement = conn.prepare("SELECT invitation_id, authority, label, password_protected, created_at, expires_at, state, remote_revision, redeemed_user_id, redeemed_at, revoked_at, pending_action, pending_action_at, last_synced_at, local_provisioned_user_id, local_provisioned_at FROM user_invitations ORDER BY created_at DESC")?;
Ok(statement Ok(statement
.query_map([], |row| { .query_map([], |row| {
Ok(InvitationSummary { Ok(InvitationSummary {
invitation_id: row.get(0)?, invitation_id: row.get(0)?,
created_at: row.get(1)?, authority: InvitationAuthority::parse(&row.get::<_, String>(1)?).map_err(
expires_at: row.get(2)?, |error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)),
state: InvitationState::parse(&row.get::<_, String>(3)?).map_err(|error| { )?,
label: row.get(2)?,
password_protected: row.get(3)?,
created_at: row.get(4)?,
expires_at: row.get(5)?,
state: InvitationState::parse(&row.get::<_, String>(6)?).map_err(|error| {
rusqlite::Error::ToSqlConversionFailure(Box::new(error)) rusqlite::Error::ToSqlConversionFailure(Box::new(error))
})?, })?,
redeemed_user_id: row.get(4)?, remote_revision: row.get(7)?,
redeemed_at: row.get(5)?, redeemed_user_id: row.get(8)?,
revoked_at: row.get(6)?, redeemed_at: row.get(9)?,
revoked_at: row.get(10)?,
pending_action: row
.get::<_, Option<String>>(11)?
.map(|value| PendingAction::parse(&value))
.transpose()
.map_err(|error| {
rusqlite::Error::ToSqlConversionFailure(Box::new(error))
})?,
pending_action_at: row.get(12)?,
last_synced_at: row.get(13)?,
local_provisioned_user_id: row.get(14)?,
local_provisioned_at: row.get(15)?,
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?) .collect::<Result<Vec<_>, _>>()?)
}) })
} }
pub fn revoke(invitation_id: &str, revoked_at: i64) -> Result<bool, StorageError> { pub fn expire_pending(now: i64) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
tx.execute(
"UPDATE user_invitations SET state = 'expired' WHERE authority = 'iota' AND state = 'pending' AND expires_at IS NOT NULL AND expires_at <= ?1",
params![now],
)?;
Ok(())
})
}
pub fn mark_revoke_pending(invitation_id: i64, requested_at: i64) -> Result<bool, StorageError> {
db::with_immediate_transaction(|tx| mark_revoke_pending_in_tx(tx, invitation_id, requested_at))
}
fn mark_revoke_pending_in_tx(
tx: &rusqlite::Transaction<'_>,
invitation_id: i64,
requested_at: i64,
) -> Result<bool, StorageError> {
let changed = tx.execute(
"UPDATE user_invitations SET pending_action = 'revoke', pending_action_at = COALESCE(pending_action_at, ?2) WHERE invitation_id = ?1 AND authority = 'omega' AND state IN ('pending', 'provisioning', 'revoked')",
params![invitation_id, requested_at],
)?;
Ok(changed == 1)
}
pub fn apply_revoke_result(
invitation_id: i64,
state: InvitationState,
remote_revision: i64,
revoked_at: Option<i64>,
synced_at: i64,
) -> Result<bool, StorageError> {
db::with_immediate_transaction(|tx| { db::with_immediate_transaction(|tx| {
let changed = tx.execute( let changed = tx.execute(
"UPDATE user_invitations SET state = 'revoked', revoked_at = ?2 WHERE invitation_id = ?1 AND state = 'pending'", "UPDATE user_invitations SET state = ?2, remote_revision = ?3, revoked_at = ?4, pending_action = NULL, pending_action_at = NULL, last_synced_at = ?5 WHERE invitation_id = ?1 AND ?3 >= remote_revision",
params![invitation_id, revoked_at], params![invitation_id, state.as_str(), remote_revision, revoked_at, synced_at],
)?; )?;
Ok(changed == 1) Ok(changed == 1)
}) })
} }
pub fn apply_external_invitation_provisioning(
invitation_id: i64,
invitation_revision: i64,
user: &UserProfile,
changed_at: i64,
) -> Result<ProvisioningResult, StorageError> {
db::with_immediate_transaction(|tx| {
apply_external_invitation_provisioning_in_tx(
tx,
invitation_id,
invitation_revision,
user,
changed_at,
)
})
}
fn apply_external_invitation_provisioning_in_tx(
tx: &rusqlite::Transaction<'_>,
invitation_id: i64,
invitation_revision: i64,
user: &UserProfile,
changed_at: i64,
) -> Result<ProvisioningResult, StorageError> {
tx.execute(
"UPDATE user_invitations SET state = 'provisioning', remote_revision = ?2, last_synced_at = ?3 WHERE invitation_id = ?1 AND authority = 'omega' AND ?2 >= remote_revision AND state NOT IN ('revoked', 'expired')",
params![invitation_id, invitation_revision, changed_at],
)?;
let invitation = tx
.query_row(
"SELECT state, pending_action, redeemed_user_id FROM user_invitations WHERE invitation_id = ?1 AND authority = 'omega'",
params![invitation_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<i64>>(2)?)),
)
.optional()?;
let Some((state, pending_action, redeemed_user_id)) = invitation else {
return Ok(ProvisioningResult::MissingInvitation);
};
if pending_action.as_deref() == Some("revoke") {
return Ok(ProvisioningResult::RevocationPending);
}
if !matches!(state.as_str(), "pending" | "provisioning" | "redeemed")
|| redeemed_user_id.is_some_and(|id| id != user.user_id)
{
return Ok(ProvisioningResult::Conflict);
}
let existing = tx
.query_row(
"SELECT username, public_key FROM users WHERE user_id = ?1",
params![user.user_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()?;
if let Some((username, public_key)) = existing {
if username != user.username || public_key != user.public_key {
return Ok(ProvisioningResult::Conflict);
}
tx.execute(
"UPDATE user_invitations SET local_provisioned_user_id = ?2, local_provisioned_at = COALESCE(local_provisioned_at, ?3) WHERE invitation_id = ?1",
params![invitation_id, user.user_id, changed_at],
)?;
return Ok(ProvisioningResult::AlreadyApplied);
}
tx.execute(
"INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) VALUES (?1, ?2, ?3, NULL, NULL, ?4, NULL)",
params![user.user_id, user.username, user.public_key, user.created_at],
)?;
tx.execute(
"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at) VALUES (?1, ?2, 'managed', 'present', 'external', ?3)",
params![user.user_id, user.username, changed_at],
)?;
tx.execute(
"UPDATE user_invitations SET local_provisioned_user_id = ?2, local_provisioned_at = ?3 WHERE invitation_id = ?1",
params![invitation_id, user.user_id, changed_at],
)?;
Ok(ProvisioningResult::Created)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn database() -> Result<Connection, StorageError> {
let connection = Connection::open_in_memory()?;
connection.execute_batch(
"CREATE TABLE user_invitations (invitation_id INTEGER PRIMARY KEY, authority TEXT NOT NULL, token_hash BLOB, label TEXT, password_protected INTEGER NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER, state TEXT NOT NULL, redeemed_user_id INTEGER, redeemed_at INTEGER, revoked_at INTEGER, pending_action TEXT, pending_action_at INTEGER, remote_revision INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER, local_provisioned_user_id INTEGER, local_provisioned_at INTEGER); CREATE TABLE users (user_id INTEGER PRIMARY KEY, username TEXT NOT NULL, public_key TEXT NOT NULL, private_key_hash TEXT, reset_token TEXT, created_at INTEGER NOT NULL, display_name TEXT); CREATE TABLE user_residency (user_id INTEGER PRIMARY KEY, username TEXT NOT NULL, lifecycle_state TEXT NOT NULL, data_state TEXT NOT NULL, credential_origin TEXT NOT NULL, updated_at INTEGER NOT NULL);",
)?;
Ok(connection)
}
fn summary(revision: i64, state: InvitationState) -> InvitationSummary {
InvitationSummary {
invitation_id: 7,
authority: InvitationAuthority::Omega,
label: None,
password_protected: false,
created_at: 10,
expires_at: Some(20),
state,
remote_revision: revision,
redeemed_user_id: None,
redeemed_at: None,
revoked_at: None,
pending_action: None,
pending_action_at: None,
last_synced_at: None,
local_provisioned_user_id: None,
local_provisioned_at: None,
}
}
fn profile(public_key: &str) -> UserProfile {
UserProfile::new_with_created_at(9, "alice".into(), None, public_key.into(), None, None, 30)
}
#[test]
fn offline_revoke_preserves_authoritative_state() -> Result<(), StorageError> {
let mut connection = database()?;
let tx = connection.transaction()?;
insert_in_tx(&tx, &summary(3, InvitationState::Pending), None, 30)?;
assert!(mark_revoke_pending_in_tx(&tx, 7, 31)?);
let row: (String, Option<String>) = tx.query_row(
"SELECT state, pending_action FROM user_invitations WHERE invitation_id = 7",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(row, ("pending".into(), Some("revoke".into())));
Ok(())
}
#[test]
fn stale_snapshot_is_ignored_and_pending_action_is_preserved() -> Result<(), StorageError> {
let mut connection = database()?;
let tx = connection.transaction()?;
insert_in_tx(&tx, &summary(5, InvitationState::Pending), None, 30)?;
mark_revoke_pending_in_tx(&tx, 7, 31)?;
insert_in_tx(&tx, &summary(4, InvitationState::Expired), None, 32)?;
let stale: (String, i64, Option<String>) = tx.query_row(
"SELECT state, remote_revision, pending_action FROM user_invitations WHERE invitation_id = 7",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(stale, ("pending".into(), 5, Some("revoke".into())));
insert_in_tx(&tx, &summary(6, InvitationState::Expired), None, 33)?;
let fresh: (String, i64, Option<String>) = tx.query_row(
"SELECT state, remote_revision, pending_action FROM user_invitations WHERE invitation_id = 7",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(fresh, ("expired".into(), 6, Some("revoke".into())));
Ok(())
}
#[test]
fn provisioning_replay_is_idempotent_and_conflicts_do_not_overwrite() -> Result<(), StorageError>
{
let mut connection = database()?;
let tx = connection.transaction()?;
insert_in_tx(&tx, &summary(1, InvitationState::Pending), None, 30)?;
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-a"), 31)?,
ProvisioningResult::Created
);
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-a"), 32)?,
ProvisioningResult::AlreadyApplied
);
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-b"), 33)?,
ProvisioningResult::Conflict
);
let public_key: String = tx.query_row(
"SELECT public_key FROM users WHERE user_id = 9",
[],
|row| row.get(0),
)?;
assert_eq!(public_key, "key-a");
let invitation: (String, i64, Option<i64>) = tx.query_row(
"SELECT state, remote_revision, local_provisioned_user_id FROM user_invitations WHERE invitation_id = 7",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(invitation, ("provisioning".into(), 2, Some(9)));
Ok(())
}
#[test]
fn stale_provisioning_cannot_override_authoritative_revocation() -> Result<(), StorageError> {
let mut connection = database()?;
let tx = connection.transaction()?;
insert_in_tx(&tx, &summary(3, InvitationState::Revoked), None, 30)?;
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-a"), 31)?,
ProvisioningResult::Conflict
);
let users: i64 = tx.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
assert_eq!(users, 0);
Ok(())
}
#[test]
fn pending_revoke_blocks_provisioning() -> Result<(), StorageError> {
let mut connection = database()?;
let tx = connection.transaction()?;
insert_in_tx(&tx, &summary(1, InvitationState::Pending), None, 30)?;
mark_revoke_pending_in_tx(&tx, 7, 31)?;
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-a"), 32)?,
ProvisioningResult::RevocationPending
);
let users: i64 = tx.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
assert_eq!(users, 0);
Ok(())
}
#[test]
fn missing_invitation_is_reported_separately() -> Result<(), StorageError> {
let mut connection = database()?;
let tx = connection.transaction()?;
assert_eq!(
apply_external_invitation_provisioning_in_tx(&tx, 7, 2, &profile("key-a"), 32)?,
ProvisioningResult::MissingInvitation
);
Ok(())
}
}

View file

@ -934,6 +934,47 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
conn.pragma_update(None, "user_version", 25)?; conn.pragma_update(None, "user_version", 25)?;
} }
if current_version < 27 {
let invitation_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_invitations')",
[],
|row| row.get(0),
)?;
if invitation_exists {
conn.execute_batch(
r#"
ALTER TABLE user_invitations RENAME TO user_invitations_before_authority;
CREATE TABLE user_invitations (
invitation_id INTEGER PRIMARY KEY,
authority TEXT NOT NULL CHECK (authority IN ('omega', 'iota')),
token_hash BLOB NOT NULL,
label TEXT,
password_protected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER,
state TEXT NOT NULL CHECK (state IN ('pending', 'provisioning', 'redeemed', 'revoked', 'expired')),
redeemed_user_id INTEGER,
redeemed_at INTEGER,
revoked_at INTEGER,
local_revocation_pending INTEGER NOT NULL DEFAULT 0
);
INSERT INTO user_invitations (
invitation_id, authority, token_hash, created_at, expires_at,
state, redeemed_user_id, redeemed_at, revoked_at
)
SELECT CAST(invitation_id AS INTEGER), 'omega', token_hash, created_at, expires_at,
state, redeemed_user_id, redeemed_at, revoked_at
FROM user_invitations_before_authority
WHERE CAST(invitation_id AS INTEGER) > 0;
DROP TABLE user_invitations_before_authority;
CREATE INDEX idx_user_invitations_state_created
ON user_invitations (state, created_at);
"#,
)?;
}
conn.pragma_update(None, "user_version", 27)?;
}
if current_version < 26 { if current_version < 26 {
conn.execute_batch( conn.execute_batch(
r#" r#"
@ -947,7 +988,63 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
); );
"#, "#,
)?; )?;
conn.pragma_update(None, "user_version", 26)?; conn.pragma_update(None, "user_version", 27)?;
}
if current_version < 28 {
let invitation_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_invitations')",
[],
|row| row.get(0),
)?;
if invitation_exists {
conn.execute_batch(
r#"
ALTER TABLE user_invitations RENAME TO user_invitations_before_revision;
CREATE TABLE user_invitations (
invitation_id INTEGER PRIMARY KEY,
authority TEXT NOT NULL CHECK (authority IN ('omega', 'iota')),
token_hash BLOB,
label TEXT,
password_protected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER,
state TEXT NOT NULL CHECK (state IN ('pending', 'provisioning', 'redeemed', 'revoked', 'expired')),
redeemed_user_id INTEGER,
redeemed_at INTEGER,
revoked_at INTEGER,
pending_action TEXT CHECK (pending_action IS NULL OR pending_action = 'revoke'),
pending_action_at INTEGER,
remote_revision INTEGER NOT NULL DEFAULT 0,
last_synced_at INTEGER
);
INSERT INTO user_invitations (
invitation_id, authority, token_hash, label, password_protected,
created_at, expires_at, state, redeemed_user_id, redeemed_at,
revoked_at, pending_action, pending_action_at
)
SELECT invitation_id, authority,
CASE WHEN authority = 'iota' THEN token_hash ELSE NULL END,
label, password_protected, created_at, expires_at, state,
redeemed_user_id, redeemed_at, revoked_at,
CASE WHEN local_revocation_pending = 1 THEN 'revoke' ELSE NULL END,
CASE WHEN local_revocation_pending = 1 THEN revoked_at ELSE NULL END
FROM user_invitations_before_revision;
DROP TABLE user_invitations_before_revision;
CREATE INDEX idx_user_invitations_state_created
ON user_invitations (state, created_at);
"#,
)?;
}
conn.pragma_update(None, "user_version", 28)?;
}
if current_version < 29 {
conn.execute_batch(
"ALTER TABLE user_invitations ADD COLUMN local_provisioned_user_id INTEGER; \
ALTER TABLE user_invitations ADD COLUMN local_provisioned_at INTEGER;",
)?;
conn.pragma_update(None, "user_version", 29)?;
} }
Ok(()) Ok(())
@ -1024,7 +1121,7 @@ mod tests {
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 26); assert_eq!(version, 29);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement = let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -1043,7 +1140,7 @@ mod tests {
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 26); assert_eq!(version, 29);
for table in [ for table in [
"sync_heads", "sync_heads",
"sync_events", "sync_events",
@ -1090,7 +1187,7 @@ mod tests {
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 26); assert_eq!(version, 29);
for column in [ for column in [
"id", "id",
"user_id", "user_id",
@ -1185,7 +1282,40 @@ mod tests {
)?; )?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(preserved, "remote_committed"); assert_eq!(preserved, "remote_committed");
assert_eq!(version, 26); assert_eq!(version, 29);
Ok(())
}
#[test]
fn invitation_authority_migration_preserves_numeric_invitations() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
r#"
CREATE TABLE user_invitations (
invitation_id TEXT PRIMARY KEY,
token_hash BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
state TEXT NOT NULL,
redeemed_user_id INTEGER,
redeemed_at INTEGER,
revoked_at INTEGER
);
INSERT INTO user_invitations (
invitation_id, token_hash, created_at, expires_at, state
) VALUES ('42', X'0102', 10, 20, 'pending');
PRAGMA user_version = 26;
"#,
)?;
run_migrations_on_connection(&conn)?;
let row: (i64, String, String, Option<String>) = conn.query_row(
"SELECT invitation_id, authority, state, pending_action FROM user_invitations",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
assert_eq!(row, (42, "omega".into(), "pending".into(), None));
Ok(()) Ok(())
} }
} }

View file

@ -17,3 +17,4 @@ serde_json = "1.0"
reqwest = "0.13.2" reqwest = "0.13.2"
fs2 = "0.4.3" fs2 = "0.4.3"
rusqlite = "0.40.0" rusqlite = "0.40.0"
chrono = "0.4.43"

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@ use sha2::{Digest, Sha256};
pub struct ReleaseManifest { pub struct ReleaseManifest {
pub product_version: String, pub product_version: String,
pub channel: String, pub channel: String,
pub release_sequence: u64,
pub published_at: String, pub published_at: String,
pub expires_at: String,
pub minimum_data_schema: u64, pub minimum_data_schema: u64,
pub supported_ipc_min: u16, pub supported_ipc_min: u16,
pub supported_ipc_max: u16, pub supported_ipc_max: u16,

View file

@ -6,6 +6,12 @@ use std::{
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
}; };
#[derive(Clone, Debug)]
pub struct Activation {
pub previous_target: PathBuf,
pub new_target: PathBuf,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct UpdateTransaction { pub struct UpdateTransaction {
pub root: PathBuf, pub root: PathBuf,
@ -43,6 +49,11 @@ impl UpdateTransaction {
.context("update already in progress")?; .context("update already in progress")?;
Ok(UpdateLock { _file: file }) Ok(UpdateLock { _file: file })
} }
pub fn current_target(&self) -> Result<PathBuf> {
let current = self.root.join("current");
fs::read_link(&current)
.with_context(|| format!("read current release link {}", current.display()))
}
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> { pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
let artifact_path = Path::new(&artifact.path); let artifact_path = Path::new(&artifact.path);
if artifact_path.is_absolute() if artifact_path.is_absolute()
@ -65,9 +76,15 @@ impl UpdateTransaction {
set_executable_if_binary(&target, artifact)?; set_executable_if_binary(&target, artifact)?;
Ok(target) Ok(target)
} }
pub fn activate(&self, version: &str) -> Result<()> { pub fn activate(&self, version: &str) -> Result<Activation> {
let current = self.root.join("current");
let previous_target = self.current_target()?;
let version_dir = self.root.join("versions").join(version); let version_dir = self.root.join("versions").join(version);
fs::create_dir_all(version_dir.parent().unwrap())?; fs::create_dir_all(
version_dir
.parent()
.context("release version directory has no parent")?,
)?;
if version_dir.exists() { if version_dir.exists() {
anyhow::bail!("release version already exists: {}", version_dir.display()); anyhow::bail!("release version already exists: {}", version_dir.display());
} }
@ -80,22 +97,46 @@ impl UpdateTransaction {
} }
Err(error) => return Err(error).context("activate staged release"), Err(error) => return Err(error).context("activate staged release"),
} }
let current_tmp = self.root.join("current.new"); replace_symlink(&current, &version_dir, &self.root.join("current.new"))?;
let _ = fs::remove_file(&current_tmp); Ok(Activation {
std::os::unix::fs::symlink(&version_dir, &current_tmp)?; previous_target,
fs::rename(current_tmp, self.root.join("current"))?; new_target: version_dir,
Ok(()) })
}
pub fn restore_activation(&self, activation: &Activation) -> Result<()> {
replace_symlink(
&self.root.join("current"),
&activation.previous_target,
&self.root.join("current.rollback"),
)
} }
pub fn rollback(&self, previous: &str) -> Result<()> { pub fn rollback(&self, previous: &str) -> Result<()> {
let current = self.root.join("current"); replace_symlink(
let tmp = self.root.join("current.rollback"); &self.root.join("current"),
let _ = fs::remove_file(&tmp); &self.root.join("versions").join(previous),
std::os::unix::fs::symlink(self.root.join("versions").join(previous), &tmp)?; &self.root.join("current.rollback"),
fs::rename(tmp, current)?; )
Ok(())
} }
} }
#[cfg(unix)]
fn replace_symlink(link: &Path, target: &Path, temporary: &Path) -> Result<()> {
match fs::remove_file(temporary) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).context("remove stale temporary release link"),
}
std::os::unix::fs::symlink(target, temporary).with_context(|| {
format!(
"create temporary symlink {} -> {}",
temporary.display(),
target.display()
)
})?;
fs::rename(temporary, link).with_context(|| format!("replace symlink {}", link.display()))?;
Ok(())
}
#[cfg(unix)] #[cfg(unix)]
fn set_executable_if_binary(path: &Path, artifact: &Artifact) -> Result<()> { fn set_executable_if_binary(path: &Path, artifact: &Artifact) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@ -169,12 +210,21 @@ mod tests {
drop(lock); drop(lock);
assert!(tx.acquire().is_ok()); assert!(tx.acquire().is_ok());
assert!(tx.lock_file.exists()); assert!(tx.lock_file.exists());
let old_release = dir.path().join("versions/0.9.0");
std::fs::create_dir_all(&old_release).unwrap();
std::os::unix::fs::symlink(&old_release, dir.path().join("current")).unwrap();
std::fs::create_dir_all(&tx.staging).unwrap(); std::fs::create_dir_all(&tx.staging).unwrap();
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap(); std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
tx.activate("1.0.0").unwrap(); let activation = tx.activate("1.0.0").unwrap();
assert_eq!( assert_eq!(
std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(), std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(),
"ok" "ok"
); );
assert_eq!(activation.previous_target, old_release);
tx.restore_activation(&activation).unwrap();
assert_eq!(
std::fs::read_link(dir.path().join("current")).unwrap(),
old_release
);
} }
} }

View file

@ -843,6 +843,32 @@ async fn run_command(
} }
} }
} }
ResponsePayload::InvitationCreated(invitation) => {
println!("Invitation: {}", invitation.invitation_id);
println!("Token: {}", invitation.raw_token.0);
if let Some(url) = invitation.short_url {
println!("Link: {url}");
}
println!("Expires: {}", invitation.expires_at);
}
ResponsePayload::Invitations(invitations) => {
if invitations.is_empty() {
println!("No invitations.");
} else {
for invitation in invitations {
println!(
"{} ({:?}, {:?})",
invitation.invitation_id, invitation.authority, invitation.state
);
}
}
}
ResponsePayload::InvitationUpdated(invitation) => {
println!(
"Invitation {} is {:?}",
invitation.invitation_id, invitation.state
);
}
ResponsePayload::UserCreated { user_id, username } => { ResponsePayload::UserCreated { user_id, username } => {
println!( println!(
"{} {} ({})", "{} {} ({})",
@ -1023,6 +1049,35 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<
fn render_table(payload: &ResponsePayload) { fn render_table(payload: &ResponsePayload) {
match payload { match payload {
ResponsePayload::InvitationCreated(invitation) => {
println!("{:<15} {}", "Invitation ID", invitation.invitation_id);
println!(
"{:<15} {}",
"Authority",
format!("{:?}", invitation.authority)
);
println!("{:<15} {}", "Token", invitation.raw_token.0);
println!("{:<15} {}", "Expires", invitation.expires_at);
if let Some(url) = &invitation.short_url {
println!("{:<15} {}", "Link", url);
}
}
ResponsePayload::Invitations(invitations) => {
println!("{:<16} {:<10} {:<14} LABEL", "ID", "AUTHORITY", "STATE");
for invitation in invitations {
println!(
"{:<16} {:<10} {:<14} {}",
invitation.invitation_id,
format!("{:?}", invitation.authority),
format!("{:?}", invitation.state),
invitation.label.as_deref().unwrap_or("-")
);
}
}
ResponsePayload::InvitationUpdated(invitation) => {
println!("{:<15} {}", "Invitation ID", invitation.invitation_id);
println!("{:<15} {:?}", "State", invitation.state);
}
ResponsePayload::Users(users) => { ResponsePayload::Users(users) => {
if users.is_empty() { if users.is_empty() {
println!("No users."); println!("No users.");

@ -1 +1 @@
Subproject commit 2388c225b50db4d566653fb220786931eb3989c7 Subproject commit 91d03974632a2897de5457d52170dda9d9e36c3d

View file

@ -8,6 +8,7 @@ pub enum OmikronError {
Timeout(String), Timeout(String),
Authentication(String), Authentication(String),
Rejected(CommunicationType, String), Rejected(CommunicationType, String),
Storage(String),
Internal(String), Internal(String),
} }
@ -18,6 +19,7 @@ impl std::fmt::Display for OmikronError {
| Self::Timeout(v) | Self::Timeout(v)
| Self::Authentication(v) | Self::Authentication(v)
| Self::Rejected(_, v) | Self::Rejected(_, v)
| Self::Storage(v)
| Self::Internal(v) => f.write_str(v), | Self::Internal(v) => f.write_str(v),
} }
} }
@ -42,6 +44,12 @@ pub trait OmikronClient: Send + Sync {
value: &CommunicationValue, value: &CommunicationValue,
timeout: Duration, timeout: Duration,
) -> Result<CommunicationValue, OmikronError>; ) -> Result<CommunicationValue, OmikronError>;
async fn sync_omega_invitations(&self) -> Result<(), OmikronError> {
Err(OmikronError::Internal(
"invitation synchronization is unavailable".into(),
))
}
async fn flush_pending_invitation_actions(&self) {}
async fn reconnect(&self) -> Result<(), OmikronError>; async fn reconnect(&self) -> Result<(), OmikronError>;
/// Replace the local Iota identity and wait for the new identity to /// Replace the local Iota identity and wait for the new identity to
/// register/authenticate. This is deliberately available while offline: /// register/authenticate. This is deliberately available while offline:

View file

@ -9,13 +9,13 @@ use iota_storage::util::{
use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_helper::{self, keyring_from_base64};
use iota_util::crypto_util::{self}; use iota_util::crypto_util::{self};
use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
use mtp::crypto::{Keyring, PublicKeyBundle}; use mtp::crypto::{Keyring, PublicKeyBundle};
use rand_core::RngCore; use rand_core::RngCore;
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch};
@ -128,6 +128,119 @@ const RECONNECT_DELAY: Duration = Duration::from_secs(5);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300);
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45); const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45);
const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5);
fn container_value<'a>(
fields: &'a [(DataTypeId, DataValue)],
kind: DataType,
type_map: &TypeMap,
) -> Option<&'a DataValue> {
let id = kind.try_to_id(type_map)?;
fields
.iter()
.find_map(|(field_id, value)| (*field_id == id).then_some(value))
}
fn container_signed_i64(
fields: &[(DataTypeId, DataValue)],
kind: DataType,
type_map: &TypeMap,
) -> Option<i64> {
container_value(fields, kind, type_map)
.and_then(DataValue::as_signed_number)
.and_then(|value| i64::try_from(value).ok())
}
fn parse_omega_invitation(
fields: &[(DataTypeId, DataValue)],
type_map: &TypeMap,
expected_iota_id: i64,
synced_at: i64,
) -> Result<iota_storage::users::invitations::InvitationSummary, OmikronError> {
use iota_storage::users::invitations::{InvitationState, InvitationSummary};
let invitation_id = container_signed_i64(fields, DataType::InvitationId, type_map)
.filter(|value| *value > 0)
.ok_or_else(|| OmikronError::Internal("invalid Omega invitation ID".into()))?;
if container_value(fields, DataType::InvitationAuthority, type_map).and_then(DataValue::as_str)
!= Some("omega")
{
return Err(OmikronError::Internal(
"invalid Omega invitation authority".into(),
));
}
if container_signed_i64(fields, DataType::IotaId, type_map) != Some(expected_iota_id) {
return Err(OmikronError::Internal(
"Omega invitation snapshot contained the wrong Iota ID".into(),
));
}
let remote_revision = container_signed_i64(fields, DataType::InvitationRevision, type_map)
.filter(|value| *value > 0)
.ok_or_else(|| OmikronError::Internal("invalid Omega invitation revision".into()))?;
let state = match container_value(fields, DataType::InvitationState, type_map)
.and_then(DataValue::as_str)
{
Some("pending") => InvitationState::Pending,
Some("provisioning") => InvitationState::Provisioning,
Some("redeemed") => InvitationState::Redeemed,
Some("revoked") => InvitationState::Revoked,
Some("expired") => InvitationState::Expired,
_ => {
return Err(OmikronError::Internal(
"invalid Omega invitation state".into(),
));
}
};
let label = match container_value(fields, DataType::InvitationLabel, type_map) {
Some(value) => Some(
value
.as_str()
.ok_or_else(|| OmikronError::Internal("invalid Omega invitation label".into()))?,
),
None => None,
};
let password_protected =
container_value(fields, DataType::InvitationPasswordProtected, type_map)
.and_then(DataValue::as_bool)
.ok_or_else(|| {
OmikronError::Internal("invalid Omega invitation password protection flag".into())
})?;
let created_at = container_signed_i64(fields, DataType::InvitationCreatedAt, type_map)
.filter(|value| *value > 0)
.ok_or_else(|| OmikronError::Internal("invalid Omega invitation creation time".into()))?;
let expires_at = container_signed_i64(fields, DataType::InvitationExpiresAt, type_map)
.filter(|value| *value > 0)
.ok_or_else(|| OmikronError::Internal("invalid Omega invitation expiry time".into()))?;
let redeemed_user_id = match container_value(fields, DataType::UserId, type_map) {
Some(value) => Some(
value
.as_signed_number()
.and_then(|value| i64::try_from(value).ok())
.filter(|value| *value > 0)
.ok_or_else(|| {
OmikronError::Internal("invalid Omega invitation redeemed user ID".into())
})?,
),
None => None,
};
Ok(InvitationSummary {
invitation_id,
authority: iota_storage::users::invitations::InvitationAuthority::Omega,
label: label.map(str::to_owned),
password_protected,
created_at,
expires_at: Some(expires_at),
state,
remote_revision,
redeemed_user_id,
redeemed_at: None,
revoked_at: None,
pending_action: None,
pending_action_at: None,
last_synced_at: Some(synced_at),
local_provisioned_user_id: None,
local_provisioned_at: None,
})
}
const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
const TASK_MAX_AGE: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60);
const MAX_CONCURRENT_HANDLERS: usize = 20; const MAX_CONCURRENT_HANDLERS: usize = 20;
@ -140,6 +253,26 @@ struct ResolvedOmikronEndpoint {
public_key: PublicKeyBundle, public_key: PublicKeyBundle,
} }
struct InvitationSyncRuntime {
flush_in_progress: AtomicBool,
next_retry_at: Mutex<Option<Instant>>,
retry_delay: Mutex<Duration>,
}
impl InvitationSyncRuntime {
fn new() -> Self {
Self {
flush_in_progress: AtomicBool::new(false),
next_retry_at: Mutex::new(None),
retry_delay: Mutex::new(Duration::from_secs(5)),
}
}
}
fn next_invitation_retry_delay(current: Duration) -> Duration {
(current * 2).min(Duration::from_secs(300))
}
#[derive(Debug)] #[derive(Debug)]
pub enum IdentityError { pub enum IdentityError {
Storage(mtp::files::FileError), Storage(mtp::files::FileError),
@ -320,6 +453,7 @@ pub struct OmikronConnection {
cancellation: CancellationToken, cancellation: CancellationToken,
pub(crate) active_tasks: Arc<DashSet<String>>, pub(crate) active_tasks: Arc<DashSet<String>>,
pub(crate) app: Arc<std::sync::Mutex<AppState>>, pub(crate) app: Arc<std::sync::Mutex<AppState>>,
invitation_sync: Arc<InvitationSyncRuntime>,
} }
impl OmikronConnection { impl OmikronConnection {
@ -353,6 +487,7 @@ impl OmikronConnection {
cancellation, cancellation,
active_tasks, active_tasks,
app, app,
invitation_sync: Arc::new(InvitationSyncRuntime::new()),
} }
} }
@ -546,6 +681,13 @@ impl OmikronConnection {
log_t!("omikron_authenticated"); log_t!("omikron_authenticated");
self.classify_legacy_pending_relays().await; self.classify_legacy_pending_relays().await;
if let Err(error) = self.sync_omega_invitations().await {
log!(
"Omega invitation snapshot synchronization failed: {}",
error
);
}
self.flush_pending_invitation_actions().await;
let maintenance_self = self.clone(); let maintenance_self = self.clone();
let maintenance_handle = tokio::spawn(async move { let maintenance_handle = tokio::spawn(async move {
@ -585,6 +727,155 @@ impl OmikronConnection {
load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone()) load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone())
} }
async fn sync_pending_invitation_revocations(&self) -> Result<(), String> {
let invitations = iota_storage::users::invitations::list()
.map_err(|error| format!("failed to load pending invitation actions: {error}"))?;
for invitation in invitations.into_iter().filter(|invitation| {
invitation.authority == iota_storage::users::invitations::InvitationAuthority::Omega
&& invitation.pending_action
== Some(iota_storage::users::invitations::PendingAction::Revoke)
}) {
let request = CommunicationValue::new(CommunicationType::RevokeUserInvitation)
.add_typed_default(
DataType::InvitationAuthority,
DataValue::Str("omega".into()),
)
.add_typed_default(
DataType::InvitationId,
DataValue::SignedNumber(invitation.invitation_id.into()),
);
let response = self
.await_response(&request, Some(Duration::from_secs(20)))
.await?;
if response.is_type(CommunicationType::Success) {
let Some(revision) = response
.get_data(DataType::InvitationRevision)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok())
else {
return Err("Omega revoke response omitted invitation revision".into());
};
let state = match response.get_data(DataType::InvitationState).as_str() {
Some("pending") => iota_storage::users::invitations::InvitationState::Pending,
Some("provisioning") => {
iota_storage::users::invitations::InvitationState::Provisioning
}
Some("redeemed") => iota_storage::users::invitations::InvitationState::Redeemed,
Some("revoked") => iota_storage::users::invitations::InvitationState::Revoked,
Some("expired") => iota_storage::users::invitations::InvitationState::Expired,
_ => return Err("Omega revoke response contained invalid state".into()),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
iota_storage::users::invitations::apply_revoke_result(
invitation.invitation_id,
state,
revision,
(state == iota_storage::users::invitations::InvitationState::Revoked)
.then_some(now),
now,
)
.map_err(|error| format!("failed to apply invitation revoke result: {error}"))?;
} else {
return Err("Omega returned the wrong invitation revoke response type".into());
}
}
Ok(())
}
pub async fn flush_pending_invitation_actions(&self) {
if self
.invitation_sync
.flush_in_progress
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let now = Instant::now();
if self
.invitation_sync
.next_retry_at
.lock()
.await
.is_some_and(|retry_at| retry_at > now)
{
self.invitation_sync
.flush_in_progress
.store(false, Ordering::Release);
return;
}
match self.sync_pending_invitation_revocations().await {
Ok(()) => {
*self.invitation_sync.next_retry_at.lock().await = None;
*self.invitation_sync.retry_delay.lock().await = Duration::from_secs(5);
}
Err(error) => {
log!("Pending invitation action flush failed: {}", error);
let mut delay = self.invitation_sync.retry_delay.lock().await;
*self.invitation_sync.next_retry_at.lock().await = Some(now + *delay);
*delay = next_invitation_retry_delay(*delay);
}
}
self.invitation_sync
.flush_in_progress
.store(false, Ordering::Release);
}
pub async fn sync_omega_invitations(&self) -> Result<(), OmikronError> {
let request = CommunicationValue::new(CommunicationType::ListUserInvitations)
.add_typed_default(
DataType::InvitationAuthority,
DataValue::Str("omega".into()),
);
let response = self
.await_response(&request, Some(Duration::from_secs(20)))
.await
.map_err(map_await_response_error)?;
if !response.is_type(CommunicationType::ListUserInvitations) {
return Err(OmikronError::Internal(
"Omega returned the wrong invitation snapshot response type".into(),
));
}
let Some(DataValue::Array(remote)) = response.get_data(DataType::Invitations) else {
return Err(OmikronError::Internal(
"Omega invitation snapshot omitted Invitations".into(),
));
};
let type_map = TypeMap::latest();
let synced_at = now_millis_i64();
let expected_iota_id = CONFIG
.load()
.iota_id
.and_then(|value| i64::try_from(value).ok())
.filter(|value| *value > 0)
.ok_or_else(|| OmikronError::Internal("Iota identity is not configured".into()))?;
let mut invitations = Vec::with_capacity(remote.len());
for invitation in remote {
let DataValue::Container(fields) = invitation else {
return Err(OmikronError::Internal(
"Omega invitation snapshot contained a non-container record".into(),
));
};
invitations.push(parse_omega_invitation(
fields,
&type_map,
expected_iota_id,
synced_at,
)?);
}
iota_storage::users::invitations::merge_omega_snapshot(&invitations, synced_at).map_err(
|error| {
OmikronError::Storage(format!(
"failed to persist Omega invitation snapshot: {error}"
))
},
)?;
Ok(())
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Omikron discovery (via Omega's HTTP API, replacing the static // Omikron discovery (via Omega's HTTP API, replacing the static
// host/port/public-key-file model) // host/port/public-key-file model)
@ -827,6 +1118,10 @@ impl OmikronConnection {
self.classify_legacy_pending_relays().await; self.classify_legacy_pending_relays().await;
self.flush_pending_relays().await; self.flush_pending_relays().await;
let invitation_self = self.clone();
tokio::spawn(async move {
invitation_self.flush_pending_invitation_actions().await;
});
if let Err(error) = relay_replay::prune_completed( if let Err(error) = relay_replay::prune_completed(
now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS), now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS),
) { ) {
@ -2407,14 +2702,22 @@ impl OmikronConnection {
let public_key = cv.get_data(DataType::PublicKey).as_str().map(str::to_owned); let public_key = cv.get_data(DataType::PublicKey).as_str().map(str::to_owned);
let invitation_id = cv let invitation_id = cv
.get_data(DataType::InvitationId) .get_data(DataType::InvitationId)
.as_str() .as_signed_number()
.filter(|value| uuid::Uuid::parse_str(value).is_ok()) .and_then(|value| i64::try_from(value).ok())
.map(str::to_owned); .filter(|value| *value > 0);
let Some((user_id, username, public_key, invitation_id)) = user_id let invitation_revision = cv
.get_data(DataType::InvitationRevision)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok())
.filter(|value| *value > 0);
let Some((user_id, username, public_key, invitation_id, invitation_revision)) = user_id
.zip(username) .zip(username)
.zip(public_key) .zip(public_key)
.zip(invitation_id) .zip(invitation_id)
.map(|(((id, username), key), invitation_id)| (id, username, key, invitation_id)) .zip(invitation_revision)
.map(|((((id, username), key), invitation_id), revision)| {
(id, username, key, invitation_id, revision)
})
else { else {
let _ = self let _ = self
.send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) .send_message(&error_response(cv, CommunicationType::ErrorInvalidData))
@ -2424,22 +2727,65 @@ impl OmikronConnection {
let profile = iota_storage::users::user_profile::UserProfile::new( let profile = iota_storage::users::user_profile::UserProfile::new(
user_id, username, None, public_key, None, None, user_id, username, None, public_key, None, None,
); );
if iota_storage::users::user_manager::try_add_user_with_credential_origin( let now = std::time::SystemTime::now()
profile, .duration_since(std::time::UNIX_EPOCH)
iota_storage::users::user_manager::CredentialOrigin::External, .unwrap_or_default()
) .as_millis() as i64;
.is_err() let mut result = iota_storage::users::invitations::apply_external_invitation_provisioning(
{ invitation_id,
invitation_revision,
&profile,
now,
);
if matches!(
result,
Ok(iota_storage::users::invitations::ProvisioningResult::MissingInvitation)
) {
if self.sync_omega_invitations().await.is_ok() {
result = iota_storage::users::invitations::apply_external_invitation_provisioning(
invitation_id,
invitation_revision,
&profile,
now,
);
}
}
match result {
Ok(
iota_storage::users::invitations::ProvisioningResult::Created
| iota_storage::users::invitations::ProvisioningResult::AlreadyApplied,
) => {}
Ok(iota_storage::users::invitations::ProvisioningResult::RevocationPending) => {
self.flush_pending_invitation_actions().await;
return;
}
Ok(iota_storage::users::invitations::ProvisioningResult::Conflict) => {
let _ = self
.send_message(&error_response(cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
Ok(iota_storage::users::invitations::ProvisioningResult::MissingInvitation) => {
let _ = self
.send_message(&error_response(cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
Err(_) => {
let _ = self let _ = self
.send_message(&error_response(cv, CommunicationType::ErrorInternal)) .send_message(&error_response(cv, CommunicationType::ErrorInternal))
.await; .await;
return; return;
} }
}
let acknowledgement = let acknowledgement =
CommunicationValue::new(CommunicationType::AcknowledgeIotaUserProvision) CommunicationValue::new(CommunicationType::AcknowledgeIotaUserProvision)
.with_request_id(cv) .with_request_id(cv)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()))
.add_typed_default(DataType::InvitationId, DataValue::Str(invitation_id)); .add_typed_default(
DataType::InvitationId,
DataValue::SignedNumber(invitation_id.into()),
);
let _ = self.send_message(&acknowledgement).await; let _ = self.send_message(&acknowledgement).await;
} }
@ -3555,6 +3901,14 @@ impl OmikronClient for OmikronConnection {
.map_err(map_await_response_error) .map_err(map_await_response_error)
} }
async fn sync_omega_invitations(&self) -> Result<(), OmikronError> {
Self::sync_omega_invitations(self).await
}
async fn flush_pending_invitation_actions(&self) {
Self::flush_pending_invitation_actions(self).await;
}
async fn reconnect(&self) -> Result<(), OmikronError> { async fn reconnect(&self) -> Result<(), OmikronError> {
let this = Arc::new(Self { let this = Arc::new(Self {
state: self.state.clone(), state: self.state.clone(),
@ -3574,6 +3928,7 @@ impl OmikronClient for OmikronConnection {
cancellation: self.cancellation.clone(), cancellation: self.cancellation.clone(),
active_tasks: self.active_tasks.clone(), active_tasks: self.active_tasks.clone(),
app: self.app.clone(), app: self.app.clone(),
invitation_sync: self.invitation_sync.clone(),
}); });
Self::reconnect(&this).await; Self::reconnect(&this).await;
Ok(()) Ok(())
@ -3598,6 +3953,7 @@ impl OmikronClient for OmikronConnection {
cancellation: self.cancellation.clone(), cancellation: self.cancellation.clone(),
active_tasks: self.active_tasks.clone(), active_tasks: self.active_tasks.clone(),
app: self.app.clone(), app: self.app.clone(),
invitation_sync: self.invitation_sync.clone(),
}); });
Self::rotate_identity(&this).await Self::rotate_identity(&this).await
} }
@ -3704,4 +4060,87 @@ mod tests {
assert!(recipient_block_policy_applies(true, 42, 43)); assert!(recipient_block_policy_applies(true, 42, 43));
assert!(!recipient_block_policy_applies(false, 42, 43)); assert!(!recipient_block_policy_applies(false, 42, 43));
} }
#[test]
fn omega_invitation_snapshot_parser_requires_authoritative_fields() {
let type_map = TypeMap::latest();
let mut fields = Vec::new();
for (kind, value) in [
(
DataType::InvitationAuthority,
DataValue::Str("omega".into()),
),
(DataType::InvitationId, DataValue::SignedNumber(7)),
(DataType::IotaId, DataValue::SignedNumber(42)),
(DataType::InvitationRevision, DataValue::SignedNumber(3)),
(
DataType::InvitationState,
DataValue::Str("provisioning".into()),
),
(DataType::InvitationCreatedAt, DataValue::SignedNumber(10)),
(DataType::InvitationExpiresAt, DataValue::SignedNumber(20)),
(
DataType::InvitationPasswordProtected,
DataValue::Bool(false),
),
] {
fields.push((kind.try_to_id(&type_map).expect("type is mapped"), value));
}
let parsed = parse_omega_invitation(&fields, &type_map, 42, 20).expect("snapshot parses");
assert_eq!(parsed.invitation_id, 7);
assert_eq!(parsed.remote_revision, 3);
assert_eq!(
parsed.authority,
iota_storage::users::invitations::InvitationAuthority::Omega
);
fields.retain(|(id, _)| Some(*id) != DataType::InvitationRevision.try_to_id(&type_map));
assert!(parse_omega_invitation(&fields, &type_map, 42, 20).is_err());
}
#[test]
fn omega_invitation_snapshot_parser_rejects_wrong_authority_or_iota() {
let type_map = TypeMap::latest();
let fields = [
(DataType::InvitationAuthority, DataValue::Str("iota".into())),
(DataType::InvitationId, DataValue::SignedNumber(7)),
(DataType::IotaId, DataValue::SignedNumber(42)),
(DataType::InvitationRevision, DataValue::SignedNumber(3)),
(DataType::InvitationState, DataValue::Str("pending".into())),
(DataType::InvitationCreatedAt, DataValue::SignedNumber(10)),
(DataType::InvitationExpiresAt, DataValue::SignedNumber(20)),
(
DataType::InvitationPasswordProtected,
DataValue::Bool(false),
),
]
.into_iter()
.map(|(kind, value)| (kind.try_to_id(&type_map).expect("type is mapped"), value))
.collect::<Vec<_>>();
assert!(parse_omega_invitation(&fields, &type_map, 42, 20).is_err());
let mut fields = fields;
let authority_id = DataType::InvitationAuthority
.try_to_id(&type_map)
.expect("type is mapped");
let authority = fields
.iter_mut()
.find(|(id, _)| *id == authority_id)
.expect("authority field exists");
authority.1 = DataValue::Str("omega".into());
assert!(parse_omega_invitation(&fields, &type_map, 43, 20).is_err());
}
#[test]
fn invitation_action_retry_delay_backs_off_and_caps() {
assert_eq!(
next_invitation_retry_delay(Duration::from_secs(5)),
Duration::from_secs(10)
);
assert_eq!(
next_invitation_retry_delay(Duration::from_secs(300)),
Duration::from_secs(300)
);
}
} }

View file

@ -1,17 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [[ "$#" -ne 6 ]]; then if [[ "$#" -ne 9 ]]; then
echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION UPDATE_MANIFEST_URL UPDATE_PUBLIC_KEY UPDATE_SIGNATURE_URL OUTPUT.zip" >&2 echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION RELEASE_MANIFEST UPDATE_MANIFEST_URL UPDATE_PUBLIC_KEY UPDATE_SIGNATURE_URL UPDATE_CHANNEL UPDATE_SIGNING_KEY_ID OUTPUT.zip" >&2
exit 2 exit 2
fi fi
binary_directory="$1" binary_directory="$1"
product_version="$2" product_version="$2"
update_manifest_url="$3" release_manifest="$3"
update_public_key="$4" update_manifest_url="$4"
update_signature_url="$5" update_public_key="$5"
output="$6" update_signature_url="$6"
update_channel="$7"
update_signing_key_id="$8"
output="$9"
repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
contract="$repository_directory/iota-installer/bundle-files.txt" contract="$repository_directory/iota-installer/bundle-files.txt"
staging_directory="$(mktemp -d)" staging_directory="$(mktemp -d)"
@ -21,6 +24,20 @@ if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "product version contains unsupported characters: $product_version" >&2 echo "product version contains unsupported characters: $product_version" >&2
exit 2 exit 2
fi fi
if ! jq -e \
--arg product_version "$product_version" \
--arg channel "$update_channel" \
--arg key_id "$update_signing_key_id" \
'.product_version == $product_version
and .channel == $channel
and .release_signing_key_id == $key_id
and (.release_sequence | type == "number" and . > 0)
and (.published_at | type == "string" and length > 0)
and (.expires_at | type == "string" and length > 0)' \
"$release_manifest" >/dev/null; then
echo "release manifest identity or anti-rollback metadata is invalid" >&2
exit 2
fi
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")" output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
@ -37,13 +54,15 @@ while IFS= read -r bundle_path; do
install -m 0755 "$binary_directory/${bundle_path#bin/}" "$destination" install -m 0755 "$binary_directory/${bundle_path#bin/}" "$destination"
;; ;;
manifest.json) manifest.json)
printf '{"product_version":"%s"}\n' "$product_version" > "$destination" install -m 0644 "$release_manifest" "$destination"
;; ;;
systemd/update.env) systemd/update.env)
printf 'IOTA_UPDATE_MANIFEST=%s\nIOTA_UPDATE_PUBLIC_KEY=%s\nIOTA_UPDATE_SIGNATURE=%s\n' \ printf 'IOTA_UPDATE_MANIFEST=%s\nIOTA_UPDATE_PUBLIC_KEY=%s\nIOTA_UPDATE_SIGNATURE=%s\nIOTA_UPDATE_CHANNEL=%s\nIOTA_UPDATE_SIGNING_KEY_ID=%s\n' \
"$update_manifest_url" \ "$update_manifest_url" \
"$update_public_key" \ "$update_public_key" \
"$update_signature_url" \ "$update_signature_url" \
"$update_channel" \
"$update_signing_key_id" \
> "$destination" > "$destination"
;; ;;
*) *)

View file

@ -1,25 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [[ "$#" -ne 8 ]]; then if [[ "$#" -ne 10 ]]; then
echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION CHANNEL PUBLISHED_AT OS ARCHITECTURE BASE_URL OUTPUT.json" >&2 echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION CHANNEL RELEASE_SEQUENCE PUBLISHED_AT EXPIRES_AT OS ARCHITECTURE BASE_URL OUTPUT.json" >&2
exit 2 exit 2
fi fi
binary_directory="$1" binary_directory="$1"
product_version="$2" product_version="$2"
channel="$3" channel="$3"
published_at="$4" release_sequence="$4"
operating_system="$5" published_at="$5"
architecture="$6" expires_at="$6"
base_url="$(printf '%s' "$7" | sed 's#/$##')" operating_system="$7"
output="$8" architecture="$8"
base_url="$(printf '%s' "$9" | sed 's#/$##')"
output="${10}"
repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
contract="$repository_directory/iota-updater/artifacts.tsv" contract="$repository_directory/iota-updater/artifacts.tsv"
staging_directory="$(mktemp -d)" staging_directory="$(mktemp -d)"
artifacts="$staging_directory/artifacts.jsonl" artifacts="$staging_directory/artifacts.jsonl"
trap 'rm -rf "$staging_directory"' EXIT trap 'rm -rf "$staging_directory"' EXIT
if [[ ! "$release_sequence" =~ ^[1-9][0-9]*$ ]]; then
echo "release sequence must be a positive integer" >&2
exit 2
fi
published_epoch="$(date -d "$published_at" +%s)"
expires_epoch="$(date -d "$expires_at" +%s)"
if (( expires_epoch <= published_epoch )); then
echo "expires_at must be after published_at" >&2
exit 2
fi
while IFS=$'\t' read -r role artifact_path; do while IFS=$'\t' read -r role artifact_path; do
[[ -n "$role" && -n "$artifact_path" ]] || continue [[ -n "$role" && -n "$artifact_path" ]] || continue
asset_name="$(basename "$artifact_path")" asset_name="$(basename "$artifact_path")"
@ -40,12 +53,16 @@ mkdir -p "$(dirname "$output")"
jq -s \ jq -s \
--arg product_version "$product_version" \ --arg product_version "$product_version" \
--arg channel "$channel" \ --arg channel "$channel" \
--argjson release_sequence "$release_sequence" \
--arg published_at "$published_at" \ --arg published_at "$published_at" \
--arg expires_at "$expires_at" \
--arg release_signing_key_id "primary" \ --arg release_signing_key_id "primary" \
'{ '{
product_version: $product_version, product_version: $product_version,
channel: $channel, channel: $channel,
release_sequence: $release_sequence,
published_at: $published_at, published_at: $published_at,
expires_at: $expires_at,
minimum_data_schema: 1, minimum_data_schema: 1,
supported_ipc_min: 2, supported_ipc_min: 2,
supported_ipc_max: 4, supported_ipc_max: 4,

View file

@ -226,6 +226,7 @@ fn ipc_error_response(code: IpcErrorCode) -> HttpResponse {
let status = match code { let status = match code {
IpcErrorCode::InvalidRequest => actix_web::http::StatusCode::BAD_REQUEST, IpcErrorCode::InvalidRequest => actix_web::http::StatusCode::BAD_REQUEST,
IpcErrorCode::Conflict => actix_web::http::StatusCode::CONFLICT, IpcErrorCode::Conflict => actix_web::http::StatusCode::CONFLICT,
IpcErrorCode::Unsupported => actix_web::http::StatusCode::NOT_IMPLEMENTED,
IpcErrorCode::NotReady | IpcErrorCode::OmikronUnavailable => { IpcErrorCode::NotReady | IpcErrorCode::OmikronUnavailable => {
actix_web::http::StatusCode::SERVICE_UNAVAILABLE actix_web::http::StatusCode::SERVICE_UNAVAILABLE
} }