[Fix] IPC, cli, daemon

This commit is contained in:
Alex-Emmet 2026-07-21 23:05:34 +02:00
commit 56aad3a023
32 changed files with 1356 additions and 399 deletions

View file

@ -37,16 +37,19 @@ jobs:
DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')"
printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin" printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin"
- name: Build & Push - name: Build & Push Docker image
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 binary - name: Build release binaries
run: | run: |
set -eu set -eu
nix build .#iota --print-build-logs nix build .#iota-daemon --print-build-logs
install -Dm755 result/bin/iota dist/iota 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
@ -55,7 +58,7 @@ jobs:
run: | run: |
set -eu set -eu
VERSION="$(nix eval --raw .#iota.version)" VERSION="$(nix eval --raw .#iota-daemon.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
@ -73,18 +76,15 @@ jobs:
;; ;;
esac esac
ASSET_PATH="dist/iota"
ASSET_NAME="iota"
test -x "$ASSET_PATH"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT" echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
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"
echo "asset_path=$ASSET_PATH" >> "$FORGEJO_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$FORGEJO_OUTPUT"
- name: Create release and upload binary test -x "dist/iota-daemon"
test -x "dist/iota-ui"
- name: Create release and upload binaries
env: env:
TOKEN: ${{ forgejo.token }} TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }} API: ${{ forgejo.api_url }}
@ -93,8 +93,6 @@ 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_PATH: ${{ steps.version.outputs.asset_path }}
ASSET_NAME: ${{ steps.version.outputs.asset_name }}
DESCRIPTION: ${{ inputs.description }} DESCRIPTION: ${{ inputs.description }}
run: | run: |
nix-shell -p curl jq --run ' nix-shell -p curl jq --run '
@ -122,7 +120,11 @@ 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=$ASSET_NAME" \ curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$ASSET_PATH" -F "attachment=@dist/iota-daemon"
'
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/iota-ui"
'

5
.gitignore vendored
View file

@ -2,3 +2,8 @@ target
logs logs
agreements agreements
languages/ languages/
.envrc
.direnv
config.json
*.mk
*.sqlite*

11
Cargo.lock generated
View file

@ -2091,6 +2091,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"iota-cli", "iota-cli",
"tokio", "tokio",
"tokio-util",
] ]
[[package]] [[package]]
@ -2186,6 +2187,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tokio-util",
"tungstenite", "tungstenite",
"walkdir", "walkdir",
"warp", "warp",
@ -2213,6 +2215,7 @@ dependencies = [
"iota-storage", "iota-storage",
"omikron-connector", "omikron-connector",
"tokio", "tokio",
"tokio-util",
"web-server", "web-server",
] ]
@ -2220,15 +2223,19 @@ dependencies = [
name = "iota-daemon-lib" name = "iota-daemon-lib"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dashmap",
"iota-ipc", "iota-ipc",
"iota-logger", "iota-logger",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
"iota-util", "iota-util",
"libc",
"mtp", "mtp",
"omikron-connector", "omikron-connector",
"sysinfo", "sysinfo",
"tokio", "tokio",
"tokio-util",
"uuid",
] ]
[[package]] [[package]]
@ -3082,6 +3089,7 @@ dependencies = [
"reqwest", "reqwest",
"sha2 0.10.9", "sha2 0.10.9",
"tokio", "tokio",
"tokio-util",
"uuid", "uuid",
"x448", "x448",
] ]
@ -4898,6 +4906,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"futures-util",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@ -5263,10 +5272,10 @@ dependencies = [
"bytes", "bytes",
"http 1.4.2", "http 1.4.2",
"iota-logger", "iota-logger",
"iota-state",
"iota-util", "iota-util",
"mtp", "mtp",
"tokio", "tokio",
"tokio-util",
] ]
[[package]] [[package]]

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@ FROM rust:latest AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release RUN cargo build --release -p iota-daemon
# Runtime stage # Runtime stage
FROM debian:sid FROM debian:sid
@ -13,8 +13,10 @@ WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/iota-core . COPY --from=builder /app/target/release/iota-daemon .
RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota
EXPOSE 1984 EXPOSE 1984
CMD ["./iota-core"] CMD ["./iota-daemon"]

View file

