use anyhow::{Context, Result};
use api::heddle::api::v1alpha1::{CallFailure, CallFailureCode};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use iroh::{Endpoint, protocol::Router};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{UnixListener, UnixStream},
};
use super::{
claim_authorization::{ClaimOwnerRootCall, StoredClaimAuthorization},
claim_offer::handle_owner_root_body,
hosted::{
HostedClient,
claim_protocol::{CLAIM_ALPN_V1, ClaimProtocol, VerifiedClaimPrincipal},
},
};
const MAX_BRIDGE_FRAME: usize = 1024 * 1024;
#[derive(Serialize, Deserialize)]
struct BridgeRequest {
subject: String,
authorization_hash: String,
body_b64: String,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum BridgeReply {
Ok { reply_b64: String },
Err { code: i32, message: String },
}
fn failure(code: CallFailureCode, message: impl Into<String>) -> CallFailure {
CallFailure {
code: code as i32,
message: message.into(),
error: None,
}
}
pub fn claim_bridge_socket_path(heddle_home: &Path) -> PathBuf {
repo::daemon::box_state_dir_in(heddle_home).join("heddle-netd-claim.sock")
}
pub struct DaemonClaimRouter {
router: Router,
owner_root_calls: tokio::sync::mpsc::Receiver<ClaimOwnerRootCall>,
}
#[must_use]
pub fn mount_claim_router(endpoint: Endpoint) -> DaemonClaimRouter {
let (authorization, _completion, owner_root_calls) = StoredClaimAuthorization::new();
let authorization = std::sync::Arc::new(authorization);
let router = Router::builder(endpoint)
.accept(
CLAIM_ALPN_V1,
ClaimProtocol::new(std::sync::Arc::clone(&authorization), authorization),
)
.spawn();
DaemonClaimRouter {
router,
owner_root_calls,
}
}
impl DaemonClaimRouter {
pub async fn serve_owner_root_bridge(mut self, socket_path: PathBuf) -> Result<()> {
let listener = bind_bridge_listener(&socket_path)
.with_context(|| format!("binding claim co-sign bridge socket {}", socket_path.display()))?;
let mut worker: Option<UnixStream> = None;
loop {
tokio::select! {
accepted = listener.accept() => {
match accepted {
Ok((stream, _)) if peer_is_same_uid(&stream) => {
worker = Some(stream);
}
Ok((stream, _)) => {
tracing::warn!("rejecting claim co-sign worker: peer uid mismatch");
drop(stream);
}
Err(error) => {
tracing::warn!(%error, "claim co-sign bridge accept failed");
}
}
}
call = self.owner_root_calls.recv() => {
let Some(call) = call else {
break;
};
let bridge = OwnerRootBridgeCall::new(call);
match worker.as_mut() {
Some(stream) => match exchange(stream, bridge.request_bytes()).await {
Ok(reply) => bridge.respond(&reply),
Err(error) => {
tracing::warn!(%error, "claim co-sign worker exchange failed");
worker = None;
bridge.respond_unavailable();
}
},
None => bridge.respond_unavailable(),
}
}
}
}
let _ = std::fs::remove_file(&socket_path);
if let Err(error) = self.router.shutdown().await {
tracing::warn!(%error, "claim router shutdown failed");
}
Ok(())
}
}
struct OwnerRootBridgeCall {
call: ClaimOwnerRootCall,
request: Vec<u8>,
}
impl OwnerRootBridgeCall {
fn new(call: ClaimOwnerRootCall) -> Self {
let request = serde_json::to_vec(&BridgeRequest {
subject: call.principal().subject.clone(),
authorization_hash: call.principal().authorization_hash.clone(),
body_b64: URL_SAFE_NO_PAD.encode(call.body()),
})
.unwrap_or_default();
Self { call, request }
}
fn request_bytes(&self) -> &[u8] {
&self.request
}
fn respond(self, reply_frame: &[u8]) {
let response = match serde_json::from_slice::<BridgeReply>(reply_frame) {
Ok(BridgeReply::Ok { reply_b64 }) => match URL_SAFE_NO_PAD.decode(reply_b64) {
Ok(reply) => Ok(reply),
Err(_) => Err(failure(
CallFailureCode::Internal,
"claim co-sign worker returned a malformed reply",
)),
},
Ok(BridgeReply::Err { code, message }) => Err(CallFailure {
code,
message,
error: None,
}),
Err(_) => Err(failure(
CallFailureCode::Internal,
"claim co-sign worker returned an unparseable reply",
)),
};
self.call.respond(response);
}
fn respond_unavailable(self) {
self.call.respond(Err(failure(
CallFailureCode::FailedPrecondition,
"no foreground `heddle claim` process is armed to co-sign the owner root",
)));
}
}
pub(crate) struct ClaimBridgeWorker {
stream: UnixStream,
}
impl ClaimBridgeWorker {
pub(crate) async fn arm(socket_path: &Path) -> Result<Self> {
let stream = UnixStream::connect(socket_path).await.with_context(|| {
format!(
"connecting to the claim co-sign bridge at {}; is `heddle netd serve` running?",
socket_path.display()
)
})?;
Ok(Self { stream })
}
pub(crate) async fn serve_next(&mut self, client: &HostedClient) -> Result<bool> {
let Some(request) = read_frame(&mut self.stream).await? else {
return Ok(false);
};
let reply = cosign_owner_root_request(&request, client).await;
write_frame(&mut self.stream, &reply).await?;
Ok(true)
}
#[cfg(test)]
pub(crate) async fn serve_next_canned<F>(&mut self, respond: F) -> Result<bool>
where
F: FnOnce(&str, &str, &[u8]) -> std::result::Result<Vec<u8>, CallFailure>,
{
let Some(request) = read_frame(&mut self.stream).await? else {
return Ok(false);
};
let request: BridgeRequest =
serde_json::from_slice(&request).context("decoding forwarded owner-root request")?;
let body = URL_SAFE_NO_PAD
.decode(&request.body_b64)
.context("decoding forwarded owner-root body")?;
let reply = match respond(&request.subject, &request.authorization_hash, &body) {
Ok(reply) => BridgeReply::Ok {
reply_b64: URL_SAFE_NO_PAD.encode(reply),
},
Err(failure) => BridgeReply::Err {
code: failure.code,
message: failure.message,
},
};
write_frame(&mut self.stream, &serde_json::to_vec(&reply)?).await?;
Ok(true)
}
}
async fn cosign_owner_root_request(request_frame: &[u8], client: &HostedClient) -> Vec<u8> {
let reply = match serde_json::from_slice::<BridgeRequest>(request_frame) {
Ok(request) => match URL_SAFE_NO_PAD.decode(&request.body_b64) {
Ok(body) => {
let principal = VerifiedClaimPrincipal {
subject: request.subject,
authorization_hash: request.authorization_hash,
};
match handle_owner_root_body(client, &principal, &body).await {
Ok(reply) => BridgeReply::Ok {
reply_b64: URL_SAFE_NO_PAD.encode(reply),
},
Err(failure) => BridgeReply::Err {
code: failure.code,
message: failure.message,
},
}
}
Err(_) => BridgeReply::Err {
code: CallFailureCode::Internal as i32,
message: "claim co-sign request body was malformed".to_string(),
},
},
Err(_) => BridgeReply::Err {
code: CallFailureCode::Internal as i32,
message: "claim co-sign request was unparseable".to_string(),
},
};
serde_json::to_vec(&reply).unwrap_or_default()
}
fn bind_bridge_listener(socket_path: &Path) -> Result<UnixListener> {
let listener = repo::daemon::bind_unix_socket(socket_path)
.map_err(|error| anyhow::anyhow!("{error}"))?;
listener
.set_nonblocking(true)
.context("marking claim bridge socket non-blocking")?;
UnixListener::from_std(listener).context("adopting claim bridge socket into the async runtime")
}
fn peer_is_same_uid(stream: &UnixStream) -> bool {
match stream.peer_cred() {
Ok(peer) => peer.uid() == unsafe { libc::getuid() },
Err(error) => {
tracing::warn!(%error, "could not read claim co-sign peer credentials");
false
}
}
}
async fn exchange(stream: &mut UnixStream, request: &[u8]) -> Result<Vec<u8>> {
write_frame(stream, request).await?;
read_frame(stream)
.await?
.context("claim co-sign worker closed the connection before replying")
}
async fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
let length = u32::try_from(payload.len()).context("claim bridge frame is too large")?;
stream.write_all(&length.to_be_bytes()).await?;
stream.write_all(payload).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame(stream: &mut UnixStream) -> Result<Option<Vec<u8>>> {
let mut length = [0u8; 4];
match stream.read_exact(&mut length).await {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(error) => return Err(error).context("reading claim bridge frame length"),
}
let length = u32::from_be_bytes(length) as usize;
if length > MAX_BRIDGE_FRAME {
anyhow::bail!("claim bridge frame of {length} bytes exceeds the {MAX_BRIDGE_FRAME} limit");
}
let mut payload = vec![0u8; length];
stream
.read_exact(&mut payload)
.await
.context("reading claim bridge frame body")?;
Ok(Some(payload))
}