Skip to content

Commit 05a746c

Browse files
author
Cody Kickertz
committed
feat(syndesis): mDNS discovery, pairing protocol, and renderer registry
Implements task #124: QUIC-ready transport layer, renderer pairing handshake, session authentication, mDNS advertisement/discovery, and renderer management API. - syndesis: new crate — TLS cert generation + fingerprinting (rcgen/sha2), protocol frames (newline-delimited JSON), argon2id API key hashing, pairing handshake (TOFU cert pinning), session init handler with tests - harmonia-db: renderers table (migration 008) + full CRUD repo - paroche: mDNS advertisement (mdns-sd 0.18 DaemonEvent::Announce), renderer management routes (GET/DELETE/PATCH) behind RequireAdmin - harmonia-host: renderer-side mDNS discovery with preferred-fingerprint sorting, TOML credential storage, `harmonia render` CLI subcommand Gate-Passed: kanon 0.1.0
1 parent f09f84e commit 05a746c

28 files changed

Lines changed: 1692 additions & 65 deletions

File tree

Cargo.lock

Lines changed: 53 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ members = [
1414
"crates/zetesis",
1515
"crates/ergasia",
1616
"crates/syndesmos",
17+
"crates/syndesis",
1718
"crates/aitesis",
1819
"crates/syntaxis",
1920
"crates/harmonia-host",
2021
"crates/prostheke",
2122
"crates/theatron/core",
2223
"crates/akouo-core",
23-
"crates/syndesis",
2424
]
2525
exclude = [
2626
"akouo/shared/akouo-core",
@@ -46,12 +46,12 @@ komide = { path = "crates/komide" }
4646
zetesis = { path = "crates/zetesis" }
4747
ergasia = { path = "crates/ergasia" }
4848
syndesmos = { path = "crates/syndesmos" }
49+
syndesis = { path = "crates/syndesis" }
4950
aitesis = { path = "crates/aitesis" }
5051
syntaxis = { path = "crates/syntaxis" }
5152
prostheke = { path = "crates/prostheke" }
5253
theatron-core = { path = "crates/theatron/core" }
5354
akouo-core = { path = "crates/akouo-core" }
54-
syndesis = { path = "crates/syndesis" }
5555

5656
# ── Async runtime ──────────────────────────────────────────────────────────────
5757
tokio = { version = "1", features = ["full"] }
@@ -117,11 +117,19 @@ fast_image_resize = "6.0"
117117
# ── QUIC transport ─────────────────────────────────────────────────────────────
118118
quinn = "0.11"
119119
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
120-
rcgen = "0.13"
120+
rcgen = { version = "0.13", features = ["pem"] }
121121

122122
# ── mDNS discovery ────────────────────────────────────────────────────────────
123123
mdns-sd = "0.18"
124124

125+
# ── Crypto utilities ──────────────────────────────────────────────────────────
126+
sha2 = "0.10"
127+
base64 = "0.22"
128+
rand_core = { version = "0.6", features = ["getrandom"] }
129+
130+
# ── TOML (credential files) ───────────────────────────────────────────────────
131+
toml = "0.8"
132+
125133
# ── Scheduling ────────────────────────────────────────────────────────────────
126134
tokio-cron-scheduler = "0.15"
127135

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Renderer registry: tracks paired playback renderers.
2+
CREATE TABLE renderers (
3+
id TEXT PRIMARY KEY,
4+
name TEXT NOT NULL,
5+
api_key_hash TEXT NOT NULL,
6+
cert_fingerprint TEXT NOT NULL,
7+
last_seen TEXT,
8+
paired_at TEXT NOT NULL,
9+
enabled INTEGER NOT NULL DEFAULT 1
10+
);
11+
12+
CREATE INDEX idx_renderers_enabled ON renderers (enabled);

crates/harmonia-db/src/repo/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod play_history;
88
pub mod podcast;
99
pub mod quality;
1010
pub mod registry;
11+
pub mod renderer;
1112
pub mod tv;
1213
pub mod user;
1314
pub mod want;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Renderer registry: CRUD for paired playback renderers
2+
use sqlx::SqlitePool;
3+
4+
use crate::error::{DbError, QuerySnafu};
5+
use snafu::ResultExt;
6+
7+
#[derive(Debug, Clone, sqlx::FromRow)]
8+
pub struct Renderer {
9+
pub id: String,
10+
pub name: String,
11+
pub api_key_hash: String,
12+
pub cert_fingerprint: String,
13+
pub last_seen: Option<String>,
14+
pub paired_at: String,
15+
pub enabled: i64,
16+
}
17+
18+
pub async fn create_renderer(
19+
pool: &SqlitePool,
20+
id: &str,
21+
name: &str,
22+
api_key_hash: &str,
23+
cert_fingerprint: &str,
24+
) -> Result<Renderer, DbError> {
25+
let now = jiff::Zoned::now()
26+
.strftime("%Y-%m-%dT%H:%M:%SZ")
27+
.to_string();
28+
sqlx::query(
29+
"INSERT INTO renderers (id, name, api_key_hash, cert_fingerprint, last_seen, paired_at, enabled)
30+
VALUES (?, ?, ?, ?, NULL, ?, 1)",
31+
)
32+
.bind(id)
33+
.bind(name)
34+
.bind(api_key_hash)
35+
.bind(cert_fingerprint)
36+
.bind(&now)
37+
.execute(pool)
38+
.await
39+
.context(QuerySnafu { table: "renderers" })?;
40+
41+
get_renderer(pool, id)
42+
.await?
43+
.ok_or_else(|| DbError::NotFound {
44+
table: "renderers".to_string(),
45+
id: id.to_string(),
46+
location: snafu::location!(),
47+
})
48+
}
49+
50+
pub async fn get_renderer(pool: &SqlitePool, id: &str) -> Result<Option<Renderer>, DbError> {
51+
sqlx::query_as::<_, Renderer>(
52+
"SELECT id, name, api_key_hash, cert_fingerprint, last_seen, paired_at, enabled
53+
FROM renderers WHERE id = ?",
54+
)
55+
.bind(id)
56+
.fetch_optional(pool)
57+
.await
58+
.context(QuerySnafu { table: "renderers" })
59+
}
60+
61+
pub async fn list_renderers(pool: &SqlitePool) -> Result<Vec<Renderer>, DbError> {
62+
sqlx::query_as::<_, Renderer>(
63+
"SELECT id, name, api_key_hash, cert_fingerprint, last_seen, paired_at, enabled
64+
FROM renderers ORDER BY paired_at DESC",
65+
)
66+
.fetch_all(pool)
67+
.await
68+
.context(QuerySnafu { table: "renderers" })
69+
}
70+
71+
pub async fn update_last_seen(pool: &SqlitePool, id: &str) -> Result<(), DbError> {
72+
let now = jiff::Zoned::now()
73+
.strftime("%Y-%m-%dT%H:%M:%SZ")
74+
.to_string();
75+
sqlx::query("UPDATE renderers SET last_seen = ? WHERE id = ?")
76+
.bind(&now)
77+
.bind(id)
78+
.execute(pool)
79+
.await
80+
.context(QuerySnafu { table: "renderers" })?;
81+
Ok(())
82+
}
83+
84+
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> Result<(), DbError> {
85+
sqlx::query("UPDATE renderers SET enabled = ? WHERE id = ?")
86+
.bind(if enabled { 1i64 } else { 0i64 })
87+
.bind(id)
88+
.execute(pool)
89+
.await
90+
.context(QuerySnafu { table: "renderers" })?;
91+
Ok(())
92+
}
93+
94+
pub async fn rename_renderer(pool: &SqlitePool, id: &str, name: &str) -> Result<(), DbError> {
95+
sqlx::query("UPDATE renderers SET name = ? WHERE id = ?")
96+
.bind(name)
97+
.bind(id)
98+
.execute(pool)
99+
.await
100+
.context(QuerySnafu { table: "renderers" })?;
101+
Ok(())
102+
}
103+
104+
pub async fn delete_renderer(pool: &SqlitePool, id: &str) -> Result<(), DbError> {
105+
sqlx::query("DELETE FROM renderers WHERE id = ?")
106+
.bind(id)
107+
.execute(pool)
108+
.await
109+
.context(QuerySnafu { table: "renderers" })?;
110+
Ok(())
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use crate::migrate::MIGRATOR;
117+
use sqlx::SqlitePool;
118+
119+
async fn setup() -> SqlitePool {
120+
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
121+
MIGRATOR.run(&pool).await.unwrap();
122+
pool
123+
}
124+
125+
fn renderer_id() -> String {
126+
uuid::Uuid::now_v7().to_string()
127+
}
128+
129+
#[tokio::test]
130+
async fn renderer_crud() {
131+
let pool = setup().await;
132+
let id = renderer_id();
133+
134+
// create
135+
let r = create_renderer(&pool, &id, "Living Room", "hash1", "fp1")
136+
.await
137+
.unwrap();
138+
assert_eq!(r.name, "Living Room");
139+
assert_eq!(r.enabled, 1);
140+
assert!(r.last_seen.is_none());
141+
142+
// get
143+
let fetched = get_renderer(&pool, &id).await.unwrap().unwrap();
144+
assert_eq!(fetched.api_key_hash, "hash1");
145+
assert_eq!(fetched.cert_fingerprint, "fp1");
146+
147+
// update_last_seen
148+
update_last_seen(&pool, &id).await.unwrap();
149+
let updated = get_renderer(&pool, &id).await.unwrap().unwrap();
150+
assert!(updated.last_seen.is_some());
151+
152+
// rename
153+
rename_renderer(&pool, &id, "Kitchen").await.unwrap();
154+
let renamed = get_renderer(&pool, &id).await.unwrap().unwrap();
155+
assert_eq!(renamed.name, "Kitchen");
156+
157+
// disable
158+
set_enabled(&pool, &id, false).await.unwrap();
159+
let disabled = get_renderer(&pool, &id).await.unwrap().unwrap();
160+
assert_eq!(disabled.enabled, 0);
161+
162+
// re-enable
163+
set_enabled(&pool, &id, true).await.unwrap();
164+
let enabled = get_renderer(&pool, &id).await.unwrap().unwrap();
165+
assert_eq!(enabled.enabled, 1);
166+
167+
// delete
168+
delete_renderer(&pool, &id).await.unwrap();
169+
assert!(get_renderer(&pool, &id).await.unwrap().is_none());
170+
}
171+
172+
#[tokio::test]
173+
async fn list_renderers_empty() {
174+
let pool = setup().await;
175+
let result = list_renderers(&pool).await.unwrap();
176+
assert!(result.is_empty());
177+
}
178+
179+
#[tokio::test]
180+
async fn list_renderers_multiple() {
181+
let pool = setup().await;
182+
let id1 = renderer_id();
183+
let id2 = renderer_id();
184+
185+
create_renderer(&pool, &id1, "A", "h1", "f1").await.unwrap();
186+
create_renderer(&pool, &id2, "B", "h2", "f2").await.unwrap();
187+
188+
let list = list_renderers(&pool).await.unwrap();
189+
assert_eq!(list.len(), 2);
190+
}
191+
}

0 commit comments

Comments
 (0)