@ -38,10 +38,13 @@
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"]; extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"];
}; };
commonBuildInputs = with pkgs; [openssl sqlite];
commonNativeBuildInputs = with pkgs; [cmake perl pkg-config];
in { in {
packages = { packages = {
default = self'.packages.iota; default = self'.packages.iota-daemon;
iota = pkgs.rustPlatform.buildRustPackage {
iota-daemon = pkgs.rustPlatform.buildRustPackage {
pname = "iota-daemon"; pname = "iota-daemon";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
@ -50,8 +53,8 @@
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true; allowBuiltinFetchGit = true;
}; };
nativeBuildInputs = with pkgs; [cmake perl pkg-config]; nativeBuildInputs = commonNativeBuildInputs;
buildInputs = with pkgs; [openssl sqlite]; buildInputs = commonBuildInputs;
dontUseCmakeConfigure = true; dontUseCmakeConfigure = true;
postInstall = '' postInstall = ''
for f in $out/bin/*; do for f in $out/bin/*; do
@ -62,11 +65,36 @@
''; '';
passthru.dataDir = "/var/lib/iota"; passthru.dataDir = "/var/lib/iota";
}; };
iota-ui = pkgs.rustPlatform.buildRustPackage {
pname = "iota-ui";
version = "0.1.0";
src = ./.;
cargoBuildFlags = ["-p" "iota"];
cargoLock = {
lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true;
};
nativeBuildInputs = commonNativeBuildInputs;
buildInputs = commonBuildInputs;
dontUseCmakeConfigure = true;
postInstall = ''
for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota" ]; then
rm "$f"
fi
done
# Rename to avoid confusion
if [ -f "$out/bin/iota" ]; then
mv "$out/bin/iota" "$out/bin/iota-ui"
fi
'';
};
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config]; nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config];
buildInputs = with pkgs; [openssl sqlite]; buildInputs = commonBuildInputs;
}; };
}; };
@ -78,7 +106,7 @@
... ...
}: let }: let
cfg = config.services.iota; cfg = config.services.iota;
defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}");
configFile = configFile =
if cfg.settingsFile != null if cfg.settingsFile != null
@ -158,14 +186,27 @@
users.groups.iota = {}; users.groups.iota = {};
systemd.services.iota = { systemd.sockets.iota-daemon = {
description = "${descriptionText} IPC socket";
wantedBy = ["sockets.target"];
socketConfig = {
ListenStream = "/run/iota/iota.sock";
SocketMode = "0660";
SocketUser = "iota";
SocketGroup = "iota";
Backlog = 5;
RemoveOnStop = "true";
};
};
systemd.services.iota-daemon = {
description = descriptionText; description = descriptionText;
wantedBy = ["multi-user.target"];
after = ["network.target"]; after = ["network.target"];
requires = ["iota-daemon.socket"];
serviceConfig = serviceConfig =
{ {
Type = "simple"; Type = "notify";
User = "iota"; User = "iota";
Group = "iota"; Group = "iota";
WorkingDirectory = cfg.dataDir; WorkingDirectory = cfg.dataDir;
@ -186,11 +227,19 @@
'') '')
]; ];
Restart = "always"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
RuntimeDirectory = "iota"; RuntimeDirectory = "iota";
RuntimeDirectoryMode = "0750"; RuntimeDirectoryMode = "0750";
# Exit code 75 = restart requested
RestartPreventExitStatus = "0";
RestartForceExitStatus = "75";
TimeoutStopSec = "10";
KillMode = "mixed";
KillSignal = "SIGTERM";
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];

View file

@ -17,7 +17,7 @@ iota-logger = { path = "../iota-logger", optional = true }
iota-state = { path = "../iota-state" } iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage", optional = true } iota-storage = { path = "../iota-storage", optional = true }
iota-terms = { path = "../iota-terms" } iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util", optional = true } iota-util = { path = "../iota-util", optional = true }
iota-ipc = { path = "../iota-ipc" } iota-ipc = { path = "../iota-ipc" }
omikron-connector = { path = "../omikron-connector", optional = true } omikron-connector = { path = "../omikron-connector", optional = true }
@ -64,6 +64,7 @@ strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
sysinfo = "0.38.3" sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
tokio-tungstenite = { version = "*", features = ["native-tls"] } tokio-tungstenite = { version = "*", features = ["native-tls"] }
tungstenite = "*" tungstenite = "*"
walkdir = "2.5.0" walkdir = "2.5.0"

View file

@ -1,18 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
#[cfg(feature = "legacy-commands")]
use iota_logger::{log, log_cv};
#[cfg(feature = "legacy-commands")]
use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN};
#[cfg(feature = "legacy-commands")]
use iota_storage::users::{user_manager, user_profile::UserProfile};
#[cfg(feature = "legacy-commands")]
use iota_storage::util::config_util::modify_config;
#[cfg(feature = "legacy-commands")]
use iota_util::file_util;
#[cfg(feature = "legacy-commands")]
use mtp::codec::{CommunicationType, CommunicationValue};
#[cfg(feature = "legacy-commands")]
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
@ -47,7 +33,7 @@ pub struct ConsoleCard {
cursor: Arc<Mutex<bool>>, cursor: Arc<Mutex<bool>>,
last_swap: Arc<Mutex<Instant>>, last_swap: Arc<Mutex<Instant>>,
tab_index: usize, pending_restore: Arc<Mutex<Option<String>>>,
} }
impl ConsoleCard { impl ConsoleCard {
@ -62,7 +48,7 @@ impl ConsoleCard {
joins: Borders::NONE, joins: Borders::NONE,
cursor: Arc::new(Mutex::new(true)), cursor: Arc::new(Mutex::new(true)),
last_swap: Arc::new(Mutex::new(Instant::now())), last_swap: Arc::new(Mutex::new(Instant::now())),
tab_index: 0, pending_restore: Arc::new(Mutex::new(None)),
} }
} }
@ -112,12 +98,12 @@ impl ConsoleCard {
spans.push(Span::styled(" ", Style::default().fg(Color::White))); spans.push(Span::styled(" ", Style::default().fg(Color::White)));
} }
spans.push(Span::styled( spans.push(Span::styled(
"send command (<help> for info)", "send command (/help for info)",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} else { } else {
spans.push(Span::styled( spans.push(Span::styled(
" send command (<help> for info)", " send command (/help for info)",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} }
@ -295,6 +281,12 @@ impl InteractableElement for ConsoleCard {
} }
fn interact(&mut self, key: KeyEvent) -> InteractionResult { fn interact(&mut self, key: KeyEvent) -> InteractionResult {
// Check if a previously failed command should be restored.
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
self.content = restored;
self.cursor_position = self.content.chars().count();
}
match key.code { match key.code {
KeyCode::Enter => { KeyCode::Enter => {
if self.content.is_empty() { if self.content.is_empty() {
@ -303,17 +295,15 @@ impl InteractableElement for ConsoleCard {
let command = self.content.clone(); let command = self.content.clone();
let ipc = self.ipc.clone(); let ipc = self.ipc.clone();
let seq = std::time::SystemTime::now() let restore = self.pending_restore.clone();
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
tokio::spawn(async move { tokio::spawn(async move {
let _ = ipc.send_command(seq, command).await; if ipc.send_command(0, command.clone()).await.is_err() {
*restore.lock().unwrap() = Some(command);
}
}); });
self.content.clear(); self.content.clear();
self.cursor_position = 0; self.cursor_position = 0;
self.tab_index = 0;
InteractionResult::Handled InteractionResult::Handled
} }
KeyCode::Backspace => { KeyCode::Backspace => {
@ -350,14 +340,7 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count(); self.cursor_position = self.content.chars().count();
InteractionResult::Handled InteractionResult::Handled
} }
KeyCode::Tab => { KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
if let Some(prefix) = self.current_prefix() {
if prefix == "/" {
self.tab_index = self.tab_index.saturating_add(1);
}
}
InteractionResult::Handled
}
_ => { _ => {
if let Some(c) = key.code.as_char() { if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c); self.insert_at_cursor(c);
@ -381,144 +364,3 @@ impl InteractableElement for ConsoleCard {
self.focused = f; self.focused = f;
} }
} }
#[cfg(feature = "legacy-commands")]
pub async fn run_command(command: &str) {
let parts = command.split(" ").collect::<Vec<&str>>();
match parts.as_slice() {
["tasks"] => {
let active_tasks: Vec<String> =
ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect();
let info = if *SHUTDOWN.read().await && *RELOAD.read().await {
"Rebooting, "
} else if *SHUTDOWN.read().await {
"Shutting , "
} else {
""
};
log!("{}Active tasks: {:?}", info, active_tasks);
}
["fps"] => {
let (fps, skips) = *FPS.read().await;
log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips);
}
["help"] => {
log!("Available commands: tasks, fps, ping, user, reconnect, regenerate");
}
["help", "tasks"] => {
log!("Tasks command usage: tasks");
}
["help", "fps"] => {
log!("FPS command usage: fps");
}
["help", "ping"] => {
log!("Ping command usage: ping [time]");
}
["help", "user"] => {
log!("User command usage: user add <username> | user remove <username> | user list");
}
["help", "reconnect"] => {
log!("Reconnect command usage: reconnect. Retry connecting to the Omikron server");
}
["help", "regenerate"] => {
log!(
"Regenerate command usage: regenerate keys. Generate a new Iota key pair and reconnect"
);
}
["ping"] => {
ping(20).await;
}
["ping", time] => {
let time = time.parse::<u64>().unwrap_or(20);
ping(time).await;
}
["user", "add", username] => {
if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await
{
log!("Created user {}", user.user_id);
} else {
log!("User creation: Failed to create user. See errors above.");
}
}
["user", "remove", username] => {
if let Some(user) = user_manager::get_user_by_username(username) {
let msg = CommunicationValue::new(CommunicationType::DeleteUser)
.with_sender(user.user_id as u64);
let _ = OMIKRON_CONNECTION.send_message(&msg).await;
user_manager::remove_user(user.user_id);
log!("Removed user {}", user.user_id);
} else {
log!("User removal: Username doesn't exist");
}
}
["user", "list"] => {
let users: Vec<UserProfile> = user_manager::get_users();
for user in users {
let storage = file_util::get_designed_storage(user.user_id);
log!(
"> Username: {}, ID: {}, created at: {}, storage: {}",
user.username,
user.user_id,
user.created_at,
storage
);
}
}
["user", "info", username] => {
if let Some(user) = user_manager::get_user_by_username(username) {
user_manager::remove_user(user.user_id);
log!("Removed user {}", user.user_id);
} else {
log!("User info: Username doesn't exist");
}
}
["reconnect"] => {
log!("Reconnecting to Omikron server...");
OMIKRON_CONNECTION.reconnect().await;
log!("Reconnected to Omikron server");
}
["regenerate", "keys"] => {
log!("Regenerating Iota key pair...");
modify_config(|cfg| {
cfg.public_key = None;
cfg.private_key = None;
cfg.iota_id = None;
});
log!("Key pair regenerated. Reconnecting to Omikron server...");
OMIKRON_CONNECTION.reconnect().await;
log!("Reconnected with new key pair");
}
["reload"] | ["restart"] => {
log!("Restarting");
*RELOAD.write().await = true;
*SHUTDOWN.write().await = true;
}
["shutdown"] | ["stop"] => {
log!("Shutting down");
*SHUTDOWN.write().await = true;
}
_ => {
log!("Unknown command");
}
}
}
#[cfg(feature = "legacy-commands")]
pub async fn ping(time: u64) {
let conn = OMIKRON_CONNECTION.clone();
let response_cv = conn
.await_response(
&CommunicationValue::new(CommunicationType::Ping),
Some(Duration::from_secs(time)),
)
.await;
match response_cv {
Ok(response) => log_cv!(response),
Err(err) => log!("Ping error: {:?}", err),
}
}

View file

@ -1,43 +1,472 @@
use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; use iota_ipc::{
ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry}; use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
use std::io::Result; use std::io::Result;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::net::unix::OwnedWriteHalf; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::Mutex; use tokio::sync::{Mutex, oneshot, watch};
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(10);
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
/// Connection state exposed to the UI.
#[derive(Clone, Debug)]
pub enum IpcConnectionState {
Connecting,
Connected,
Reconnecting { attempt: u32 },
Incompatible { message: String },
Disconnected,
}
/// Pending request awaiting a response.
struct PendingRequest {
response_tx: oneshot::Sender<ResponseResult>,
}
/* The TUI owns this cache. IPC updates replace daemon snapshots and append /* The TUI owns this cache. IPC updates replace daemon snapshots and append
* logs, so rendering never reaches into daemon-owned storage or connections. */ * logs, so rendering never reaches into daemon-owned storage or connections. */
pub struct IpcClient { pub struct IpcClient {
state: ClientState, state: ClientState,
writer: Mutex<OwnedWriteHalf>, writer: Mutex<OwnedWriteHalf>,
next_request_id: AtomicU64,
pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>,
path: PathBuf,
} }
impl IpcClient { impl IpcClient {
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> { pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let stream = UnixStream::connect(path).await?; let path = path.as_ref().to_path_buf();
let stream = Self::try_connect(&path).await?;
let (mut reader, writer) = stream.into_split(); let (mut reader, writer) = stream.into_split();
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting);
let client = Arc::new(Self { let client = Arc::new(Self {
state: ClientState::new(), state: ClientState::new(),
writer: Mutex::new(writer), writer: Mutex::new(writer),
next_request_id: AtomicU64::new(1),
pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx,
path: path.clone(),
}); });
// --- Handshake: send Hello, read HelloAck ---
{
let mut w = client.writer.lock().await;
write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
)
.await?;
}
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = client
.connection_state
.send(IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
});
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Protocol version mismatch: daemon={}, minimum={}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
));
}
}
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck from daemon",
));
}
Err(e) => return Err(e),
}
let _ = client.connection_state.send(IpcConnectionState::Connected);
// Start reader task (continues reading after handshake)
let reader_client = client.clone(); let reader_client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(message) = read_msg::<_, DaemonMessage>(&mut reader).await { reader_client.read_loop(reader).await;
reader_client.apply(message).await;
}
}); });
client.send(ClientMessage::Subscribe).await?;
// Subscribe to events
client
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await?;
Ok(client) Ok(client)
} }
/// Try to connect with retries for socket activation.
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let max_attempts = 30;
for attempt in 0..max_attempts {
match Self::connect(&path).await {
Ok(client) => return Ok(client),
Err(error) => {
if attempt < max_attempts - 1 {
let delay = Duration::from_millis(100 + attempt as u64 * 100);
tokio::time::sleep(delay).await;
continue;
}
return Err(error);
}
}
}
unreachable!()
}
async fn try_connect(path: &Path) -> Result<UnixStream> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match UnixStream::connect(path).await {
Ok(stream) => return Ok(stream),
Err(error) => {
if tokio::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}
}
/// Start the reconnection actor.
pub fn spawn_reconnector(self: &Arc<Self>) {
let client = self.clone();
tokio::spawn(async move {
client.reconnection_loop().await;
});
}
async fn reconnection_loop(self: Arc<Self>) {
let mut rx = self.connection_status();
loop {
// Wait until the connection enters the Disconnected state.
loop {
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
if disconnected {
break;
}
if rx.changed().await.is_err() {
return; // sender dropped
}
}
let mut backoff = INITIAL_BACKOFF;
let mut attempt: u32 = 0;
// Attempt reconnection until success or max attempts.
loop {
tokio::time::sleep(backoff).await;
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: "Max reconnection attempts exceeded".into(),
});
return;
}
let _ = self
.connection_state
.send(IpcConnectionState::Reconnecting { attempt });
match Self::try_connect(&self.path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
*self.writer.lock().await = writer;
// Re-handshake
{
let mut w = self.writer.lock().await;
if write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![
MIN_PROTOCOL_VERSION,
PROTOCOL_VERSION,
],
},
)
.await
.is_err()
{
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Read HelloAck
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = self.connection_state.send(
IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
},
);
return;
}
}
_ => {
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Clear pending requests with connection-lost errors
{
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request.response_tx.send(
ResponseResult::Error(
iota_ipc::IpcErrorCode::Disconnected,
),
);
}
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
// Start new reader loop
let reader_client = self.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
});
// Resubscribe
let _ = self
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await;
// Successfully reconnected; go back to waiting for
// the next disconnect.
break;
}
Err(_) => {
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
}
}
}
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
loop {
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(message) => self.apply(message).await,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
}
Err(_) => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
}
}
}
}
pub fn state(&self) -> ClientState { pub fn state(&self) -> ClientState {
self.state.clone() self.state.clone()
} }
pub async fn send_command(&self, seq: u64, line: String) -> Result<()> { pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.send(ClientMessage::Command { seq, line }).await self.connection_state.subscribe()
}
pub fn connection_status_snapshot(&self) -> IpcConnectionState {
self.connection_state.borrow().clone()
}
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (response_tx, response_rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(request_id, PendingRequest { response_tx });
}
let envelope = RequestEnvelope {
request_id,
protocol_version: PROTOCOL_VERSION,
request,
};
self.send(ClientMessage::Request(envelope)).await?;
match tokio::time::timeout(Duration::from_secs(30), response_rx).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
Err(_) => {
self.pending.lock().await.remove(&request_id);
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected))
}
}
}
/// Parse a legacy console command string into a typed request.
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["tasks"] => Some(LocalRequest::ListTasks),
["user", "add", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user", "remove", user_id_str] => {
let user_id = user_id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id })
}
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
_ => None,
}
}
/// Legacy command interface: parse text command, send as typed request.
pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> {
let trimmed = line.trim_start_matches('/').trim();
// Handle ping as a direct Ping message (not a LocalRequest).
if trimmed == "ping" || trimmed.starts_with("ping ") {
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send ping: {}", e),
is_error: true,
});
return Err(e);
}
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: "Ping sent".into(),
is_error: false,
});
return Ok(());
}
if let Some(request) = Self::parse_console_command(&line) {
match self.send_request(request).await {
Ok(result) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let message = match &result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&result, ResponseResult::Error(_)),
});
Ok(())
}
Err(e) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send: {}", e),
is_error: true,
});
Err(e)
}
}
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: if line.trim() == "help" {
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
.into()
} else {
format!("Unknown command: {}", line)
},
is_error: false,
});
Ok(())
}
} }
async fn send(&self, message: ClientMessage) -> Result<()> { async fn send(&self, message: ClientMessage) -> Result<()> {
@ -46,19 +475,26 @@ impl IpcClient {
} }
async fn apply(&self, message: DaemonMessage) { async fn apply(&self, message: DaemonMessage) {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
match message { match message {
DaemonMessage::LogEntry(entry) => state.push_log(UiLogEntry { DaemonMessage::LogEntry(entry) => {
timestamp_ms: entry.timestamp_ms, let mut state = self
sender: entry.sender, .state
message: entry.message, .app
is_error: entry.is_error, .lock()
}), .unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
});
}
DaemonMessage::StateUpdate(snapshot) => { DaemonMessage::StateUpdate(snapshot) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.cpu = snapshot.cpu; state.cpu = snapshot.cpu;
state.ram = snapshot.ram; state.ram = snapshot.ram;
state.ping = snapshot.ping; state.ping = snapshot.ping;
@ -66,18 +502,106 @@ impl IpcClient {
state.net_down = snapshot.net_down; state.net_down = snapshot.net_down;
state.sys_info = snapshot.sys_info; state.sys_info = snapshot.sys_info;
} }
DaemonMessage::CommandResult { DaemonMessage::MetricSample(sample) => {
success, message, .. let mut state = self
} => state.push_log(UiLogEntry { .state
timestamp_ms: std::time::SystemTime::now() .app
.duration_since(std::time::UNIX_EPOCH) .lock()
.unwrap_or_default() .unwrap_or_else(|error| error.into_inner());
.as_millis(), if let Some(cpu) = sample.cpu {
sender: "Command".into(), let idx = state.cpu.len() as f64;
message, state.cpu.push((idx, cpu));
is_error: !success, if state.cpu.len() > iota_state::MAX_POINTS {
}), state.cpu.remove(0);
}
}
if let Some(ram) = sample.ram {
let idx = state.ram.len() as f64;
state.ram.push((idx, ram));
if state.ram.len() > iota_state::MAX_POINTS {
state.ram.remove(0);
}
}
if let Some(ping) = sample.ping {
state.push_ping_val(ping);
}
if let Some(net_up) = sample.net_up {
let idx = state.net_up.len() as f64;
state.net_up.push((idx, net_up));
if state.net_up.len() > iota_state::MAX_POINTS {
state.net_up.remove(0);
}
}
if let Some(net_down) = sample.net_down {
let idx = state.net_down.len() as f64;
state.net_down.push((idx, net_down));
if state.net_down.len() > iota_state::MAX_POINTS {
state.net_down.remove(0);
}
}
}
DaemonMessage::Response(response) => {
let mut pending = self.pending.lock().await;
if let Some(request) = pending.remove(&response.request_id) {
let _ = request.response_tx.send(response.result);
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&response.result, ResponseResult::Error(_)),
});
}
}
DaemonMessage::HelloAck(_) => {}
DaemonMessage::Pong { .. } => {} DaemonMessage::Pong { .. } => {}
DaemonMessage::LifecycleEvent(event) => match event {
iota_ipc::LifecycleEvent::Shutdown { reason } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Daemon".into(),
message: format!("Daemon shutting down: {}", reason),
is_error: true,
});
}
_ => {}
},
DaemonMessage::Gap { skipped } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "System".into(),
message: format!("Skipped {} messages, resynchronizing", skipped),
is_error: false,
});
}
} }
} }
} }

