mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat(homecore-hap): add fail-closed network foundation
This commit is contained in:
Generated
+4
@@ -3600,9 +3600,13 @@ name = "homecore-hap"
|
||||
version = "0.1.0-alpha.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"ed25519-dalek",
|
||||
"homecore",
|
||||
"httparse",
|
||||
"mdns-sd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -10,7 +10,7 @@ version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Apple Home HomeKit Accessory Protocol bridge — ADR-125 P1 scaffold"
|
||||
description = "Fail-closed HomeKit Accessory Protocol network foundation for HOMECORE"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[lib]
|
||||
@@ -19,18 +19,24 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
|
||||
hap-server = []
|
||||
# Enables the bounded TCP/HTTP listener and real `_hap._tcp` mDNS advertiser.
|
||||
# Pair-Setup, Pair-Verify, and encrypted HAP transport remain deliberately
|
||||
# unavailable until their complete cryptographic phases are implemented.
|
||||
hap-server = ["dep:httparse", "dep:mdns-sd"]
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore" }
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
ed25519-dalek = "2.1"
|
||||
tempfile = "3"
|
||||
httparse = { version = "1", optional = true }
|
||||
mdns-sd = { version = "0.11", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time", "test-util"] }
|
||||
|
||||
@@ -1,121 +1,112 @@
|
||||
# homecore-hap
|
||||
|
||||
Apple Home HomeKit Accessory Protocol bridge for HOMECORE with HAP-1.1 trait surface and mDNS advertisement (P2).
|
||||
`homecore-hap` is the fail-closed network foundation for HOMECORE's Apple
|
||||
HomeKit Accessory Protocol bridge (ADR-125). It maps HOMECORE entities to HAP
|
||||
services and provides the bounded server, persistence, discovery, and request
|
||||
gating needed by a complete HAP implementation.
|
||||
|
||||
[](https://crates.io/crates/homecore-hap)
|
||||

|
||||

|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
|
||||
It does **not currently complete Apple Home pairing**. Pairing requests receive
|
||||
a valid TLV8 `Unavailable` error, and accessory/characteristic endpoints return
|
||||
HTTP 470 until an authenticated Pair-Verify session exists. There is no
|
||||
plaintext header, bearer-token, or test credential bypass.
|
||||
|
||||
**P1 scaffold**: trait surface for HAP accessories + characteristics, entity→HAP mapping rules, and bridge ownership. The actual HAP-1.1 TLS server and real mDNS integration are gated behind `--features hap-server` (P2).
|
||||
## Implemented
|
||||
|
||||
## What this crate does
|
||||
- Bounded Tokio TCP lifecycle with connection, header, body, request-time, and
|
||||
shutdown limits.
|
||||
- Incremental HTTP/1.1 parsing through `httparse`; duplicate
|
||||
`Content-Length`, transfer encoding, truncated input, and oversized input
|
||||
fail closed.
|
||||
- Versioned controller pairing records with bounded parsing, atomic same-
|
||||
directory replacement, Unix `0600` files/`0700` created directories, and
|
||||
refusal to load permissive or symlinked files.
|
||||
- Controller identifiers, administrator invariants, and Ed25519 public keys
|
||||
validated through `ed25519-dalek`.
|
||||
- Session state machine for Connected, Pair-Setup, Pair-Verify, Authenticated,
|
||||
and Closing. Authentication requires a valid signature from a persisted
|
||||
controller over the Pair-Verify transcript supplied by the future protocol
|
||||
phase.
|
||||
- Real `_hap._tcp.local.` advertisement through `mdns-sd` when
|
||||
`hap-server` is enabled. `NullAdvertiser` provides deterministic,
|
||||
network-free tests and deployments.
|
||||
- HAP-shaped `/accessories`, `/characteristics`, event subscription, and
|
||||
`EVENT/1.0` flow backed by `HapBridge` snapshots and bounded broadcasts.
|
||||
These handlers are structurally present but network-inaccessible until
|
||||
encrypted Pair-Verify is complete.
|
||||
|
||||
`homecore-hap` bridges HOMECORE entity state to Apple HomeKit Accessory Protocol (HAP-1.1), allowing HomeKit-native apps (Home, Control Center, Siri) to control HOMECORE devices. It provides:
|
||||
## Deliberately incomplete protocol phases
|
||||
|
||||
- **HapAccessoryType enum** — 11 accessory types matching HA's HomeKit integration (`Light`, `Switch`, `Thermostat`, `Lock`, `Door`, etc.)
|
||||
- **HapCharacteristic enum** — HAP characteristic types (`On`, `Brightness`, `Temperature`, `TargetLockState`, etc.)
|
||||
- **EntityToAccessoryMapper** — bidirectional rules for mapping HOMECORE entities to HAP accessories (e.g., `light.kitchen` → `Light` accessory + `On` + `Brightness` characteristics)
|
||||
- **HapBridge** — owns and exposes a collection of mapped accessories over HAP
|
||||
- **MdnsAdvertiser trait** — abstraction over mDNS advertisement; P1 ships `NullAdvertiser` (no-op), P2 adds real mDNS via `mdns-sd`
|
||||
- **RuViewToHapMapper** — bridges RuView sensing data (temperature, humidity, occupancy) to HAP characteristics
|
||||
The following must land together before this crate may claim Apple Home
|
||||
interoperability:
|
||||
|
||||
The bridge itself is a HAP Accessory Bridge (HAP-1.1 spec §8.3), advertising a single service with characteristic slots for each exposed accessory.
|
||||
1. Pair-Setup M1-M6: SRP-6a proof exchange, setup-code policy, accessory
|
||||
Ed25519 identity persistence, HKDF derivation, and ChaCha20-Poly1305
|
||||
encrypted sub-TLVs.
|
||||
2. Pair-Verify M1-M4: ephemeral X25519 exchange, accessory/controller Ed25519
|
||||
transcript signatures, HKDF session derivation, and encrypted sub-TLVs.
|
||||
3. Encrypted HAP transport: length-prefixed frames, independent read/write
|
||||
ChaCha20-Poly1305 keys and monotonically increasing nonces, with strict
|
||||
frame limits and connection teardown on authentication failure.
|
||||
4. Authenticated `/pairings` add/remove/list semantics and live mDNS `sf`
|
||||
updates.
|
||||
5. Stable persisted AID/IID allocation and a HOMECORE service-call adapter for
|
||||
writable characteristics. The present endpoint is read/event-only.
|
||||
6. Validation against Apple Home or a known-conformant HAP controller,
|
||||
including pair, restart, event delivery, write, unpair, and re-pair.
|
||||
|
||||
## Features
|
||||
No cryptographic primitive should be implemented locally. The remaining work
|
||||
must use reviewed RustCrypto/PAKE crates and protocol test vectors.
|
||||
|
||||
- **11 accessory types** — Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem
|
||||
- **Bi-directional mapping** — HOMECORE entity state ↔ HAP characteristic values with type-safe enums
|
||||
- **HAP-1.1 spec compliance** — characteristic types and permissions match HomeKit's published spec
|
||||
- **Trait-based advertisement** — `MdnsAdvertiser` abstraction; swappable implementations (null, real mDNS, etc.)
|
||||
- **RuView integration** — maps WiFi sensing data (occupancy, temperature, vital signs) to HomeKit sensor accessories
|
||||
- **No TLS server in P1** — bridge compiles and tests pass with `--no-default-features`; real server lands in P2 with `--features hap-server`
|
||||
- **Home.app compatible** — exposed accessories appear in Home app on any HomeKit hub (Apple TV, HomePod, HomePod mini)
|
||||
## Server integration
|
||||
|
||||
## Capabilities
|
||||
```rust,no_run
|
||||
use std::{net::IpAddr, sync::Arc};
|
||||
use homecore_hap::{
|
||||
start_server, HapBridge, HapServerConfig, HapServiceRecord,
|
||||
MdnsSdAdvertiser, PairingStore,
|
||||
};
|
||||
|
||||
| Capability | Type | Method | Notes |
|
||||
|------------|------|--------|-------|
|
||||
| Define accessory type | Trait | `HapAccessoryType::Light` etc. (11 variants) | Enum; no instantiation yet (P1) |
|
||||
| Define characteristic | Trait | `HapCharacteristic::On`, `Brightness`, etc. | Enum; values encoded as HAP TLV |
|
||||
| Map entity to accessory | Mapping | `EntityToAccessoryMapper::map_light()` | Takes `EntityId` + `State`; returns `HapAccessory` |
|
||||
| Expose accessory | Bridge | `HapBridge::expose(accessory)` | Adds to the bridge's characteristic list |
|
||||
| Advertise bridge | mDNS | `NullAdvertiser::advertise()` (P1) | No-op stub; real mDNS in P2 |
|
||||
| Advertise bridge (P2) | mDNS | `mdns_sd::ServiceInstanceBuilder` | Real mDNS via `--features hap-server` |
|
||||
| Bridge state query | Bridge | `HapBridge::list_accessories()` | Returns exposed accessories + their characteristics |
|
||||
| Characteristic write | Characteristic | HAP `WriteRequest` TLV (P2) | Home.app button press → service call |
|
||||
| Characteristic read | Characteristic | HAP `ReadResponse` TLV (P2) | Home.app query → current entity state |
|
||||
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let record = HapServiceRecord::bridge(
|
||||
"HOMECORE Bridge",
|
||||
51826,
|
||||
"AA:BB:CC:DD:EE:FF",
|
||||
);
|
||||
let bridge = HapBridge::new(record);
|
||||
let pairings = Arc::new(PairingStore::open(
|
||||
"/var/lib/homecore-hap/pairings.json",
|
||||
)?);
|
||||
let advertiser = Arc::new(MdnsSdAdvertiser::new(
|
||||
"homecore",
|
||||
"192.168.1.50".parse::<IpAddr>()?,
|
||||
)?);
|
||||
|
||||
## Comparison to Home Assistant
|
||||
let server = start_server(
|
||||
HapServerConfig::default(),
|
||||
bridge.clone(),
|
||||
pairings,
|
||||
advertiser,
|
||||
).await?;
|
||||
|
||||
| Aspect | Home Assistant | homecore-hap |
|
||||
|--------|----------------|--------------|
|
||||
| Framework | HA's `hap-python` (pure Python) | Rust 1.89+ with HAP trait abstraction |
|
||||
| Server type | Python asyncio HAP-1.1 server | TLS server trait (P2); stub in P1 |
|
||||
| Accessory types | 30+ (Light, Switch, Thermostat, etc.) | 11 (Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem) |
|
||||
| mDNS | mdns-py broadcast via asyncio | Abstraction + real mDNS (P2) or no-op stub (P1) |
|
||||
| Entity filtering | YAML `include_domains` + `exclude_entities` | Mapper rules (planned P2) |
|
||||
| HomeKit hub requirement | Yes (for remote access) | Yes (same as HomeKit) |
|
||||
| Pairing code generation | Automatic (HA web UI) | Manual setup code (P2) |
|
||||
| Characteristic persistence | HomeKit cloud only | Paired with homecore state machine |
|
||||
|
||||
## Performance
|
||||
|
||||
- **Entity→HAP mapping** — < 100 μs per entity (enum lookups + type conversions)
|
||||
- **HAP write latency** — ~10 ms (TLS decrypt + characteristic parse + entity state set); bounded by homecore state machine lock contention
|
||||
- **mDNS advertisement** (P2) — ~50 ms multicast broadcast; periodic rediscovery on network change
|
||||
- **Memory overhead per accessory** — ~500 bytes (enum + characteristic slots + metadata)
|
||||
- **No per-crate benchmarks yet** — a follow-up issue tracks baseline measurements
|
||||
|
||||
## Usage
|
||||
|
||||
Mapping an entity (P1):
|
||||
|
||||
```rust
|
||||
use homecore_hap::{EntityToAccessoryMapper, HapBridge, HapAccessoryType};
|
||||
use homecore::{EntityId, State};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let light_id = EntityId::parse("light.kitchen").unwrap();
|
||||
let state = State::new("on", HashMap::new());
|
||||
|
||||
// Map the entity to a HAP Light accessory
|
||||
let mut mapper = EntityToAccessoryMapper::new();
|
||||
if let Ok(accessory) = mapper.map_light(&light_id, &state) {
|
||||
println!("Mapped to HAP: {:?}", accessory.accessory_type);
|
||||
|
||||
// Expose it via the bridge
|
||||
let mut bridge = HapBridge::new();
|
||||
bridge.expose(accessory);
|
||||
println!("Exposed {} accessories", bridge.list_accessories().len());
|
||||
}
|
||||
}
|
||||
// Feed HOMECORE StateChanged events through bridge.update_accessory(...).
|
||||
// On process shutdown:
|
||||
server.shutdown().await?;
|
||||
# Ok(())
|
||||
# }
|
||||
```
|
||||
|
||||
Real HAP server (P2, via `--features hap-server`):
|
||||
Build and test:
|
||||
|
||||
```bash
|
||||
cargo build -p homecore-hap --features hap-server
|
||||
# The server will advertise over mDNS and accept HomeKit pairing requests
|
||||
cargo test -p homecore-hap --no-default-features
|
||||
cargo test -p homecore-hap --features hap-server
|
||||
```
|
||||
|
||||
## Relation to other HOMECORE crates
|
||||
Real mDNS requires the advertised address to be LAN-routable and the runtime to
|
||||
have multicast access. Containers normally need host networking or macvlan.
|
||||
|
||||
```
|
||||
homecore-hap (HomeKit bridge)
|
||||
├─ homecore (state machine; bridge reads entity states)
|
||||
├─ homecore-api (exposes HAP state via REST /api for remote debugging)
|
||||
├─ homecore-server (starts the bridge on homecore init)
|
||||
└─ homecore-automation (can trigger state changes via service calls)
|
||||
```
|
||||
## Decisions
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-125: HOMECORE Apple Home / HomeKit Bridge](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
|
||||
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
|
||||
- [HomeKit Accessory Protocol Specification (HAP-1.1)](https://developer.apple.com/homekit/)
|
||||
- [user-guide-apple-homepod.md](../../docs/user-guide-apple-homepod.md)
|
||||
- [README — wifi-densepose](../../../README.md)
|
||||
- [ADR-125 — native Apple Home HAP bridge](../../docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md)
|
||||
- [ADR-130 — bounded async REST/WebSocket server patterns](../../docs/adr/ADR-130-homecore-rest-websocket-api.md)
|
||||
- [ADR-161 — server-layer security and explicit HAP deferral](../../docs/adr/ADR-161-homecore-server-layer-security.md)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories.
|
||||
//!
|
||||
//! P1 does not start a real HAP-1.1 server; it ships the API surface so other
|
||||
//! crates (and P2's `hap-server` feature) can register accessories and query
|
||||
//! their current mapping. The actual mDNS + HAP pairing is gated to P2.
|
||||
//! The bridge owns mappings and their event stream. The feature-gated network
|
||||
//! lifecycle is started separately with `start_server`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use homecore::entity::EntityId;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::accessory::HapAccessoryType;
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::error::HapError;
|
||||
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
||||
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||
@@ -22,37 +22,49 @@ pub struct ExposedAccessory {
|
||||
pub mapping: AccessoryMapping,
|
||||
}
|
||||
|
||||
/// A characteristic snapshot emitted after a registered entity changes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CharacteristicEvent {
|
||||
pub entity_id: EntityId,
|
||||
pub accessory_type: HapAccessoryType,
|
||||
pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
|
||||
}
|
||||
|
||||
struct BridgeInner {
|
||||
accessories: HashMap<EntityId, ExposedAccessory>,
|
||||
}
|
||||
|
||||
/// The P1 HAP bridge.
|
||||
/// HOMECORE-to-HAP accessory bridge state.
|
||||
///
|
||||
/// Call [`HapBridge::add_accessory`] to register entities and
|
||||
/// [`HapBridge::running_accessories`] to read back what is currently
|
||||
/// registered. In P2, `start()` will spawn the `hap` server task.
|
||||
/// registered. Use `start_server` for the bounded TCP lifecycle.
|
||||
#[derive(Clone)]
|
||||
pub struct HapBridge {
|
||||
inner: Arc<RwLock<BridgeInner>>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
events: broadcast::Sender<CharacteristicEvent>,
|
||||
pub service_record: HapServiceRecord,
|
||||
}
|
||||
|
||||
impl HapBridge {
|
||||
/// Create a bridge with the given service record and a `NullAdvertiser`
|
||||
/// (P1 default — real mDNS lands in P2).
|
||||
/// Create a bridge with the given service record and a `NullAdvertiser`.
|
||||
pub fn new(service_record: HapServiceRecord) -> Self {
|
||||
Self::with_advertiser(service_record, Arc::new(NullAdvertiser))
|
||||
}
|
||||
|
||||
/// Create a bridge with a custom `MdnsAdvertiser` (used in tests and P2).
|
||||
/// Create a bridge with a custom `MdnsAdvertiser`.
|
||||
pub fn with_advertiser(
|
||||
service_record: HapServiceRecord,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
) -> Self {
|
||||
let (events, _) = broadcast::channel(128);
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
|
||||
inner: Arc::new(RwLock::new(BridgeInner {
|
||||
accessories: HashMap::new(),
|
||||
})),
|
||||
advertiser,
|
||||
events,
|
||||
service_record,
|
||||
}
|
||||
}
|
||||
@@ -97,9 +109,46 @@ impl HapBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh a registered accessory and notify event subscribers.
|
||||
pub fn update_accessory(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
state: &homecore::entity::State,
|
||||
) -> Result<(), HapError> {
|
||||
let mapping = EntityToAccessoryMapper::map(entity_id, state)?;
|
||||
let accessory_type = mapping.accessory_type;
|
||||
{
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let accessory = inner
|
||||
.accessories
|
||||
.get_mut(entity_id)
|
||||
.ok_or_else(|| HapError::EntityNotFound(entity_id.as_str().to_owned()))?;
|
||||
accessory.accessory_type = accessory_type;
|
||||
accessory.mapping = mapping.clone();
|
||||
}
|
||||
let _ = self.events.send(CharacteristicEvent {
|
||||
entity_id: entity_id.clone(),
|
||||
accessory_type,
|
||||
characteristics: mapping.characteristics,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to bounded characteristic updates. Lagging receivers receive
|
||||
/// Tokio's explicit `Lagged` error and must resynchronize from a snapshot.
|
||||
pub fn subscribe_events(&self) -> broadcast::Receiver<CharacteristicEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Snapshot all currently registered accessories.
|
||||
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
|
||||
self.inner.read().unwrap().accessories.values().cloned().collect()
|
||||
self.inner
|
||||
.read()
|
||||
.unwrap()
|
||||
.accessories
|
||||
.values()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of registered accessories.
|
||||
@@ -111,21 +160,25 @@ impl HapBridge {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
|
||||
/// In P1 this only fires the null advertiser.
|
||||
/// Start advertisement only.
|
||||
///
|
||||
/// This legacy lifecycle does not bind a TCP listener. New integrations
|
||||
/// should call `start_server`, which advertises only after binding.
|
||||
pub async fn start(&self) -> Result<(), HapError> {
|
||||
self.advertiser.advertise(&self.service_record).await?;
|
||||
tracing::info!(
|
||||
instance = %self.service_record.instance_name,
|
||||
port = self.service_record.port,
|
||||
"HapBridge started (P1 — no real HAP server; mDNS stub only)"
|
||||
"HAP advertisement started without a TCP server"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Graceful shutdown — retracts mDNS advertisement.
|
||||
pub async fn stop(&self) -> Result<(), HapError> {
|
||||
self.advertiser.retract(&self.service_record.instance_name).await?;
|
||||
self.advertiser
|
||||
.retract(&self.service_record.instance_name)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -137,18 +190,22 @@ mod tests {
|
||||
use homecore::event::Context;
|
||||
|
||||
fn make_bridge() -> HapBridge {
|
||||
HapBridge::new(HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
})
|
||||
HapBridge::new(HapServiceRecord::bridge(
|
||||
"RuView Sense",
|
||||
51826,
|
||||
"AA:BB:CC:DD:EE:FF",
|
||||
))
|
||||
}
|
||||
|
||||
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(&format!("light.{name}")).unwrap();
|
||||
let eid = EntityId::parse(format!("light.{name}")).unwrap();
|
||||
let attrs = serde_json::json!({"brightness": brightness});
|
||||
let s = State::new(eid.clone(), if on { "on" } else { "off" }, attrs, Context::default());
|
||||
let s = State::new(
|
||||
eid.clone(),
|
||||
if on { "on" } else { "off" },
|
||||
attrs,
|
||||
Context::default(),
|
||||
);
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
@@ -187,6 +244,22 @@ mod tests {
|
||||
assert!(matches!(err, HapError::EntityNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_emits_characteristic_event() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, initial) = light_state("kitchen", false, 10);
|
||||
bridge.add_accessory(&eid, &initial).unwrap();
|
||||
let mut events = bridge.subscribe_events();
|
||||
let (_, updated) = light_state("kitchen", true, 200);
|
||||
bridge.update_accessory(&eid, &updated).unwrap();
|
||||
|
||||
let event = events.recv().await.unwrap();
|
||||
assert_eq!(event.entity_id, eid);
|
||||
assert!(event
|
||||
.characteristics
|
||||
.contains(&(HapCharacteristic::On, HapCharacteristicValue::Bool(true))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_stop_with_null_advertiser() {
|
||||
let bridge = make_bridge();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Unified error type for `homecore-hap`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the HAP bridge and its sub-components.
|
||||
@@ -19,4 +20,31 @@ pub enum HapError {
|
||||
|
||||
#[error("bridge not running")]
|
||||
NotRunning,
|
||||
|
||||
#[error("pairing store error: {0}")]
|
||||
PairingStore(String),
|
||||
|
||||
#[error("invalid pairing record: {0}")]
|
||||
InvalidPairingRecord(String),
|
||||
|
||||
#[error("controller pairing already exists: {0}")]
|
||||
PairingAlreadyExists(String),
|
||||
|
||||
#[error("controller pairing not found: {0}")]
|
||||
PairingNotFound(String),
|
||||
|
||||
#[error("insecure permissions on {path}: mode {mode:o}; expected no group/other access")]
|
||||
InsecurePermissions { path: PathBuf, mode: u32 },
|
||||
|
||||
#[error("invalid HAP session transition from {from} to {to}")]
|
||||
InvalidSessionTransition {
|
||||
from: &'static str,
|
||||
to: &'static str,
|
||||
},
|
||||
|
||||
#[error("HAP protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
#[error("HAP server error: {0}")]
|
||||
Server(String),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
|
||||
//!
|
||||
//! # P1 scope
|
||||
//! # Network foundation scope
|
||||
//!
|
||||
//! Ships the trait surface and type definitions needed to map HOMECORE entity
|
||||
//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS
|
||||
//! server and real mDNS advertisement are gated behind the `hap-server`
|
||||
//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and
|
||||
//! all tests pass with `--no-default-features`.
|
||||
//! The crate provides persisted controller records, a fail-closed session
|
||||
//! state machine, bounded TLV8 parsing, characteristic event flow, and (with
|
||||
//! `hap-server`) a bounded TCP/HTTP listener plus real mDNS. It does **not**
|
||||
//! yet implement SRP Pair-Setup, X25519/HKDF/ChaCha20-Poly1305 Pair-Verify, or
|
||||
//! encrypted HAP framing, so Apple Home pairing is deliberately unavailable.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
@@ -16,7 +16,11 @@
|
||||
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
||||
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
||||
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
||||
//! | [`pairing`] | Atomic controller pairing persistence |
|
||||
//! | [`protocol`] | Bounded TLV8 protocol primitives |
|
||||
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
||||
//! | [`session`] | Authenticated request-gating state machine |
|
||||
//! | `server` | Feature-gated bounded TCP/HTTP lifecycle |
|
||||
//! | [`error`] | Unified `HapError` type |
|
||||
|
||||
pub mod accessory;
|
||||
@@ -24,11 +28,22 @@ pub mod bridge;
|
||||
pub mod error;
|
||||
pub mod mapping;
|
||||
pub mod mdns;
|
||||
pub mod pairing;
|
||||
pub mod protocol;
|
||||
pub mod ruview;
|
||||
#[cfg(feature = "hap-server")]
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
|
||||
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
pub use bridge::{ExposedAccessory, HapBridge};
|
||||
pub use bridge::{CharacteristicEvent, ExposedAccessory, HapBridge};
|
||||
pub use error::HapError;
|
||||
pub use mapping::EntityToAccessoryMapper;
|
||||
pub use mdns::{MdnsAdvertiser, NullAdvertiser};
|
||||
#[cfg(feature = "hap-server")]
|
||||
pub use mdns::MdnsSdAdvertiser;
|
||||
pub use mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||
pub use pairing::{ControllerPairing, PairingStore};
|
||||
pub use ruview::RuViewToHapMapper;
|
||||
#[cfg(feature = "hap-server")]
|
||||
pub use server::{start_server, HapServerConfig, HapServerHandle};
|
||||
pub use session::{Session, SessionState};
|
||||
|
||||
@@ -1,61 +1,206 @@
|
||||
//! mDNS advertisement trait and P1 no-op stub.
|
||||
//!
|
||||
//! Real mDNS via the `mdns-sd` crate (https://crates.io/crates/mdns-sd)
|
||||
//! lands in P2 behind the `hap-server` feature flag. P1 ships `NullAdvertiser`
|
||||
//! so the bridge compiles and tests pass without any mDNS infrastructure.
|
||||
//! HAP `_hap._tcp` advertisement.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Service record advertised over mDNS for HAP discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
/// HAP service record advertised over mDNS.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HapServiceRecord {
|
||||
/// Service instance name shown in Apple Home ("RuView Sense").
|
||||
/// Service instance shown in discovery UI.
|
||||
pub instance_name: String,
|
||||
/// TCP port the HAP server listens on (default 51826).
|
||||
/// Bound HAP TCP port.
|
||||
pub port: u16,
|
||||
/// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX).
|
||||
pub setup_code: String,
|
||||
/// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4).
|
||||
/// Stable colon-separated accessory device identifier.
|
||||
pub device_id: String,
|
||||
/// Accessory model (`md` TXT key).
|
||||
pub model: String,
|
||||
/// Configuration number (`c#`), incremented when accessory layout changes.
|
||||
pub configuration_number: u32,
|
||||
/// Current state number (`s#`).
|
||||
pub state_number: u32,
|
||||
/// HAP accessory category identifier. Bridges use `2`.
|
||||
pub category: u16,
|
||||
/// Whether a controller pairing already exists.
|
||||
pub paired: bool,
|
||||
}
|
||||
|
||||
/// Advertise (and retract) a HAP accessory over mDNS (`_hap._tcp`).
|
||||
///
|
||||
/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can
|
||||
/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`.
|
||||
impl HapServiceRecord {
|
||||
pub fn bridge(
|
||||
instance_name: impl Into<String>,
|
||||
port: u16,
|
||||
device_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance_name: instance_name.into(),
|
||||
port,
|
||||
device_id: device_id.into(),
|
||||
model: "HOMECORE Bridge".into(),
|
||||
configuration_number: 1,
|
||||
state_number: 1,
|
||||
category: 2,
|
||||
paired: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), HapError> {
|
||||
if self.instance_name.is_empty() || self.instance_name.len() > 63 {
|
||||
return Err(HapError::MdnsError(
|
||||
"instance_name must contain 1..=63 bytes".into(),
|
||||
));
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err(HapError::MdnsError("advertised port cannot be zero".into()));
|
||||
}
|
||||
let parts: Vec<&str> = self.device_id.split(':').collect();
|
||||
if parts.len() != 6
|
||||
|| parts
|
||||
.iter()
|
||||
.any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
{
|
||||
return Err(HapError::MdnsError(
|
||||
"device_id must be six colon-separated hexadecimal octets".into(),
|
||||
));
|
||||
}
|
||||
if self.model.is_empty() || self.model.len() > 64 {
|
||||
return Err(HapError::MdnsError(
|
||||
"model must contain 1..=64 bytes".into(),
|
||||
));
|
||||
}
|
||||
if self.configuration_number == 0 || self.state_number == 0 {
|
||||
return Err(HapError::MdnsError(
|
||||
"configuration and state numbers must be non-zero".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Standard HAP Bonjour TXT keys. Setup codes and controller data are
|
||||
/// intentionally never included because TXT records are plaintext.
|
||||
pub fn txt_records(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("c#".into(), self.configuration_number.to_string()),
|
||||
("ci".into(), self.category.to_string()),
|
||||
("ff".into(), "0".into()),
|
||||
("id".into(), self.device_id.to_ascii_uppercase()),
|
||||
("md".into(), self.model.clone()),
|
||||
("pv".into(), "1.1".into()),
|
||||
("s#".into(), self.state_number.to_string()),
|
||||
("sf".into(), if self.paired { "0" } else { "1" }.into()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Advertise and retract a HAP service.
|
||||
#[async_trait]
|
||||
pub trait MdnsAdvertiser: Send + Sync {
|
||||
/// Begin advertising the service. Idempotent.
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
||||
|
||||
/// Stop advertising. Called on bridge shutdown.
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
||||
}
|
||||
|
||||
/// No-op advertiser for P1 / test environments.
|
||||
///
|
||||
/// All calls succeed without touching the network.
|
||||
/// Deterministic no-network advertiser for tests and disabled deployments.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct NullAdvertiser;
|
||||
|
||||
#[async_trait]
|
||||
impl MdnsAdvertiser for NullAdvertiser {
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||
record.validate()?;
|
||||
tracing::debug!(
|
||||
instance = %record.instance_name,
|
||||
port = record.port,
|
||||
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
|
||||
"HAP mDNS advertisement disabled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %instance_name,
|
||||
"NullAdvertiser: skipping mDNS retract (P1 stub)"
|
||||
);
|
||||
tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Network-backed Bonjour advertiser.
|
||||
#[cfg(feature = "hap-server")]
|
||||
pub struct MdnsSdAdvertiser {
|
||||
daemon: mdns_sd::ServiceDaemon,
|
||||
hostname: String,
|
||||
address: String,
|
||||
registrations: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "hap-server")]
|
||||
impl std::fmt::Debug for MdnsSdAdvertiser {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("MdnsSdAdvertiser")
|
||||
.field("hostname", &self.hostname)
|
||||
.field("address", &self.address)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hap-server")]
|
||||
impl MdnsSdAdvertiser {
|
||||
/// Bind the mDNS daemon for a LAN-routable host/address.
|
||||
pub fn new(hostname: impl Into<String>, address: std::net::IpAddr) -> Result<Self, HapError> {
|
||||
let mut hostname = hostname.into();
|
||||
if !hostname.ends_with(".local.") {
|
||||
hostname = format!("{}.local.", hostname.trim_end_matches('.'));
|
||||
}
|
||||
let daemon = mdns_sd::ServiceDaemon::new()
|
||||
.map_err(|error| HapError::MdnsError(error.to_string()))?;
|
||||
Ok(Self {
|
||||
daemon,
|
||||
hostname,
|
||||
address: address.to_string(),
|
||||
registrations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn service_info(&self, record: &HapServiceRecord) -> Result<mdns_sd::ServiceInfo, HapError> {
|
||||
record.validate()?;
|
||||
let properties: std::collections::HashMap<String, String> =
|
||||
record.txt_records().into_iter().collect();
|
||||
mdns_sd::ServiceInfo::new(
|
||||
"_hap._tcp.local.",
|
||||
&record.instance_name,
|
||||
&self.hostname,
|
||||
self.address.as_str(),
|
||||
record.port,
|
||||
Some(properties),
|
||||
)
|
||||
.map_err(|error| HapError::MdnsError(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hap-server")]
|
||||
#[async_trait]
|
||||
impl MdnsAdvertiser for MdnsSdAdvertiser {
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||
let info = self.service_info(record)?;
|
||||
let fullname = info.get_fullname().to_owned();
|
||||
self.daemon
|
||||
.register(info)
|
||||
.map_err(|error| HapError::MdnsError(error.to_string()))?;
|
||||
self.registrations
|
||||
.lock()
|
||||
.map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
|
||||
.insert(record.instance_name.clone(), fullname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||
let fullname = self
|
||||
.registrations
|
||||
.lock()
|
||||
.map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
|
||||
.remove(instance_name);
|
||||
if let Some(fullname) = fullname {
|
||||
self.daemon
|
||||
.unregister(&fullname)
|
||||
.map_err(|error| HapError::MdnsError(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -64,16 +209,28 @@ impl MdnsAdvertiser for NullAdvertiser {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn txt_record_is_hap_shaped_and_contains_no_setup_secret() {
|
||||
let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
|
||||
let txt: std::collections::HashMap<_, _> = record.txt_records().into_iter().collect();
|
||||
assert_eq!(txt.get("pv").map(String::as_str), Some("1.1"));
|
||||
assert_eq!(txt.get("sf").map(String::as_str), Some("1"));
|
||||
assert_eq!(txt.get("ci").map(String::as_str), Some("2"));
|
||||
assert!(!txt
|
||||
.keys()
|
||||
.any(|key| key.contains("pin") || key.contains("code")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_advertiser_is_noop() {
|
||||
let adv = NullAdvertiser;
|
||||
let rec = HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
};
|
||||
adv.advertise(&rec).await.unwrap();
|
||||
adv.retract(&rec.instance_name).await.unwrap();
|
||||
async fn null_advertiser_validates_without_network_io() {
|
||||
let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
|
||||
NullAdvertiser.advertise(&record).await.unwrap();
|
||||
NullAdvertiser.retract(&record.instance_name).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_record_is_rejected() {
|
||||
let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id");
|
||||
assert!(NullAdvertiser.advertise(&record).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Durable controller pairing records.
|
||||
//!
|
||||
//! This module persists only long-term controller identities and Ed25519 public
|
||||
//! keys. It does not implement HAP Pair-Setup or Pair-Verify. Those protocol
|
||||
//! phases must populate this store only after their cryptographic transcript
|
||||
//! has been authenticated.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::Builder;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
const STORE_VERSION: u32 = 1;
|
||||
const MAX_STORE_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_CONTROLLER_ID_BYTES: usize = 64;
|
||||
|
||||
/// A controller authorized by a completed HAP pairing ceremony.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ControllerPairing {
|
||||
/// HAP controller pairing identifier.
|
||||
pub controller_id: String,
|
||||
/// Controller Ed25519 long-term public key.
|
||||
pub public_key: [u8; 32],
|
||||
/// Whether this controller may manage other pairings.
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl ControllerPairing {
|
||||
/// Validate bounded identifiers and the encoded Ed25519 point.
|
||||
pub fn validate(&self) -> Result<(), HapError> {
|
||||
let len = self.controller_id.len();
|
||||
if len == 0
|
||||
|| len > MAX_CONTROLLER_ID_BYTES
|
||||
|| self.controller_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err(HapError::InvalidPairingRecord(
|
||||
"controller_id must contain 1..=64 bytes and no control characters".into(),
|
||||
));
|
||||
}
|
||||
VerifyingKey::from_bytes(&self.public_key).map_err(|_| {
|
||||
HapError::InvalidPairingRecord("controller public key is not valid Ed25519".into())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PairingFile {
|
||||
version: u32,
|
||||
controllers: Vec<ControllerPairing>,
|
||||
}
|
||||
|
||||
/// Thread-safe, atomically persisted controller pairing store.
|
||||
#[derive(Debug)]
|
||||
pub struct PairingStore {
|
||||
path: PathBuf,
|
||||
controllers: RwLock<BTreeMap<String, ControllerPairing>>,
|
||||
}
|
||||
|
||||
impl PairingStore {
|
||||
/// Open an existing store or create an empty in-memory store.
|
||||
///
|
||||
/// Existing files with group/other permission bits on Unix are rejected
|
||||
/// instead of silently accepting exposed controller keys.
|
||||
pub fn open(path: impl Into<PathBuf>) -> Result<Self, HapError> {
|
||||
let path = path.into();
|
||||
let controllers = if path.exists() {
|
||||
load_file(&path)?
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
controllers: RwLock::new(controllers),
|
||||
})
|
||||
}
|
||||
|
||||
/// Path used for durable records.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Return a deterministic snapshot ordered by controller identifier.
|
||||
pub fn list(&self) -> Result<Vec<ControllerPairing>, HapError> {
|
||||
Ok(self
|
||||
.controllers
|
||||
.read()
|
||||
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
|
||||
.values()
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Look up one controller.
|
||||
pub fn get(&self, controller_id: &str) -> Result<Option<ControllerPairing>, HapError> {
|
||||
Ok(self
|
||||
.controllers
|
||||
.read()
|
||||
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
|
||||
.get(controller_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
/// Whether at least one controller has completed pairing.
|
||||
pub fn is_paired(&self) -> Result<bool, HapError> {
|
||||
Ok(!self
|
||||
.controllers
|
||||
.read()
|
||||
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
|
||||
.is_empty())
|
||||
}
|
||||
|
||||
/// Add a controller and durably commit it before exposing it in memory.
|
||||
pub fn add(&self, pairing: ControllerPairing) -> Result<(), HapError> {
|
||||
pairing.validate()?;
|
||||
let mut guard = self
|
||||
.controllers
|
||||
.write()
|
||||
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
|
||||
if guard.contains_key(&pairing.controller_id) {
|
||||
return Err(HapError::PairingAlreadyExists(pairing.controller_id));
|
||||
}
|
||||
let mut next = guard.clone();
|
||||
next.insert(pairing.controller_id.clone(), pairing);
|
||||
persist_file(&self.path, &next)?;
|
||||
*guard = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a controller, refusing to orphan remaining non-admin pairings.
|
||||
pub fn remove(&self, controller_id: &str) -> Result<(), HapError> {
|
||||
let mut guard = self
|
||||
.controllers
|
||||
.write()
|
||||
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
|
||||
if !guard.contains_key(controller_id) {
|
||||
return Err(HapError::PairingNotFound(controller_id.to_owned()));
|
||||
}
|
||||
let mut next = guard.clone();
|
||||
next.remove(controller_id);
|
||||
if !next.is_empty() && !next.values().any(|pairing| pairing.admin) {
|
||||
return Err(HapError::InvalidPairingRecord(
|
||||
"cannot remove the last administrator while pairings remain".into(),
|
||||
));
|
||||
}
|
||||
persist_file(&self.path, &next)?;
|
||||
*guard = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file(path: &Path) -> Result<BTreeMap<String, ControllerPairing>, HapError> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| HapError::PairingStore(format!("metadata {}: {error}", path.display())))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(HapError::PairingStore(format!(
|
||||
"{} must be a regular, non-symlink file",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.len() > MAX_STORE_BYTES {
|
||||
return Err(HapError::PairingStore(format!(
|
||||
"{} exceeds the {MAX_STORE_BYTES}-byte limit",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
validate_permissions(path, &metadata)?;
|
||||
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
File::open(path)
|
||||
.and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| HapError::PairingStore(format!("read {}: {error}", path.display())))?;
|
||||
if bytes.len() as u64 > MAX_STORE_BYTES {
|
||||
return Err(HapError::PairingStore(
|
||||
"pairing store exceeds size limit".into(),
|
||||
));
|
||||
}
|
||||
let file: PairingFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?;
|
||||
if file.version != STORE_VERSION {
|
||||
return Err(HapError::PairingStore(format!(
|
||||
"unsupported pairing store version {}",
|
||||
file.version
|
||||
)));
|
||||
}
|
||||
|
||||
let mut controllers = BTreeMap::new();
|
||||
for pairing in file.controllers {
|
||||
pairing.validate()?;
|
||||
let id = pairing.controller_id.clone();
|
||||
if controllers.insert(id.clone(), pairing).is_some() {
|
||||
return Err(HapError::InvalidPairingRecord(format!(
|
||||
"duplicate controller_id {id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !controllers.is_empty() && !controllers.values().any(|pairing| pairing.admin) {
|
||||
return Err(HapError::InvalidPairingRecord(
|
||||
"persisted pairings have no administrator".into(),
|
||||
));
|
||||
}
|
||||
Ok(controllers)
|
||||
}
|
||||
|
||||
fn persist_file(
|
||||
path: &Path,
|
||||
controllers: &BTreeMap<String, ControllerPairing>,
|
||||
) -> Result<(), HapError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
create_private_dir(parent)?;
|
||||
let payload = serde_json::to_vec_pretty(&PairingFile {
|
||||
version: STORE_VERSION,
|
||||
controllers: controllers.values().cloned().collect(),
|
||||
})
|
||||
.map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?;
|
||||
|
||||
let mut temp = Builder::new()
|
||||
.prefix(".homecore-hap-pairings-")
|
||||
.tempfile_in(parent)
|
||||
.map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?;
|
||||
set_private_file_permissions(temp.as_file())?;
|
||||
temp.write_all(&payload)
|
||||
.and_then(|_| temp.flush())
|
||||
.and_then(|_| temp.as_file().sync_all())
|
||||
.map_err(|error| HapError::PairingStore(format!("write temporary store: {error}")))?;
|
||||
temp.persist(path).map_err(|error| {
|
||||
HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
|
||||
})?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
File::open(parent)
|
||||
.and_then(|directory| directory.sync_all())
|
||||
.map_err(|error| {
|
||||
HapError::PairingStore(format!("sync {}: {error}", parent.display()))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_private_dir(path: &Path) -> Result<(), HapError> {
|
||||
let created = !path.exists();
|
||||
if created {
|
||||
fs::create_dir_all(path).map_err(|error| {
|
||||
HapError::PairingStore(format!("create {}: {error}", path.display()))
|
||||
})?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if created {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||||
HapError::PairingStore(format!("chmod {}: {error}", path.display()))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_private_file_permissions(_file: &File) -> Result<(), HapError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
_file
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| HapError::PairingStore(format!("chmod temporary store: {error}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(HapError::InsecurePermissions {
|
||||
path: path.to_path_buf(),
|
||||
mode: mode & 0o777,
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = (path, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn pairing(id: &str, byte: u8, admin: bool) -> ControllerPairing {
|
||||
let public_key = SigningKey::from_bytes(&[byte; 32])
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
ControllerPairing {
|
||||
controller_id: id.into(),
|
||||
public_key,
|
||||
admin,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_loads_atomically_persisted_pairings() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("pairings.json");
|
||||
let store = PairingStore::open(&path).unwrap();
|
||||
store.add(pairing("controller-1", 7, true)).unwrap();
|
||||
drop(store);
|
||||
|
||||
let reopened = PairingStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
reopened.list().unwrap(),
|
||||
vec![pairing("controller-1", 7, true)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_duplicate_records_fail_closed() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("pairings.json");
|
||||
fs::write(&path, br#"{"version":1,"controllers":[{"controller_id":"","public_key":[0,1],"admin":true}]}"#).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
assert!(PairingStore::open(path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_remove_last_admin_while_members_remain() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
|
||||
store.add(pairing("admin", 1, true)).unwrap();
|
||||
store.add(pairing("member", 2, false)).unwrap();
|
||||
assert!(store.remove("admin").is_err());
|
||||
assert_eq!(store.list().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn permissive_existing_file_is_rejected() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("pairings.json");
|
||||
fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(matches!(
|
||||
PairingStore::open(path),
|
||||
Err(HapError::InsecurePermissions { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Bounded HAP TLV8 primitives and fail-closed pairing responses.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
pub const TLV_METHOD: u8 = 0x00;
|
||||
pub const TLV_IDENTIFIER: u8 = 0x01;
|
||||
pub const TLV_PUBLIC_KEY: u8 = 0x03;
|
||||
pub const TLV_STATE: u8 = 0x06;
|
||||
pub const TLV_ERROR: u8 = 0x07;
|
||||
pub const TLV_SIGNATURE: u8 = 0x0a;
|
||||
|
||||
pub const TLV_ERROR_AUTHENTICATION: u8 = 0x02;
|
||||
pub const TLV_ERROR_UNAVAILABLE: u8 = 0x06;
|
||||
pub const TLV_ERROR_BUSY: u8 = 0x07;
|
||||
|
||||
const MAX_TLV_BYTES: usize = 4096;
|
||||
const MAX_TLV_TYPES: usize = 32;
|
||||
|
||||
/// Parsed TLV8 values. Repeated fragments of a type are concatenated.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Tlv8 {
|
||||
values: BTreeMap<u8, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Tlv8 {
|
||||
pub fn parse(input: &[u8]) -> Result<Self, HapError> {
|
||||
if input.len() > MAX_TLV_BYTES {
|
||||
return Err(HapError::Protocol("TLV8 body exceeds 4096 bytes".into()));
|
||||
}
|
||||
let mut values: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
|
||||
let mut offset = 0usize;
|
||||
while offset < input.len() {
|
||||
if input.len() - offset < 2 {
|
||||
return Err(HapError::Protocol("truncated TLV8 header".into()));
|
||||
}
|
||||
let kind = input[offset];
|
||||
let len = input[offset + 1] as usize;
|
||||
offset += 2;
|
||||
let end = offset
|
||||
.checked_add(len)
|
||||
.filter(|end| *end <= input.len())
|
||||
.ok_or_else(|| HapError::Protocol("truncated TLV8 value".into()))?;
|
||||
if !values.contains_key(&kind) && values.len() == MAX_TLV_TYPES {
|
||||
return Err(HapError::Protocol("too many TLV8 types".into()));
|
||||
}
|
||||
values
|
||||
.entry(kind)
|
||||
.or_default()
|
||||
.extend_from_slice(&input[offset..end]);
|
||||
offset = end;
|
||||
}
|
||||
Ok(Self { values })
|
||||
}
|
||||
|
||||
pub fn get(&self, kind: u8) -> Option<&[u8]> {
|
||||
self.values.get(&kind).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn byte(&self, kind: u8) -> Option<u8> {
|
||||
let value = self.get(kind)?;
|
||||
(value.len() == 1).then_some(value[0])
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, kind: u8, value: impl Into<Vec<u8>>) {
|
||||
self.values.insert(kind, value.into());
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
for (&kind, value) in &self.values {
|
||||
if value.is_empty() {
|
||||
encoded.extend_from_slice(&[kind, 0]);
|
||||
continue;
|
||||
}
|
||||
for chunk in value.chunks(u8::MAX as usize) {
|
||||
encoded.push(kind);
|
||||
encoded.push(chunk.len() as u8);
|
||||
encoded.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
}
|
||||
|
||||
/// Standards-shaped response used while cryptographic pairing phases are
|
||||
/// unavailable. It is a real TLV8 error, never a success-shaped placeholder.
|
||||
pub fn pairing_unavailable_response(request: &[u8]) -> Result<Vec<u8>, HapError> {
|
||||
let request = Tlv8::parse(request)?;
|
||||
let request_state = request
|
||||
.byte(TLV_STATE)
|
||||
.ok_or_else(|| HapError::Protocol("pairing request lacks one-byte State".into()))?;
|
||||
let response_state = request_state.saturating_add(1);
|
||||
let mut response = Tlv8::default();
|
||||
response.insert(TLV_STATE, vec![response_state]);
|
||||
response.insert(TLV_ERROR, vec![TLV_ERROR_UNAVAILABLE]);
|
||||
Ok(response.encode())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tlv8_roundtrip_supports_fragmented_values() {
|
||||
let mut tlv = Tlv8::default();
|
||||
tlv.insert(TLV_PUBLIC_KEY, vec![3; 300]);
|
||||
tlv.insert(TLV_STATE, vec![1]);
|
||||
let encoded = tlv.encode();
|
||||
let decoded = Tlv8::parse(&encoded).unwrap();
|
||||
assert_eq!(decoded, tlv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_tlv_is_rejected() {
|
||||
assert!(Tlv8::parse(&[TLV_STATE]).is_err());
|
||||
assert!(Tlv8::parse(&[TLV_STATE, 2, 1]).is_err());
|
||||
assert!(Tlv8::parse(&vec![0; MAX_TLV_BYTES + 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_pairing_is_an_explicit_error_not_success() {
|
||||
let response = pairing_unavailable_response(&[TLV_STATE, 1, 1]).unwrap();
|
||||
let decoded = Tlv8::parse(&response).unwrap();
|
||||
assert_eq!(decoded.byte(TLV_STATE), Some(2));
|
||||
assert_eq!(decoded.byte(TLV_ERROR), Some(TLV_ERROR_UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Bounded async TCP/HTTP foundation for HAP.
|
||||
//!
|
||||
//! The listener parses plaintext pairing HTTP and has authenticated accessory
|
||||
//! endpoint handlers ready for a future encrypted transport. Because Pair-
|
||||
//! Setup, Pair-Verify, and HAP frame encryption are not complete, no network
|
||||
//! request can currently transition a session to authenticated.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use httparse::Status;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{oneshot, Semaphore};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio::time::{timeout, Instant};
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::bridge::{CharacteristicEvent, ExposedAccessory, HapBridge};
|
||||
use crate::error::HapError;
|
||||
use crate::mdns::MdnsAdvertiser;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::protocol::pairing_unavailable_response;
|
||||
use crate::session::Session;
|
||||
|
||||
const HAP_JSON: &str = "application/hap+json";
|
||||
const HAP_TLV: &str = "application/pairing+tlv8";
|
||||
|
||||
/// Resource bounds for the HAP listener.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HapServerConfig {
|
||||
pub bind_addr: SocketAddr,
|
||||
pub max_connections: usize,
|
||||
pub max_header_bytes: usize,
|
||||
pub max_body_bytes: usize,
|
||||
pub request_timeout: Duration,
|
||||
pub shutdown_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for HapServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind_addr: SocketAddr::from(([0, 0, 0, 0], 51826)),
|
||||
max_connections: 32,
|
||||
max_header_bytes: 16 * 1024,
|
||||
max_body_bytes: 64 * 1024,
|
||||
request_timeout: Duration::from_secs(10),
|
||||
shutdown_timeout: Duration::from_secs(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HapServerConfig {
|
||||
fn validate(&self) -> Result<(), HapError> {
|
||||
if self.max_connections == 0
|
||||
|| self.max_header_bytes < 512
|
||||
|| self.max_body_bytes == 0
|
||||
|| self.request_timeout.is_zero()
|
||||
|| self.shutdown_timeout.is_zero()
|
||||
{
|
||||
return Err(HapError::Server(
|
||||
"invalid zero or undersized server limit".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Running server handle. Dropping it aborts the listener; [`shutdown`] also
|
||||
/// retracts mDNS and waits for connection tasks within the configured bound.
|
||||
pub struct HapServerHandle {
|
||||
local_addr: SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
task: Option<JoinHandle<Result<(), HapError>>>,
|
||||
shutdown_timeout: Duration,
|
||||
}
|
||||
|
||||
impl HapServerHandle {
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.local_addr
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) -> Result<(), HapError> {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
let Some(mut task) = self.task.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
match timeout(self.shutdown_timeout, &mut task).await {
|
||||
Ok(result) => {
|
||||
result.map_err(|error| HapError::Server(format!("server task failed: {error}")))?
|
||||
}
|
||||
Err(_) => {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
Err(HapError::Server(
|
||||
"server shutdown timed out; task aborted".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HapServerHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(task) = &self.task {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the bounded listener and advertise the actual bound port.
|
||||
pub async fn start_server(
|
||||
config: HapServerConfig,
|
||||
bridge: HapBridge,
|
||||
pairings: Arc<PairingStore>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
) -> Result<HapServerHandle, HapError> {
|
||||
config.validate()?;
|
||||
let listener = TcpListener::bind(config.bind_addr)
|
||||
.await
|
||||
.map_err(|error| HapError::Server(format!("bind {}: {error}", config.bind_addr)))?;
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|error| HapError::Server(format!("read local address: {error}")))?;
|
||||
|
||||
let mut record = bridge.service_record.clone();
|
||||
record.port = local_addr.port();
|
||||
record.paired = pairings.is_paired()?;
|
||||
advertiser.advertise(&record).await?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let task_config = config.clone();
|
||||
let task = tokio::spawn(run_listener(
|
||||
listener,
|
||||
task_config,
|
||||
bridge,
|
||||
pairings,
|
||||
advertiser,
|
||||
record.instance_name,
|
||||
shutdown_rx,
|
||||
));
|
||||
Ok(HapServerHandle {
|
||||
local_addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
task: Some(task),
|
||||
// The listener owns the configured drain window; the handle allows a
|
||||
// small scheduling/retraction margin before enforcing its outer abort.
|
||||
shutdown_timeout: config
|
||||
.shutdown_timeout
|
||||
.saturating_add(Duration::from_secs(1)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_listener(
|
||||
listener: TcpListener,
|
||||
config: HapServerConfig,
|
||||
bridge: HapBridge,
|
||||
pairings: Arc<PairingStore>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
instance_name: String,
|
||||
mut shutdown: oneshot::Receiver<()>,
|
||||
) -> Result<(), HapError> {
|
||||
let permits = Arc::new(Semaphore::new(config.max_connections));
|
||||
let mut connections = JoinSet::new();
|
||||
|
||||
loop {
|
||||
let permit = tokio::select! {
|
||||
_ = &mut shutdown => break,
|
||||
permit = permits.clone().acquire_owned() => {
|
||||
permit.map_err(|_| HapError::Server("connection semaphore closed".into()))?
|
||||
}
|
||||
};
|
||||
let accepted = tokio::select! {
|
||||
_ = &mut shutdown => {
|
||||
drop(permit);
|
||||
break;
|
||||
}
|
||||
accepted = listener.accept() => accepted
|
||||
};
|
||||
match accepted {
|
||||
Ok((stream, peer)) => {
|
||||
let bridge = bridge.clone();
|
||||
let pairings = pairings.clone();
|
||||
let limits = config.clone();
|
||||
connections.spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(error) =
|
||||
serve_connection(stream, peer, limits, bridge, pairings).await
|
||||
{
|
||||
tracing::debug!(%peer, %error, "HAP connection closed");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "HAP accept failed");
|
||||
}
|
||||
}
|
||||
while connections.try_join_next().is_some() {}
|
||||
}
|
||||
|
||||
drop(listener);
|
||||
let deadline = Instant::now() + config.shutdown_timeout;
|
||||
while !connections.is_empty() {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() || timeout(remaining, connections.join_next()).await.is_err() {
|
||||
connections.abort_all();
|
||||
while connections.join_next().await.is_some() {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
advertiser.retract(&instance_name).await
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
mut stream: TcpStream,
|
||||
_peer: SocketAddr,
|
||||
config: HapServerConfig,
|
||||
bridge: HapBridge,
|
||||
pairings: Arc<PairingStore>,
|
||||
) -> Result<(), HapError> {
|
||||
let mut buffer = ConnectionBuffer::default();
|
||||
let mut session = Session::new();
|
||||
let mut subscriptions = HashSet::new();
|
||||
let mut events = bridge.subscribe_events();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
request = timeout(
|
||||
config.request_timeout,
|
||||
read_request(&mut stream, &mut buffer, &config),
|
||||
) => {
|
||||
let request = match request {
|
||||
Ok(Ok(Some(request))) => request,
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(error)) => {
|
||||
let response = error_response(&error);
|
||||
write_response(&mut stream, response).await?;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
write_response(&mut stream, Response::plain(408, b"request timeout".to_vec())).await?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let close = request.connection_close;
|
||||
let response = dispatch_request(
|
||||
request,
|
||||
&mut session,
|
||||
&bridge,
|
||||
&pairings,
|
||||
&mut subscriptions,
|
||||
);
|
||||
write_response(&mut stream, response).await?;
|
||||
if close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
event = events.recv(), if session.state().is_authenticated() && !subscriptions.is_empty() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let Some(payload) = event_payload(&bridge, &event, &subscriptions) {
|
||||
write_event(&mut stream, payload).await?;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
// A lagged controller must resynchronize through GET /characteristics.
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Request {
|
||||
method: String,
|
||||
target: String,
|
||||
body: Vec<u8>,
|
||||
connection_close: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConnectionBuffer {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn read_request(
|
||||
stream: &mut TcpStream,
|
||||
buffer: &mut ConnectionBuffer,
|
||||
config: &HapServerConfig,
|
||||
) -> Result<Option<Request>, RequestReadError> {
|
||||
let header_end = loop {
|
||||
if let Some(position) = find_header_end(&buffer.bytes) {
|
||||
break position + 4;
|
||||
}
|
||||
if buffer.bytes.len() >= config.max_header_bytes {
|
||||
return Err(RequestReadError::HeadersTooLarge);
|
||||
}
|
||||
let mut chunk = [0u8; 2048];
|
||||
let read = stream
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.map_err(RequestReadError::Io)?;
|
||||
if read == 0 {
|
||||
return if buffer.bytes.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(RequestReadError::Malformed("truncated HTTP headers"))
|
||||
};
|
||||
}
|
||||
buffer.bytes.extend_from_slice(&chunk[..read]);
|
||||
};
|
||||
if header_end > config.max_header_bytes {
|
||||
return Err(RequestReadError::HeadersTooLarge);
|
||||
}
|
||||
|
||||
let mut headers = [httparse::EMPTY_HEADER; 32];
|
||||
let mut parsed = httparse::Request::new(&mut headers);
|
||||
match parsed.parse(&buffer.bytes[..header_end]) {
|
||||
Ok(Status::Complete(_)) => {}
|
||||
Ok(Status::Partial) => return Err(RequestReadError::Malformed("partial HTTP request")),
|
||||
Err(error) => return Err(RequestReadError::MalformedOwned(error.to_string())),
|
||||
}
|
||||
if parsed.version != Some(1) {
|
||||
return Err(RequestReadError::Malformed("HTTP/1.1 required"));
|
||||
}
|
||||
let method = parsed
|
||||
.method
|
||||
.ok_or(RequestReadError::Malformed("missing method"))?
|
||||
.to_owned();
|
||||
let target = parsed
|
||||
.path
|
||||
.ok_or(RequestReadError::Malformed("missing request target"))?
|
||||
.to_owned();
|
||||
if target.len() > 2048 || !target.starts_with('/') {
|
||||
return Err(RequestReadError::Malformed("invalid request target"));
|
||||
}
|
||||
|
||||
let mut content_length = None;
|
||||
let mut connection_close = false;
|
||||
for header in parsed.headers.iter() {
|
||||
if header.name.eq_ignore_ascii_case("transfer-encoding") {
|
||||
return Err(RequestReadError::Malformed(
|
||||
"Transfer-Encoding is unsupported",
|
||||
));
|
||||
}
|
||||
if header.name.eq_ignore_ascii_case("content-length") {
|
||||
if content_length.is_some() {
|
||||
return Err(RequestReadError::Malformed("duplicate Content-Length"));
|
||||
}
|
||||
let value = std::str::from_utf8(header.value)
|
||||
.map_err(|_| RequestReadError::Malformed("non-UTF8 Content-Length"))?;
|
||||
content_length = Some(
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| RequestReadError::Malformed("invalid Content-Length"))?,
|
||||
);
|
||||
}
|
||||
if header.name.eq_ignore_ascii_case("connection")
|
||||
&& header.value.eq_ignore_ascii_case(b"close")
|
||||
{
|
||||
connection_close = true;
|
||||
}
|
||||
}
|
||||
let content_length = content_length.unwrap_or(0);
|
||||
if content_length > config.max_body_bytes {
|
||||
return Err(RequestReadError::BodyTooLarge);
|
||||
}
|
||||
let request_end = header_end
|
||||
.checked_add(content_length)
|
||||
.ok_or(RequestReadError::BodyTooLarge)?;
|
||||
while buffer.bytes.len() < request_end {
|
||||
let remaining = request_end - buffer.bytes.len();
|
||||
let mut chunk = vec![0u8; remaining.min(8192)];
|
||||
let read = stream
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.map_err(RequestReadError::Io)?;
|
||||
if read == 0 {
|
||||
return Err(RequestReadError::Malformed("truncated HTTP body"));
|
||||
}
|
||||
buffer.bytes.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let body = buffer.bytes[header_end..request_end].to_vec();
|
||||
buffer.bytes.drain(..request_end);
|
||||
Ok(Some(Request {
|
||||
method,
|
||||
target,
|
||||
body,
|
||||
connection_close,
|
||||
}))
|
||||
}
|
||||
|
||||
fn find_header_end(bytes: &[u8]) -> Option<usize> {
|
||||
bytes.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RequestReadError {
|
||||
Io(std::io::Error),
|
||||
Malformed(&'static str),
|
||||
MalformedOwned(String),
|
||||
HeadersTooLarge,
|
||||
BodyTooLarge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RequestReadError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "{error}"),
|
||||
Self::Malformed(message) => formatter.write_str(message),
|
||||
Self::MalformedOwned(message) => formatter.write_str(message),
|
||||
Self::HeadersTooLarge => formatter.write_str("HTTP headers too large"),
|
||||
Self::BodyTooLarge => formatter.write_str("HTTP body too large"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: &RequestReadError) -> Response {
|
||||
match error {
|
||||
RequestReadError::HeadersTooLarge => Response::plain(431, error.to_string().into_bytes()),
|
||||
RequestReadError::BodyTooLarge => Response::plain(413, error.to_string().into_bytes()),
|
||||
_ => Response::plain(400, error.to_string().into_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
struct Response {
|
||||
status: u16,
|
||||
content_type: &'static str,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
fn plain(status: u16, body: Vec<u8>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
content_type: "text/plain; charset=utf-8",
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn json(status: u16, value: Value) -> Self {
|
||||
Self {
|
||||
status,
|
||||
content_type: HAP_JSON,
|
||||
body: serde_json::to_vec(&value).expect("JSON value serialization cannot fail"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_request(
|
||||
request: Request,
|
||||
session: &mut Session,
|
||||
bridge: &HapBridge,
|
||||
pairings: &PairingStore,
|
||||
subscriptions: &mut HashSet<(u64, u64)>,
|
||||
) -> Response {
|
||||
match (
|
||||
request.method.as_str(),
|
||||
request.target.split('?').next().unwrap_or(""),
|
||||
) {
|
||||
("POST", "/pair-setup") => {
|
||||
let _ = session.begin_pair_setup(pairings.is_paired().unwrap_or(true));
|
||||
let response = pairing_unavailable_response(&request.body);
|
||||
let _ = session.reset_pairing();
|
||||
match response {
|
||||
Ok(body) => Response {
|
||||
status: 200,
|
||||
content_type: HAP_TLV,
|
||||
body,
|
||||
},
|
||||
Err(error) => Response::plain(400, error.to_string().into_bytes()),
|
||||
}
|
||||
}
|
||||
("POST", "/pair-verify") => {
|
||||
let _ = session.begin_pair_verify();
|
||||
let response = pairing_unavailable_response(&request.body);
|
||||
let _ = session.reset_pairing();
|
||||
match response {
|
||||
Ok(body) => Response {
|
||||
status: 200,
|
||||
content_type: HAP_TLV,
|
||||
body,
|
||||
},
|
||||
Err(error) => Response::plain(400, error.to_string().into_bytes()),
|
||||
}
|
||||
}
|
||||
_ if !session.state().is_authenticated() => Response::json(
|
||||
470,
|
||||
json!({"status": -70401, "message": "Connection Authorization Required"}),
|
||||
),
|
||||
("GET", "/accessories") => Response::json(200, accessories_json(bridge)),
|
||||
("GET", "/characteristics") => characteristics_response(&request.target, bridge),
|
||||
("PUT", "/characteristics") => {
|
||||
characteristic_subscription_response(&request.body, subscriptions)
|
||||
}
|
||||
("POST", "/pairings") => Response::json(
|
||||
501,
|
||||
json!({"status": -70406, "message": "Pairings management awaits encrypted transport"}),
|
||||
),
|
||||
_ => Response::plain(404, b"not found".to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessories_json(bridge: &HapBridge) -> Value {
|
||||
let accessories = indexed_accessories(bridge);
|
||||
let mut output = vec![json!({
|
||||
"aid": 1,
|
||||
"services": [accessory_information(1, "HOMECORE Bridge")]
|
||||
})];
|
||||
for (aid, accessory) in accessories {
|
||||
let mut characteristics = Vec::new();
|
||||
for (index, (kind, value)) in accessory.mapping.characteristics.iter().enumerate() {
|
||||
characteristics.push(json!({
|
||||
"iid": 8 + index as u64,
|
||||
"type": characteristic_type(*kind),
|
||||
"perms": ["pr", "ev"],
|
||||
"format": characteristic_format(value),
|
||||
"value": characteristic_value(value),
|
||||
}));
|
||||
}
|
||||
output.push(json!({
|
||||
"aid": aid,
|
||||
"services": [
|
||||
accessory_information(1, accessory.entity_id.as_str()),
|
||||
{
|
||||
"iid": 7,
|
||||
"type": service_type(accessory.accessory_type),
|
||||
"primary": true,
|
||||
"characteristics": characteristics,
|
||||
}
|
||||
]
|
||||
}));
|
||||
}
|
||||
json!({ "accessories": output })
|
||||
}
|
||||
|
||||
fn accessory_information(iid: u64, name: &str) -> Value {
|
||||
json!({
|
||||
"iid": iid,
|
||||
"type": "3E",
|
||||
"characteristics": [
|
||||
{"iid": iid + 1, "type": "23", "perms": ["pr"], "format": "string", "value": name},
|
||||
{"iid": iid + 2, "type": "20", "perms": ["pr"], "format": "string", "value": "HOMECORE"},
|
||||
{"iid": iid + 3, "type": "21", "perms": ["pr"], "format": "string", "value": "HOMECORE HAP Bridge"},
|
||||
{"iid": iid + 4, "type": "30", "perms": ["pr"], "format": "string", "value": name},
|
||||
{"iid": iid + 5, "type": "52", "perms": ["pr"], "format": "string", "value": env!("CARGO_PKG_VERSION")}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn indexed_accessories(bridge: &HapBridge) -> Vec<(u64, ExposedAccessory)> {
|
||||
let mut accessories = bridge.running_accessories();
|
||||
accessories.sort_by(|left, right| left.entity_id.as_str().cmp(right.entity_id.as_str()));
|
||||
accessories
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, accessory)| (index as u64 + 2, accessory))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn characteristics_response(target: &str, bridge: &HapBridge) -> Response {
|
||||
let Some(query) = target.split_once('?').map(|(_, query)| query) else {
|
||||
return Response::plain(400, b"missing characteristic query".to_vec());
|
||||
};
|
||||
let Some(ids) = query.split('&').find_map(|part| part.strip_prefix("id=")) else {
|
||||
return Response::plain(400, b"missing id query".to_vec());
|
||||
};
|
||||
if ids.len() > 4096 || ids.split(',').count() > 128 {
|
||||
return Response::plain(400, b"characteristic query too large".to_vec());
|
||||
}
|
||||
let accessories = indexed_accessories(bridge);
|
||||
let mut values = Vec::new();
|
||||
for id in ids.split(',') {
|
||||
let Some((aid, iid)) = parse_aid_iid(id) else {
|
||||
return Response::plain(400, b"invalid aid.iid".to_vec());
|
||||
};
|
||||
let value = accessories
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == aid)
|
||||
.and_then(|(_, accessory)| {
|
||||
accessory
|
||||
.mapping
|
||||
.characteristics
|
||||
.get(iid.saturating_sub(8) as usize)
|
||||
})
|
||||
.map(|(_, value)| characteristic_value(value));
|
||||
values.push(match value {
|
||||
Some(value) => json!({"aid": aid, "iid": iid, "value": value}),
|
||||
None => json!({"aid": aid, "iid": iid, "status": -70409}),
|
||||
});
|
||||
}
|
||||
Response::json(207, json!({"characteristics": values}))
|
||||
}
|
||||
|
||||
fn characteristic_subscription_response(
|
||||
body: &[u8],
|
||||
subscriptions: &mut HashSet<(u64, u64)>,
|
||||
) -> Response {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(body) else {
|
||||
return Response::plain(400, b"invalid characteristic JSON".to_vec());
|
||||
};
|
||||
let Some(items) = value.get("characteristics").and_then(Value::as_array) else {
|
||||
return Response::plain(400, b"missing characteristics array".to_vec());
|
||||
};
|
||||
if items.len() > 128 {
|
||||
return Response::plain(400, b"too many characteristic writes".to_vec());
|
||||
}
|
||||
for item in items {
|
||||
let (Some(aid), Some(iid), Some(enabled)) = (
|
||||
item.get("aid").and_then(Value::as_u64),
|
||||
item.get("iid").and_then(Value::as_u64),
|
||||
item.get("ev").and_then(Value::as_bool),
|
||||
) else {
|
||||
// Entity writes are not yet connected to HOMECORE service calls.
|
||||
return Response::json(207, json!({"characteristics": [{"status": -70405}]}));
|
||||
};
|
||||
if enabled {
|
||||
subscriptions.insert((aid, iid));
|
||||
} else {
|
||||
subscriptions.remove(&(aid, iid));
|
||||
}
|
||||
}
|
||||
Response {
|
||||
status: 204,
|
||||
content_type: HAP_JSON,
|
||||
body: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_payload(
|
||||
bridge: &HapBridge,
|
||||
event: &CharacteristicEvent,
|
||||
subscriptions: &HashSet<(u64, u64)>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let (aid, _) = indexed_accessories(bridge)
|
||||
.into_iter()
|
||||
.find(|(_, accessory)| accessory.entity_id == event.entity_id)?;
|
||||
let values: Vec<Value> = event
|
||||
.characteristics
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, (_, value))| {
|
||||
let iid = index as u64 + 8;
|
||||
subscriptions
|
||||
.contains(&(aid, iid))
|
||||
.then(|| json!({"aid": aid, "iid": iid, "value": characteristic_value(value)}))
|
||||
})
|
||||
.collect();
|
||||
(!values.is_empty())
|
||||
.then(|| serde_json::to_vec(&json!({"characteristics": values})).expect("serialize event"))
|
||||
}
|
||||
|
||||
fn parse_aid_iid(value: &str) -> Option<(u64, u64)> {
|
||||
let (aid, iid) = value.split_once('.')?;
|
||||
Some((aid.parse().ok()?, iid.parse().ok()?))
|
||||
}
|
||||
|
||||
fn characteristic_value(value: &HapCharacteristicValue) -> Value {
|
||||
match value {
|
||||
HapCharacteristicValue::Bool(value) => json!(value),
|
||||
HapCharacteristicValue::UInt8(value) => json!(value),
|
||||
HapCharacteristicValue::Float(value) => json!(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn characteristic_format(value: &HapCharacteristicValue) -> &'static str {
|
||||
match value {
|
||||
HapCharacteristicValue::Bool(_) => "bool",
|
||||
HapCharacteristicValue::UInt8(_) => "uint8",
|
||||
HapCharacteristicValue::Float(_) => "float",
|
||||
}
|
||||
}
|
||||
|
||||
fn service_type(kind: HapAccessoryType) -> &'static str {
|
||||
match kind {
|
||||
HapAccessoryType::Lightbulb => "43",
|
||||
HapAccessoryType::Switch => "49",
|
||||
HapAccessoryType::OccupancySensor => "86",
|
||||
HapAccessoryType::MotionSensor => "85",
|
||||
HapAccessoryType::TemperatureSensor => "8A",
|
||||
HapAccessoryType::HumiditySensor => "82",
|
||||
HapAccessoryType::LeakSensor => "83",
|
||||
HapAccessoryType::ContactSensor => "80",
|
||||
HapAccessoryType::Door => "81",
|
||||
HapAccessoryType::Lock => "45",
|
||||
HapAccessoryType::SecuritySystem => "7E",
|
||||
}
|
||||
}
|
||||
|
||||
fn characteristic_type(kind: HapCharacteristic) -> &'static str {
|
||||
match kind {
|
||||
HapCharacteristic::On => "25",
|
||||
HapCharacteristic::Brightness => "8",
|
||||
HapCharacteristic::CurrentTemperature => "11",
|
||||
HapCharacteristic::CurrentRelativeHumidity => "10",
|
||||
HapCharacteristic::OccupancyDetected => "71",
|
||||
HapCharacteristic::MotionDetected => "22",
|
||||
HapCharacteristic::LeakDetected => "70",
|
||||
HapCharacteristic::ContactSensorState => "6A",
|
||||
HapCharacteristic::CurrentDoorState => "E",
|
||||
HapCharacteristic::LockCurrentState => "1D",
|
||||
HapCharacteristic::SecuritySystemCurrentState => "66",
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_response(stream: &mut TcpStream, response: Response) -> Result<(), HapError> {
|
||||
let reason = match response.status {
|
||||
200 => "OK",
|
||||
204 => "No Content",
|
||||
207 => "Multi-Status",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
408 => "Request Timeout",
|
||||
413 => "Payload Too Large",
|
||||
431 => "Request Header Fields Too Large",
|
||||
470 => "Connection Authorization Required",
|
||||
501 => "Not Implemented",
|
||||
_ => "Error",
|
||||
};
|
||||
let header = format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n",
|
||||
response.status,
|
||||
reason,
|
||||
response.content_type,
|
||||
response.body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(header.as_bytes())
|
||||
.await
|
||||
.map_err(|error| HapError::Server(format!("write response header: {error}")))?;
|
||||
stream
|
||||
.write_all(&response.body)
|
||||
.await
|
||||
.map_err(|error| HapError::Server(format!("write response body: {error}")))
|
||||
}
|
||||
|
||||
async fn write_event(stream: &mut TcpStream, body: Vec<u8>) -> Result<(), HapError> {
|
||||
let header = format!(
|
||||
"EVENT/1.0 200 OK\r\nContent-Type: {HAP_JSON}\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(header.as_bytes())
|
||||
.await
|
||||
.map_err(|error| HapError::Server(format!("write event header: {error}")))?;
|
||||
stream
|
||||
.write_all(&body)
|
||||
.await
|
||||
.map_err(|error| HapError::Server(format!("write event body: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mdns::{HapServiceRecord, NullAdvertiser};
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn bridge() -> HapBridge {
|
||||
let bridge = HapBridge::new(HapServiceRecord::bridge(
|
||||
"RuView Sense",
|
||||
51826,
|
||||
"AA:BB:CC:DD:EE:FF",
|
||||
));
|
||||
let entity_id = EntityId::parse("binary_sensor.room_occupancy").unwrap();
|
||||
let state = State::new(
|
||||
entity_id.clone(),
|
||||
"on",
|
||||
json!({"device_class": "occupancy"}),
|
||||
Context::default(),
|
||||
);
|
||||
bridge.add_accessory(&entity_id, &state).unwrap();
|
||||
bridge
|
||||
}
|
||||
|
||||
async fn server() -> (HapServerHandle, tempfile::TempDir) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pairings =
|
||||
Arc::new(PairingStore::open(directory.path().join("pairings.json")).unwrap());
|
||||
let config = HapServerConfig {
|
||||
bind_addr: "127.0.0.1:0".parse().unwrap(),
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_timeout: Duration::from_secs(1),
|
||||
..HapServerConfig::default()
|
||||
};
|
||||
let handle = start_server(config, bridge(), pairings, Arc::new(NullAdvertiser))
|
||||
.await
|
||||
.unwrap();
|
||||
(handle, directory)
|
||||
}
|
||||
|
||||
async fn exchange(addr: SocketAddr, request: &[u8]) -> Vec<u8> {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
stream.write_all(request).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await.unwrap();
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_binds_and_shuts_down() {
|
||||
let (server, _directory) = server().await;
|
||||
assert_ne!(server.local_addr().port(), 0);
|
||||
server.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_remains_bounded_with_idle_connection() {
|
||||
let (server, _directory) = server().await;
|
||||
let _idle = TcpStream::connect(server.local_addr()).await.unwrap();
|
||||
timeout(Duration::from_secs(3), server.shutdown())
|
||||
.await
|
||||
.expect("shutdown exceeded its outer bound")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_accessory_request_is_gated() {
|
||||
let (server, _directory) = server().await;
|
||||
let response = exchange(
|
||||
server.local_addr(),
|
||||
b"GET /accessories HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await;
|
||||
assert!(response.starts_with(b"HTTP/1.1 470"));
|
||||
server.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pairing_endpoint_returns_explicit_unavailable_tlv() {
|
||||
let (server, _directory) = server().await;
|
||||
let response = exchange(
|
||||
server.local_addr(),
|
||||
b"POST /pair-setup HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\nConnection: close\r\n\r\n\x06\x01\x01",
|
||||
)
|
||||
.await;
|
||||
assert!(response.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(response.ends_with(b"\x06\x01\x02\x07\x01\x06"));
|
||||
server.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_and_oversized_requests_are_rejected() {
|
||||
let (server, _directory) = server().await;
|
||||
let malformed = exchange(
|
||||
server.local_addr(),
|
||||
b"GET / HTTP/1.0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await;
|
||||
assert!(malformed.starts_with(b"HTTP/1.1 400"));
|
||||
let oversized = exchange(
|
||||
server.local_addr(),
|
||||
b"POST /pair-setup HTTP/1.1\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await;
|
||||
assert!(oversized.starts_with(b"HTTP/1.1 413"));
|
||||
server.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_internal_dispatch_exposes_accessories_and_events() {
|
||||
let bridge = bridge();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pairings = PairingStore::open(directory.path().join("pairings.json")).unwrap();
|
||||
let mut session = Session::authenticated_for_test(true);
|
||||
let mut subscriptions = HashSet::new();
|
||||
let response = dispatch_request(
|
||||
Request {
|
||||
method: "GET".into(),
|
||||
target: "/accessories".into(),
|
||||
body: Vec::new(),
|
||||
connection_close: false,
|
||||
},
|
||||
&mut session,
|
||||
&bridge,
|
||||
&pairings,
|
||||
&mut subscriptions,
|
||||
);
|
||||
assert_eq!(response.status, 200);
|
||||
let body: Value = serde_json::from_slice(&response.body).unwrap();
|
||||
assert_eq!(body["accessories"].as_array().unwrap().len(), 2);
|
||||
|
||||
let response = dispatch_request(
|
||||
Request {
|
||||
method: "PUT".into(),
|
||||
target: "/characteristics".into(),
|
||||
body: br#"{"characteristics":[{"aid":2,"iid":8,"ev":true}]}"#.to_vec(),
|
||||
connection_close: false,
|
||||
},
|
||||
&mut session,
|
||||
&bridge,
|
||||
&pairings,
|
||||
&mut subscriptions,
|
||||
);
|
||||
assert_eq!(response.status, 204);
|
||||
assert!(subscriptions.contains(&(2, 8)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Per-connection HAP authentication state.
|
||||
//!
|
||||
//! The transition to [`SessionState::Authenticated`] requires an Ed25519
|
||||
//! signature from a persisted controller. The caller is responsible for
|
||||
//! supplying the exact Pair-Verify transcript once X25519/HKDF/ChaCha20-
|
||||
//! Poly1305 transport is implemented; this crate never substitutes a bearer
|
||||
//! token or plaintext shortcut.
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
|
||||
use crate::error::HapError;
|
||||
use crate::pairing::PairingStore;
|
||||
|
||||
/// Authentication phase of one TCP connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SessionState {
|
||||
Connected,
|
||||
PairSetup,
|
||||
PairVerify,
|
||||
Authenticated { controller_id: String, admin: bool },
|
||||
Closing,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Connected => "connected",
|
||||
Self::PairSetup => "pair-setup",
|
||||
Self::PairVerify => "pair-verify",
|
||||
Self::Authenticated { .. } => "authenticated",
|
||||
Self::Closing => "closing",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether accessory, characteristic, event, and pairing-management
|
||||
/// endpoints may be processed.
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, Self::Authenticated { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed state machine for one HAP connection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: SessionState::Connected,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &SessionState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub fn begin_pair_setup(&mut self, already_paired: bool) -> Result<(), HapError> {
|
||||
if already_paired {
|
||||
return Err(HapError::Protocol(
|
||||
"Pair-Setup is unavailable after a controller is paired".into(),
|
||||
));
|
||||
}
|
||||
self.transition(SessionState::PairSetup)
|
||||
}
|
||||
|
||||
pub fn begin_pair_verify(&mut self) -> Result<(), HapError> {
|
||||
self.transition(SessionState::PairVerify)
|
||||
}
|
||||
|
||||
/// Authenticate a completed Pair-Verify transcript with the controller's
|
||||
/// persisted Ed25519 long-term public key.
|
||||
///
|
||||
/// This method is intentionally not called by the current network server:
|
||||
/// the encrypted Pair-Verify transcript is not implemented yet.
|
||||
pub fn authenticate_pair_verify(
|
||||
&mut self,
|
||||
controller_id: &str,
|
||||
signed_transcript: &[u8],
|
||||
signature: &[u8; 64],
|
||||
pairings: &PairingStore,
|
||||
) -> Result<(), HapError> {
|
||||
if !matches!(self.state, SessionState::PairVerify) {
|
||||
return Err(HapError::InvalidSessionTransition {
|
||||
from: self.state.name(),
|
||||
to: "authenticated",
|
||||
});
|
||||
}
|
||||
let pairing = pairings
|
||||
.get(controller_id)?
|
||||
.ok_or_else(|| HapError::PairingNotFound(controller_id.to_owned()))?;
|
||||
let key = VerifyingKey::from_bytes(&pairing.public_key)
|
||||
.map_err(|_| HapError::InvalidPairingRecord("invalid Ed25519 public key".into()))?;
|
||||
let signature = Signature::from_bytes(signature);
|
||||
key.verify(signed_transcript, &signature)
|
||||
.map_err(|_| HapError::Protocol("Pair-Verify controller signature rejected".into()))?;
|
||||
self.state = SessionState::Authenticated {
|
||||
controller_id: pairing.controller_id,
|
||||
admin: pairing.admin,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reset_pairing(&mut self) -> Result<(), HapError> {
|
||||
match self.state {
|
||||
SessionState::PairSetup | SessionState::PairVerify => {
|
||||
self.state = SessionState::Connected;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(HapError::InvalidSessionTransition {
|
||||
from: self.state.name(),
|
||||
to: "connected",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.state = SessionState::Closing;
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "hap-server"))]
|
||||
pub(crate) fn authenticated_for_test(admin: bool) -> Self {
|
||||
Self {
|
||||
state: SessionState::Authenticated {
|
||||
controller_id: "test-controller".into(),
|
||||
admin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&mut self, next: SessionState) -> Result<(), HapError> {
|
||||
if matches!(self.state, SessionState::Connected) {
|
||||
self.state = next;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HapError::InvalidSessionTransition {
|
||||
from: self.state.name(),
|
||||
to: next.name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pairing::ControllerPairing;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
#[test]
|
||||
fn authenticated_transition_requires_persisted_valid_signature() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
|
||||
let signing_key = SigningKey::from_bytes(&[42; 32]);
|
||||
store
|
||||
.add(ControllerPairing {
|
||||
controller_id: "controller".into(),
|
||||
public_key: signing_key.verifying_key().to_bytes(),
|
||||
admin: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let transcript = b"pair-verify transcript supplied by protocol implementation";
|
||||
let signature = signing_key.sign(transcript).to_bytes();
|
||||
let mut session = Session::new();
|
||||
session.begin_pair_verify().unwrap();
|
||||
session
|
||||
.authenticate_pair_verify("controller", transcript, &signature, &store)
|
||||
.unwrap();
|
||||
assert!(session.state().is_authenticated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_signature_and_skipped_verify_fail_closed() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
|
||||
let signing_key = SigningKey::from_bytes(&[9; 32]);
|
||||
store
|
||||
.add(ControllerPairing {
|
||||
controller_id: "controller".into(),
|
||||
public_key: signing_key.verifying_key().to_bytes(),
|
||||
admin: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut session = Session::new();
|
||||
assert!(session
|
||||
.authenticate_pair_verify("controller", b"x", &[0; 64], &store)
|
||||
.is_err());
|
||||
session.begin_pair_verify().unwrap();
|
||||
assert!(session
|
||||
.authenticate_pair_verify("controller", b"x", &[0; 64], &store)
|
||||
.is_err());
|
||||
assert!(!session.state().is_authenticated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_phases_cannot_overlap() {
|
||||
let mut session = Session::new();
|
||||
session.begin_pair_setup(false).unwrap();
|
||||
assert!(session.begin_pair_verify().is_err());
|
||||
session.reset_pairing().unwrap();
|
||||
session.begin_pair_verify().unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user