feat: add authenticated IPC and feresectl
This commit is contained in:
1 parent
3d0007ba12
commit
d6acfa89e7
12 files changed
+774
-2
No files matched your search
@@ -0,0 +1,392 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::thread;
|
||||
|
||||
use ferese_core::LayoutMode;
|
||||
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use ferese_layout::Direction;
|
||||
use serde_json::{Value, json};
|
||||
use smithay::reexports::calloop::{EventLoop, channel};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
const REQUEST_QUEUE_CAPACITY: usize = 128;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IpcCall {
|
||||
request: Request,
|
||||
response: SyncSender<Response>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct IpcSocketGuard {
|
||||
path: PathBuf,
|
||||
device: u64,
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
impl Drop for IpcSocketGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.path.symlink_metadata().is_ok_and(|metadata| {
|
||||
metadata.file_type().is_socket()
|
||||
&& metadata.dev() == self.device
|
||||
&& metadata.ino() == self.inode
|
||||
}) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init(
|
||||
event_loop: &mut EventLoop<'static, Ferese>,
|
||||
) -> Result<IpcSocketGuard, Box<dyn std::error::Error>> {
|
||||
let path = socket_path()?;
|
||||
prepare_parent(&path)?;
|
||||
let listener = bind_listener(&path)?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
let guard = IpcSocketGuard {
|
||||
path: path.clone(),
|
||||
device: metadata.dev(),
|
||||
inode: metadata.ino(),
|
||||
};
|
||||
|
||||
let (sender, receiver): (channel::SyncSender<IpcCall>, channel::Channel<IpcCall>) =
|
||||
channel::sync_channel(REQUEST_QUEUE_CAPACITY);
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(receiver, |event, _, state| {
|
||||
if let channel::Event::Msg(call) = event {
|
||||
let response = state.handle_ipc_request(call.request);
|
||||
let _ = call.response.send(response);
|
||||
}
|
||||
})?;
|
||||
|
||||
thread::Builder::new()
|
||||
.name("ferese-ipc-listener".to_owned())
|
||||
.spawn(move || accept_connections(listener, sender))?;
|
||||
|
||||
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcCall>) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept() {
|
||||
Ok(connection) => connection,
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "IPC listener stopped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match peer_uid(&stream) {
|
||||
Ok(uid) if uid == effective_uid() => {}
|
||||
Ok(uid) => {
|
||||
tracing::warn!(uid, "rejected IPC connection from a different user");
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not authenticate IPC peer");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let sender = sender.clone();
|
||||
if let Err(error) = thread::Builder::new()
|
||||
.name("ferese-ipc-client".to_owned())
|
||||
.spawn(move || serve_connection(stream, sender))
|
||||
{
|
||||
tracing::warn!(%error, "could not start IPC connection worker");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>) {
|
||||
loop {
|
||||
let request = match read_frame(&mut stream) {
|
||||
Ok(request) => request,
|
||||
Err(ferese_ipc::FrameError::Io(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::UnexpectedEof
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::BrokenPipe
|
||||
) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "closing invalid IPC connection");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (response, receiver) = sync_channel(1);
|
||||
let call = IpcCall { request, response };
|
||||
if sender.try_send(call).is_err() {
|
||||
tracing::warn!("disconnecting IPC client because the request queue is full");
|
||||
return;
|
||||
}
|
||||
let Ok(response) = receiver.recv() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = write_frame(&mut stream, &response) {
|
||||
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn handle_ipc_request(&mut self, request: Request) -> Response {
|
||||
if request.version != VERSION {
|
||||
return Response::error(
|
||||
request.id,
|
||||
"unsupported_version",
|
||||
format!("supported version is {VERSION}"),
|
||||
);
|
||||
}
|
||||
if request.kind != "command" {
|
||||
return Response::error(request.id, "invalid_type", "expected type \"command\"");
|
||||
}
|
||||
|
||||
match self.dispatch_ipc_command(&request.command, &request.args) {
|
||||
Ok(result) => Response::success(request.id, result),
|
||||
Err(error) => Response::error(request.id, error.code, error.message),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
|
||||
match command {
|
||||
"focus" => self.focus_direction(direction_arg(args)?),
|
||||
"move" => self.move_direction(direction_arg(args)?),
|
||||
"resize" => self.resize_direction(direction_arg(args)?),
|
||||
"workspace" => self.switch_workspace(workspace_arg(args)?),
|
||||
"move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?),
|
||||
"toggle-floating" => self.toggle_focused_floating(),
|
||||
"toggle-fullscreen" => self.toggle_focused_fullscreen(),
|
||||
"close" => self.close_focused_window(),
|
||||
"get-focused-window" => return Ok(self.focused_window_json()),
|
||||
"get-workspaces" => return Ok(self.workspaces_json()),
|
||||
"get-outputs" => return Ok(self.outputs_json()),
|
||||
_ => {
|
||||
return Err(CommandError::new(
|
||||
"unknown_command",
|
||||
format!("unknown command {command:?}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn focused_window_json(&self) -> Value {
|
||||
self.focused_window
|
||||
.map(|window| json!({ "id": window.0 }))
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn workspaces_json(&self) -> Value {
|
||||
let active = self.workspaces.active_id();
|
||||
let mut workspaces = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
let mode = match workspace.layout.mode() {
|
||||
LayoutMode::Scrolling => "scrolling",
|
||||
LayoutMode::Tree => "tree",
|
||||
};
|
||||
json!({
|
||||
"id": workspace.id.0,
|
||||
"name": workspace.name,
|
||||
"active": workspace.id == active,
|
||||
"layout": mode,
|
||||
"focused_window": workspace.last_focused.map(|window| window.0),
|
||||
"fullscreen_window": workspace.fullscreen.map(|window| window.0),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
workspaces.sort_by_key(|workspace| workspace["id"].as_u64());
|
||||
Value::Array(workspaces)
|
||||
}
|
||||
|
||||
fn outputs_json(&self) -> Value {
|
||||
let focused = self.output_workspaces.focused_output();
|
||||
let mut outputs = self
|
||||
.space
|
||||
.outputs()
|
||||
.filter_map(|output| {
|
||||
let id = self.output_id(output)?;
|
||||
let geometry = self.space.output_geometry(output)?;
|
||||
let workspace = self.output_workspaces.active_workspace(id)?;
|
||||
Some(json!({
|
||||
"id": id.0,
|
||||
"name": output.name(),
|
||||
"focused": Some(id) == focused,
|
||||
"workspace": workspace.0,
|
||||
"x": geometry.loc.x,
|
||||
"y": geometry.loc.y,
|
||||
"width": geometry.size.w,
|
||||
"height": geometry.size.h,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
outputs.sort_by_key(|output| output["id"].as_u64());
|
||||
Value::Array(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CommandError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn direction_arg(args: &Value) -> Result<Direction, CommandError> {
|
||||
match args.get("direction").and_then(Value::as_str) {
|
||||
Some("left") => Ok(Direction::Left),
|
||||
Some("right") => Ok(Direction::Right),
|
||||
Some("up") => Ok(Direction::Up),
|
||||
Some("down") => Ok(Direction::Down),
|
||||
_ => Err(CommandError::new(
|
||||
"invalid_argument",
|
||||
"direction must be left, right, up, or down",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_arg(args: &Value) -> Result<u32, CommandError> {
|
||||
args.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|index| u32::try_from(index).ok())
|
||||
.filter(|index| *index > 0)
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"invalid_argument",
|
||||
"index must be a positive 32-bit integer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|directory| directory.join("ferese/control.sock"))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||
}
|
||||
|
||||
fn prepare_parent(socket: &Path) -> Result<(), io::Error> {
|
||||
let parent = socket
|
||||
.parent()
|
||||
.expect("the Ferese control socket always has a parent");
|
||||
match fs::symlink_metadata(parent) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} is not a real directory", parent.display()),
|
||||
));
|
||||
}
|
||||
if metadata.uid() != effective_uid() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!("{} is owned by another user", parent.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(parent)?,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
|
||||
}
|
||||
|
||||
fn bind_listener(path: &Path) -> Result<UnixListener, io::Error> {
|
||||
match UnixListener::bind(path) {
|
||||
Ok(listener) => Ok(listener),
|
||||
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
|
||||
if UnixStream::connect(path).is_ok() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
"another Ferese IPC server is already running",
|
||||
));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_socket() || metadata.uid() != effective_uid() {
|
||||
return Err(error);
|
||||
}
|
||||
fs::remove_file(path)?;
|
||||
UnixListener::bind(path)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_uid(stream: &UnixStream) -> Result<u32, io::Error> {
|
||||
let mut credentials = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
|
||||
// SAFETY: `credentials` and `length` point to initialized, correctly sized
|
||||
// storage for Linux SO_PEERCRED, and the stream owns a live file descriptor.
|
||||
let result = unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&raw mut credentials).cast(),
|
||||
&raw mut length,
|
||||
)
|
||||
};
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(credentials.uid)
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_uid() -> u32 {
|
||||
// SAFETY: `geteuid` has no preconditions and cannot fail.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_direction_arguments() {
|
||||
assert_eq!(
|
||||
direction_arg(&json!({ "direction": "left" })).unwrap(),
|
||||
Direction::Left
|
||||
);
|
||||
assert!(direction_arg(&json!({ "direction": "diagonal" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_workspace_arguments() {
|
||||
assert_eq!(workspace_arg(&json!({ "index": 9 })).unwrap(), 9);
|
||||
assert!(workspace_arg(&json!({ "index": 0 })).is_err());
|
||||
assert!(workspace_arg(&json!({ "index": -1 })).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ mod cursor;
|
||||
mod grabs;
|
||||
mod handlers;
|
||||
mod input;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod window_rules;
|
||||
mod winit;
|
||||
|
||||
use std::{error::Error, io, process::Command};
|
||||
|
||||
use calloop::signals::{Signal, Signals};
|
||||
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
|
||||
pub use state::{Ferese, RuntimeConfig};
|
||||
use tracing::{info, warn};
|
||||
@@ -29,6 +31,13 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let bindings = config.bindings(&input_settings)?;
|
||||
let window_rules = config.window_rules()?;
|
||||
let mut event_loop = EventLoop::try_new()?;
|
||||
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(signals, |event, _, state: &mut Ferese| {
|
||||
info!(signal = ?event.signal(), "stopping Ferese");
|
||||
state.loop_signal.stop();
|
||||
})?;
|
||||
let display = Display::new()?;
|
||||
let runtime = RuntimeConfig {
|
||||
layout_mode: config.layout_mode(),
|
||||
|
||||
+10
-2
@@ -112,6 +112,7 @@ pub struct Ferese {
|
||||
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||
pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
|
||||
_ipc_socket: Option<crate::ipc::IpcSocketGuard>,
|
||||
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
||||
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
|
||||
next_window_id: u64,
|
||||
@@ -260,6 +261,10 @@ impl Ferese {
|
||||
.find_map(|(output, id)| (*id == focused).then_some(output))
|
||||
}
|
||||
|
||||
pub(crate) fn output_id(&self, output: &Output) -> Option<OutputId> {
|
||||
self.output_ids.get(output).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
||||
let output = self.space.outputs().find_map(|output| {
|
||||
let geometry = self.space.output_geometry(output)?;
|
||||
@@ -369,7 +374,7 @@ impl Ferese {
|
||||
let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default);
|
||||
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
||||
|
||||
Ok(Self {
|
||||
let mut state = Self {
|
||||
start_time,
|
||||
socket_name,
|
||||
display_handle,
|
||||
@@ -405,6 +410,7 @@ impl Ferese {
|
||||
idle_inhibitors: HashMap::new(),
|
||||
active_shortcuts_inhibitor: None,
|
||||
direct_backend: None,
|
||||
_ipc_socket: None,
|
||||
pending_dmabuf_imports: Vec::new(),
|
||||
pending_screencopies: Vec::new(),
|
||||
next_window_id: 1,
|
||||
@@ -437,7 +443,9 @@ impl Ferese {
|
||||
xdg_foreign_state,
|
||||
xdg_shell_state,
|
||||
xdg_toplevel_icon_manager,
|
||||
})
|
||||
};
|
||||
state._ipc_socket = Some(crate::ipc::init(event_loop)?);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn init_wayland_listener(
|
||||
|
||||
Reference in new issue
Block a user