View file

@ -6,6 +6,7 @@ use crate::{
log_card::LogCard, log_card::LogCard,
}, },
interaction_result::InteractionResult, interaction_result::InteractionResult,
ipc_client::IpcConnectionState,
screens::screens::{NavDirection, Screen}, screens::screens::{NavDirection, Screen},
ui::UI, ui::UI,
}; };
@ -16,6 +17,7 @@ use ratatui::{
layout::{Constraint, Layout, Margin, Rect}, layout::{Constraint, Layout, Margin, Rect},
widgets::{Block, Borders}, widgets::{Block, Borders},
}; };
use tokio::sync::watch;
use std::{any::Any, sync::Arc}; use std::{any::Any, sync::Arc};
@ -24,6 +26,7 @@ pub struct MainScreen {
nav_grid: Vec<Vec<Option<usize>>>, nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize), selected_coords: (usize, usize),
graphs_open: bool, graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
} }
impl MainScreen { impl MainScreen {
@ -58,11 +61,14 @@ impl MainScreen {
let graphs_open = true; let graphs_open = true;
let connection_status_rx = ui.ipc().connection_status();
let mut screen = MainScreen { let mut screen = MainScreen {
elements, elements,
nav_grid, nav_grid,
selected_coords: (1, 0), selected_coords: (1, 0),
graphs_open, graphs_open,
connection_status_rx,
}; };
screen.focus_current(); screen.focus_current();
screen screen
@ -147,6 +153,40 @@ impl MainScreen {
self.focus_current(); self.focus_current();
} }
/// Cycle focus between unique elements in the navigation grid.
fn navigate_focus(&mut self, forward: bool) {
// Collect unique elements in grid order.
let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col)
let mut seen: Vec<Option<usize>> = Vec::new();
for (y, row) in self.nav_grid.iter().enumerate() {
for (x, elem_opt) in row.iter().enumerate() {
if elem_opt.is_some() && !seen.contains(elem_opt) {
seen.push(*elem_opt);
positions.push((y, x));
}
}
}
let current = self.selected_coords;
let current_pos = positions
.iter()
.position(|&(r, c)| r == current.0 && c == current.1);
let next_pos = if let Some(idx) = current_pos {
if forward {
(idx + 1) % positions.len()
} else {
(idx + positions.len() - 1) % positions.len()
}
} else {
0
};
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = positions[next_pos];
self.focus_current();
}
} }
impl Screen for MainScreen { impl Screen for MainScreen {
@ -159,7 +199,21 @@ impl Screen for MainScreen {
} }
fn render(&self, f: &mut Frame, rect: Rect) { fn render(&self, f: &mut Frame, rect: Rect) {
let main_block = Block::default().title("Main").borders(Borders::ALL); let status = self.connection_status_rx.borrow();
let status_text = match &*status {
IpcConnectionState::Connected => "Connected".to_string(),
IpcConnectionState::Connecting => "Connecting...".to_string(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {})...", attempt)
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {}", message)
}
IpcConnectionState::Disconnected => "Disconnected".to_string(),
};
let main_block = Block::default()
.title(format!("Main [{}]", status_text))
.borders(Borders::ALL);
f.render_widget(main_block, rect); f.render_widget(main_block, rect);
let inner = rect.inner(Margin { let inner = rect.inner(Margin {
@ -215,10 +269,14 @@ impl Screen for MainScreen {
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
match event.code { match event.code {
KeyCode::Up => self.navigate(NavDirection::Up), KeyCode::Tab => {
KeyCode::Down => self.navigate(NavDirection::Down), self.navigate_focus(true);
KeyCode::Left => self.navigate(NavDirection::Left), return InteractionResult::Handled;
KeyCode::Right => self.navigate(NavDirection::Right), }
KeyCode::BackTab => {
self.navigate_focus(false);
return InteractionResult::Handled;
}
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open; self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() { for element in self.elements.iter_mut() {
@ -232,7 +290,17 @@ impl Screen for MainScreen {
let (y, x) = self.selected_coords; let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) { if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
if let Some(el) = self.elements.get_mut(*index) { if let Some(el) = self.elements.get_mut(*index) {
return el.interact(event); let result = el.interact(event);
if matches!(result, InteractionResult::Unhandled) {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
_ => {}
}
}
return result;
} }
} }
} }

View file

@ -24,3 +24,4 @@ pnet = "0.35.0"
ratatui = "0.30.0" ratatui = "0.30.0"
reqwest = "0.13.2" reqwest = "0.13.2"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

View file

@ -153,7 +153,7 @@ async fn main() {
if !web_server::start(port).await { if !web_server::start(port).await {
log!("Failed to start the MTP web server on port {}", port); log!("Failed to start the MTP web server on port {}", port);
} }
let _ = omikron::omikron_connection::get_omikron_connection().await; let _ = omikron::omikron_connection::get_omikron_connection(tokio_util::sync::CancellationToken::new()).await;
log_t!("setup_completed"); log_t!("setup_completed");
loop { loop {

View file

@ -11,5 +11,9 @@ iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" } omikron-connector = { path = "../omikron-connector" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
dashmap = "6.1.0"
libc = "0.2"
sysinfo = "0.38.3" sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "*", features = ["v4"] }

View file

@ -1,5 +1,7 @@
use crate::DaemonRuntime; use crate::DaemonRuntime;
use iota_ipc::DaemonMessage; use iota_ipc::{
IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult,
};
use iota_logger::{log, log_command}; use iota_logger::{log, log_command};
use iota_storage::users::user_manager; use iota_storage::users::user_manager;
use iota_storage::util::config_util::modify_config; use iota_storage::util::config_util::modify_config;
@ -8,6 +10,8 @@ use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::daemon_state::ShutdownReason;
#[derive(Clone)] #[derive(Clone)]
pub struct CommandRouter { pub struct CommandRouter {
runtime: Arc<DaemonRuntime>, runtime: Arc<DaemonRuntime>,
@ -18,85 +22,124 @@ impl CommandRouter {
Self { runtime } Self { runtime }
} }
pub async fn route(&self, seq: u64, line: String) -> DaemonMessage { pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
log_command!("{}", line); log_command!("{:?}", request);
let result = self.execute(&line).await; let result = self.execute(request).await;
DaemonMessage::CommandResult { ResponseEnvelope {
seq, request_id,
success: result.is_ok(), result,
message: result.unwrap_or_else(|error| error),
} }
} }
async fn execute(&self, line: &str) -> Result<String, String> { /// Parse a legacy console command string into a typed request.
let parts = line pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line
.trim_start_matches('/') .trim_start_matches('/')
.split_whitespace() .split_whitespace()
.collect::<Vec<_>>(); .collect();
match parts.as_slice() { match parts.as_slice() {
["tasks"] => Ok(self ["help"] => None,
.runtime ["tasks"] => Some(LocalRequest::ListTasks),
.state ["ping", _] | ["ping"] => None,
.active_tasks ["user", "add", username] => Some(LocalRequest::CreateUser {
.iter() username: username.to_string(),
.map(|task| task.to_string()) }),
.collect::<Vec<_>>()
.join(", ")),
["help"] => Ok(
"Available commands: tasks, ping, user, reconnect, regenerate, reload, shutdown"
.into(),
),
["ping"] => self.ping(20).await,
["ping", seconds] => self.ping(seconds.parse::<u64>().unwrap_or(20)).await,
["user", "add", username] => {
let (user, _) = omikron_connector::user_ops::create_user(username).await;
user.map(|user| format!("Created user {}", user.user_id))
.ok_or_else(|| "User creation failed".into())
}
["user", "remove", username] => { ["user", "remove", username] => {
let user = user_manager::get_user_by_username(username) let user = user_manager::get_user_by_username(username)?;
.ok_or_else(|| "Username does not exist".to_string())?; Some(LocalRequest::RemoveUser {
user_id: user.user_id,
})
}
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
_ => None,
}
}
async fn execute(&self, request: LocalRequest) -> ResponseResult {
match request {
LocalRequest::GetStatus => {
let phase = self.runtime.current_startup_phase();
let degraded = self.runtime.degraded_reason.borrow().clone();
let tasks: Vec<String> = self
.runtime
.state
.active_tasks
.iter()
.map(|task| task.to_string())
.collect();
let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", "));
if let Some(reason) = degraded {
info.push_str(&format!(", Degraded: {}", reason));
}
ResponseResult::Ok(info)
}
LocalRequest::ListTasks => {
let tasks: Vec<String> = self
.runtime
.state
.active_tasks
.iter()
.map(|task| task.to_string())
.collect();
ResponseResult::Ok(tasks.join(", "))
}
LocalRequest::ListUsers => {
let users: Vec<String> = user_manager::get_users()
.into_iter()
.map(|user| format!("{} ({})", user.username, user.user_id))
.collect();
ResponseResult::Ok(users.join("\n"))
}
LocalRequest::CreateUser { username } => {
match omikron_connector::user_ops::create_user(&username).await {
(Some(user), _) => {
ResponseResult::Ok(format!("Created user {}", user.user_id))
}
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
}
}
LocalRequest::RemoveUser { user_id } => {
let user = match user_manager::get_user(user_id) {
Some(user) => user,
None => return ResponseResult::Error(IpcErrorCode::NotFound),
};
let message = CommunicationValue::new(CommunicationType::DeleteUser) let message = CommunicationValue::new(CommunicationType::DeleteUser)
.with_sender(user.user_id as u64); .with_sender(user.user_id as u64);
OMIKRON_CONNECTION if let Err(_e) = OMIKRON_CONNECTION.send_message(&message).await {
.send_message(&message) return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
.await }
.map_err(|error| error.to_string())?;
user_manager::remove_user(user.user_id); user_manager::remove_user(user.user_id);
Ok(format!("Removed user {}", user.user_id)) ResponseResult::Ok(format!("Removed user {}", user.user_id))
} }
["user", "list"] => Ok(user_manager::get_users() LocalRequest::ReconnectOmikron => {
.into_iter()
.map(|user| format!("{} ({})", user.username, user.user_id))
.collect::<Vec<_>>()
.join("\n")),
["reconnect"] => {
OMIKRON_CONNECTION.reconnect().await; OMIKRON_CONNECTION.reconnect().await;
Ok("Reconnected to Omikron server".into()) ResponseResult::Ok("Reconnected to Omikron server".into())
} }
["regenerate", "keys"] => { LocalRequest::RotateIotaIdentity => {
modify_config(|config| { modify_config(|config| {
config.public_key = None; config.public_key = None;
config.private_key = None; config.private_key = None;
config.iota_id = None; config.iota_id = None;
}); });
OMIKRON_CONNECTION.reconnect().await; OMIKRON_CONNECTION.reconnect().await;
Ok("Key pair regenerated and Omikron reconnection requested".into()) ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into())
} }
["reload"] | ["restart"] => { LocalRequest::RestartDaemon => {
*self.runtime.state.reload.write().await = true; self.runtime.shutdown(ShutdownReason::Restart);
*self.runtime.state.shutdown.write().await = true; ResponseResult::Ok("Daemon restart requested".into())
Ok("Daemon restart requested".into())
} }
["shutdown"] | ["stop"] => { LocalRequest::StopDaemon => {
*self.runtime.state.shutdown.write().await = true; self.runtime.shutdown(ShutdownReason::Stop);
Ok("Daemon shutdown requested".into()) ResponseResult::Ok("Daemon shutdown requested".into())
} }
_ => Err("Unknown command".into()),
} }
} }
async fn ping(&self, seconds: u64) -> Result<String, String> { pub async fn ping(&self, seconds: u64) -> Result<String, String> {
let response = OMIKRON_CONNECTION let response = OMIKRON_CONNECTION
.await_response( .await_response(
&CommunicationValue::new(CommunicationType::Ping), &CommunicationValue::new(CommunicationType::Ping),

View file

@ -3,21 +3,123 @@ use iota_state::DaemonState;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use sysinfo::{RefreshKind, System}; use sysinfo::{RefreshKind, System};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
/// Reason the daemon is shutting down.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShutdownReason {
Stop,
Restart,
Fatal(String),
}
impl ShutdownReason {
pub fn exit_code(&self) -> i32 {
match self {
ShutdownReason::Stop => 0,
ShutdownReason::Restart => 75,
ShutdownReason::Fatal(_) => 1,
}
}
}
/// Tracks the lifecycle phase of the daemon for IPC visibility.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StartupPhase {
Starting,
MigratingStorage,
LoadingUsers,
StartingServices,
Ready,
Degraded,
Stopping,
}
impl From<StartupPhase> for iota_ipc::StartupPhase {
fn from(phase: StartupPhase) -> Self {
match phase {
StartupPhase::Starting => iota_ipc::StartupPhase::Starting,
StartupPhase::MigratingStorage => iota_ipc::StartupPhase::MigratingStorage,
StartupPhase::LoadingUsers => iota_ipc::StartupPhase::LoadingUsers,
StartupPhase::StartingServices => iota_ipc::StartupPhase::StartingServices,
StartupPhase::Ready => iota_ipc::StartupPhase::Ready,
StartupPhase::Degraded => iota_ipc::StartupPhase::Degraded,
StartupPhase::Stopping => iota_ipc::StartupPhase::Stopping,
}
}
}
/* This wrapper exposes daemon state as IPC-safe snapshots while preserving a /* This wrapper exposes daemon state as IPC-safe snapshots while preserving a
* single owned state instance for all daemon subsystems. */ * single owned state instance for all daemon subsystems. The cancellation token
#[derive(Clone, Default)] * is the single lifecycle signal all subsystems check it instead of a
* separate boolean. */
pub struct DaemonRuntime { pub struct DaemonRuntime {
pub state: Arc<DaemonState>, pub state: Arc<DaemonState>,
pub cancellation: CancellationToken,
pub shutdown_tx: watch::Sender<Option<ShutdownReason>>,
pub startup_phase: watch::Sender<StartupPhase>,
pub degraded_reason: watch::Sender<Option<String>>,
}
impl Clone for DaemonRuntime {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
cancellation: self.cancellation.clone(),
shutdown_tx: self.shutdown_tx.clone(),
startup_phase: self.startup_phase.clone(),
degraded_reason: self.degraded_reason.clone(),
}
}
}
impl Default for DaemonRuntime {
fn default() -> Self {
Self::new()
}
} }
impl DaemonRuntime { impl DaemonRuntime {
pub fn new() -> Self { pub fn new() -> Self {
let (shutdown_tx, _) = watch::channel(None);
let (startup_phase, _) = watch::channel(StartupPhase::Starting);
let (degraded_reason, _) = watch::channel(None);
Self { Self {
state: Arc::new(DaemonState::new()), state: Arc::new(DaemonState::new()),
cancellation: CancellationToken::new(),
shutdown_tx,
startup_phase,
degraded_reason,
} }
} }
pub fn shutdown(&self, reason: ShutdownReason) {
self.cancellation.cancel();
let _ = self.shutdown_tx.send(Some(reason));
}
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
self.shutdown_tx.borrow().clone()
}
pub fn is_shutting_down(&self) -> bool {
self.cancellation.is_cancelled()
}
pub fn set_startup_phase(&self, phase: StartupPhase) {
let _ = self.startup_phase.send(phase);
}
pub fn current_startup_phase(&self) -> StartupPhase {
*self.startup_phase.borrow()
}
pub fn mark_degraded(&self, reason: String) {
let _ = self.degraded_reason.send(Some(reason.clone()));
let _ = self.startup_phase.send(StartupPhase::Degraded);
}
pub fn snapshot(&self) -> StateSnapshot { pub fn snapshot(&self) -> StateSnapshot {
let state = self let state = self
.state .state
@ -41,7 +143,7 @@ impl DaemonRuntime {
let mut system = System::new_with_specifics(RefreshKind::everything()); let mut system = System::new_with_specifics(RefreshKind::everything());
let mut counter = 0.0; let mut counter = 0.0;
loop { loop {
if *runtime.state.shutdown.read().await { if runtime.is_shutting_down() {
break; break;
} }
system.refresh_cpu_all(); system.refresh_cpu_all();

View file

@ -1,28 +1,42 @@
use crate::{CommandRouter, DaemonRuntime}; use crate::{CommandRouter, DaemonRuntime};
use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg}; use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
write_msg,
};
use iota_logger::log;
use std::io::Result; use std::io::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::{env, os::fd::FromRawFd}; use std::{env, os::fd::FromRawFd};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::broadcast; use tokio::sync::{broadcast, mpsc, watch};
use uuid::Uuid;
/// Per-client outbound queue capacity.
const CLIENT_CHANNEL_SIZE: usize = 256;
/// Maximum handshake retries before giving up.
const MAX_HANDSHAKE_RETRIES: u32 = 10;
pub struct IpcServer { pub struct IpcServer {
path: PathBuf, path: PathBuf,
runtime: Arc<DaemonRuntime>, runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>, log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
} }
impl IpcServer { impl IpcServer {
pub fn new( pub fn new(
path: impl Into<PathBuf>, path: impl Into<PathBuf>,
runtime: Arc<DaemonRuntime>, runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>, log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
) -> Self { ) -> Self {
Self { Self {
path: path.into(), path: path.into(),
runtime, runtime,
messages, log_tx,
state_rx,
} }
} }
@ -38,11 +52,14 @@ impl IpcServer {
} }
}; };
loop { loop {
let (stream, _) = listener.accept().await?; let (stream, _addr) = listener.accept().await?;
let runtime = self.runtime.clone(); let runtime = self.runtime.clone();
let messages = self.messages.clone(); let log_tx = self.log_tx.clone();
let state_rx = self.state_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _ = handle_client(stream, runtime, messages).await; if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await {
eprintln!("IPC client error: {error}");
}
}); });
} }
} }
@ -72,34 +89,159 @@ async fn remove_stale_socket(path: &Path) -> Result<()> {
} }
} }
#[derive(Clone, Debug)]
struct PeerIdentity {
pid: i32,
uid: u32,
gid: u32,
}
fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
unsafe {
let mut cred: libc::ucred = std::mem::zeroed();
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let fd = stream.as_raw_fd();
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
);
PeerIdentity {
pid: cred.pid,
uid: cred.uid,
gid: cred.gid,
}
}
}
#[cfg(not(target_os = "linux"))]
{
PeerIdentity {
pid: 0,
uid: 0,
gid: 0,
}
}
}
async fn handle_client( async fn handle_client(
stream: UnixStream, stream: UnixStream,
runtime: Arc<DaemonRuntime>, runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>, log_tx: broadcast::Sender<DaemonMessage>,
_state_rx: watch::Sender<iota_ipc::StateSnapshot>,
) -> Result<()> { ) -> Result<()> {
let peer = peer_credentials(&stream);
let (mut reader, mut writer) = stream.into_split(); let (mut reader, mut writer) = stream.into_split();
let mut outgoing = messages.subscribe(); let (directed_tx, directed_rx) = mpsc::channel::<DaemonMessage>(CLIENT_CHANNEL_SIZE);
let initial = DaemonMessage::StateUpdate(runtime.snapshot());
write_msg(&mut writer, &initial).await?; // --- Handshake ---
let writer_task = tokio::spawn(async move { let mut negotiated_version: Option<u16> = None;
while let Ok(message) = outgoing.recv().await { for _ in 0..MAX_HANDSHAKE_RETRIES {
if write_msg(&mut writer, &message).await.is_err() { match read_msg::<_, ClientMessage>(&mut reader).await {
Ok(ClientMessage::Hello { supported_versions }) => {
let version = supported_versions
.iter()
.copied()
.find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
.unwrap_or(PROTOCOL_VERSION);
negotiated_version = Some(version);
let instance_id = Uuid::new_v4().to_string();
let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id,
startup_phase: runtime.current_startup_phase().into(),
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
});
write_msg(&mut writer, &ack).await?;
break; break;
} }
Ok(_) => {
// Unexpected first message — send error and close.
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected Hello as first message",
));
}
Err(e) => return Err(e),
} }
}); }
let _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);
// --- Send initial state snapshot ---
let initial = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(initial).await;
// --- Writer task: merge directed responses + shared log events ---
let mut log_rx = log_tx.subscribe();
let directed_for_writer = directed_tx.clone();
let writer_task = {
let runtime = runtime.clone();
tokio::spawn(async move {
let mut directed_rx = directed_rx;
loop {
tokio::select! {
// Directed messages (responses to this client's requests)
msg = directed_rx.recv() => {
match msg {
Some(message) => {
if write_msg(&mut writer, &message).await.is_err() {
break;
}
}
None => break,
}
}
// Shared log events
result = log_rx.recv() => {
match result {
Ok(message) => {
if write_msg(&mut writer, &message).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await;
// Then send current snapshot for resync
let _ = directed_for_writer.send(
DaemonMessage::StateUpdate(runtime.snapshot())
).await;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
})
};
// --- Reader loop ---
let router = CommandRouter::new(runtime.clone()); let router = CommandRouter::new(runtime.clone());
loop { loop {
match read_msg::<_, ClientMessage>(&mut reader).await { match read_msg::<_, ClientMessage>(&mut reader).await {
Ok(ClientMessage::Command { seq, line }) => { Ok(ClientMessage::Request(envelope)) => {
let result = router.route(seq, line).await; let response = router.route(envelope.request_id, envelope.request).await;
let _ = messages.send(result); let _ = directed_tx.send(DaemonMessage::Response(response)).await;
} }
Ok(ClientMessage::Subscribe) => { Ok(ClientMessage::Subscribe { .. }) => {
let _ = messages.send(DaemonMessage::StateUpdate(runtime.snapshot())); let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
} }
Ok(ClientMessage::Ping { seq }) => { Ok(ClientMessage::Ping { seq }) => {
let _ = messages.send(DaemonMessage::Pong { seq }); let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
}
Ok(ClientMessage::Hello { .. }) => {
// Re-handshake on existing connection: treat as resubscribe
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
} }
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(error) => { Err(error) => {
@ -109,5 +251,10 @@ async fn handle_client(
} }
} }
writer_task.abort(); writer_task.abort();
log!(
"IPC client disconnected (pid={}, uid={})",
peer.pid,
peer.uid
);
Ok(()) Ok(())
} }

View file

@ -4,5 +4,5 @@ pub mod ipc_server;
pub mod log_broadcaster; pub mod log_broadcaster;
pub use command_router::CommandRouter; pub use command_router::CommandRouter;
pub use daemon_state::DaemonRuntime; pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
pub use ipc_server::IpcServer; pub use ipc_server::IpcServer;

View file

@ -12,3 +12,4 @@ iota-storage = { path = "../iota-storage" }
omikron-connector = { path = "../omikron-connector" } omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" } web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

View file

@ -1,12 +1,11 @@
use iota_daemon_lib::{DaemonRuntime, IpcServer, log_broadcaster}; use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster};
use iota_logger::{self as logger, log, log_t}; use iota_logger::{self as logger, log, log_t};
use iota_storage::users::user_manager; use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG; use iota_storage::util::config_util::CONFIG;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::broadcast; use tokio::sync::{broadcast, watch};
fn socket_path() -> PathBuf { fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET") std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from) .map(PathBuf::from)
@ -17,42 +16,79 @@ fn socket_path() -> PathBuf {
async fn main() { async fn main() {
logger::startup(); logger::startup();
iota_storage::util::config_util::load_config(); iota_storage::util::config_util::load_config();
let runtime = Arc::new(DaemonRuntime::new());
runtime.set_startup_phase(StartupPhase::LoadingUsers);
if user_manager::load_users().await.is_err() { if user_manager::load_users().await.is_err() {
log_t!("user_load_failed"); log_t!("user_load_failed");
} }
let runtime = Arc::new(DaemonRuntime::new());
// --- IPC infrastructure ---
let (log_tx, _) = broadcast::channel(512);
log_broadcaster::spawn(log_tx.clone());
let (state_tx, _state_rx) = watch::channel(iota_ipc::StateSnapshot::default());
// --- Start IPC server early (before services) so clients can see startup phases ---
runtime.set_startup_phase(StartupPhase::StartingServices);
let ipc_server = IpcServer::new(
socket_path(),
runtime.clone(),
log_tx.clone(),
state_tx.clone(),
);
tokio::spawn(async move {
if let Err(error) = ipc_server.run().await {
eprintln!("iota-daemon IPC server failed: {error}");
}
});
log!("iota-daemon IPC server started");
// --- System monitor ---
runtime.spawn_system_monitor(); runtime.spawn_system_monitor();
let (messages, _) = broadcast::channel(512);
log_broadcaster::spawn(messages.clone()); // --- State update publisher (watch-based, no full broadcast per tick) ---
let state_updates = runtime.clone(); let state_publisher = runtime.clone();
let state_messages = messages.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if *state_updates.state.shutdown.read().await { if state_publisher.is_shutting_down() {
break; break;
} }
let _ = state_messages.send(iota_ipc::DaemonMessage::StateUpdate( let snapshot = state_publisher.snapshot();
state_updates.snapshot(), let _ = state_tx.send(snapshot);
));
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
} }
}); });
// --- Web server ---
let port = CONFIG.load().port; let port = CONFIG.load().port;
if !web_server::start(port).await { if !web_server::start(port, runtime.cancellation.clone()).await {
log!("Failed to start the MTP web server on port {}", port); log!("Failed to start the MTP web server on port {}", port);
runtime.mark_degraded("MTP web server failed to start".into());
} }
let _ = omikron_connector::omikron_connection::get_omikron_connection().await;
let server = IpcServer::new(socket_path(), runtime.clone(), messages); // --- Omikron connection ---
tokio::spawn(async move { let omikron_result =
if let Err(error) = server.run().await { omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone())
eprintln!("iota-daemon IPC server failed: {error}"); .await;
} if omikron_result.is_none() {
}); runtime.mark_degraded("Omikron connection unavailable".into());
log!("iota-daemon started");
while !*runtime.state.shutdown.read().await {
tokio::time::sleep(Duration::from_millis(250)).await;
} }
log!("iota-daemon stopping");
runtime.set_startup_phase(StartupPhase::Ready);
log!("iota-daemon started (phase: Ready)");
// --- Main lifecycle loop ---
runtime.cancellation.cancelled().await;
let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop);
log!("iota-daemon shutting down (reason: {:?})", reason);
runtime.set_startup_phase(StartupPhase::Stopping);
// Wait a moment for in-flight operations to complete
tokio::time::sleep(Duration::from_millis(500)).await;
let exit_code = reason.exit_code();
log!("iota-daemon exited (code: {})", exit_code);
std::process::exit(exit_code);
} }

