[WIP] User Invites
This commit is contained in:
parent
0827882bb3
commit
7164f4671d
24 changed files with 2829 additions and 170 deletions
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,73 +366,67 @@ 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 {
|
std::time::Duration::from_secs(15),
|
||||||
match timeout(
|
read_msg::<_, ClientMessage>(&mut reader),
|
||||||
std::time::Duration::from_secs(15),
|
)
|
||||||
read_msg::<_, ClientMessage>(&mut reader),
|
.await
|
||||||
)
|
{
|
||||||
.await
|
Err(_) => {
|
||||||
{
|
return Err(std::io::Error::new(
|
||||||
Err(_) => {
|
std::io::ErrorKind::TimedOut,
|
||||||
|
"IPC Hello timed out",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(result) => match result {
|
||||||
|
Ok(ClientMessage::Hello { supported_versions }) => {
|
||||||
|
let version = supported_versions
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
||||||
|
.max()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"No compatible IPC protocol version",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let ack = DaemonMessage::HelloAck(HelloAck {
|
||||||
|
protocol_version: version,
|
||||||
|
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
instance_id: instance_id.clone(),
|
||||||
|
startup_phase: runtime.current_startup_phase().into(),
|
||||||
|
capabilities: vec![
|
||||||
|
"commands".into(),
|
||||||
|
"metrics".into(),
|
||||||
|
"logs".into(),
|
||||||
|
"user_management_v2".into(),
|
||||||
|
"tu_inspection_v1".into(),
|
||||||
|
"credential_export_v1".into(),
|
||||||
|
"user_invitations_v1".into(),
|
||||||
|
],
|
||||||
|
lifecycle: *runtime.lifecycle.borrow(),
|
||||||
|
health: runtime.overall_health(),
|
||||||
|
deployment_mode: from_environment().mode,
|
||||||
|
supervisor: from_environment().supervisor,
|
||||||
|
});
|
||||||
|
write_client_message(&mut writer, &ack).await?;
|
||||||
|
eprintln!(
|
||||||
|
"IPC handshake acknowledged (pid={}, uid={})",
|
||||||
|
peer.pid, peer.uid
|
||||||
|
);
|
||||||
|
version
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// Unexpected first message, send an error and close.
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::TimedOut,
|
std::io::ErrorKind::InvalidData,
|
||||||
"IPC Hello timed out",
|
"Expected Hello as first message",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(result) => match result {
|
Err(e) => return Err(e),
|
||||||
Ok(ClientMessage::Hello { supported_versions }) => {
|
},
|
||||||
let version = supported_versions
|
};
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
|
||||||
.max()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Unsupported,
|
|
||||||
"No compatible IPC protocol version",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
negotiated_version = Some(version);
|
|
||||||
let ack = DaemonMessage::HelloAck(HelloAck {
|
|
||||||
protocol_version: version,
|
|
||||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
instance_id: instance_id.clone(),
|
|
||||||
startup_phase: runtime.current_startup_phase().into(),
|
|
||||||
capabilities: vec![
|
|
||||||
"commands".into(),
|
|
||||||
"metrics".into(),
|
|
||||||
"logs".into(),
|
|
||||||
"user_management_v2".into(),
|
|
||||||
"tu_inspection_v1".into(),
|
|
||||||
"credential_export_v1".into(),
|
|
||||||
],
|
|
||||||
lifecycle: *runtime.lifecycle.borrow(),
|
|
||||||
health: runtime.overall_health(),
|
|
||||||
deployment_mode: from_environment().mode,
|
|
||||||
supervisor: from_environment().supervisor,
|
|
||||||
});
|
|
||||||
write_client_message(&mut writer, &ack).await?;
|
|
||||||
eprintln!(
|
|
||||||
"IPC handshake acknowledged (pid={}, uid={})",
|
|
||||||
peer.pid, peer.uid
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// Unexpected first message, send an error and close.
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"Expected Hello as first message",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(¤t)
|
||||||
|
.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(¤t, &version_dir, &self.root.join("current.new"))?;
|
||||||
let _ = fs::remove_file(¤t_tmp);
|
Ok(Activation {
|
||||||
std::os::unix::fs::symlink(&version_dir, ¤t_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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
let _ = self
|
invitation_revision,
|
||||||
.send_message(&error_response(cv, CommunicationType::ErrorInternal))
|
&profile,
|
||||||
.await;
|
now,
|
||||||
return;
|
);
|
||||||
|
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
|
||||||
|
.send_message(&error_response(cv, CommunicationType::ErrorInternal))
|
||||||
|
.await;
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue