From 24905afbff7afc334b92fef125e89a5a4df106b7 Mon Sep 17 00:00:00 2001 From: dawn Date: Fri, 16 Oct 2026 20:43:20 +0300 Subject: [PATCH] knot-config: add the list of services allowed to sync private repos Signed-off-by: dawn --- Cargo.lock | 2 + knot2/crates/knot-config/Cargo.toml | 2 + knot2/crates/knot-config/src/lib.rs | 113 ++++++++++++++++++++++++- knot2/crates/knot-types/src/ids.rs | 24 ++++++ knot2/crates/knot-types/src/lib.rs | 3 + knot2/crates/knot-types/src/syncers.rs | 51 +++++++++++ knot2/crates/knot-xrpc/src/repos.rs | 5 +- knot2/example.toml | 11 +++ 8 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 knot2/crates/knot-types/src/syncers.rs diff --git a/Cargo.lock b/Cargo.lock index 75d26c34b..7daf2ac8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5323,6 +5323,8 @@ dependencies = [ "knot-messages", "knot-runtime", "knot-types", + "serde", + "serde_json", "tempfile", "thiserror 2.0.18", "url", diff --git a/knot2/crates/knot-config/Cargo.toml b/knot2/crates/knot-config/Cargo.toml index 514df8acc..e20fcf6d5 100644 --- a/knot2/crates/knot-config/Cargo.toml +++ b/knot2/crates/knot-config/Cargo.toml @@ -12,5 +12,7 @@ knot-messages = { workspace = true } confique = { workspace = true } thiserror = { workspace = true } base64 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } url = { workspace = true } diff --git a/knot2/crates/knot-config/src/lib.rs b/knot2/crates/knot-config/src/lib.rs index 8032ff1ef..0be1169b3 100644 --- a/knot2/crates/knot-config/src/lib.rs +++ b/knot2/crates/knot-config/src/lib.rs @@ -8,9 +8,10 @@ use base64::Engine; use confique::Config; use knot_runtime::HttpLimits; use knot_types::{ - AccountDid, AdmissionPolicy, AppviewEndpoint, ContributionPolicy, ProxyNetError, - TrustedProxies, comma_separated, + AccountDid, AdmissionPolicy, AppviewEndpoint, ContributionPolicy, Nsid, ProxyNetError, + ServiceDid, Syncer, SyncerError, Syncers, TrustedProxies, comma_separated, }; +use serde::Deserialize; use url::Url; #[derive(Debug, Config)] @@ -51,6 +52,8 @@ pub struct KnotConfig { pub resources: ResourcesConfig, #[config(nested)] pub homepage: HomepageConfig, + #[config(nested)] + pub sync: SyncConfig, #[config(nested)] pub ci: CiConfig, @@ -177,6 +180,22 @@ pub struct CiConfig { pub logs_addr: Option, } +#[derive(Debug, Config)] +pub struct SyncConfig { + /// Services that may read private repos while a member's lease covers them, + /// each a `did` and the `lxm` methods it may call, for example + /// `{ did = "did:web:bobbin.example", lxm = ["com.atproto.sync.getRepo"] }`. + /// The env var takes the same list as JSON. + #[config(env = "KNOT_SYNC_SYNCERS", parse_env = parse_syncers, default = [])] + pub syncers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SyncerConfig { + pub did: ServiceDid, + pub lxm: Vec, +} + #[derive(Debug, Config)] pub struct HomepageConfig { #[config(env = "KNOT_HOMEPAGE_ENABLED", default = true)] @@ -502,7 +521,20 @@ fn parse_admins(raw: &str) -> Result, knot_types::ParseError> { .collect() } +fn parse_syncers(raw: &str) -> Result, serde_json::Error> { + serde_json::from_str(raw) +} + impl KnotConfig { + pub fn syncers(&self) -> Result { + self.sync + .syncers + .iter() + .map(|syncer| Syncer::new(syncer.did.clone(), syncer.lxm.clone())) + .collect::>() + .and_then(Syncers::new) + } + pub fn object_format(&self) -> Option { knot_types::ObjectFormat::from_capability(&self.git.object_format) } @@ -905,6 +937,9 @@ impl KnotConfig { self.trusted_proxies() .err() .map(|error| format!("xrpc.trusted_proxies: {error}")), + self.syncers() + .err() + .map(|error| format!("sync.syncers: {error}")), self.acl .legacy_admin_secret_env .as_deref() @@ -1412,6 +1447,19 @@ mod tests { ci: CiConfig { logs_addr: Some("logs.oyster.cafe:3333".to_string()), }, + sync: SyncConfig { + syncers: Vec::new(), + }, + } + } + + fn syncer(did: &str, lxm: &[&str]) -> SyncerConfig { + SyncerConfig { + did: ServiceDid::new(did).unwrap(), + lxm: lxm + .iter() + .map(|nsid| Nsid::new_owned(*nsid).unwrap()) + .collect(), } } @@ -1458,6 +1506,17 @@ mod tests { false, false, ), + ( + "a_declared_syncer", + |config| { + config.sync.syncers = vec![syncer( + "did:web:bobbin.example", + &["com.atproto.sync.getRepo"], + )] + }, + false, + false, + ), ( "a_legacy_admin_secret_env_var", |config| { @@ -1780,6 +1839,21 @@ mod tests { |config| config.homepage.path = Some(PathBuf::from("homepage.html")), "homepage.path must be absolute path", ), + ( + "a_syncer_with_no_methods", + |config| config.sync.syncers = vec![syncer("did:web:bobbin.example", &[])], + "lists no methods", + ), + ( + "a_syncer_declared_twice", + |config| { + config.sync.syncers = vec![ + syncer("did:web:bobbin.example", &["com.atproto.sync.getRepo"]), + syncer("did:web:bobbin.example", &["com.atproto.sync.listRepos"]), + ] + }, + "is declared twice", + ), ( "trusted_proxies_without_the_header_the_knot_honors", |config| config.xrpc.trusted_proxies = vec!["127.0.0.1".to_owned()], @@ -1846,6 +1920,41 @@ mod tests { }); } + #[test] + fn syncers_parse_from_json_env() { + let parsed = parse_syncers( + r#"[{ "did": "did:web:bobbin.example", "lxm": ["com.atproto.sync.getRepo"] }]"#, + ) + .unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].lxm[0].as_str(), "com.atproto.sync.getRepo"); + assert!(parse_syncers("did:web:bobbin.example").is_err()); + assert!( + parse_syncers(r#"[{ "did": "bobbin", "lxm": ["com.atproto.sync.getRepo"] }]"#).is_err() + ); + assert!( + parse_syncers(r#"[{ "did": "did:web:bobbin.example", "lxm": ["getRepo"] }]"#).is_err() + ); + } + + #[test] + fn declared_syncers_resolve_by_did() { + let mut config = sample(); + config.sync.syncers = vec![syncer( + "did:web:bobbin.example", + &["com.atproto.sync.getRepo"], + )]; + let syncers = config.syncers().unwrap(); + let bobbin = AccountDid::new("did:web:bobbin.example").unwrap(); + let method = knot_types::Nsid::new_owned("com.atproto.sync.getRepo").unwrap(); + assert!(syncers.get(&bobbin).unwrap().allows(&method)); + assert!( + syncers + .get(&AccountDid::new("did:plc:nel").unwrap()) + .is_none() + ); + } + #[test] fn admins_parse_from_comma_separated_env() { let parsed = parse_admins("did:plc:nel, did:plc:olaren").unwrap(); diff --git a/knot2/crates/knot-types/src/ids.rs b/knot2/crates/knot-types/src/ids.rs index b124ac41e..50fe776e4 100644 --- a/knot2/crates/knot-types/src/ids.rs +++ b/knot2/crates/knot-types/src/ids.rs @@ -648,6 +648,30 @@ impl From for OwnerDid { } } +impl ServiceDid { + pub fn is(&self, account: &AccountDid) -> bool { + self.as_str() == account.as_str() + } +} + +impl From for ServiceDid { + fn from(account: AccountDid) -> Self { + ServiceDid(account.0) + } +} + +impl From<&AccountDid> for ServiceDid { + fn from(account: &AccountDid) -> Self { + ServiceDid(account.0.clone()) + } +} + +impl From<&ServiceDid> for AccountDid { + fn from(service: &ServiceDid) -> Self { + AccountDid(service.0.clone()) + } +} + impl From for AccountDid { fn from(repo: RepoDid) -> Self { AccountDid(repo.0) diff --git a/knot2/crates/knot-types/src/lib.rs b/knot2/crates/knot-types/src/lib.rs index 894628b05..224c3c799 100644 --- a/knot2/crates/knot-types/src/lib.rs +++ b/knot2/crates/knot-types/src/lib.rs @@ -26,6 +26,9 @@ pub use policy::{ RepoPolicy, RepoRole, Visibility, }; +mod syncers; +pub use syncers::{Syncer, SyncerError, Syncers}; + mod hex; pub use hex::{decode_hex, lowercase_hex}; diff --git a/knot2/crates/knot-types/src/syncers.rs b/knot2/crates/knot-types/src/syncers.rs new file mode 100644 index 000000000..117caec49 --- /dev/null +++ b/knot2/crates/knot-types/src/syncers.rs @@ -0,0 +1,51 @@ +use std::collections::HashSet; + +use crate::{AccountDid, Nsid, ServiceDid}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Syncer { + did: ServiceDid, + lxm: Vec, +} + +impl Syncer { + pub fn new(did: ServiceDid, lxm: Vec) -> Result { + match lxm.is_empty() { + true => Err(SyncerError::NoMethods(did)), + false => Ok(Self { did, lxm }), + } + } + + pub fn did(&self) -> &ServiceDid { + &self.did + } + + pub fn allows(&self, method: &Nsid) -> bool { + self.lxm.contains(method) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Syncers(Vec); + +impl Syncers { + pub fn new(declared: Vec) -> Result { + let mut seen = HashSet::new(); + match declared.iter().find(|syncer| !seen.insert(&syncer.did)) { + Some(twice) => Err(SyncerError::Twice(twice.did.clone())), + None => Ok(Self(declared)), + } + } + + pub fn get(&self, did: &AccountDid) -> Option<&Syncer> { + self.0.iter().find(|syncer| syncer.did.is(did)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SyncerError { + #[error("{0} lists no methods")] + NoMethods(ServiceDid), + #[error("{0} is declared twice")] + Twice(ServiceDid), +} diff --git a/knot2/crates/knot-xrpc/src/repos.rs b/knot2/crates/knot-xrpc/src/repos.rs index 5850f0938..d11f3d40a 100644 --- a/knot2/crates/knot-xrpc/src/repos.rs +++ b/knot2/crates/knot-xrpc/src/repos.rs @@ -183,7 +183,7 @@ pub(crate) async fn create_repo( source: input.source, }; - let owner = OwnerDid::new(actor.as_str()).expect("account DID is always a valid owner DID"); + let owner = OwnerDid::from(actor.clone()); match state.index.resolve_repo(&owner, &rkey) { Resolved::Ready(Some(existing)) => match state.index.rkey_of(&existing) { Resolved::Ready(Some(canonical)) if canonical == rkey => { @@ -607,8 +607,7 @@ async fn provision_repo_did( } None => { let knot_signer = state.secrets.signer(&state.knot_did)?; - let owner = - OwnerDid::new(actor.as_str()).expect("account DID is always a valid owner DID"); + let owner = OwnerDid::from(actor.clone()); let nonce = knot_atproto::MintNonce::mint(&*state.entropy, &owner, rkey); let prepared = knot_atproto::prepare_repo_did(&knot_signer, &state.knot_service_url, &nonce) diff --git a/knot2/example.toml b/knot2/example.toml index cffbb0dea..7e1cdf7a6 100644 --- a/knot2/example.toml +++ b/knot2/example.toml @@ -483,6 +483,17 @@ # Can also be specified via environment variable `KNOT_HOMEPAGE_PATH`. #path = +[sync] +# Services that may read private repos while a member's lease covers them, +# each a `did` and the `lxm` methods it may call, for example +# `{ did = "did:web:bobbin.example", lxm = ["com.atproto.sync.getRepo"] }`. +# The env var takes the same list as JSON. +# +# Can also be specified via environment variable `KNOT_SYNC_SYNCERS`. +# +# Default value: [] +#syncers = [] + [ci] # Can also be specified via environment variable `KNOT_CI_LOGS_ADDR`. #logs_addr = -- 2.54.0