Skip to content

Commit 98879ab

Browse files
committed
refactor: consolidate server logic into a shared module and modernize initialization flow
1 parent 43a088f commit 98879ab

11 files changed

Lines changed: 296 additions & 359 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ If you are an AI assistant working on this repository or a project that consumes
1212
> [!NOTE]
1313
> There is an **experimental and optional** RGB control interface available (`com.performativenonsense.contextd.rgb`) which allows for active state changes on supported hardware when enabled.
1414
15-
- **Unprivileged Access**: No root/sudo is required to query the core socket or RGB sockets. The RGB control socket is currently configured with open permissions (0666).
16-
- **Authoritative Control**: To persist an authorized RGB controller across boots, a superuser can create `/etc/contextd/rgb-authorized-app` containing the process name of the allowed controller.
15+
- **Unprivileged Access**: No root/sudo is required to query the core socket or RGB sockets.
16+
- **Authoritative Control**: To authorize an application (e.g., an RGB controller), a superuser must add the application's systemd unit name (e.g., `openrgb.service`) to the `authorized_units` list in `/etc/contextd/config.toml`.
1717
- **Data Integrity**: Process IDs and hardware nodes are verified by the daemon before being reported.
1818

1919
### 2. Available Interfaces

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,28 @@ To detach/uninstall:
105105
sudo portablectl detach contextd
106106
```
107107

108+
## Configuration
109+
110+
`contextd` can be configured via a TOML file located at `/etc/contextd/config.toml`. A sample configuration is provided in `examples/config.sample.toml`.
111+
112+
### Key Settings:
113+
- **TTLs**: Control how frequently the daemon polls for games, hardware, and diagnostics.
114+
- **Blacklisting**: Ignore specific processes or hardware devices.
115+
- **Security**: Authorize specific systemd units for restricted operations.
116+
117+
## Security & Access Control
118+
119+
The daemon implements a "dumb" but secure peer validation system:
120+
- **Unprivileged Public Sockets**: Basic context (active game, hardware list) is accessible via `/run/contextd/public/*.socket` to all users.
121+
- **Restricted Private Sockets**: Control operations (RGB lighting, controller registration) are restricted via `/run/contextd/private/*.socket`.
122+
- **Peer Validation**: Uses `SO_PEERCRED` to identify the systemd unit of the calling process.
123+
- **Granular Authorization**: Restricted methods are only allowed if the caller's systemd unit is listed in the `authorized_units` whitelist in `config.toml`.
124+
108125
## Development
109126

110127
- **Repository**: [https://github.com/shanefagan/contextd](https://github.com/shanefagan/contextd)
111-
112128
- **Author**: Shane Fagan
113129

114-
115130
## License
116131

117132
MIT

docs/todo.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919

2020
## Phase 7: Refinement & Security Hardening
21-
- [ ] **Systemd Peer Validation**: Use `SO_PEERCRED` to identify calling services and apply granular access control based on systemd units.
21+
- [x] **Systemd Peer Validation**: Uses `SO_PEERCRED` to identify calling services and apply granular access control based on systemd units.
2222
- [x] **Configuration Overrides**: Implemented `src/config.rs` with `/etc/contextd/config.toml` support for TTLs and blacklisting.
23+
- [x] **Directory Structure Cleanup**: Refactored logic into `server.rs`, `auth.rs`, and centralized detector management.
24+
- [ ] **Privacy Masking**: Implement logic to mask specific apps/devices from public Varlink calls via `config.toml`.
25+
- [ ] **Expanded Launcher Support**: Add Legendary (Epic) and Minigalaxy (GOG) manifests.
2326
- [ ] **Arch Linux PKGBUILD**: Finalize the AUR package for the 1.0 release.

examples/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,12 @@ Remember the daemon exposes three interfaces:
3838
2. **RGB Observer**: `/run/contextd/public/contextd-rgb-observer.socket`
3939
3. **RGB Control**: `/run/contextd/private/contextd-rgb-control.socket`
4040

41+
## Security Note
42+
43+
Methods like `RegisterController` and `SetLightingContext` are restricted. To run examples that use these methods (like `register_controller.py`), you must ensure:
44+
1. You are connecting to the **Private** socket path if applicable.
45+
2. The systemd unit running your script/app is listed in the `authorized_units` whitelist in `/etc/contextd/config.toml`.
46+
47+
If you are running an example script manually in a terminal, it will likely be identified by its `session-X.scope`. You can add that scope to the whitelist for testing, or run the script as a transient service with `systemd-run`.
48+
4149
> **Note:** The daemon must be running for these examples to work.

src/auth.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ impl PeerInfo {
8383
}
8484
}
8585

86+
/// Checks if the current calling peer is authorized based on a list of systemd units.
87+
/// Returns Ok(()) if authorized (or if the list is empty), otherwise returns Err(unit_name).
88+
pub fn verify_unit_access(authorized_units: &[String]) -> Result<(), String> {
89+
if authorized_units.is_empty() {
90+
return Ok(());
91+
}
92+
93+
let peer = get_current_peer();
94+
let unit = peer
95+
.as_ref()
96+
.and_then(|p| p.unit.as_ref())
97+
.map(|s| s.as_str())
98+
.unwrap_or("unknown");
99+
100+
if authorized_units.iter().any(|u| u == unit) {
101+
Ok(())
102+
} else {
103+
Err(unit.to_string())
104+
}
105+
}
106+
86107
/// Sets the current peer info for the local thread.
87108
pub fn set_current_peer(info: Option<PeerInfo>) {
88109
CURRENT_PEER.with(|p| *p.borrow_mut() = info);

src/detectors/games/manager.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use super::heroic::HeroicDetector;
2+
use super::lutris::LutrisDetector;
3+
use super::steam::SteamDetector;
14
use super::{Game, GameDetector};
25
use crate::config::CONFIG;
36
use std::time::{Duration, Instant};
@@ -11,12 +14,17 @@ pub struct GameManager {
1114
impl GameManager {
1215
pub fn new() -> Self {
1316
Self {
14-
detectors: Vec::new(),
17+
detectors: vec![
18+
Box::new(SteamDetector::new()),
19+
Box::new(LutrisDetector::new()),
20+
Box::new(HeroicDetector::new()),
21+
],
1522
installed_cache: None,
1623
active_cache: None,
1724
}
1825
}
1926

27+
#[allow(dead_code)]
2028
pub fn add_detector(&mut self, detector: Box<dyn GameDetector>) {
2129
self.detectors.push(detector);
2230
self.installed_cache = None; // Invalidate cache
@@ -59,6 +67,7 @@ impl GameManager {
5967
game
6068
}
6169
}
70+
6271
impl Default for GameManager {
6372
fn default() -> Self {
6473
Self::new()
@@ -89,6 +98,9 @@ mod tests {
8998
#[test]
9099
fn test_game_manager_cache() {
91100
let mut mgr = GameManager::new();
101+
// Clear default detectors for testing
102+
mgr.detectors.clear();
103+
92104
let game = Game {
93105
name: "Test Game".to_string(),
94106
id: Some("123".to_string()),
@@ -107,8 +119,6 @@ mod tests {
107119
assert_eq!(games[0].name, "Test Game");
108120

109121
// Even if we add more games to the detector, cache should return old value
110-
// Note: In this simple mock we can't easily change the detector's state since it's boxed
111-
// but we can verify that the second call is immediate.
112122
let games2 = mgr.list_all_installed();
113123
assert_eq!(games2.len(), 1);
114124
}

src/detectors/hardware/manager.rs

Lines changed: 46 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
1+
use super::udev::UdevDetector;
12
use super::{Device, HardwareDetector};
23
use crate::config::CONFIG;
34
use std::time::{Duration, Instant};
45

56
pub struct HardwareManager {
67
detectors: Vec<Box<dyn HardwareDetector>>,
7-
cache: Option<(Vec<Device>, Instant)>,
8+
device_cache: Option<(Vec<Device>, Instant)>,
89
rgb_cache: Option<(Vec<Device>, Instant)>,
910
}
1011

1112
impl HardwareManager {
1213
pub fn new() -> Self {
1314
Self {
14-
detectors: Vec::new(),
15-
cache: None,
15+
detectors: vec![Box::new(UdevDetector::new())],
16+
device_cache: None,
1617
rgb_cache: None,
1718
}
1819
}
1920

21+
#[allow(dead_code)]
2022
pub fn add_detector(&mut self, detector: Box<dyn HardwareDetector>) {
2123
self.detectors.push(detector);
22-
self.cache = None;
23-
self.rgb_cache = None;
24+
self.invalidate_caches();
2425
}
2526

2627
#[allow(dead_code)]
27-
pub fn invalidate(&mut self) {
28-
log::debug!("Hardware cache invalidated due to hotplug event");
29-
self.cache = None;
28+
pub fn invalidate_caches(&mut self) {
29+
self.device_cache = None;
3030
self.rgb_cache = None;
3131
}
3232

3333
pub fn list_all_devices(&mut self) -> Vec<Device> {
3434
let ttl = Duration::from_secs(CONFIG.ttls.hardware);
35-
if let Some((cache, ts)) = &self.cache
35+
if let Some((cache, ts)) = &self.device_cache
3636
&& ts.elapsed() < ttl
3737
{
3838
return cache.clone();
@@ -43,10 +43,9 @@ impl HardwareManager {
4343
.iter()
4444
.flat_map(|d| d.list_devices())
4545
.filter(|d| !CONFIG.blacklist.devices.contains(&d.path))
46-
.filter(|d| self.is_gaming_device(d))
4746
.collect();
4847

49-
self.cache = Some((devices.clone(), Instant::now()));
48+
self.device_cache = Some((devices.clone(), Instant::now()));
5049
devices
5150
}
5251

@@ -58,61 +57,15 @@ impl HardwareManager {
5857
return cache.clone();
5958
}
6059

61-
let devices: Vec<Device> = self
62-
.detectors
63-
.iter()
64-
.flat_map(|d| d.list_devices())
65-
.filter(|d| !CONFIG.blacklist.devices.contains(&d.path))
66-
.filter(|d| self.is_rgb_device(d))
67-
.collect();
60+
// For now, we consider all detected devices as candidates for RGB control
61+
// hints, though in the future we might filter for "hid" or "audio" classes.
62+
let devices = self.list_all_devices();
6863

6964
self.rgb_cache = Some((devices.clone(), Instant::now()));
7065
devices
7166
}
72-
73-
fn is_gaming_device(&self, dev: &Device) -> bool {
74-
// Exclude security keys (Yubico)
75-
if dev.vendor_id == "1050" {
76-
return false;
77-
}
78-
79-
// Exclude obvious lighting controllers from main list
80-
let name = dev.name.to_lowercase();
81-
if name.contains("lighting")
82-
|| name.contains("aura")
83-
|| name.contains("fan")
84-
|| name.contains("rgb")
85-
{
86-
return false;
87-
}
88-
89-
// Must have uaccess for many gaming devices, or be a classic input
90-
let is_classic = dev.classes.contains(&"mouse".to_string())
91-
|| dev.classes.contains(&"keyboard".to_string())
92-
|| dev.classes.contains(&"controller".to_string())
93-
|| dev.classes.contains(&"audio".to_string())
94-
|| dev.classes.contains(&"wheel".to_string())
95-
|| dev.classes.contains(&"flight_stick".to_string());
96-
97-
// Ensure audio devices actually have user-level permissions (filters out motherboard HDMI/PCI noise)
98-
if dev.classes.contains(&"audio".to_string()) && !dev.has_uaccess {
99-
return false;
100-
}
101-
102-
is_classic
103-
}
104-
105-
fn is_rgb_device(&self, dev: &Device) -> bool {
106-
let name = dev.name.to_lowercase();
107-
name.contains("rgb")
108-
|| name.contains("lighting")
109-
|| name.contains("led")
110-
|| name.contains("fan")
111-
|| name.contains("aura")
112-
|| name.contains("glow")
113-
|| name.contains("litra")
114-
}
11567
}
68+
11669
impl Default for HardwareManager {
11770
fn default() -> Self {
11871
Self::new()
@@ -123,56 +76,45 @@ impl Default for HardwareManager {
12376
mod tests {
12477
use super::*;
12578

126-
fn mock_device(name: &str, vendor_id: &str, classes: Vec<&str>, uaccess: bool) -> Device {
127-
Device {
128-
name: name.to_string(),
129-
vendor: "TestVendor".to_string(),
130-
vendor_id: vendor_id.to_string(),
131-
product_id: "0001".to_string(),
132-
bus_type: "usb".to_string(),
133-
path: "/dev/test".to_string(),
134-
classes: classes.into_iter().map(|s| s.to_string()).collect(),
135-
has_uaccess: uaccess,
136-
controllers: Vec::new(),
137-
}
79+
struct MockDetector {
80+
devices: Vec<Device>,
13881
}
13982

140-
#[test]
141-
fn test_is_gaming_device() {
142-
let mgr = HardwareManager::new();
143-
144-
// Standard Mouse
145-
let mouse = mock_device("Gaming Mouse", "1234", vec!["mouse"], true);
146-
assert!(mgr.is_gaming_device(&mouse));
147-
148-
// YubiKey (Security Key) - Should be blocked
149-
let yubikey = mock_device("YubiKey", "1050", vec!["keyboard"], true);
150-
assert!(!mgr.is_gaming_device(&yubikey));
151-
152-
// RGB Fan - Should be blocked from main list
153-
let fan = mock_device("LianLi Fan RGB", "9999", vec!["hid"], true);
154-
assert!(!mgr.is_gaming_device(&fan));
155-
156-
// Audio Device (No UAccess) - Should be blocked (filtered noise)
157-
let audio_noise = mock_device("HDMI Audio", "1002", vec!["audio"], false);
158-
assert!(!mgr.is_gaming_device(&audio_noise));
159-
160-
// BEACN Mic (Audio with UAccess)
161-
let beacn = mock_device("BEACN Mic", "33ae", vec!["audio"], true);
162-
assert!(mgr.is_gaming_device(&beacn));
83+
impl HardwareDetector for MockDetector {
84+
fn name(&self) -> &str {
85+
"Mock"
86+
}
87+
fn list_devices(&self) -> Vec<Device> {
88+
self.devices.clone()
89+
}
16390
}
16491

16592
#[test]
166-
fn test_is_rgb_device() {
167-
let mgr = HardwareManager::new();
93+
fn test_hardware_manager_cache() {
94+
let mut mgr = HardwareManager::new();
95+
mgr.detectors.clear(); // Clear default detectors for testing
96+
97+
let dev = Device {
98+
name: "Test Mouse".to_string(),
99+
vendor: "TestVendor".to_string(),
100+
vendor_id: "1234".to_string(),
101+
product_id: "5678".to_string(),
102+
bus_type: "usb".to_string(),
103+
path: "/dev/input/event0".to_string(),
104+
classes: vec!["mouse".to_string()],
105+
has_uaccess: true,
106+
controllers: Vec::new(),
107+
};
168108

169-
let rgb_strip = mock_device("Lighting Node PRO", "1b1c", vec!["hid"], true);
170-
assert!(mgr.is_rgb_device(&rgb_strip));
109+
mgr.add_detector(Box::new(MockDetector {
110+
devices: vec![dev.clone()],
111+
}));
171112

172-
let fan = mock_device("Cooler RGB Fan", "0000", vec!["hid"], true);
173-
assert!(mgr.is_rgb_device(&fan));
113+
let devices = mgr.list_all_devices();
114+
assert_eq!(devices.len(), 1);
115+
assert_eq!(devices[0].name, "Test Mouse");
174116

175-
let mouse = mock_device("Plain Mouse", "0000", vec!["mouse"], true);
176-
assert!(!mgr.is_rgb_device(&mouse));
117+
let devices2 = mgr.list_all_devices();
118+
assert_eq!(devices2.len(), 1);
177119
}
178120
}

0 commit comments

Comments
 (0)