cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/hero-release-note
goq / crates / sigil-host / src / authorization_admin.rs
authorization_admin.rs 21.4K
1use std::path::{Path, PathBuf};2use std::sync::Arc;3use std::time::Duration;45use anyhow::{Context, Result, ensure};6use iroh::EndpointId;7use serde::{Deserialize, Serialize};8use sigil_protocol::{InvitationGrants, MAX_INVITATION_TTL_SECS, SignedInvitation};9use tokio::io::{AsyncReadExt, AsyncWriteExt};1011use crate::authorization::{12 AuthorizationMutation, AuthorizationStore, EnrolledViewer, grant_names, unix_timestamp_now,13};14use crate::secure_state;15use crate::server::{InputOperations, SessionRegistry};1617pub const ADMIN_SOCKET_FILE: &str = "authorization-admin-v1.sock";18const MAX_ADMIN_REQUEST_BYTES: u64 = 16 * 1024;19const MAX_ADMIN_RESPONSE_BYTES: usize = 64 * 1024;20const ADMIN_IO_TIMEOUT: Duration = Duration::from_secs(5);2122#[derive(Clone, Debug, Deserialize, Serialize)]23#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]24pub enum AuthorizationAdminRequest {25 List,26 InspectRevision,27 CreateInvitation {28 peer_node_id: String,29 grants: u8,30 expires_in_seconds: u64,31 },32 RevokeViewer {33 handle: String,34 },35 ReplaceGrants {36 handle: String,37 grants: Option<u8>,38 },39}4041#[derive(Clone, Debug, Deserialize, Serialize)]42#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)]43pub enum AuthorizationAdminResult {44 Viewers {45 committed_revision: u64,46 enrollment_epoch: u64,47 viewers: Vec<AdminViewer>,48 },49 Revision {50 committed_revision: u64,51 },52 Invitation {53 token: String,54 expires_at_unix: u64,55 committed_revision: u64,56 },57 Mutation {58 handle: String,59 committed_revision: u64,60 disconnected: bool,61 input_neutralized: bool,62 },63}6465#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]66#[serde(deny_unknown_fields)]67pub struct AdminViewer {68 pub handle: String,69 pub grants: Vec<String>,70 pub enrolled_at_unix: u64,71 pub authorization_revision: u64,72}7374#[derive(Clone, Debug, Deserialize, Serialize)]75#[serde(deny_unknown_fields)]76struct AuthorizationAdminResponse {77 ok: bool,78 #[serde(skip_serializing_if = "Option::is_none")]79 result: Option<AuthorizationAdminResult>,80 #[serde(skip_serializing_if = "Option::is_none")]81 error: Option<String>,82}8384pub struct AuthorizationAdminServer {85 socket_path: PathBuf,86 shutdown: Option<tokio::sync::oneshot::Sender<()>>,87 task: Option<tokio::task::JoinHandle<()>>,88}8990impl AuthorizationAdminServer {91 pub fn start(92 state_directory: &Path,93 store: AuthorizationStore,94 sessions: Arc<SessionRegistry>,95 input_operations: Arc<InputOperations>,96 host_secret: [u8; 32],97 ) -> Result<Self> {98 #[cfg(not(unix))]99 {100 let _ = (101 state_directory,102 store,103 sessions,104 input_operations,105 host_secret,106 );107 anyhow::bail!("authorization administration requires a Unix host");108 }109 #[cfg(unix)]110 {111 let socket_path =112 secure_state::prepare_private_unix_socket(state_directory, ADMIN_SOCKET_FILE)?;113 let listener = tokio::net::UnixListener::bind(&socket_path).with_context(|| {114 format!(115 "binding authorization administration socket {}",116 socket_path.display()117 )118 })?;119 secure_state::secure_private_unix_socket(&socket_path)?;120 let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel();121 let task_path = socket_path.clone();122 let task = tokio::spawn(async move {123 loop {124 tokio::select! {125 _ = &mut shutdown_rx => break,126 accepted = listener.accept() => {127 match accepted {128 Ok((stream, _address)) => {129 if let Err(error) = serve_request(130 stream,131 &store,132 &sessions,133 &input_operations,134 host_secret,135 ).await {136 tracing::warn!(%error, "authorization administration request failed");137 }138 }139 Err(error) => {140 tracing::warn!(%error, "authorization administration listener failed");141 break;142 }143 }144 }145 }146 }147 let _ = std::fs::remove_file(&task_path);148 });149 Ok(Self {150 socket_path,151 shutdown: Some(shutdown),152 task: Some(task),153 })154 }155 }156157 pub async fn shutdown(mut self) {158 if let Some(shutdown) = self.shutdown.take() {159 let _ = shutdown.send(());160 }161 if let Some(task) = self.task.take() {162 let _ = task.await;163 }164 }165}166167impl Drop for AuthorizationAdminServer {168 fn drop(&mut self) {169 if let Some(shutdown) = self.shutdown.take() {170 let _ = shutdown.send(());171 }172 if let Some(task) = self.task.take() {173 task.abort();174 }175 let _ = std::fs::remove_file(&self.socket_path);176 }177}178179#[cfg(unix)]180async fn serve_request(181 mut stream: tokio::net::UnixStream,182 store: &AuthorizationStore,183 sessions: &SessionRegistry,184 input_operations: &InputOperations,185 host_secret: [u8; 32],186) -> Result<()> {187 let response = match tokio::time::timeout(ADMIN_IO_TIMEOUT, async {188 let mut bytes = Vec::new();189 (&mut stream)190 .take(MAX_ADMIN_REQUEST_BYTES + 1)191 .read_to_end(&mut bytes)192 .await?;193 ensure!(194 !bytes.is_empty() && bytes.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,195 "authorization administration request is empty or oversized"196 );197 let request: AuthorizationAdminRequest = serde_json::from_slice(&bytes)198 .context("parsing authorization administration request")?;199 handle_request(request, store, sessions, input_operations, host_secret).await200 })201 .await202 {203 Ok(Ok(result)) => AuthorizationAdminResponse {204 ok: true,205 result: Some(result),206 error: None,207 },208 Ok(Err(error)) => AuthorizationAdminResponse {209 ok: false,210 result: None,211 error: Some(format!("{error:#}")),212 },213 Err(_) => AuthorizationAdminResponse {214 ok: false,215 result: None,216 error: Some("authorization administration request timed out".to_owned()),217 },218 };219 let encoded = serde_json::to_vec(&response)?;220 ensure!(221 encoded.len() <= MAX_ADMIN_RESPONSE_BYTES,222 "authorization administration response exceeds its fixed bound"223 );224 tokio::time::timeout(ADMIN_IO_TIMEOUT, async {225 stream.write_all(&encoded).await?;226 stream.shutdown().await227 })228 .await229 .context("timed out writing authorization administration response")??;230 Ok(())231}232233async fn handle_request(234 request: AuthorizationAdminRequest,235 store: &AuthorizationStore,236 sessions: &SessionRegistry,237 input_operations: &InputOperations,238 host_secret: [u8; 32],239) -> Result<AuthorizationAdminResult> {240 match request {241 AuthorizationAdminRequest::List => {242 let snapshot = store.list_viewers()?;243 Ok(AuthorizationAdminResult::Viewers {244 committed_revision: snapshot.committed_revision,245 enrollment_epoch: snapshot.epoch,246 viewers: snapshot.viewers.iter().map(AdminViewer::from).collect(),247 })248 }249 AuthorizationAdminRequest::InspectRevision => {250 let snapshot = store.list_viewers()?;251 Ok(AuthorizationAdminResult::Revision {252 committed_revision: snapshot.committed_revision,253 })254 }255 AuthorizationAdminRequest::CreateInvitation {256 peer_node_id,257 grants,258 expires_in_seconds,259 } => {260 let peer = peer_node_id261 .parse::<EndpointId>()262 .context("invitation peer node ID is invalid")?;263 let grants = InvitationGrants::new(grants)?;264 ensure!(265 grants.contains(InvitationGrants::VIEW),266 "invitations must grant view permission"267 );268 ensure!(269 (1..=MAX_INVITATION_TTL_SECS).contains(&expires_in_seconds),270 "invitation TTL is outside the fixed bound"271 );272 let snapshot = store.list_viewers()?;273 let mut nonce = [0_u8; 32];274 getrandom::fill(&mut nonce).context("generating invitation nonce")?;275 let now = unix_timestamp_now()?;276 let host = iroh::SecretKey::from_bytes(&host_secret).public();277 let claims = AuthorizationStore::issue_claims_from_snapshot(278 host,279 snapshot.clone(),280 peer,281 grants,282 expires_in_seconds,283 now,284 nonce,285 )?;286 let expires_at_unix = claims.expires_at_unix;287 let token = SignedInvitation::issue(claims, &host_secret)?.encode();288 Ok(AuthorizationAdminResult::Invitation {289 token,290 expires_at_unix,291 committed_revision: snapshot.committed_revision,292 })293 }294 AuthorizationAdminRequest::RevokeViewer { handle } => {295 let mutation = store.revoke_viewer(&handle, unix_timestamp_now()?)?;296 apply_mutation(mutation, sessions, input_operations)297 }298 AuthorizationAdminRequest::ReplaceGrants { handle, grants } => {299 let grants = grants.map(InvitationGrants::new).transpose()?;300 let mutation = store.replace_viewer_grants(&handle, grants, unix_timestamp_now()?)?;301 apply_mutation(mutation, sessions, input_operations)302 }303 }304}305306fn apply_mutation(307 mutation: AuthorizationMutation,308 sessions: &SessionRegistry,309 input_operations: &InputOperations,310) -> Result<AuthorizationAdminResult> {311 let effect = sessions.apply_authorization_mutation(&mutation)?;312 if effect.neutralize_input {313 if let Some(transition_id) = effect.focus_transition_id {314 input_operations.neutralize_focus_transition(315 sessions,316 transition_id,317 sigil_protocol::FocusTransitionReasonV2::Revoked,318 )?;319 } else {320 input_operations.reset()?;321 }322 sessions.complete_authorization_neutralization(323 mutation.peer,324 mutation.authorization_revision,325 None,326 )?;327 }328 Ok(AuthorizationAdminResult::Mutation {329 handle: mutation.handle,330 committed_revision: mutation.committed_revision,331 disconnected: effect.disconnected,332 input_neutralized: effect.neutralize_input,333 })334}335336impl From<&EnrolledViewer> for AdminViewer {337 fn from(viewer: &EnrolledViewer) -> Self {338 Self {339 handle: viewer.handle.clone(),340 grants: grant_names(viewer.grants)341 .split(',')342 .map(str::to_owned)343 .collect(),344 enrolled_at_unix: viewer.enrolled_at_unix,345 authorization_revision: viewer.authorization_revision,346 }347 }348}349350#[cfg(unix)]351pub async fn request(352 state_directory: &Path,353 request: &AuthorizationAdminRequest,354) -> Result<AuthorizationAdminResult> {355 secure_state::validate_private_directory(state_directory)?;356 let socket_path = state_directory.join(ADMIN_SOCKET_FILE);357 let metadata = std::fs::symlink_metadata(&socket_path)358 .with_context(|| format!("inspecting administration socket {}", socket_path.display()))?;359 use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};360 ensure!(361 metadata.file_type().is_socket()362 && !metadata.file_type().is_symlink()363 && metadata.uid() == unsafe { libc::geteuid() }364 && metadata.mode() & 0o077 == 0,365 "authorization administration socket is unsafe"366 );367 let encoded = serde_json::to_vec(request)?;368 ensure!(369 encoded.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,370 "authorization administration request exceeds its fixed bound"371 );372 let mut stream = tokio::time::timeout(373 ADMIN_IO_TIMEOUT,374 tokio::net::UnixStream::connect(&socket_path),375 )376 .await377 .context("timed out connecting to authorization administration socket")??;378 tokio::time::timeout(ADMIN_IO_TIMEOUT, async {379 stream.write_all(&encoded).await?;380 stream.shutdown().await381 })382 .await383 .context("timed out writing authorization administration request")??;384 let mut response = Vec::new();385 tokio::time::timeout(386 ADMIN_IO_TIMEOUT,387 (&mut stream)388 .take(MAX_ADMIN_RESPONSE_BYTES as u64 + 1)389 .read_to_end(&mut response),390 )391 .await392 .context("timed out reading authorization administration response")??;393 ensure!(394 !response.is_empty() && response.len() <= MAX_ADMIN_RESPONSE_BYTES,395 "authorization administration response is empty or oversized"396 );397 let response: AuthorizationAdminResponse = serde_json::from_slice(&response)398 .context("parsing authorization administration response")?;399 if response.ok {400 response401 .result402 .context("successful administration response has no result")403 } else {404 anyhow::bail!(405 "{}",406 response407 .error408 .unwrap_or_else(|| "authorization administration request failed".to_owned())409 )410 }411}412413#[cfg(all(test, unix))]414mod tests {415 use super::*;416 use crate::config::{HostConfig, InputMode, VideoSource};417 use crate::input::InputBackend;418 use sigil_protocol::{ControllerSlot, FocusCommandActionV2, FocusCommandV2};419 use std::fs;420 use std::os::unix::fs::PermissionsExt;421422 fn disabled_input_backend(state_path: &Path) -> InputBackend {423 InputBackend::initialize(&HostConfig {424 identity_path: state_path.join("unused-identity"),425 state_path: state_path.to_path_buf(),426 max_viewers: crate::config::DEFAULT_MAX_VIEWERS,427 focus_owner: None,428 source: VideoSource::TestPattern,429 width: Some(1280),430 height: Some(800),431 framerate: 60,432 codec: "h264".to_owned(),433 input_mode: InputMode::Disabled,434 uinput: None,435 ffmpeg_path: PathBuf::from("ffmpeg"),436 gamescope_pipewire: None,437 audio: None,438 })439 .unwrap()440 }441442 async fn issue_and_redeem(443 directory: &Path,444 store: &AuthorizationStore,445 peer: EndpointId,446 grants: InvitationGrants,447 ) -> crate::authorization::AuthorizedViewer {448 let result = request(449 directory,450 &AuthorizationAdminRequest::CreateInvitation {451 peer_node_id: peer.to_string(),452 grants: grants.bits(),453 expires_in_seconds: 600,454 },455 )456 .await457 .unwrap();458 let AuthorizationAdminResult::Invitation { token, .. } = result else {459 panic!("unexpected invitation response");460 };461 store462 .authorize_or_redeem_viewer(peer, Some(&token), unix_timestamp_now().unwrap())463 .unwrap()464 }465466 #[tokio::test]467 async fn socket_administers_multiple_viewers_and_persists_mutations() {468 let directory = tempfile::tempdir().unwrap();469 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();470 let host = iroh::SecretKey::from_bytes(&[7; 32]);471 let peer_one = iroh::SecretKey::from_bytes(&[8; 32]).public();472 let peer_two = iroh::SecretKey::from_bytes(&[9; 32]).public();473 let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();474 let sessions = Arc::new(SessionRegistry::default());475 let operations = Arc::new(InputOperations::new(disabled_input_backend(476 directory.path(),477 )));478 let server = AuthorizationAdminServer::start(479 directory.path(),480 store.clone(),481 Arc::clone(&sessions),482 operations,483 host.to_bytes(),484 )485 .unwrap();486487 let first =488 issue_and_redeem(directory.path(), &store, peer_one, InvitationGrants::ALL).await;489 let second =490 issue_and_redeem(directory.path(), &store, peer_two, InvitationGrants::VIEW).await;491 assert_ne!(first.handle, second.handle);492493 let AuthorizationAdminResult::Viewers {494 viewers,495 committed_revision,496 ..497 } = request(directory.path(), &AuthorizationAdminRequest::List)498 .await499 .unwrap()500 else {501 panic!("unexpected list response");502 };503 assert_eq!(viewers.len(), 2);504 assert!(committed_revision >= 3);505 assert!(506 viewers507 .iter()508 .all(|viewer| !viewer.handle.contains(&peer_one.to_string()))509 );510511 request(512 directory.path(),513 &AuthorizationAdminRequest::ReplaceGrants {514 handle: first.handle.clone(),515 grants: Some(InvitationGrants::VIEW.bits()),516 },517 )518 .await519 .unwrap();520 assert!(521 sessions522 .claim_v2_authorized(peer_one, [9; 16], first.clone())523 .is_err(),524 "an admission decision from before the committed mutation must be stale"525 );526 request(527 directory.path(),528 &AuthorizationAdminRequest::RevokeViewer {529 handle: second.handle,530 },531 )532 .await533 .unwrap();534535 server.shutdown().await;536 let snapshot = store.list_viewers().unwrap();537 assert_eq!(snapshot.viewers.len(), 1);538 assert_eq!(snapshot.viewers[0].handle, first.handle);539 assert_eq!(snapshot.viewers[0].grants, InvitationGrants::VIEW);540 assert!(directory.path().join("authorization-v2.json").is_file());541 assert!(!directory.path().join(ADMIN_SOCKET_FILE).exists());542 }543544 #[tokio::test]545 async fn committed_reduction_defocuses_then_view_revocation_disconnects() {546 let directory = tempfile::tempdir().unwrap();547 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();548 let host = iroh::SecretKey::from_bytes(&[17; 32]);549 let peer = iroh::SecretKey::from_bytes(&[18; 32]).public();550 let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();551 let sessions = Arc::new(SessionRegistry::default());552 let operations = Arc::new(InputOperations::new(disabled_input_backend(553 directory.path(),554 )));555 let server = AuthorizationAdminServer::start(556 directory.path(),557 store.clone(),558 Arc::clone(&sessions),559 Arc::clone(&operations),560 host.to_bytes(),561 )562 .unwrap();563 let authorized =564 issue_and_redeem(directory.path(), &store, peer, InvitationGrants::ALL).await;565 let handle = authorized.handle.clone();566 let media = sessions567 .claim_v2_authorized(peer, [3; 16], authorized)568 .unwrap();569 let granted = sessions570 .apply_focus_command(571 peer,572 media.session_id,573 &FocusCommandV2 {574 request_id: 1,575 action: FocusCommandActionV2::Request,576 slot: ControllerSlot::ZERO,577 expected_revision: 1,578 expected_focus_generation: None,579 expected_proposal_id: None,580 },581 )582 .unwrap();583 let focus_generation = granted.snapshot.self_focus_generation().unwrap();584585 let AuthorizationAdminResult::Mutation {586 input_neutralized,587 disconnected,588 ..589 } = request(590 directory.path(),591 &AuthorizationAdminRequest::ReplaceGrants {592 handle: handle.clone(),593 grants: Some(InvitationGrants::VIEW.bits()),594 },595 )596 .await597 .unwrap()598 else {599 panic!("unexpected grant mutation response");600 };601 assert!(input_neutralized);602 assert!(!disconnected);603 assert!(!sessions.is_v2_focus_owner(604 peer,605 media.session_id,606 ControllerSlot::ZERO,607 focus_generation608 ));609 assert!(sessions.is_active(peer, media.session_id));610611 let AuthorizationAdminResult::Mutation { disconnected, .. } = request(612 directory.path(),613 &AuthorizationAdminRequest::RevokeViewer { handle },614 )615 .await616 .unwrap() else {617 panic!("unexpected revoke response");618 };619 assert!(disconnected);620 assert!(!sessions.is_active(peer, media.session_id));621 server.shutdown().await;622 }623}