use super::actions::PublishingActions;
use super::broker::{Broker, BrokerRefusal, ObservedEcho};
use super::budgets::LysBudgets;
use super::context_watch::ContextReporter;
use super::keys::{ChannelKeySender, ConnectionPublisher, ServicePublisher};
use super::lys_http::{HttpLys, LysAnswer, LysHttp};
use super::seats::LysSeatStarter;
use super::{
BudgetPolicy, BudgetSource, KeySender, SeatStarter, ServiceAction, ServiceEffectError,
ServiceKind, ServiceRequest, Services,
};
use crate::auth_lys::LysPassConsumer;
use crate::config::types::LysConfig;
use crate::server::connection::LiminalConnectionServices;
use std::net::SocketAddr;
use std::sync::Arc;
mod debug;
pub struct ServiceBindings {
administrative: Option<AdministrativeBindings>,
context: Arc<ContextReporter>,
watch_context: bool,
broker: Option<Broker>,
echo: Option<ObservedEcho>,
relay: Option<Arc<super::relay::Relay>>,
relay_topology_local: Option<String>,
}
#[derive(Debug)]
struct AdministrativeBindings {
seats: LysSeatStarter,
keys: ChannelKeySender,
budgets: LysBudgets,
actions: PublishingActions,
}
impl std::fmt::Debug for ServiceBindings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ServiceBindings")
.field("administrative", &self.administrative)
.field("watch_context", &self.watch_context)
.field("broker", &self.broker)
.field("echo", &self.echo)
.field("relay", &self.relay)
.finish_non_exhaustive()
}
}
impl ServiceBindings {
#[must_use]
pub fn new(
http: &Arc<dyn LysHttp>,
bearer: &str,
machine: String,
publisher: &Arc<dyn ServicePublisher>,
) -> Self {
let context = Arc::new(ContextReporter::new(Arc::clone(http), bearer.to_owned()));
Self {
administrative: Some(AdministrativeBindings {
seats: LysSeatStarter::new(Arc::clone(http), bearer.to_owned(), machine),
keys: ChannelKeySender::new(Arc::clone(publisher)),
budgets: LysBudgets::new(Arc::clone(http), bearer.to_owned()),
actions: PublishingActions::new(Arc::clone(publisher), Arc::clone(&context)),
}),
context,
watch_context: false,
broker: None,
echo: None,
relay: None,
relay_topology_local: None,
}
}
#[must_use]
pub const fn watches_context(&self) -> bool {
self.watch_context
}
#[must_use]
pub fn services(&self) -> Services<'_> {
Services {
seats: self,
keys: self,
budgets: self,
actions: self,
}
}
#[must_use]
pub fn context_reporter(&self) -> Arc<ContextReporter> {
Arc::clone(&self.context)
}
#[must_use]
pub const fn broker(&self) -> Option<&Broker> {
self.broker.as_ref()
}
#[must_use]
pub fn echo_address(&self) -> Option<SocketAddr> {
self.echo.as_ref().map(ObservedEcho::local_addr)
}
#[must_use]
pub const fn relay(&self) -> Option<&Arc<super::relay::Relay>> {
self.relay.as_ref()
}
#[must_use]
pub fn relay_topology_local(&self) -> Option<&str> {
self.relay_topology_local.as_deref()
}
pub fn serve_broker(
&self,
session: Option<&dyn crate::auth_lys::session::LysSession>,
subject: &str,
request: &ServiceRequest<'_>,
now: u64,
) -> Result<serde_json::Value, BrokerRefusal> {
let broker = self
.broker
.as_ref()
.ok_or(BrokerRefusal::Named("broker_not_configured"))?;
let session = session.ok_or(BrokerRefusal::Named("pass_not_admitted"))?;
broker.serve(session, subject, request, now)
}
pub fn configure(
lys: &LysConfig,
services: Arc<LiminalConnectionServices>,
handle: tokio::runtime::Handle,
now: u64,
) -> Result<Option<Self>, ServiceEffectError> {
if lys.services.is_none()
&& lys.broker.is_none()
&& !lys.relay.as_ref().is_some_and(|relay| relay.enabled)
{
return Ok(None);
}
let machine = lys
.machine
.as_ref()
.ok_or_else(|| ServiceEffectError("auth.lys.machine is absent".into()))?;
let pass = match crate::link_pass::MachinePass::configured(machine) {
Ok(pass) => pass,
Err(error) if !machine.agent_id.is_empty() && error.source_absent() => return Ok(None),
Err(error) => return Err(ServiceEffectError(error.to_string())),
};
let bearer = machine_bearer(lys, now)?;
let http: Arc<dyn LysHttp> = Arc::new(MachineHttp {
http: HttpLys::new(lys.api_url.as_deref().unwrap_or(&lys.issuer), handle)?,
pass,
});
let host = lys
.services
.as_ref()
.map_or("broker", |section| section.host.as_str());
let topology_local = services.console().device().to_owned();
let publisher: Arc<dyn ServicePublisher> =
Arc::new(ConnectionPublisher::new(services, host.to_owned()));
let mut bindings = if let Some(section) = &lys.services {
let mut bindings = Self::new(&http, &bearer, section.machine.clone(), &publisher);
bindings.watch_context = section.context_watch;
bindings
} else {
Self {
administrative: None,
context: Arc::new(ContextReporter::new(Arc::clone(&http), bearer)),
watch_context: false,
broker: None,
echo: None,
relay: None,
relay_topology_local: None,
}
};
if let Some(section) = &lys.broker {
let machine = lys.machine.as_ref().ok_or_else(|| {
ServiceEffectError("auth.lys.broker requires auth.lys.machine".to_owned())
})?;
let recorded = machine
.machines
.iter()
.map(|(holder, key)| {
let mut bytes = [0; 32];
hex::decode_to_slice(key, &mut bytes).map_err(|_| {
ServiceEffectError(format!(
"auth.lys.machine.machines: invalid key for holder {holder}"
))
})?;
let identity = crate::link_handshake::MachineIdentity::new(bytes);
identity.x25519_public().map_err(|error| {
ServiceEffectError(format!(
"auth.lys.machine.machines: holder {holder}: {error}"
))
})?;
Ok((holder.clone(), identity))
})
.collect::<Result<_, ServiceEffectError>>()?;
let broker = Broker::new(lys.network_id.clone(), recorded, publisher);
bindings.echo = Some(
ObservedEcho::bind(section.udp_listen, broker.counters()).map_err(|error| {
ServiceEffectError(format!("auth.lys.broker.udp_listen: {error}"))
})?,
);
bindings.broker = Some(broker);
}
if lys.relay.as_ref().is_some_and(|relay| relay.enabled) {
let machine = lys.machine.as_ref().ok_or_else(|| {
ServiceEffectError("auth.lys.relay requires auth.lys.machine".to_owned())
})?;
let recorded = machine
.machines
.iter()
.map(|(holder, key)| {
let mut bytes = [0; 32];
hex::decode_to_slice(key, &mut bytes).map_err(|_| {
ServiceEffectError(format!("invalid recorded machine for holder {holder}"))
})?;
let identity = crate::link_handshake::MachineIdentity::new(bytes);
identity
.x25519_public()
.map_err(|error| ServiceEffectError(error.to_string()))?;
Ok((holder.clone(), identity))
})
.collect::<Result<_, ServiceEffectError>>()?;
bindings.relay = Some(super::relay::Relay::new(lys.network_id.clone(), recorded));
bindings.relay_topology_local = Some(topology_local);
}
Ok(Some(bindings))
}
fn administrative(&self) -> Result<&AdministrativeBindings, ServiceEffectError> {
self.administrative
.as_ref()
.ok_or_else(|| ServiceEffectError("auth.lys.services is not configured".to_owned()))
}
}
impl SeatStarter for ServiceBindings {
fn start(&self, seat: &str, operation: &str) -> Result<(), ServiceEffectError> {
self.administrative()?.seats.start(seat, operation)
}
}
impl KeySender for ServiceBindings {
fn send(&self, session: &str, keys: &[u8], operation: &str) -> Result<(), ServiceEffectError> {
self.administrative()?.keys.send(session, keys, operation)
}
}
impl BudgetSource for ServiceBindings {
fn read(&self, name: &str) -> Result<Option<BudgetPolicy>, ServiceEffectError> {
self.administrative()?.budgets.read(name)
}
}
impl ServiceAction for ServiceBindings {
fn act(
&self,
service: ServiceKind,
payload: &[u8],
operation: &str,
) -> Result<(), ServiceEffectError> {
self.administrative()?
.actions
.act(service, payload, operation)
}
}
pub fn machine_bearer(lys: &LysConfig, now: u64) -> Result<String, ServiceEffectError> {
let machine = lys.machine.as_ref().ok_or_else(|| {
ServiceEffectError(
"auth.lys.services or auth.lys.broker needs auth.lys.machine: services use this machine's pass"
.to_owned(),
)
})?;
let keys_json = std::fs::read_to_string(&lys.keys_path).map_err(|error| {
ServiceEffectError(format!(
"auth.lys.keys_path {}: {error}",
lys.keys_path.display()
))
})?;
let adapter = crate::auth_lys::lys_pass_adapter::LysPassAdapter::from_keys_json(
&keys_json,
lys.issuer.clone(),
)
.map_err(|error| ServiceEffectError(format!("auth.lys key set refused: {error}")))?;
let pass = crate::link_pass::MachinePass::configured(machine)
.map_err(|error| ServiceEffectError(error.to_string()))?;
let pass_bytes = pass
.current()
.map_err(|error| ServiceEffectError(error.to_string()))?;
adapter
.verify_offline(
pass_bytes.as_bytes(),
crate::auth_lys::LIMINAL_PASS_AUDIENCE,
now,
)
.map_err(|error| ServiceEffectError(format!("auth.lys.machine pass refused: {error}")))?;
Ok(pass_bytes.trim().to_owned())
}
struct MachineHttp {
http: HttpLys,
pass: crate::link_pass::MachinePass,
}
impl LysHttp for MachineHttp {
fn get(&self, _: &str, path: &str) -> Result<LysAnswer, ServiceEffectError> {
let token = self
.pass
.current()
.map_err(|error| ServiceEffectError(error.to_string()))?;
self.http.get(&token, path)
}
fn post(
&self,
_: &str,
path: &str,
body: &serde_json::Value,
) -> Result<LysAnswer, ServiceEffectError> {
let token = self
.pass
.current()
.map_err(|error| ServiceEffectError(error.to_string()))?;
self.http.post(&token, path, body)
}
}
#[cfg(test)]
mod tests {
use super::ServiceBindings;
use crate::config::types::LysConfig;
use crate::server::connection::LiminalConnectionServices;
use crate::services::ServiceRequest;
use lys_core::keys::Ed25519Identity;
use serde_json::json;
use std::net::UdpSocket;
use std::sync::Arc;
use std::time::Duration;
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut encoded = String::new();
for chunk in bytes.chunks(3) {
let first = chunk[0];
encoded.push(char::from(ALPHABET[usize::from(first >> 2)]));
let second = chunk.get(1).copied().unwrap_or(0);
encoded.push(char::from(
ALPHABET[usize::from((first & 3) << 4 | second >> 4)],
));
if chunk.len() > 1 {
let third = chunk.get(2).copied().unwrap_or(0);
encoded.push(char::from(
ALPHABET[usize::from((second & 15) << 2 | third >> 6)],
));
if chunk.len() > 2 {
encoded.push(char::from(ALPHABET[usize::from(third & 63)]));
}
}
}
encoded
}
fn machine_pass() -> Result<(String, String), serde_json::Error> {
let issuer = Ed25519Identity::from_seed(&zeroize::Zeroizing::new([9; 32]));
let keys = json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": "broker",
"x": base64url(&issuer.public_key_bytes()), "alg": "EdDSA", "use": "sig"}]});
let claims = json!({"iss": "https://issuer.example", "sub": "broker", "aud": "liminal",
"iat": 0, "exp": 20, "rights": [],
"holder": {"id": "broker", "kind": "machine", "responsible": "owner"}});
let header = base64url(br#"{"alg":"EdDSA","typ":"JWT","kid":"broker"}"#);
let signed = format!("{header}.{}", base64url(&serde_json::to_vec(&claims)?));
let token = format!("{signed}.{}", base64url(&issuer.sign(signed.as_bytes())));
Ok((keys.to_string(), token))
}
#[test]
fn broker_only_configuration_binds_echo_and_refuses_unadmitted_calls()
-> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let keys = root.path().join("issuer-keys.json");
let pass = root.path().join("machine.pass");
let (keys_json, token) = machine_pass()?;
std::fs::write(&keys, keys_json)?;
std::fs::write(&pass, token)?;
let mut lys: LysConfig = serde_json::from_value(json!({
"issuer": "https://issuer.example", "network_id": "network-a",
"keys_path": keys, "broker": {"udp_listen": "127.0.0.1:0"},
"machine": {"identity_path": "unused", "pass_path": pass}
}))?;
let runtime = tokio::runtime::Builder::new_current_thread().build()?;
let services = Arc::new(LiminalConnectionServices::empty()?);
let bindings =
ServiceBindings::configure(&lys, services.clone(), runtime.handle().clone(), 10)?
.ok_or("broker did not configure")?;
let refused = bindings
.serve_broker(
None,
"net.broker.lookup",
&ServiceRequest {
operation: "lookup",
payload: b"{}",
},
10,
)
.err()
.ok_or("unadmitted call accepted")?;
assert_eq!(refused.to_string(), "pass_not_admitted");
let refused = bindings
.services()
.seats
.start("unused", "operation")
.err()
.ok_or("unconfigured administrative service acted")?;
assert!(
refused
.to_string()
.contains("auth.lys.services is not configured")
);
let address = bindings.echo_address().ok_or("echo missing")?;
assert_ne!(address.port(), 0);
let counters = bindings.broker().ok_or("broker missing")?.counters();
assert_eq!(counters.snapshot().echo_answered, 0);
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_read_timeout(Some(Duration::from_secs(3)))?;
socket.send_to(&[b"LMOB".as_slice(), &[3; 16]].concat(), address)?;
let mut reply = [0; 64];
let (length, _) = socket.recv_from(&mut reply)?;
assert_eq!(length, 27);
assert_eq!(&reply[..20], &[b"LMOA".as_slice(), &[3; 16]].concat());
drop(bindings);
assert_eq!(counters.snapshot().echo_answered, 1);
let rebound = UdpSocket::bind(address)?;
assert_eq!(rebound.local_addr()?, address);
lys.broker = None;
lys.services = Some(toml::from_str("machine = 'unused'\nhost = 'broker'")?);
let bindings = ServiceBindings::configure(&lys, services, runtime.handle().clone(), 10)?
.ok_or("administrative services did not configure")?;
assert_eq!(bindings.echo_address(), None);
Ok(())
}
}