View file

@ -1,5 +1,14 @@
pub mod protocol; pub mod protocol;
pub mod transport; pub mod transport;
pub use protocol::{ClientMessage, DaemonMessage, LogEntry, StateSnapshot}; pub use protocol::{
ClientMessage, DaemonMessage, HelloAck, LogEntry, StateSnapshot, MetricSample,
RequestEnvelope, ResponseEnvelope, ResponseResult, LocalRequest, IpcErrorCode,
ConnectionStatus, StartupPhase, LifecycleEvent,
};
pub use transport::{read_msg, write_msg}; pub use transport::{read_msg, write_msg};
/// Current IPC protocol version.
pub const PROTOCOL_VERSION: u16 = 2;
/// Minimum protocol version this daemon understands.
pub const MIN_PROTOCOL_VERSION: u16 = 2;

View file

@ -1,28 +1,123 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Client → Daemon
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")] #[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
Command { seq: u64, line: String }, Hello { supported_versions: Vec<u16> },
Subscribe, Subscribe { log_classes: Vec<String>, metric_interval_ms: Option<u64> },
Request(RequestEnvelope),
Ping { seq: u64 }, Ping { seq: u64 },
} }
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RequestEnvelope {
pub request_id: u64,
pub protocol_version: u16,
pub request: LocalRequest,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LocalRequest {
GetStatus,
ListTasks,
ListUsers,
CreateUser { username: String },
RemoveUser { user_id: i64 },
ReconnectOmikron,
RotateIotaIdentity,
RestartDaemon,
StopDaemon,
}
// ---------------------------------------------------------------------------
// Daemon → Client
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")] #[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum DaemonMessage { pub enum DaemonMessage {
HelloAck(HelloAck),
LogEntry(LogEntry), LogEntry(LogEntry),
StateUpdate(StateSnapshot), StateUpdate(StateSnapshot),
CommandResult { MetricSample(MetricSample),
seq: u64, Response(ResponseEnvelope),
success: bool, Pong { seq: u64 },
message: String, LifecycleEvent(LifecycleEvent),
}, Gap { skipped: u64 },
Pong {
seq: u64,
},
} }
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct HelloAck {
pub protocol_version: u16,
pub daemon_version: String,
pub instance_id: String,
pub startup_phase: StartupPhase,
pub capabilities: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ResponseEnvelope {
pub request_id: u64,
pub result: ResponseResult,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ResponseResult {
Ok(String),
Error(IpcErrorCode),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IpcErrorCode {
InvalidRequest,
NotFound,
Conflict,
StorageFailure,
OmikronUnavailable,
UnsupportedVersion,
NotReady,
Disconnected,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LifecycleEvent {
StateChanged(ConnectionStatus),
Shutdown { reason: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Connected,
Reconnecting,
Degraded,
Disconnected,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartupPhase {
Starting,
MigratingStorage,
LoadingUsers,
StartingServices,
Ready,
Degraded,
Stopping,
}
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntry { pub struct LogEntry {
pub timestamp_ms: u128, pub timestamp_ms: u128,
@ -40,3 +135,12 @@ pub struct StateSnapshot {
pub net_down: Vec<(f64, f64)>, pub net_down: Vec<(f64, f64)>,
pub sys_info: String, pub sys_info: String,
} }
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct MetricSample {
pub cpu: Option<f64>,
pub ram: Option<f64>,
pub ping: Option<f64>,
pub net_up: Option<f64>,
pub net_down: Option<f64>,
}

View file

@ -41,19 +41,20 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{read_msg, write_msg}; use super::{read_msg, write_msg};
use crate::protocol::ClientMessage; use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope};
#[tokio::test] #[tokio::test]
async fn round_trips_framed_messages() { async fn round_trips_framed_messages() {
let (mut writer, mut reader) = tokio::io::duplex(1024); let (mut writer, mut reader) = tokio::io::duplex(1024);
let message = ClientMessage::Command { let message = ClientMessage::Request(RequestEnvelope {
seq: 4, request_id: 4,
line: "help".into(), protocol_version: 2,
}; request: LocalRequest::GetStatus,
});
write_msg(&mut writer, &message) write_msg(&mut writer, &message)
.await .await
.expect("write succeeds"); .expect("write succeeds");
let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds"); let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds");
assert!(matches!(received, ClientMessage::Command { seq: 4, line } if line == "help")); assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4));
} }
} }

BIN
iota.mk

Binary file not shown.

View file

@ -6,3 +6,4 @@ edition = "2024"
[dependencies] [dependencies]
iota-cli = { path = "../iota-cli" } iota-cli = { path = "../iota-cli" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

View file

@ -10,16 +10,18 @@ fn socket_path() -> PathBuf {
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() { async fn main() {
let path = socket_path(); let path = socket_path();
let ipc = match IpcClient::connect(&path).await { let ipc = match IpcClient::connect_or_activate(&path).await {
Ok(client) => client, Ok(client) => client,
Err(error) => { Err(error) => {
eprintln!( eprintln!(
"Cannot connect to iota-daemon at {}: {error}", "Cannot connect to iota-daemon at {}: {error}",
path.display() path.display()
); );
eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running.");
std::process::exit(1); std::process::exit(1);
} }
}; };
ipc.spawn_reconnector();
let ui = start_tui(ipc); let ui = start_tui(ipc);
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await)) ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
.await; .await;

View file

@ -19,6 +19,7 @@ dashmap = "6.2.1"
json = "*" json = "*"
reqwest = "0.13.2" reqwest = "0.13.2"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "*", features = ["v4"] } uuid = { version = "*", features = ["v4"] }
base64 = "0.22.1" base64 = "0.22.1"
hex = "*" hex = "*"

View file

@ -1,6 +1,6 @@
use dashmap::DashMap; use dashmap::DashMap;
use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_logger::{log, log_cv_in, log_cv_out, log_t};
use iota_state::{ACTIVE_TASKS, SHUTDOWN}; use iota_state::ACTIVE_TASKS;
use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::chat_files::{self, MessageState, change_message_state};
use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::config_util::{CONFIG, modify_config};
use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret};
@ -16,6 +16,7 @@ use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::sleep; use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
use crate::omega_discovery; use crate::omega_discovery;
@ -161,10 +162,15 @@ pub struct OmikronConnection {
pub app_sessions: Arc<DashMap<u64, (i64, String)>>, pub app_sessions: Arc<DashMap<u64, (i64, String)>>,
pub(crate) missed_pongs: Arc<AtomicU32>, pub(crate) missed_pongs: Arc<AtomicU32>,
handler_semaphore: Arc<Semaphore>, handler_semaphore: Arc<Semaphore>,
cancellation: CancellationToken,
} }
impl OmikronConnection { impl OmikronConnection {
pub fn new() -> Self { pub fn new() -> Self {
Self::with_cancellation(CancellationToken::new())
}
pub fn with_cancellation(cancellation: CancellationToken) -> Self {
let (shutdown_tx, _) = watch::channel(false); let (shutdown_tx, _) = watch::channel(false);
let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected); let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected);
@ -183,6 +189,7 @@ impl OmikronConnection {
app_sessions: Arc::new(DashMap::new()), app_sessions: Arc::new(DashMap::new()),
missed_pongs: Arc::new(AtomicU32::new(0)), missed_pongs: Arc::new(AtomicU32::new(0)),
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
cancellation,
} }
} }
@ -237,7 +244,7 @@ impl OmikronConnection {
} }
if let Some(sender) = self.sender.read().await.as_ref() { if let Some(sender) = self.sender.read().await.as_ref() {
sender.close(); sender.close().await;
} }
self.set_state(ConnectionState::Disconnected).await; self.set_state(ConnectionState::Disconnected).await;
@ -250,7 +257,7 @@ impl OmikronConnection {
let mut shutdown_rx = shutdown_rx; let mut shutdown_rx = shutdown_rx;
loop { loop {
if *shutdown_rx.borrow() || *SHUTDOWN.read().await { if *shutdown_rx.borrow() || self.cancellation.is_cancelled() {
log_t!("omikron_connection_loop_shutdown"); log_t!("omikron_connection_loop_shutdown");
break; break;
} }
@ -621,7 +628,7 @@ impl OmikronConnection {
self.missed_pongs.load(Ordering::Relaxed) self.missed_pongs.load(Ordering::Relaxed)
); );
if let Some(sender) = self.sender.read().await.as_ref() { if let Some(sender) = self.sender.read().await.as_ref() {
sender.close(); sender.close().await;
} }
break; break;
} }
@ -1750,7 +1757,7 @@ impl OmikronConnection {
if !sender.is_open() { if !sender.is_open() {
drop(sender_guard); drop(sender_guard);
if let Some(sender) = self.sender.write().await.take() { if let Some(sender) = self.sender.write().await.take() {
sender.close(); sender.close().await;
} }
self.fail_all_waiting_tasks(format!( self.fail_all_waiting_tasks(format!(
"Send failed: connection closed (connection_id={})", "Send failed: connection closed (connection_id={})",
@ -1933,11 +1940,12 @@ pub static OMIKRON_CONNECTION: LazyLock<Arc<OmikronConnection>> = LazyLock::new(
conn conn
}); });
pub async fn get_omikron_connection() -> Arc<OmikronConnection> { pub async fn get_omikron_connection(
let conn = OMIKRON_CONNECTION.clone(); cancellation: CancellationToken,
) -> Option<Arc<OmikronConnection>> {
let conn = Arc::new(OmikronConnection::with_cancellation(cancellation));
conn.connect().await; conn.connect().await;
conn Some(conn)
} }
impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection {

View file

@ -1,6 +1,5 @@
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use iota_logger::{PrintType, log, log_cv, log_t}; use iota_logger::{PrintType, log, log_cv, log_t};
use iota_state::{RELOAD, SHUTDOWN};
use iota_storage::users::user_manager::{add_user, save_users}; use iota_storage::users::user_manager::{add_user, save_users};
use iota_storage::users::user_profile::UserProfile; use iota_storage::users::user_profile::UserProfile;
use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64};
@ -81,8 +80,6 @@ pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>
log_t!("User creation: Response returned none"); log_t!("User creation: Response returned none");
return (None, None); return (None, None);
} }
*SHUTDOWN.write().await = true;
*RELOAD.write().await = true;
log!("Created User"); log!("Created User");
save_file( save_file(
"", "",

View file

@ -5,12 +5,22 @@ Wants=network-online.target
Requires=iota-daemon.socket Requires=iota-daemon.socket
[Service] [Service]
Type=simple Type=notify
ExecStart=/usr/bin/iota-daemon ExecStart=/usr/bin/iota-daemon
Restart=on-failure Restart=on-failure
RestartSec=5s
RuntimeDirectory=iota RuntimeDirectory=iota
RuntimeDirectoryMode=0750 RuntimeDirectoryMode=0750
Environment=IOTA_SOCKET=/run/iota/iota.sock Environment=IOTA_SOCKET=/run/iota/iota.sock
# Exit code 75 = restart requested (daemon-specific convention)
RestartPreventExitStatus=0
RestartForceExitStatus=75
# Graceful shutdown
TimeoutStopSec=10
KillMode=mixed
KillSignal=SIGTERM
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -6,6 +6,7 @@ ListenStream=/run/iota/iota.sock
SocketMode=0660 SocketMode=0660
SocketUser=iota SocketUser=iota
SocketGroup=iota SocketGroup=iota
Backlog=5
RemoveOnStop=true RemoveOnStop=true
[Install] [Install]

View file

@ -8,7 +8,7 @@ edition = "2024"
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] }
bytes = "1" bytes = "1"
http = "1" http = "1"
iota-state = { path = "../iota-state" }
iota-util = { path = "../iota-util" }
iota-logger = { path = "../iota-logger" } iota-logger = { path = "../iota-logger" }
iota-util = { path = "../iota-util" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

View file

@ -1,11 +1,10 @@
use bytes::Bytes; use bytes::Bytes;
use iota_logger::log; use iota_logger::log;
use iota_state::{ACTIVE_TASKS, SHUTDOWN};
use iota_util::file_util::load_file_vec; use iota_util::file_util::load_file_vec;
use mtp::host::HostConfig; use mtp::host::HostConfig;
use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig}; use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use tokio::time::{Duration, sleep}; use tokio_util::sync::CancellationToken;
const CERT_PATH: &str = "certs/cert.pem"; const CERT_PATH: &str = "certs/cert.pem";
const KEY_PATH: &str = "certs/cert.key"; const KEY_PATH: &str = "certs/cert.key";
@ -63,7 +62,7 @@ fn content_type(name: &str) -> &'static str {
} }
} }
pub async fn start(port: u16) -> bool { pub async fn start(port: u16, cancellation: CancellationToken) -> bool {
let certificate = match tokio::fs::read(CERT_PATH).await { let certificate = match tokio::fs::read(CERT_PATH).await {
Ok(certificate) => certificate, Ok(certificate) => certificate,
Err(error) => { Err(error) => {
@ -102,7 +101,6 @@ pub async fn start(port: u16) -> bool {
log!("MTP web server running on port {}", port); log!("MTP web server running on port {}", port);
tokio::spawn(async move { tokio::spawn(async move {
ACTIVE_TASKS.insert("WebServer".into());
loop { loop {
tokio::select! { tokio::select! {
result = server.accept() => { result = server.accept() => {
@ -112,22 +110,12 @@ pub async fn start(port: u16) -> bool {
Err(error) => log!("MTP webserver connection failed: {}", error), Err(error) => log!("MTP webserver connection failed: {}", error),
} }
} }
_ = wait_for_shutdown() => { _ = cancellation.cancelled() => {
server.shutdown().await; server.shutdown().await;
break; break;
} }
} }
} }
ACTIVE_TASKS.remove("WebServer");
}); });
true true
} }
async fn wait_for_shutdown() {
loop {
if *SHUTDOWN.read().await {
break;
}
sleep(Duration::from_millis(100)).await;
}
}