Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/omachatd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ repository.workspace = true
ed25519-dalek = "=2.2.0"
getrandom = "=0.4.3"
hex = "=0.4.3"
libc = "=0.2.189"
omachat-crypto = { path = "../omachat-crypto", version = "=0.0.1" }
omachat-nostr = { path = "../omachat-nostr", version = "=0.0.1" }
omachat-proto = { path = "../omachat-proto", version = "=0.0.1" }
Expand Down
27 changes: 25 additions & 2 deletions crates/omachatd/src/ipc_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ use serde_json::to_value;
use std::{
error::Error,
fmt, fs,
fs::{File, OpenOptions},
future::Future,
os::unix::fs::{FileTypeExt, PermissionsExt},
os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
pin::Pin,
sync::{Arc, Mutex},
Expand Down Expand Up @@ -72,6 +73,8 @@ impl EventHub {
pub struct IpcServer<H> {
listener: UnixListener,
socket_path: PathBuf,
lock_path: PathBuf,
_instance_lock: File,
handler: Arc<H>,
events: EventHub,
}
Expand All @@ -83,6 +86,21 @@ impl<H: RequestHandler> IpcServer<H> {
events: EventHub,
) -> Result<Self, ServerError> {
let socket_path = socket_path.as_ref().to_owned();
let lock_path = socket_path.with_extension("lock");
let instance_lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.mode(0o600)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&lock_path)
.map_err(ServerError::Io)?;
instance_lock.try_lock().map_err(|error| match error {
fs::TryLockError::WouldBlock => ServerError::AlreadyRunning,
fs::TryLockError::Error(error) => ServerError::Io(error),
})?;
fs::set_permissions(&lock_path, fs::Permissions::from_mode(0o600))
.map_err(ServerError::Io)?;
if socket_path.exists() {
let metadata = fs::symlink_metadata(&socket_path).map_err(ServerError::Io)?;
if !metadata.file_type().is_socket() {
Expand All @@ -96,6 +114,8 @@ impl<H: RequestHandler> IpcServer<H> {
Ok(Self {
listener,
socket_path,
lock_path,
_instance_lock: instance_lock,
handler: Arc::new(handler),
events,
})
Expand Down Expand Up @@ -167,6 +187,7 @@ impl<H: RequestHandler> IpcServer<H> {
impl<H> Drop for IpcServer<H> {
fn drop(&mut self) {
let _ = fs::remove_file(&self.socket_path);
let _ = fs::remove_file(&self.lock_path);
}
}

Expand Down Expand Up @@ -273,6 +294,7 @@ pub enum ServerError {
Io(std::io::Error),
Protocol(IpcError),
OccupiedPath,
AlreadyRunning,
}

impl fmt::Display for ServerError {
Expand All @@ -281,6 +303,7 @@ impl fmt::Display for ServerError {
Self::Io(error) => write!(formatter, "IPC I/O failed: {error}"),
Self::Protocol(error) => write!(formatter, "IPC protocol failed: {error}"),
Self::OccupiedPath => formatter.write_str("IPC path exists and is not a socket"),
Self::AlreadyRunning => formatter.write_str("another daemon owns the IPC endpoint"),
}
}
}
Expand All @@ -290,7 +313,7 @@ impl Error for ServerError {
match self {
Self::Io(error) => Some(error),
Self::Protocol(error) => Some(error),
Self::OccupiedPath => None,
Self::OccupiedPath | Self::AlreadyRunning => None,
}
}
}
14 changes: 14 additions & 0 deletions crates/omachatd/tests/ipc_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,17 @@ async fn assert_client_eof(reader: &mut BufReader<tokio::net::unix::OwnedReadHal
.expect("read client EOF");
assert_eq!(count, 0, "server closed client after flushing response");
}

#[tokio::test]
async fn second_server_cannot_replace_a_live_ipc_endpoint() {
let temporary = tempdir().expect("temporary directory");
let socket = temporary.path().join("omachat.sock");
let first = IpcServer::bind(&socket, Handler, EventHub::default()).expect("first bind");

let second = IpcServer::bind(&socket, Handler, EventHub::default());
assert!(matches!(second, Err(ServerError::AlreadyRunning)));
assert!(socket.exists(), "the live endpoint remains addressable");

drop(first);
assert!(!socket.exists(), "the owner removes its endpoint on drop");
}
8 changes: 8 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ inputs for the Omarchy v4.0.1 clean-install gate.

## User service

OmaChat's local IPC socket is an account-local control boundary, not an
application sandbox. Socket permissions exclude other Unix users, and the
daemon refuses a concurrent instance, but processes already running as the
same account are inside this trust boundary. Run untrusted desktop software
under a separate OS identity or sandbox that cannot access the account's
runtime directory. In particular, do not describe the `0600` socket as
authenticating individual same-user applications.

After a package installs the binaries and `omachatd.service`:

```sh
Expand Down
Loading