[fix] VerNum

This commit is contained in:
Alex Emmet 2026-09-10 17:53:09 +02:00
commit 0827882bb3
No known key found for this signature in database
30 changed files with 1651 additions and 240 deletions

View file

@ -26,6 +26,8 @@ jobs:
steps: steps:
- name: Set up repository - name: Set up repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Login to Docker Hub - name: Login to Docker Hub
env: env:
@ -41,16 +43,6 @@ jobs:
run: | run: |
nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest"
- name: Build release binaries
run: |
set -eu
nix build .#iota-daemon --print-build-logs
install -Dm755 result/bin/iota-daemon dist/iota-daemon
nix build .#iota-ui --print-build-logs
install -Dm755 result/bin/iota-ui dist/iota-ui
- name: Read release metadata - name: Read release metadata
id: version id: version
env: env:
@ -58,7 +50,7 @@ jobs:
run: | run: |
set -eu set -eu
VERSION="$(nix eval --raw .#iota-daemon.version)" VERSION="$(nix eval --raw .#default.version)"
SHORT_SHA="$(git rev-parse --short=7 HEAD)" SHORT_SHA="$(git rev-parse --short=7 HEAD)"
case "$RELEASE_TYPE" in case "$RELEASE_TYPE" in
@ -80,11 +72,51 @@ jobs:
echo "tag=$TAG" >> "$FORGEJO_OUTPUT" echo "tag=$TAG" >> "$FORGEJO_OUTPUT"
echo "title=$TAG" >> "$FORGEJO_OUTPUT" echo "title=$TAG" >> "$FORGEJO_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT"
ARCH="$(uname -m)"
echo "arch=$ARCH" >> "$FORGEJO_OUTPUT"
echo "asset_name=iota-${TAG}-linux-${ARCH}.zip" >> "$FORGEJO_OUTPUT"
echo "update_manifest_name=iota-update-${TAG}-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT"
echo "channel_tag=iota-updates-${RELEASE_TYPE}-linux-${ARCH}" >> "$FORGEJO_OUTPUT"
echo "channel_manifest_name=iota-update-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT"
test -x "dist/iota-daemon" - name: Build and validate installer bundle
test -x "dist/iota-ui" env:
TAG: ${{ steps.version.outputs.tag }}
ASSET_NAME: ${{ steps.version.outputs.asset_name }}
ARCH: ${{ steps.version.outputs.arch }}
UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }}
CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }}
CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }}
RELEASE_TYPE: ${{ inputs.release_type }}
SERVER_URL: ${{ forgejo.server_url }}
REPO: ${{ forgejo.repository }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
run: |
set -eu
: "${IOTA_RELEASE_SIGNING_KEY:?IOTA_RELEASE_SIGNING_KEY secret is required}"
- name: Create release and upload binaries nix build "git+file://$PWD?submodules=1#default" --print-build-logs
mkdir -p dist/bin
install -m755 result/bin/iota dist/bin/iota
install -m755 result/bin/iota-daemon dist/bin/iota-daemon
install -m755 result/bin/iota-updater dist/bin/iota-updater
UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}"
UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}"
PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export ARCH PUBLISHED_AT 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"'
UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")"
bash scripts/build-release-bundle.sh \
dist/bin \
"$TAG" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \
"$UPDATE_PUBLIC_KEY" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \
"dist/$ASSET_NAME"
result/bin/iota-bundle "dist/$ASSET_NAME"
- name: Create release and upload release assets
env: env:
TOKEN: ${{ forgejo.token }} TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }} API: ${{ forgejo.api_url }}
@ -93,6 +125,11 @@ jobs:
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }} TITLE: ${{ steps.version.outputs.title }}
PRERELEASE: ${{ steps.version.outputs.prerelease }} PRERELEASE: ${{ steps.version.outputs.prerelease }}
ASSET_NAME: ${{ steps.version.outputs.asset_name }}
UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }}
CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }}
CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }}
RELEASE_TYPE: ${{ inputs.release_type }}
DESCRIPTION: ${{ inputs.description }} DESCRIPTION: ${{ inputs.description }}
run: | run: |
nix-shell -p curl jq --run ' nix-shell -p curl jq --run '
@ -120,11 +157,49 @@ jobs:
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi fi
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \ upload_asset() {
TARGET_RELEASE_ID="$1"
ASSET="$2"
PATHNAME="$3"
curl -fsS -X POST "$API/repos/$REPO/releases/$TARGET_RELEASE_ID/assets?name=$ASSET" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@dist/iota-daemon" -F "attachment=@$PATHNAME"
}
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \ upload_asset "$RELEASE_ID" iota dist/bin/iota
upload_asset "$RELEASE_ID" iota-daemon dist/bin/iota-daemon
upload_asset "$RELEASE_ID" iota-updater dist/bin/iota-updater
upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig"
upload_asset "$RELEASE_ID" "$ASSET_NAME" "dist/$ASSET_NAME"
upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME"
CHANNEL_STATUS=$(curl -s -w "%{http_code}" -o channel_out.json \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@dist/iota-ui" "$API/repos/$REPO/releases/tags/$CHANNEL_TAG")
if [ "$CHANNEL_STATUS" = "200" ]; then
CHANNEL_RELEASE_ID="$(jq -r .id channel_out.json)"
else
CHANNEL_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$CHANNEL_TAG" \
--arg name "Iota $RELEASE_TYPE update channel" \
--arg target "$SHA" \
'"'"'{ tag_name: $tag, name: $name, body: "Managed by the release workflow.", target_commitish: $target, draft: false, prerelease: true }'"'"')")"
CHANNEL_RELEASE_ID="$(echo "$CHANNEL_JSON" | jq -r .id)"
fi
CHANNEL_ASSETS="$(curl -fsS \
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/$CHANNEL_RELEASE_ID/assets")"
for CHANNEL_ASSET in "$CHANNEL_MANIFEST_NAME" "$CHANNEL_MANIFEST_NAME.sig"; do
echo "$CHANNEL_ASSETS" | jq -r --arg name "$CHANNEL_ASSET" '"'"'.[] | select(.name == $name) | .id'"'"' | while read -r ASSET_ID; do
[ -n "$ASSET_ID" ] || continue
curl -fsS -X DELETE "$API/repos/$REPO/releases/assets/$ASSET_ID" \
-H "Authorization: token $TOKEN"
done
done
upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig"
upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME"
' '

14
Cargo.lock generated
View file

@ -1435,6 +1435,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@ -2281,8 +2291,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ed25519-dalek 2.2.0", "ed25519-dalek 2.2.0",
"fs2",
"hex", "hex",
"iota-ipc",
"iota-paths", "iota-paths",
"reqwest",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",

View file

@ -46,7 +46,16 @@
pname = "iota"; pname = "iota";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"]; cargoBuildFlags = [
"-p"
"iota"
"-p"
"iota-daemon"
"-p"
"iota-updater"
"-p"
"iota-installer"
];
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true; allowBuiltinFetchGit = true;

View file

@ -9,7 +9,7 @@ use crate::{
util::{buttons::draw_buttons, terms_focus::Focus}, util::{buttons::draw_buttons, terms_focus::Focus},
}; };
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use iota_terms::{TermsType, get_link, get_terms}; use iota_terms::{LegalDocument, TermsType, get_link};
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
@ -28,6 +28,7 @@ pub enum UserChoice {
pub struct TermsCheckerScreen { pub struct TermsCheckerScreen {
sender: Option<oneshot::Sender<UserChoice>>, sender: Option<oneshot::Sender<UserChoice>>,
documents: [LegalDocument; 3],
eula: bool, eula: bool,
tos: bool, tos: bool,
@ -37,9 +38,10 @@ pub struct TermsCheckerScreen {
} }
impl TermsCheckerScreen { impl TermsCheckerScreen {
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self { pub fn new(sender: Option<oneshot::Sender<UserChoice>>, documents: [LegalDocument; 3]) -> Self {
Self { Self {
sender, sender,
documents,
eula: false, eula: false,
tos: false, tos: false,
pp: false, pp: false,
@ -315,21 +317,25 @@ impl Screen for TermsCheckerScreen {
_ => None, _ => None,
}; };
if let Some(terms_type) = terms_type { if let Some(terms_type) = terms_type {
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> = Box::pin( let document = self
async move { .documents
if let Some(content) = get_terms(terms_type.clone()).await { .iter()
.find(|document| document.kind() == terms_type)
.cloned();
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
Box::pin(async move {
if let Some(document) = document {
let screen: FileViewer = let screen: FileViewer =
FileViewer::new(terms_type.to_string(), &content); FileViewer::new(terms_type.to_string(), document.content());
Box::new(screen) as Box<dyn Screen> Box::new(screen) as Box<dyn Screen>
} else { } else {
let screen: FileViewer = FileViewer::new( let screen: FileViewer = FileViewer::new(
"Error".to_string(), "Error".to_string(),
"Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", "The fetched legal document is unavailable.",
); );
Box::new(screen) as Box<dyn Screen> Box::new(screen) as Box<dyn Screen>
} }
}, });
);
InteractionResult::OpenFutureScreen { screen: fut } InteractionResult::OpenFutureScreen { screen: fut }
} else { } else {
InteractionResult::Unhandled InteractionResult::Unhandled

View file

@ -827,19 +827,12 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue {
.to_string(); .to_string();
if !app_identifier.is_empty() { if !app_identifier.is_empty() {
let user = match iota_storage::users::user_manager::get_user(sender_id) { if iota_storage::users::user_manager::revoke_trusted_app(sender_id, &app_identifier)
Ok(user) => user, .is_err()
Err(_) => return error_response(cv, CommunicationType::ErrorInternal), {
};
if let Some(mut user) = user {
if user.trusted_apps.contains_key(&app_identifier) {
user.trusted_apps.remove(&app_identifier);
if iota_storage::users::user_manager::update_user(user).is_err() {
return error_response(cv, CommunicationType::ErrorInternal); return error_response(cv, CommunicationType::ErrorInternal);
} }
} }
}
}
CommunicationValue::new(CommunicationType::DeleteApp) CommunicationValue::new(CommunicationType::DeleteApp)
.with_request_id(cv) .with_request_id(cv)
@ -950,6 +943,25 @@ fn account_state_error(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::ErrorInvalidData) error_response(cv, CommunicationType::ErrorInvalidData)
} }
fn add_receipt_policy_data(
response: CommunicationValue,
policy: &receipt_policy::ReceiptPolicy,
) -> CommunicationValue {
response
.add_typed_default(
DataType::SendReadReceipts,
DataValue::Bool(policy.send_read_receipts),
)
.add_typed_default(
DataType::SendReceivedReceipts,
DataValue::Bool(policy.send_received_receipts),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(policy.revision.into()),
)
}
/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. /// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here.
pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationValue { pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationValue {
let user_id = match required_sender_id(cv) { let user_id = match required_sender_id(cv) {
@ -996,7 +1008,7 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal
message_storage_policy::MessageRetention::Forever => None, message_storage_policy::MessageRetention::Forever => None,
message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms), message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms),
}; };
let mut response = CommunicationValue::new(CommunicationType::AccountStateSnapshot) let response = CommunicationValue::new(CommunicationType::AccountStateSnapshot)
.with_request_id(cv) .with_request_id(cv)
.with_receiver(sender_wire_id(user_id)) .with_receiver(sender_wire_id(user_id))
.add_typed_default( .add_typed_default(
@ -1021,15 +1033,8 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal
.map(|id| DataValue::SignedNumber(id.into())) .map(|id| DataValue::SignedNumber(id.into()))
.collect(), .collect(),
), ),
) );
.add_typed_default( let mut response = add_receipt_policy_data(response, &receipt_policy)
DataType::SendReadReceipts,
DataValue::Bool(receipt_policy.send_read_receipts),
)
.add_typed_default(
DataType::SendReceivedReceipts,
DataValue::Bool(receipt_policy.send_received_receipts),
)
.add_typed_default( .add_typed_default(
DataType::MessageHistoryMode, DataType::MessageHistoryMode,
DataValue::Str(match message_storage_policy.history_mode { DataValue::Str(match message_storage_policy.history_mode {
@ -1060,6 +1065,45 @@ pub fn handle_account_state_applied(cv: &CommunicationValue) -> CommunicationVal
success_response(cv) success_response(cv)
} }
#[cfg(test)]
mod account_state_tests {
use super::add_receipt_policy_data;
use iota_storage::util::receipt_policy::ReceiptPolicy;
use iota_util::mtp_compat::OptionalDataValueExt;
use mtp::codec::{CommunicationType, CommunicationValue, DataType};
#[test]
fn snapshot_receipt_policy_includes_its_revision() {
let policy = ReceiptPolicy {
user_id: 7,
send_read_receipts: false,
send_received_receipts: true,
revision: 42,
updated_at: 100,
};
let response = add_receipt_policy_data(
CommunicationValue::new(CommunicationType::AccountStateSnapshot),
&policy,
);
assert_eq!(
response
.get_data(DataType::VersionNumber)
.as_signed_number(),
Some(42)
);
assert_eq!(
response.get_data(DataType::SendReadReceipts).as_bool(),
Some(false)
);
assert_eq!(
response.get_data(DataType::SendReceivedReceipts).as_bool(),
Some(true)
);
}
}
pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue {
let my_id = match cv.require_sender() { let my_id = match cv.require_sender() {
Ok(my_id) => my_id, Ok(my_id) => my_id,
@ -2222,21 +2266,10 @@ fn receipt_response(
ty: CommunicationType, ty: CommunicationType,
policy: receipt_policy::ReceiptPolicy, policy: receipt_policy::ReceiptPolicy,
) -> CommunicationValue { ) -> CommunicationValue {
CommunicationValue::new(ty) let response = CommunicationValue::new(ty)
.with_request_id(cv) .with_request_id(cv)
.with_receiver(sender_wire_id(policy.user_id)) .with_receiver(sender_wire_id(policy.user_id));
.add_typed_default( add_receipt_policy_data(response, &policy)
DataType::SendReadReceipts,
DataValue::Bool(policy.send_read_receipts),
)
.add_typed_default(
DataType::SendReceivedReceipts,
DataValue::Bool(policy.send_received_receipts),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(policy.revision.into()),
)
} }
pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue { pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue {
let Ok(user_id) = authenticated_user(cv) else { let Ok(user_id) = authenticated_user(cv) else {

View file

@ -3,14 +3,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
use iota_cli::ui::UI; use iota_cli::ui::UI;
use iota_terms::{Doc, TermsType as Type, get_current_docs}; use iota_terms::{Doc, LegalDocument, TermsType as Type, get_current_docs};
use iota_util::file_util::{load_file, save_file}; use iota_util::file_util::{load_file, save_file};
use tokio::sync::oneshot; use tokio::sync::oneshot;
pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> { pub struct ConsentCheck {
pub accepted_eula: bool,
pub accepted_services: bool,
pub documents: [LegalDocument; 3],
}
pub async fn check(ui: Arc<UI>) -> Result<ConsentCheck, String> {
let mut state = ConsentState::load_state(); let mut state = ConsentState::load_state();
ensure_initial_consent(ui.clone(), &mut state).await?; let documents = ensure_initial_consent(ui.clone(), &mut state).await?;
/* /*
* The raw legal endpoint exposes only the current document. Restore this * The raw legal endpoint exposes only the current document. Restore this
* flow when it provides future versions that users can accept early. * flow when it provides future versions that users can accept early.
@ -20,7 +26,11 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
state = state.sanitize(); state = state.sanitize();
state.save_state(); state.save_state();
Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) Ok(ConsentCheck {
accepted_eula: state.accepted_eula,
accepted_services: state.accepted_tos && state.accepted_pp,
documents,
})
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -38,18 +48,34 @@ pub fn non_interactive_consent() -> NonInteractiveConsent {
} }
} }
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> { async fn ensure_initial_consent(
if state.accepted_eula && state.accepted_tos && state.accepted_pp { ui: Arc<UI>,
return Ok(()); state: &mut ConsentState,
} ) -> Result<[LegalDocument; 3], String> {
let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| { let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| {
"Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string() "Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string()
})?; })?;
let current_eula_doc = current_eula.metadata();
let current_tos_doc = current_tos.metadata();
let current_privacy_doc = current_privacy.metadata();
let documents = [current_eula, current_tos, current_privacy];
if state.accepts_current_docs(&current_eula_doc, &current_tos_doc, &current_privacy_doc) {
return Ok(documents);
}
// A legacy consent file records the document hashes alongside its flags.
// Do not carry a flag forward to the durable consent record when its
// document has changed; the user must review and accept that revision.
state.invalidate_mismatched_consent(&current_eula_doc, &current_tos_doc, &current_privacy_doc);
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx)))) ui.set_screen(Box::new(TermsCheckerScreen::new(
Some(tx),
documents.clone(),
)))
.await; .await;
let result = rx.await.unwrap_or(UserChoice::Deny); let result = rx.await.unwrap_or(UserChoice::Deny);
@ -57,19 +83,19 @@ async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result
match result { match result {
UserChoice::AcceptEULA | UserChoice::AcceptAll => { UserChoice::AcceptEULA | UserChoice::AcceptAll => {
state.accepted_eula = true; state.accepted_eula = true;
state.eula = Some(current_eula); state.eula = Some(current_eula_doc);
if matches!(result, UserChoice::AcceptAll) { if matches!(result, UserChoice::AcceptAll) {
state.accepted_tos = true; state.accepted_tos = true;
state.accepted_pp = true; state.accepted_pp = true;
state.tos = Some(current_tos); state.tos = Some(current_tos_doc);
state.privacy = Some(current_privacy); state.privacy = Some(current_privacy_doc);
} }
let _ = &state.save_state(); let _ = &state.save_state();
Ok(()) Ok(documents)
} }
UserChoice::Deny => Ok(()), UserChoice::Deny => Ok(documents),
} }
} }
/* /*
@ -245,6 +271,22 @@ pub struct ConsentState {
} }
impl ConsentState { impl ConsentState {
fn accepts_current_docs(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool {
self.accepted_eula
&& self.accepted_tos
&& self.accepted_pp
&& eula.equals_some(&self.eula)
&& tos.equals_some(&self.tos)
&& privacy.equals_some(&self.privacy)
}
fn invalidate_mismatched_consent(&mut self, eula: &Doc, tos: &Doc, privacy: &Doc) {
self.accepted_eula &= eula.equals_some(&self.eula);
self.accepted_tos &= tos.equals_some(&self.tos);
self.accepted_pp &= privacy.equals_some(&self.privacy);
*self = self.clone().sanitize();
}
fn sanitize(mut self) -> Self { fn sanitize(mut self) -> Self {
let current_secs = SystemTime::now() let current_secs = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -488,3 +530,60 @@ impl ConsentState {
state state
} }
} }
#[cfg(test)]
mod tests {
use super::ConsentState;
use iota_terms::{Doc, TermsType as Type};
fn doc(doc_type: Type, hash: &str) -> Doc {
Doc::new("1".to_owned(), hash.to_owned(), doc_type, 0)
}
#[test]
fn accepts_current_docs_requires_each_accepted_hash_to_match() {
let eula = doc(Type::EULA, "eula-hash");
let tos = doc(Type::TOS, "tos-hash");
let privacy = doc(Type::PP, "privacy-hash");
let state = ConsentState {
eula: Some(eula.clone()),
accepted_eula: true,
future_eula: None,
tos: Some(tos.clone()),
accepted_tos: true,
future_tos: None,
privacy: Some(privacy.clone()),
accepted_pp: true,
future_privacy: None,
};
assert!(state.accepts_current_docs(&eula, &tos, &privacy));
assert!(!state.accepts_current_docs(&doc(Type::EULA, "new-eula-hash"), &tos, &privacy));
assert!(!state.accepts_current_docs(&eula, &doc(Type::TOS, "new-tos-hash"), &privacy));
assert!(!state.accepts_current_docs(&eula, &tos, &doc(Type::PP, "new-privacy-hash")));
}
#[test]
fn invalidating_a_mismatched_eula_revokes_dependent_consent() {
let eula = doc(Type::EULA, "old-eula-hash");
let tos = doc(Type::TOS, "tos-hash");
let privacy = doc(Type::PP, "privacy-hash");
let mut state = ConsentState {
eula: Some(eula),
accepted_eula: true,
future_eula: None,
tos: Some(tos.clone()),
accepted_tos: true,
future_tos: None,
privacy: Some(privacy.clone()),
accepted_pp: true,
future_privacy: None,
};
state.invalidate_mismatched_consent(&doc(Type::EULA, "new-eula-hash"), &tos, &privacy);
assert!(!state.accepted_eula);
assert!(!state.accepted_tos);
assert!(!state.accepted_pp);
}
}

View file

@ -38,7 +38,7 @@ async fn main() {
let session = start_tui(ipc).expect("interactive terminal initialization failed"); let session = start_tui(ipc).expect("interactive terminal initialization failed");
let ui = session.ui(); let ui = session.ui();
let (eula, tos_pp) = match consent_state::check(ui.clone()).await { let consent = match consent_state::check(ui.clone()).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
*state.shutdown.write().await = true; *state.shutdown.write().await = true;
@ -53,7 +53,7 @@ async fn main() {
} }
}; };
if !eula { if !consent.accepted_eula {
*state.shutdown.write().await = true; *state.shutdown.write().await = true;
loop { loop {
if state.active_tasks.is_empty() { if state.active_tasks.is_empty() {
@ -65,7 +65,7 @@ async fn main() {
println!("You can find this at 'agreements'!"); println!("You can find this at 'agreements'!");
return; return;
} }
if !tos_pp { if !consent.accepted_services {
*state.shutdown.write().await = true; *state.shutdown.write().await = true;
loop { loop {
if state.active_tasks.is_empty() { if state.active_tasks.is_empty() {
@ -102,6 +102,10 @@ async fn main() {
} }
// USER MANAGEMENT // USER MANAGEMENT
if let Err(error) = iota_storage::util::db::initialize_database() {
log!("Failed to initialize user storage: {}", error);
return;
}
if let Err(_) = user_manager::load_users_sync() { if let Err(_) = user_manager::load_users_sync() {
log_t!("user_load_failed"); log_t!("user_load_failed");
} }

View file

@ -108,7 +108,9 @@ async fn main() -> ExitCode {
let (state_tx, state_rx) = watch::channel(runtime.snapshot()); let (state_tx, state_rx) = watch::channel(runtime.snapshot());
runtime.set_startup_phase(StartupPhase::LoadingUsers); runtime.set_startup_phase(StartupPhase::LoadingUsers);
let storage_error = match iota_storage::util::db::verify_and_backup_database() { let storage_error = match iota_storage::util::db::verify_and_backup_database()
.and_then(|()| iota_storage::util::db::initialize_database())
{
Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync) Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync)
.await .await
.map_err(|error| format!("user storage task failed: {error}")) .map_err(|error| format!("user storage task failed: {error}"))

View file

@ -0,0 +1,10 @@
bin/iota
bin/iota-daemon
bin/iota-updater
systemd/iota-daemon.service
systemd/iota-daemon.socket
systemd/sysusers.d/iota.conf
systemd/iota-update.service
systemd/iota-update.timer
systemd/update.env
manifest.json

View file

@ -0,0 +1,12 @@
use anyhow::{Context, Result, bail};
use std::path::Path;
fn main() -> Result<()> {
let mut arguments = std::env::args_os();
let _program = arguments.next();
let bundle = arguments.next().context("usage: iota-bundle BUNDLE.zip")?;
if arguments.next().is_some() {
bail!("usage: iota-bundle BUNDLE.zip");
}
iota_installer::validate_linux_bundle(Path::new(&bundle))
}

View file

@ -3,17 +3,16 @@ use std::{fs, io, path::Path, process::Command};
use tempfile::tempdir; use tempfile::tempdir;
use zip::ZipArchive; use zip::ZipArchive;
const REQUIRED: &[&str] = &[ const REQUIRED: &str = include_str!("../bundle-files.txt");
"bin/iota", const DAEMON_EXECUTABLE_PLACEHOLDER: &str = "@IOTA_SYSTEM_DAEMON_EXECUTABLE@";
"bin/iota-daemon",
"bin/iota-updater", pub fn validate_linux_bundle(bundle: &Path) -> Result<()> {
"systemd/iota-daemon.service", let staging = tempdir().context("create bundle validation directory")?;
"systemd/iota-daemon.socket", extract_linux_bundle(bundle, staging.path())?;
"systemd/sysusers.d/iota.conf", product_version(staging.path())?;
"systemd/iota-update.service", validate_update_environment(staging.path())?;
"systemd/iota-update.timer", Ok(())
"manifest.json", }
];
pub fn install_linux_bundle(bundle: &Path) -> Result<()> { pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
install_linux_bundle_with_operator(bundle, None) install_linux_bundle_with_operator(bundle, None)
@ -28,55 +27,39 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
bail!("Linux systemd bundles are not supported on this platform"); bail!("Linux systemd bundles are not supported on this platform");
} }
let staging = tempdir().context("create installer staging directory")?; let staging = tempdir().context("create installer staging directory")?;
let file = fs::File::open(bundle).context("open release bundle")?; extract_linux_bundle(bundle, staging.path())?;
let mut archive = ZipArchive::new(file).context("read release bundle")?; let product_version = product_version(staging.path())?;
for name in REQUIRED { validate_update_environment(staging.path())?;
let mut entry = archive
.by_name(name)
.with_context(|| format!("bundle is missing {name}"))?;
let output = staging.path().join(name);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut out = fs::File::create(&output)?;
io::copy(&mut entry, &mut out)?;
}
render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?;
let version_dir = format!(
"{}/versions/{product_version}",
iota_paths::install_root().display()
);
install( install(
&staging.path().join("bin/iota"), &staging.path().join("bin/iota"),
&format!( &format!("{version_dir}/bin/iota"),
"{}/versions/{}/bin/iota",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755", "0755",
)?; )?;
install( install(
&staging.path().join("bin/iota-daemon"), &staging.path().join("bin/iota-daemon"),
&format!( &format!("{version_dir}/bin/iota-daemon"),
"{}/versions/{}/bin/iota-daemon",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755", "0755",
)?; )?;
let version_dir = format!(
"{}/versions/{}",
iota_paths::install_root().display(),
product_version(staging.path())
);
if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() { if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() {
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon"); bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
} }
install( install(
&staging.path().join("bin/iota-updater"), &staging.path().join("bin/iota-updater"),
&format!( &format!("{version_dir}/bin/iota-updater"),
"{}/versions/{}/bin/iota-updater",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755", "0755",
)?; )?;
install(
&staging.path().join("manifest.json"),
&format!("{version_dir}/manifest.json"),
"0644",
)?;
for unit in [ for unit in [
"iota-daemon.service", "iota-daemon.service",
"iota-daemon.socket", "iota-daemon.socket",
@ -94,6 +77,11 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
"/etc/sysusers.d/iota.conf", "/etc/sysusers.d/iota.conf",
"0644", "0644",
)?; )?;
install(
&staging.path().join("systemd/update.env"),
"/etc/iota/update.env",
"0644",
)?;
run( run(
"ln", "ln",
&[ &[
@ -118,7 +106,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
"{}/current/bin/iota-daemon", "{}/current/bin/iota-daemon",
iota_paths::install_root().display() iota_paths::install_root().display()
), ),
"/usr/local/libexec/iota/iota-daemon", iota_paths::SYSTEM_DAEMON_EXECUTABLE,
], ],
)?; )?;
run("systemd-sysusers", &[])?; run("systemd-sysusers", &[])?;
@ -138,8 +126,10 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
} }
run("systemctl", &["daemon-reload"])?; run("systemctl", &["daemon-reload"])?;
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
run("systemctl", &["enable", "--now", "iota-update.timer"])?;
run("systemctl", &["is-active", "iota-daemon.socket"])?; run("systemctl", &["is-active", "iota-daemon.socket"])?;
run("systemctl", &["is-enabled", "iota-daemon.socket"])?; run("systemctl", &["is-enabled", "iota-daemon.socket"])?;
run("systemctl", &["is-enabled", "iota-update.timer"])?;
let socket = iota_paths::socket_path(iota_paths::Scope::System); let socket = iota_paths::socket_path(iota_paths::Scope::System);
if !socket.exists() { if !socket.exists() {
bail!( bail!(
@ -150,17 +140,107 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
Ok(()) Ok(())
} }
fn product_version(staging: &Path) -> String { fn extract_linux_bundle(bundle: &Path, staging: &Path) -> Result<()> {
fs::read_to_string(staging.join("manifest.json")) let file = fs::File::open(bundle).context("open release bundle")?;
.ok() let mut archive = ZipArchive::new(file).context("read release bundle")?;
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok()) for name in REQUIRED.lines().filter(|name| !name.is_empty()) {
.and_then(|value| { let mut entry = archive
value .by_name(name)
.with_context(|| format!("bundle is missing {name}"))?;
let output = staging.join(name);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut out = fs::File::create(&output)?;
io::copy(&mut entry, &mut out)?;
}
Ok(())
}
fn render_daemon_service(path: &Path) -> Result<()> {
let template = fs::read_to_string(path)
.with_context(|| format!("read systemd unit template {}", path.display()))?;
let placeholder_count = template.matches(DAEMON_EXECUTABLE_PLACEHOLDER).count();
if placeholder_count != 1 {
bail!(
"systemd unit template {} must contain exactly one {DAEMON_EXECUTABLE_PLACEHOLDER} placeholder, found {placeholder_count}",
path.display()
);
}
fs::write(
path,
template.replace(
DAEMON_EXECUTABLE_PLACEHOLDER,
iota_paths::SYSTEM_DAEMON_EXECUTABLE,
),
)
.with_context(|| format!("render systemd unit {}", path.display()))
}
fn product_version(staging: &Path) -> Result<String> {
let manifest_path = staging.join("manifest.json");
let contents = fs::read_to_string(&manifest_path)
.with_context(|| format!("read bundle manifest {}", manifest_path.display()))?;
let manifest: serde_json::Value = serde_json::from_str(&contents)
.with_context(|| format!("parse bundle manifest {}", manifest_path.display()))?;
let version = manifest
.get("product_version") .get("product_version")
.and_then(|v| v.as_str()) .and_then(|value| value.as_str())
.map(str::to_owned) .filter(|value| !value.is_empty())
.context("bundle manifest product_version must be a non-empty string")?;
let mut characters = version.chars();
if !characters
.next()
.is_some_and(|character| character.is_ascii_alphanumeric())
|| !characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-')
}) })
.unwrap_or_else(|| "unversioned".into()) {
bail!("bundle manifest product_version contains unsupported characters");
}
Ok(version.to_owned())
}
fn validate_update_environment(staging: &Path) -> Result<()> {
let path = staging.join("systemd/update.env");
let contents = fs::read_to_string(&path)
.with_context(|| format!("read updater environment {}", path.display()))?;
let mut entries = std::collections::BTreeMap::new();
for line in contents.lines().filter(|line| !line.is_empty()) {
let (name, value) = line
.split_once('=')
.with_context(|| format!("invalid updater environment entry in {}", path.display()))?;
if entries.insert(name, value).is_some() {
bail!("duplicate updater environment variable {name}");
}
}
for name in [
"IOTA_UPDATE_MANIFEST",
"IOTA_UPDATE_PUBLIC_KEY",
"IOTA_UPDATE_SIGNATURE",
] {
let value = entries
.get(name)
.filter(|value| !value.is_empty())
.with_context(|| format!("updater environment is missing {name}"))?;
if value.chars().any(char::is_whitespace) {
bail!("updater environment variable {name} contains whitespace");
}
}
if entries.len() != 3 {
bail!("updater environment contains unexpected variables");
}
let public_key = entries
.get("IOTA_UPDATE_PUBLIC_KEY")
.context("updater environment is missing IOTA_UPDATE_PUBLIC_KEY")?;
if public_key.len() != 64
|| !public_key
.chars()
.all(|character| character.is_ascii_hexdigit())
{
bail!("IOTA_UPDATE_PUBLIC_KEY must be 32-byte hex");
}
Ok(())
} }
fn install(source: &Path, destination: &str, mode: &str) -> Result<()> { fn install(source: &Path, destination: &str, mode: &str) -> Result<()> {
@ -181,3 +261,97 @@ fn run(program: &str, args: &[&str]) -> Result<()> {
bail!("{program} failed; run the installer as root") bail!("{program} failed; run the installer as root")
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::{ZipWriter, write::SimpleFileOptions};
fn write_bundle(path: &Path, omitted: Option<&str>, product_version: &str) {
let file = fs::File::create(path).unwrap();
let mut archive = ZipWriter::new(file);
for name in REQUIRED.lines().filter(|name| !name.is_empty()) {
if omitted == Some(name) {
continue;
}
archive
.start_file(name, SimpleFileOptions::default())
.unwrap();
if name == "manifest.json" {
write!(archive, "{{\"product_version\":\"{product_version}\"}}").unwrap();
} else if name == "systemd/update.env" {
archive
.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",
)
.unwrap();
} else {
archive.write_all(b"bundle member").unwrap();
}
}
archive.finish().unwrap();
}
#[test]
fn daemon_service_uses_the_installed_daemon_entry_point() {
let directory = tempfile::tempdir().unwrap();
let unit = directory.path().join("iota-daemon.service");
fs::write(&unit, include_str!("../../systemd/iota-daemon.service")).unwrap();
render_daemon_service(&unit).unwrap();
let rendered = fs::read_to_string(unit).unwrap();
assert!(rendered.contains(&format!(
"ExecStart={}",
iota_paths::SYSTEM_DAEMON_EXECUTABLE
)));
assert!(!rendered.contains(DAEMON_EXECUTABLE_PLACEHOLDER));
}
#[test]
fn validates_complete_bundle() {
let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("release.zip");
write_bundle(&bundle, None, "0.1.0-dev-abcdef0");
validate_linux_bundle(&bundle).unwrap();
}
#[test]
fn rejects_bundle_missing_contract_member() {
let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("release.zip");
write_bundle(&bundle, Some("systemd/iota-update.timer"), "0.1.0");
let error = validate_linux_bundle(&bundle).unwrap_err();
assert!(error.to_string().contains("systemd/iota-update.timer"));
}
#[test]
fn rejects_unsafe_product_version() {
let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("release.zip");
write_bundle(&bundle, None, "../../outside");
let error = validate_linux_bundle(&bundle).unwrap_err();
assert!(error.to_string().contains("unsupported characters"));
}
#[test]
fn rejects_bundle_without_complete_updater_environment() {
let directory = tempfile::tempdir().unwrap();
let systemd = directory.path().join("systemd");
fs::create_dir_all(&systemd).unwrap();
let environment = systemd.join("update.env");
fs::write(
&environment,
"IOTA_UPDATE_MANIFEST=https://example.invalid/manifest.json\n",
)
.unwrap();
let error = validate_update_environment(directory.path()).unwrap_err();
assert!(error.to_string().contains("IOTA_UPDATE_PUBLIC_KEY"));
}
}

View file

@ -7,6 +7,9 @@ use std::env;
use std::fmt; use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Stable machine-wide daemon entry point used by the systemd unit.
pub const SYSTEM_DAEMON_EXECUTABLE: &str = "/usr/local/libexec/iota/iota-daemon";
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Scope { pub enum Scope {
User, User,

View file

@ -46,6 +46,26 @@ pub fn try_add_user_with_credential_origin(
credential_origin: CredentialOrigin, credential_origin: CredentialOrigin,
) -> Result<(), crate::storage_error::StorageError> { ) -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|tx| { db::with_immediate_transaction(|tx| {
persist_user_profile(tx, &user)?;
tx.execute(
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at)
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4)
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', credential_origin = excluded.credential_origin, updated_at = excluded.updated_at"#,
params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()],
)?;
Ok(())
})
}
pub fn update_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|tx| persist_user_profile(tx, &user))
}
/// Persist mutable profile data without modifying lifecycle-managed residency.
fn persist_user_profile(
tx: &rusqlite::Transaction<'_>,
user: &UserProfile,
) -> Result<(), crate::storage_error::StorageError> {
tx.execute( tx.execute(
r#" r#"
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name)
@ -77,18 +97,7 @@ pub fn try_add_user_with_credential_origin(
params![user.user_id, app_id, app_secret], params![user.user_id, app_id, app_secret],
)?; )?;
} }
tx.execute(
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at)
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4)
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', credential_origin = excluded.credential_origin, updated_at = excluded.updated_at"#,
params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()],
)?;
Ok(()) Ok(())
})
}
pub fn update_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> {
try_add_user(user)
} }
pub fn get_user_by_username( pub fn get_user_by_username(
@ -659,9 +668,12 @@ pub fn load_users_sync() -> std::io::Result<()> {
} }
#[cfg(test)] #[cfg(test)]
mod purge_tests { mod tests {
use super::{PurgeStage, remaining_purge_stages, run_purge_stages}; use super::{
PurgeStage, UserProfile, persist_user_profile, remaining_purge_stages, run_purge_stages,
};
use crate::users::pending_operations::PendingUserOperationPhase; use crate::users::pending_operations::PendingUserOperationPhase;
use rusqlite::{Connection, params};
#[test] #[test]
fn prepared_purge_runs_every_stage_before_completion() { fn prepared_purge_runs_every_stage_before_completion() {
@ -725,6 +737,82 @@ mod purge_tests {
assert!(!data_empty); assert!(!data_empty);
} }
} }
#[test]
fn profile_updates_preserve_external_residency_attributes() {
let mut connection = Connection::open_in_memory().unwrap();
connection
.execute_batch(
r#"
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
private_key_hash TEXT,
reset_token TEXT,
created_at INTEGER NOT NULL,
display_name TEXT
);
CREATE TABLE trusted_apps (
user_id INTEGER NOT NULL,
app_id TEXT NOT NULL,
app_secret TEXT NOT NULL,
PRIMARY KEY (user_id, app_id)
);
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
);
INSERT INTO users VALUES (1, 'alice', 'old-key', NULL, NULL, 1, NULL);
INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42);
"#,
)
.unwrap();
let mut user = UserProfile::new_with_created_at(
1,
"alice".into(),
Some("Alice".into()),
"new-key".into(),
None,
None,
1,
);
user.trusted_apps.insert("app".into(), "secret".into());
let transaction = connection.transaction().unwrap();
persist_user_profile(&transaction, &user).unwrap();
transaction.commit().unwrap();
let residency: (String, String, String, String, i64) = connection
.query_row(
"SELECT username, lifecycle_state, data_state, credential_origin, updated_at FROM user_residency WHERE user_id = ?1",
params![1],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
)
.unwrap();
assert_eq!(
residency,
(
"alice".into(),
"released".into(),
"empty".into(),
"external".into(),
42,
)
);
let trusted_app_count: i64 = connection
.query_row(
"SELECT COUNT(*) FROM trusted_apps WHERE user_id = ?1",
params![1],
|row| row.get(0),
)
.unwrap();
assert_eq!(trusted_app_count, 1);
}
} }
pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) {

View file

@ -1,4 +1,4 @@
use once_cell::sync::Lazy; use once_cell::sync::OnceCell;
use r2d2::ManageConnection; use r2d2::ManageConnection;
use rusqlite::{Connection, Transaction}; use rusqlite::{Connection, Transaction};
use std::path::PathBuf; use std::path::PathBuf;
@ -10,15 +10,16 @@ use crate::storage_error::StorageError;
const DB_NAME: &str = "messages"; const DB_NAME: &str = "messages";
/// A simple r2d2 manager for rusqlite connections. /// A simple r2d2 manager for rusqlite connections.
pub struct SqliteManager; pub struct SqliteManager {
database_path: PathBuf,
}
impl ManageConnection for SqliteManager { impl ManageConnection for SqliteManager {
type Connection = Connection; type Connection = Connection;
type Error = rusqlite::Error; type Error = rusqlite::Error;
fn connect(&self) -> Result<Connection, rusqlite::Error> { fn connect(&self) -> Result<Connection, rusqlite::Error> {
let path = db_file_path(DB_NAME); let conn = Connection::open(&self.database_path)?;
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?;
conn.busy_timeout(Duration::from_millis(250))?; conn.busy_timeout(Duration::from_millis(250))?;
Ok(conn) Ok(conn)
@ -33,18 +34,43 @@ impl ManageConnection for SqliteManager {
} }
} }
static POOL: Lazy<Arc<r2d2::Pool<SqliteManager>>> = Lazy::new(|| { static POOL: OnceCell<Arc<r2d2::Pool<SqliteManager>>> = OnceCell::new();
let manager = SqliteManager;
fn create_pool(database_path: PathBuf) -> Result<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
create_pool_with_timeout(database_path, Duration::from_secs(30))
}
fn create_pool_with_timeout(
database_path: PathBuf,
connection_timeout: Duration,
) -> Result<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
let manager = SqliteManager { database_path };
let pool = r2d2::Pool::builder() let pool = r2d2::Pool::builder()
.max_size(8) .max_size(8)
.connection_timeout(connection_timeout)
.build(manager) .build(manager)
.expect("Failed to create database connection pool"); .map_err(|error| StorageError::Pool(error.to_string()))?;
run_migrations(&pool).expect("Failed to run database migrations"); run_migrations(&pool)?;
Arc::new(pool) Ok(Arc::new(pool))
}); }
pub fn pool() -> Arc<r2d2::Pool<SqliteManager>> { /// Opens the SQLite pool and applies all schema migrations.
POOL.clone() ///
/// Daemon startup calls this after database verification and before storage is
/// reported healthy, so connection and migration failures become a storage
/// component failure instead of a lazy-initialization panic.
pub fn initialize_database() -> Result<(), StorageError> {
let storage_dir = iota_util::file_util::storage_directory();
std::fs::create_dir_all(&storage_dir)?;
let database_path = storage_dir.join(format!("{DB_NAME}.sqlite3"));
POOL.get_or_try_init(|| create_pool(database_path))?;
Ok(())
}
pub fn pool() -> Result<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
POOL.get()
.cloned()
.ok_or_else(|| StorageError::Other("database has not been initialized".into()))
} }
pub fn with_db<T, F>(f: F) -> Result<T, StorageError> pub fn with_db<T, F>(f: F) -> Result<T, StorageError>
@ -52,7 +78,8 @@ where
F: FnOnce(&Connection) -> Result<T, StorageError>, F: FnOnce(&Connection) -> Result<T, StorageError>,
{ {
blocking_region(|| { blocking_region(|| {
let conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; let pool = pool()?;
let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?;
f(&conn) f(&conn)
}) })
} }
@ -62,7 +89,8 @@ where
F: FnOnce(&Transaction<'_>) -> Result<T, StorageError>, F: FnOnce(&Transaction<'_>) -> Result<T, StorageError>,
{ {
blocking_region(|| { blocking_region(|| {
let mut conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; let pool = pool()?;
let mut conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let value = f(&tx)?; let value = f(&tx)?;
tx.commit()?; tx.commit()?;
@ -962,6 +990,18 @@ pub fn create_general_messages_db() -> Result<Arc<std::sync::Mutex<Connection>>,
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn pool_creation_failure_is_returned() -> Result<(), StorageError> {
let not_a_directory =
std::env::temp_dir().join(format!("iota-storage-pool-test-{}", std::process::id()));
std::fs::File::create(&not_a_directory)?;
let database_path = not_a_directory.join("messages.sqlite3");
assert!(create_pool_with_timeout(database_path, Duration::from_millis(1)).is_err());
std::fs::remove_file(not_a_directory)?;
Ok(())
}
#[test] #[test]
fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> { fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?; let conn = Connection::open_in_memory()?;

View file

@ -1,6 +1,52 @@
use crate::terms_getter::Type; use crate::terms_getter::Type;
use iota_util::crypto_helper::hex_hash; use iota_util::crypto_helper::hex_hash;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LegalDocument {
kind: Type,
content: String,
hash: String,
version: String,
timestamp: u64,
}
impl LegalDocument {
pub fn from_raw(kind: Type, content: String, timestamp: u64) -> Self {
Self {
kind,
hash: hex_hash(&content),
content,
version: timestamp.to_string(),
timestamp,
}
}
pub fn metadata(&self) -> Doc {
Doc::new(
self.version.clone(),
self.hash.clone(),
self.kind,
self.timestamp,
)
}
pub fn kind(&self) -> Type {
self.kind
}
pub fn content(&self) -> &str {
&self.content
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn version(&self) -> &str {
&self.version
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
#[allow(unused)] #[allow(unused)]
pub struct Doc { pub struct Doc {
@ -58,7 +104,7 @@ impl Doc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Doc; use super::{Doc, LegalDocument};
use crate::terms_getter::Type; use crate::terms_getter::Type;
use iota_util::crypto_helper::hex_hash; use iota_util::crypto_helper::hex_hash;
@ -73,6 +119,18 @@ mod tests {
assert_eq!(document.get_hash(), hex_hash(&content)); assert_eq!(document.get_hash(), hex_hash(&content));
} }
#[test]
fn legal_document_hash_is_derived_from_its_owned_content() {
let content = "# Privacy Policy\n".to_owned();
let document = LegalDocument::from_raw(Type::PP, content.clone(), 123);
assert_eq!(document.kind(), Type::PP);
assert_eq!(document.content(), content);
assert_eq!(document.hash(), hex_hash(document.content()));
assert_eq!(document.version(), "123");
assert_eq!(document.metadata().get_hash(), document.hash());
}
#[test] #[test]
fn matching_documents_ignore_the_fetch_timestamp() { fn matching_documents_ignore_the_fetch_timestamp() {
let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123); let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123);

View file

@ -10,4 +10,4 @@ pub use terms_getter::get_terms;
pub mod doc; pub mod doc;
pub use doc::Doc; pub use doc::{Doc, LegalDocument};

View file

@ -1,4 +1,4 @@
use crate::doc::Doc; use crate::doc::LegalDocument;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -41,7 +41,7 @@ pub fn get_newest_link(terms_type: Type) -> String {
get_link(terms_type) get_link(terms_type)
} }
pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { pub async fn get_current_docs() -> Option<(LegalDocument, LegalDocument, LegalDocument)> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let (eula, tos, privacy) = tokio::join!( let (eula, tos, privacy) = tokio::join!(
get_terms(Type::EULA), get_terms(Type::EULA),
@ -50,9 +50,9 @@ pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> {
); );
Some(( Some((
Doc::from_raw(Type::EULA, eula?, timestamp), LegalDocument::from_raw(Type::EULA, eula?, timestamp),
Doc::from_raw(Type::TOS, tos?, timestamp), LegalDocument::from_raw(Type::TOS, tos?, timestamp),
Doc::from_raw(Type::PP, privacy?, timestamp), LegalDocument::from_raw(Type::PP, privacy?, timestamp),
)) ))
} }

View file

@ -5,11 +5,15 @@ edition = "2024"
[dependencies] [dependencies]
iota-paths = { path = "../iota-paths" } iota-paths = { path = "../iota-paths" }
iota-ipc = { path = "../iota-ipc" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
sha2 = "0.11.0" sha2 = "0.11.0"
hex = "*" hex = "*"
serde = "1.0.228" serde = { version = "1.0.228", features = ["derive"] }
tempfile = "3.27.0" tempfile = "3.27.0"
anyhow = "1.0.102" anyhow = "1.0.102"
ed25519-dalek = "2.2.0" ed25519-dalek = "2.2.0"
serde_json = "1.0" serde_json = "1.0"
reqwest = "0.13.2"
fs2 = "0.4.3"
rusqlite = "0.40.0"

View file

@ -0,0 +1,3 @@
cli bin/iota
daemon bin/iota-daemon
updater bin/iota-updater
1 cli bin/iota
2 daemon bin/iota-daemon
3 updater bin/iota-updater

View file

@ -0,0 +1,50 @@
use anyhow::{Context, Result, bail};
use ed25519_dalek::{Signer, SigningKey};
use iota_updater::manifest::{ReleaseManifest, canonical_bytes, verify_signature};
use std::{fs, path::Path};
const SIGNING_KEY_ENV: &str = "IOTA_RELEASE_SIGNING_KEY";
fn main() -> Result<()> {
let mut arguments = std::env::args_os();
let _program = arguments.next();
let Some(command) = arguments.next() else {
bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig");
};
if command != "sign" {
bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig");
}
let manifest = arguments
.next()
.context("usage: iota-release sign MANIFEST.json MANIFEST.json.sig")?;
let signature = arguments
.next()
.context("usage: iota-release sign MANIFEST.json MANIFEST.json.sig")?;
if arguments.next().is_some() {
bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig");
}
let public_key = sign_manifest(Path::new(&manifest), Path::new(&signature))?;
println!("{}", hex::encode(public_key));
Ok(())
}
fn sign_manifest(manifest_path: &Path, signature_path: &Path) -> Result<[u8; 32]> {
let manifest_bytes = fs::read(manifest_path)
.with_context(|| format!("read release manifest {}", manifest_path.display()))?;
let manifest: ReleaseManifest =
serde_json::from_slice(&manifest_bytes).context("parse release manifest")?;
let key_text =
std::env::var(SIGNING_KEY_ENV).with_context(|| format!("{SIGNING_KEY_ENV} is required"))?;
let key_bytes = hex::decode(key_text.trim()).context("decode release signing key hex")?;
let signing_key_bytes: [u8; 32] = key_bytes
.try_into()
.map_err(|_| anyhow::anyhow!("release signing key must be 32 bytes"))?;
let signing_key = SigningKey::from_bytes(&signing_key_bytes);
let canonical = canonical_bytes(&manifest)?;
let signature = signing_key.sign(&canonical);
let public_key = signing_key.verifying_key().to_bytes();
verify_signature(&manifest, &signature.to_bytes(), &public_key)?;
fs::write(signature_path, hex::encode(signature.to_bytes()))
.with_context(|| format!("write release signature {}", signature_path.display()))?;
Ok(public_key)
}

View file

@ -1,13 +1,520 @@
pub mod manifest; pub mod manifest;
pub mod transaction; pub mod transaction;
use anyhow::Result; use anyhow::{Context, Result, bail};
use manifest::{Artifact, ReleaseManifest, verify_signature};
use std::{fs, path::Path};
use transaction::UpdateTransaction;
/// Compatibility entry point used by the UI. Updates are now manifest-driven; const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST";
/// this function only checks and never replaces the invoking executable. const SIGNATURE_ENV: &str = "IOTA_UPDATE_SIGNATURE";
const PUBLIC_KEY_ENV: &str = "IOTA_UPDATE_PUBLIC_KEY";
const REQUIRED_HOST_ARTIFACTS: &str = include_str!("../artifacts.tsv");
/// Reads the configured signed release manifest and reports whether it differs
/// from the release currently selected by the installation's `current` link.
/// A configured manifest always requires `IOTA_UPDATE_PUBLIC_KEY` (32-byte hex)
/// and a hex signature, supplied by `IOTA_UPDATE_SIGNATURE` or `<manifest>.sig`.
pub async fn check_update() -> Result<bool> { pub async fn check_update() -> Result<bool> {
if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() { let Some(release) = configured_release().await? else {
return Ok(false);
};
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
Ok(current_version(&paths.install_root)?.as_deref() != Some(&release.manifest.product_version))
}
/// Downloads, verifies, stages, and atomically activates the configured release.
/// Returns `false` when no manifest is configured or it already is current.
pub async fn apply_update() -> Result<bool> {
let Some(release) = configured_release().await? else {
return Ok(false);
};
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
if current_version(&paths.install_root)?.as_deref() == Some(&release.manifest.product_version) {
return Ok(false); return Ok(false);
} }
Ok(false) validate_manifest_compatibility(
&release.manifest,
&paths.database_file(),
iota_ipc::MIN_PROTOCOL_VERSION,
iota_ipc::PROTOCOL_VERSION,
)?;
let transaction = UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
if transaction.staging.exists() {
fs::remove_dir_all(&transaction.staging).with_context(|| {
format!(
"remove stale update staging directory {}",
transaction.staging.display()
)
})?;
}
for artifact in &release.artifacts {
let source = download_to_temporary_file(&artifact.url).await?;
transaction.stage_artifact(source.path(), artifact)?;
}
fs::write(
transaction.staging.join("manifest.json"),
serde_json::to_vec_pretty(&release.manifest)?,
)
.context("write staged release manifest")?;
transaction.activate(&release.manifest.product_version)?;
Ok(true)
}
/// Switches `current` to an already-installed release version.
pub fn rollback(version: &str) -> Result<()> {
validate_version(version)?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
let transaction = UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
let release_dir = transaction.root.join("versions").join(version);
if !release_dir.is_dir() {
bail!(
"installed release version does not exist: {}",
release_dir.display()
);
}
let active_manifest = read_installed_manifest(&transaction.root.join("current"))?;
let target_manifest = read_installed_manifest(&release_dir)?;
validate_rollback_manifests(&active_manifest, &target_manifest, version)?;
validate_manifest_compatibility(
&target_manifest,
&paths.database_file(),
iota_ipc::MIN_PROTOCOL_VERSION,
iota_ipc::PROTOCOL_VERSION,
)?;
transaction.rollback(version)
}
struct ConfiguredRelease {
manifest: ReleaseManifest,
artifacts: Vec<Artifact>,
}
async fn configured_release() -> Result<Option<ConfiguredRelease>> {
let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else {
return Ok(None);
};
let manifest_location = manifest_location
.into_string()
.map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?;
if manifest_location.is_empty() {
bail!("{MANIFEST_ENV} must not be empty");
}
let signature_location =
std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig"));
let key_text = std::env::var(PUBLIC_KEY_ENV)
.with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?;
configured_release_from_locations(&manifest_location, &signature_location, &key_text)
.await
.map(Some)
}
async fn configured_release_from_locations(
manifest_location: &str,
signature_location: &str,
key_text: &str,
) -> Result<ConfiguredRelease> {
let manifest_bytes = read_location(manifest_location).await?;
let manifest: ReleaseManifest =
serde_json::from_slice(&manifest_bytes).context("parse release manifest")?;
validate_version(&manifest.product_version)?;
let signature_text = String::from_utf8(read_location(signature_location).await?)
.context("release signature must be UTF-8 hex")?;
let signature = hex::decode(signature_text.trim()).context("decode release signature hex")?;
let key = hex::decode(key_text.trim()).context("decode release public key hex")?;
let public_key: [u8; 32] = key
.try_into()
.map_err(|_| anyhow::anyhow!("release public key must be 32 bytes"))?;
verify_signature(&manifest, &signature, &public_key)?;
let artifacts = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)?;
Ok(ConfiguredRelease {
manifest,
artifacts,
})
}
fn select_host_artifacts(
manifest: &ReleaseManifest,
operating_system: &str,
architecture: &str,
) -> Result<Vec<Artifact>> {
let requirements = required_host_artifacts()?;
let artifacts: Vec<Artifact> = manifest
.artifacts
.iter()
.filter(|artifact| artifact.os == operating_system && artifact.architecture == architecture)
.cloned()
.collect();
for artifact in &artifacts {
if !requirements
.iter()
.any(|(role, path)| *role == artifact.role.as_str() && *path == artifact.path.as_str())
{
bail!(
"release has unexpected artifact role/path for {operating_system}/{architecture}: {}/{}",
artifact.role,
artifact.path
);
}
}
for (role, path) in &requirements {
let count = artifacts
.iter()
.filter(|artifact| artifact.role == *role && artifact.path == *path)
.count();
if count != 1 {
bail!(
"release must contain exactly one {role} artifact at {path} for {operating_system}/{architecture}; found {count}"
);
}
}
if artifacts.len() != requirements.len() {
bail!(
"release has an invalid artifact count for {operating_system}/{architecture}: expected {}, found {}",
requirements.len(),
artifacts.len()
);
}
Ok(artifacts)
}
fn required_host_artifacts() -> Result<Vec<(&'static str, &'static str)>> {
REQUIRED_HOST_ARTIFACTS
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('\t')
.context("invalid embedded updater artifact contract")
})
.collect()
}
async fn read_location(location: &str) -> Result<Vec<u8>> {
if location.starts_with("https://") || location.starts_with("http://") {
let response = reqwest::get(location)
.await
.with_context(|| format!("download {location}"))?
.error_for_status()
.with_context(|| format!("download {location}"))?;
return Ok(response.bytes().await?.to_vec());
}
let path = location.strip_prefix("file://").unwrap_or(location);
fs::read(path).with_context(|| format!("read update input {path}"))
}
async fn download_to_temporary_file(location: &str) -> Result<tempfile::NamedTempFile> {
let file = tempfile::NamedTempFile::new().context("create temporary update artifact")?;
fs::write(file.path(), read_location(location).await?)
.with_context(|| format!("write downloaded update artifact from {location}"))?;
Ok(file)
}
fn current_version(install_root: &Path) -> Result<Option<String>> {
let manifest_path = install_root.join("current/manifest.json");
if !manifest_path.exists() {
return Ok(None);
}
let bytes = fs::read(&manifest_path).with_context(|| {
format!(
"read installed release manifest {}",
manifest_path.display()
)
})?;
let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| {
format!(
"parse installed release manifest {}",
manifest_path.display()
)
})?;
Ok(value
.get("product_version")
.and_then(|value| value.as_str())
.map(str::to_owned))
}
fn read_installed_manifest(release_dir: &Path) -> Result<ReleaseManifest> {
let manifest_path = release_dir.join("manifest.json");
let bytes = fs::read(&manifest_path).with_context(|| {
format!(
"read installed release manifest {}",
manifest_path.display()
)
})?;
serde_json::from_slice(&bytes).with_context(|| {
format!(
"parse installed release manifest {}",
manifest_path.display()
)
})
}
fn validate_manifest_compatibility(
manifest: &ReleaseManifest,
database_path: &Path,
installed_ipc_min: u16,
installed_ipc_max: u16,
) -> Result<()> {
if manifest.supported_ipc_min > manifest.supported_ipc_max {
bail!(
"release has an invalid IPC range: {}..={}",
manifest.supported_ipc_min,
manifest.supported_ipc_max
);
}
if installed_ipc_min > installed_ipc_max {
bail!("installed Iota has an invalid IPC compatibility range");
}
if manifest.supported_ipc_max < installed_ipc_min
|| manifest.supported_ipc_min > installed_ipc_max
{
bail!(
"release IPC range {}..={} is incompatible with installed range {}..={}",
manifest.supported_ipc_min,
manifest.supported_ipc_max,
installed_ipc_min,
installed_ipc_max
);
}
if let Some(installed_schema) = installed_data_schema(database_path)?
&& installed_schema < manifest.minimum_data_schema
{
bail!(
"release requires data schema {} or newer, but installed database uses schema {}",
manifest.minimum_data_schema,
installed_schema
);
}
Ok(())
}
fn validate_rollback_manifests(
active: &ReleaseManifest,
target: &ReleaseManifest,
requested_version: &str,
) -> Result<()> {
if !active.rollback_compatible {
bail!(
"active release {} does not permit rollback",
active.product_version
);
}
if target.product_version != requested_version {
bail!(
"rollback target manifest version {} does not match requested version {requested_version}",
target.product_version
);
}
Ok(())
}
fn installed_data_schema(database_path: &Path) -> Result<Option<u64>> {
if !database_path.exists() {
return Ok(None);
}
let connection = rusqlite::Connection::open_with_flags(
database_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| format!("open installed database {}", database_path.display()))?;
let user_version: i64 = connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.with_context(|| format!("read data schema from {}", database_path.display()))?;
let user_version = user_version.try_into().with_context(|| {
format!(
"installed database has a negative data schema: {}",
database_path.display()
)
})?;
Ok(Some(user_version))
}
fn validate_version(version: &str) -> Result<()> {
let mut characters = version.chars();
if !characters
.next()
.is_some_and(|character| character.is_ascii_alphanumeric())
|| !characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-')
})
{
bail!("release product_version contains unsupported characters");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn artifact(role: &str, path: &str, os: &str, architecture: &str) -> Artifact {
Artifact {
role: role.into(),
os: os.into(),
architecture: architecture.into(),
path: path.into(),
url: format!("file:///release/{}", path.replace('/', "-")),
sha256: "00".repeat(32),
size: 1,
}
}
fn release_manifest(artifacts: Vec<Artifact>) -> ReleaseManifest {
ReleaseManifest {
product_version: "1.2.3".into(),
channel: "stable".into(),
published_at: "2026-09-10T00:00:00Z".into(),
minimum_data_schema: 1,
supported_ipc_min: 1,
supported_ipc_max: 1,
artifacts,
release_signing_key_id: "test".into(),
rollback_compatible: true,
}
}
fn host_artifacts() -> Vec<Artifact> {
required_host_artifacts()
.unwrap()
.into_iter()
.map(|(role, path)| artifact(role, path, std::env::consts::OS, std::env::consts::ARCH))
.collect()
}
#[tokio::test]
async fn loads_a_signed_manifest_and_selects_complete_host_release() {
let directory = tempfile::tempdir().unwrap();
let mut artifacts = host_artifacts();
artifacts.push(artifact("daemon", "bin/iota-daemon", "other", "other"));
let manifest = release_manifest(artifacts);
let signing_key = SigningKey::from_bytes(&[7; 32]);
let signature = signing_key.sign(&manifest::canonical_bytes(&manifest).unwrap());
let manifest_path = directory.path().join("manifest.json");
let signature_path = directory.path().join("manifest.json.sig");
fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
fs::write(&signature_path, hex::encode(signature.to_bytes())).unwrap();
let release = configured_release_from_locations(
manifest_path.to_str().unwrap(),
signature_path.to_str().unwrap(),
&hex::encode(signing_key.verifying_key().to_bytes()),
)
.await
.unwrap();
assert_eq!(release.manifest.product_version, "1.2.3");
assert_eq!(release.artifacts.len(), 3);
assert!(
release
.artifacts
.iter()
.all(|artifact| artifact.os == std::env::consts::OS)
);
}
#[test]
fn rejects_host_release_missing_an_executable() {
let mut artifacts = host_artifacts();
artifacts.retain(|artifact| artifact.path != "bin/iota-updater");
let manifest = release_manifest(artifacts);
let error = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)
.unwrap_err();
assert!(error.to_string().contains("bin/iota-updater"));
}
#[test]
fn rejects_duplicate_or_unexpected_host_artifacts() {
let mut duplicate = host_artifacts();
duplicate.push(duplicate[0].clone());
let duplicate_error = select_host_artifacts(
&release_manifest(duplicate),
std::env::consts::OS,
std::env::consts::ARCH,
)
.unwrap_err();
assert!(duplicate_error.to_string().contains("exactly one"));
let mut unexpected = host_artifacts();
unexpected.push(artifact(
"helper",
"bin/iota-helper",
std::env::consts::OS,
std::env::consts::ARCH,
));
let unexpected_error = select_host_artifacts(
&release_manifest(unexpected),
std::env::consts::OS,
std::env::consts::ARCH,
)
.unwrap_err();
assert!(unexpected_error.to_string().contains("unexpected artifact"));
}
#[test]
fn rejects_manifest_without_an_overlapping_ipc_range() {
let directory = tempfile::tempdir().unwrap();
let mut manifest = release_manifest(host_artifacts());
manifest.supported_ipc_min = 5;
manifest.supported_ipc_max = 6;
let error =
validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4)
.unwrap_err();
assert!(error.to_string().contains("incompatible"));
}
#[test]
fn rejects_manifest_requiring_a_newer_installed_data_schema() {
let directory = tempfile::tempdir().unwrap();
let database = directory.path().join("messages.sqlite3");
let connection = rusqlite::Connection::open(&database).unwrap();
connection.pragma_update(None, "user_version", 25).unwrap();
let mut manifest = release_manifest(host_artifacts());
manifest.minimum_data_schema = 26;
manifest.supported_ipc_min = 2;
manifest.supported_ipc_max = 4;
let error = validate_manifest_compatibility(&manifest, &database, 2, 4).unwrap_err();
assert!(
error
.to_string()
.contains("installed database uses schema 25")
);
}
#[test]
fn accepts_compatible_manifest_when_no_database_exists() {
let directory = tempfile::tempdir().unwrap();
let mut manifest = release_manifest(host_artifacts());
manifest.minimum_data_schema = 26;
manifest.supported_ipc_min = 4;
manifest.supported_ipc_max = 5;
validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4)
.unwrap();
}
#[test]
fn rejects_rollback_when_active_release_disallows_it() {
let mut active = release_manifest(host_artifacts());
active.rollback_compatible = false;
let mut target = release_manifest(host_artifacts());
target.product_version = "1.1.0".into();
let error = validate_rollback_manifests(&active, &target, "1.1.0").unwrap_err();
assert!(error.to_string().contains("does not permit rollback"));
}
} }

View file

@ -4,16 +4,19 @@ use anyhow::Result;
async fn main() -> Result<()> { async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() { match command.as_str() {
"check" => println!("update check is manifest-driven"), "check" => println!("{}", iota_updater::check_update().await?),
"status" => println!("updater ready"), "status" => println!("updater ready"),
"apply" | "rollback" => { "apply" => println!("{}", iota_updater::apply_update().await?),
return Err(anyhow::anyhow!( "rollback" => {
"explicit signed transaction input is required" let version = std::env::args()
)); .nth(2)
.ok_or_else(|| anyhow::anyhow!("usage: iota-updater rollback VERSION"))?;
iota_updater::rollback(&version)?;
println!("rolled back to {version}");
} }
_ => { _ => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback" "usage: iota-updater check|status|apply|rollback VERSION"
)); ));
} }
} }

View file

@ -1,8 +1,9 @@
use crate::manifest::{Artifact, verify_artifact}; use crate::manifest::{Artifact, verify_artifact};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use fs2::FileExt;
use std::{ use std::{
fs, fs,
path::{Path, PathBuf}, path::{Component, Path, PathBuf},
}; };
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -27,31 +28,58 @@ impl UpdateTransaction {
lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?, lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?,
}) })
} }
pub fn acquire(&self) -> Result<fs::File> { pub fn acquire(&self) -> Result<UpdateLock> {
if let Some(parent) = self.lock_file.parent() { if let Some(parent) = self.lock_file.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let file = fs::OpenOptions::new() let file = fs::OpenOptions::new()
.read(true)
.write(true) .write(true)
.create_new(true) .create(true)
.truncate(false)
.open(&self.lock_file) .open(&self.lock_file)
.with_context(|| format!("open update lock {}", self.lock_file.display()))?;
file.try_lock_exclusive()
.context("update already in progress")?; .context("update already in progress")?;
Ok(file) Ok(UpdateLock { _file: file })
} }
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);
if artifact_path.is_absolute()
|| artifact_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
anyhow::bail!(
"artifact path must be a relative file path: {}",
artifact.path
);
}
fs::create_dir_all(&self.staging)?; fs::create_dir_all(&self.staging)?;
let target = self.staging.join(&artifact.path); let target = self.staging.join(artifact_path);
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
fs::copy(source, &target)?; fs::copy(source, &target)?;
verify_artifact(&target, artifact)?; verify_artifact(&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<()> {
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().unwrap())?;
fs::rename(&self.staging, &version_dir).context("activate staged release")?; if version_dir.exists() {
anyhow::bail!("release version already exists: {}", version_dir.display());
}
match fs::rename(&self.staging, &version_dir) {
Ok(()) => {}
Err(error) if error.raw_os_error() == Some(cross_device_link_error()) => {
copy_directory(&self.staging, &version_dir)?;
fs::remove_dir_all(&self.staging)
.context("remove copied update staging directory")?;
}
Err(error) => return Err(error).context("activate staged release"),
}
let current_tmp = self.root.join("current.new"); let current_tmp = self.root.join("current.new");
let _ = fs::remove_file(&current_tmp); let _ = fs::remove_file(&current_tmp);
std::os::unix::fs::symlink(&version_dir, &current_tmp)?; std::os::unix::fs::symlink(&version_dir, &current_tmp)?;
@ -68,16 +96,79 @@ impl UpdateTransaction {
} }
} }
#[cfg(unix)]
fn set_executable_if_binary(path: &Path, artifact: &Artifact) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if Path::new(&artifact.path)
.components()
.next()
.is_some_and(|component| component.as_os_str() == "bin")
{
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(not(unix))]
fn set_executable_if_binary(_path: &Path, _artifact: &Artifact) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn cross_device_link_error() -> i32 {
18
}
#[cfg(not(unix))]
fn cross_device_link_error() -> i32 {
-1
}
fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("create release directory {}", destination.display()))?;
for entry in fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_directory(&source_path, &destination_path)?;
} else {
fs::copy(&source_path, &destination_path).with_context(|| {
format!(
"copy staged artifact {} to {}",
source_path.display(),
destination_path.display()
)
})?;
fs::set_permissions(&destination_path, fs::metadata(&source_path)?.permissions())?;
}
}
Ok(())
}
#[derive(Debug)]
pub struct UpdateLock {
// Closing this file releases its advisory lock. The lock file stays in place
// so a process crash cannot leave a stale create-only lock behind.
_file: fs::File,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn lock_is_exclusive_and_activation_switches_current() { fn lock_is_exclusive_reusable_and_activation_switches_current() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let tx = UpdateTransaction::new(dir.path()); let tx = UpdateTransaction::new(dir.path());
let lock = tx.acquire().unwrap(); let lock = tx.acquire().unwrap();
assert!(tx.acquire().is_err()); assert!(tx.acquire().is_err());
drop(lock); drop(lock);
assert!(tx.acquire().is_ok());
assert!(tx.lock_file.exists());
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(); tx.activate("1.0.0").unwrap();

View file

@ -286,10 +286,10 @@ async fn run_dashboard(
let result = async { let result = async {
let consent = iota_core::consent_state::check(ui.clone()).await let consent = iota_core::consent_state::check(ui.clone()).await
.map_err(StartupError::Consent)?; .map_err(StartupError::Consent)?;
if consent != (true, true) { if !consent.accepted_eula || !consent.accepted_services {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
} }
persist_dashboard_consent().await?; persist_dashboard_consent(&consent.documents)?;
let initial = tokio::select! { let initial = tokio::select! {
result = connect_available(&endpoints) => result, result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
@ -340,15 +340,14 @@ async fn run_dashboard(
} }
} }
async fn persist_dashboard_consent() -> Result<(), StartupError> { fn persist_dashboard_consent(
let docs = iota_terms::get_current_docs().await.ok_or_else(|| { documents: &[iota_terms::LegalDocument; 3],
StartupError::Consent("Could not verify the current agreements after acceptance.".into()) ) -> Result<(), StartupError> {
})?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?; .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
let mut record = iota_terms::consent::load(&paths.state_dir); let mut record = iota_terms::consent::load(&paths.state_dir);
for document in [&docs.0, &docs.1, &docs.2] { for document in documents {
record.accept(document); record.accept(&document.metadata());
} }
iota_terms::consent::save(&paths.state_dir, &record) iota_terms::consent::save(&paths.state_dir, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}"))) .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))

View file

@ -1,5 +1,5 @@
use crate::startup_error::StartupError; use crate::startup_error::StartupError;
use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms}; use iota_terms::{LegalDocument, TermsType, consent, get_current_docs, get_terms};
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
pub enum TermsCommand { pub enum TermsCommand {
@ -71,25 +71,19 @@ async fn accept(system: bool) -> Result<(), StartupError> {
})?; })?;
let mut record = consent::load(&state_dir(system)?); let mut record = consent::load(&state_dir(system)?);
for document in [&documents.0, &documents.1, &documents.2] { for document in [&documents.0, &documents.1, &documents.2] {
let text = get_terms(document.doc_type).await.ok_or_else(|| {
StartupError::Consent(format!(
"Could not fetch {}.",
document.doc_type.to_string()
))
})?;
println!( println!(
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n", "\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
document.doc_type.to_string(), document.kind().to_string(),
document.get_version(), document.version(),
document.get_hash() document.hash()
); );
print!("{text}\n"); print!("{}\n", document.content());
if !confirm(document)? { if !confirm(document)? {
return Err(StartupError::Consent( return Err(StartupError::Consent(
"No terms were accepted. Iota remains inactive.".into(), "No terms were accepted. Iota remains inactive.".into(),
)); ));
} }
record.accept(document); record.accept(&document.metadata());
} }
consent::save(&state_dir(system)?, &record) consent::save(&state_dir(system)?, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?; .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
@ -97,13 +91,12 @@ async fn accept(system: bool) -> Result<(), StartupError> {
Ok(()) Ok(())
} }
fn confirm(document: &Doc) -> Result<bool, StartupError> { fn confirm(document: &LegalDocument) -> Result<bool, StartupError> {
let hash = document.get_hash(); let prefix = document.hash().get(..10).unwrap_or(document.hash());
let prefix = hash.get(..10).unwrap_or(&hash);
let expected = format!( let expected = format!(
"ACCEPT {} {} {}", "ACCEPT {} {} {}",
document.doc_type.to_str().to_ascii_uppercase(), document.kind().to_str().to_ascii_uppercase(),
document.get_version(), document.version(),
prefix prefix
); );
print!("To accept this exact document, type:\n{expected}\n> "); print!("To accept this exact document, type:\n{expected}\n> ");

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 6 ]]; then
echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION UPDATE_MANIFEST_URL UPDATE_PUBLIC_KEY UPDATE_SIGNATURE_URL OUTPUT.zip" >&2
exit 2
fi
binary_directory="$1"
product_version="$2"
update_manifest_url="$3"
update_public_key="$4"
update_signature_url="$5"
output="$6"
repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
contract="$repository_directory/iota-installer/bundle-files.txt"
staging_directory="$(mktemp -d)"
trap 'rm -rf "$staging_directory"' EXIT
if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "product version contains unsupported characters: $product_version" >&2
exit 2
fi
mkdir -p "$(dirname "$output")"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
bundle_files=()
while IFS= read -r bundle_path; do
[[ -n "$bundle_path" ]] || continue
bundle_files+=("$bundle_path")
destination="$staging_directory/$bundle_path"
mkdir -p "$(dirname "$destination")"
case "$bundle_path" in
bin/*)
install -m 0755 "$binary_directory/${bundle_path#bin/}" "$destination"
;;
manifest.json)
printf '{"product_version":"%s"}\n' "$product_version" > "$destination"
;;
systemd/update.env)
printf 'IOTA_UPDATE_MANIFEST=%s\nIOTA_UPDATE_PUBLIC_KEY=%s\nIOTA_UPDATE_SIGNATURE=%s\n' \
"$update_manifest_url" \
"$update_public_key" \
"$update_signature_url" \
> "$destination"
;;
*)
install -m 0644 "$repository_directory/$bundle_path" "$destination"
;;
esac
done < "$contract"
(
cd "$staging_directory"
zip -q "$output" "${bundle_files[@]}"
)

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 8 ]]; then
echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION CHANNEL PUBLISHED_AT OS ARCHITECTURE BASE_URL OUTPUT.json" >&2
exit 2
fi
binary_directory="$1"
product_version="$2"
channel="$3"
published_at="$4"
operating_system="$5"
architecture="$6"
base_url="$(printf '%s' "$7" | sed 's#/$##')"
output="$8"
repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
contract="$repository_directory/iota-updater/artifacts.tsv"
staging_directory="$(mktemp -d)"
artifacts="$staging_directory/artifacts.jsonl"
trap 'rm -rf "$staging_directory"' EXIT
while IFS=$'\t' read -r role artifact_path; do
[[ -n "$role" && -n "$artifact_path" ]] || continue
asset_name="$(basename "$artifact_path")"
source_path="$binary_directory/$asset_name"
jq -n \
--arg role "$role" \
--arg os "$operating_system" \
--arg architecture "$architecture" \
--arg path "$artifact_path" \
--arg url "$base_url/$asset_name" \
--arg sha256 "$(sha256sum "$source_path" | cut -d ' ' -f 1)" \
--argjson size "$(stat -c %s "$source_path")" \
'{role: $role, os: $os, architecture: $architecture, path: $path, url: $url, sha256: $sha256, size: $size}' \
>> "$artifacts"
done < "$contract"
mkdir -p "$(dirname "$output")"
jq -s \
--arg product_version "$product_version" \
--arg channel "$channel" \
--arg published_at "$published_at" \
--arg release_signing_key_id "primary" \
'{
product_version: $product_version,
channel: $channel,
published_at: $published_at,
minimum_data_schema: 1,
supported_ipc_min: 2,
supported_ipc_max: 4,
artifacts: .,
release_signing_key_id: $release_signing_key_id,
rollback_compatible: true
}' "$artifacts" > "$output"

View file

@ -6,9 +6,10 @@ Requires=iota-daemon.socket
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/lib/iota/iota-daemon ExecStart=@IOTA_SYSTEM_DAEMON_EXECUTABLE@
User=iota User=iota
Group=iota Group=iota
EnvironmentFile=/etc/iota/update.env
StateDirectory=iota StateDirectory=iota
StateDirectoryMode=0750 StateDirectoryMode=0750
Restart=on-failure Restart=on-failure

View file

@ -0,0 +1,9 @@
[Unit]
Description=Apply signed Tensamin Iota updates
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/iota/update.env
ExecStart=/usr/local/libexec/iota/current/bin/iota-updater apply

11
systemd/iota-update.timer Normal file
View file

@ -0,0 +1,11 @@
[Unit]
Description=Check for Tensamin Iota updates daily
[Timer]
OnBootSec=15min
OnUnitActiveSec=24h
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target