Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
12 kB · 415 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416use std::path::{Path, PathBuf};
use anyhow::Context as _;use knot_git::Layout;use knot_types::{AccountDid, BranchName, KnotHostname, ObjectFormat};use serde::Deserialize;
/// raw on-disk shape; packet section 6 has the annotated example.#[derive(Debug, Clone)]pub struct ActivateConfig { pub nginx_binary: String, pub reload_command: Option<String>, /// address the generated serving nginx binds, e.g. "127.0.0.1:8080". pub listen: String,}
impl Default for ActivateConfig { fn default() -> Self { Self { nginx_binary: "nginx".to_string(), reload_command: None, listen: "8080".to_string(), } }}
#[derive(Deserialize, Default)]struct RawActivate { #[serde(default)] nginx_binary: Option<String>, #[serde(default)] reload_command: Option<String>, #[serde(default)] listen: Option<String>,}
#[derive(Deserialize)]struct RawConfig { knot: RawKnot, repo: RawRepo, secrets: RawSecrets, #[serde(default)] atproto: RawAtproto, output: RawOutput, #[serde(default)] profile: RawProfile, #[serde(default)] mirror: Option<RawMirror>, #[serde(default)] activate: RawActivate, /// owner accounts whose records activate re-publishes on (mirror resync /// signal; static knots emit no firehose). #[serde(default)] publish: Vec<RawPublishAccount>,}
#[derive(Deserialize)]struct RawPublishAccount { did: String, handle: String, #[serde(default = "default_publish_password_env")] password_env: String,}
fn default_publish_password_env() -> String { "KNOT_STATIC_PDS_APP_PASSWORD".to_string()}
#[derive(Deserialize)]struct RawKnot { hostname: String, owner: String,}
#[derive(Deserialize)]struct RawRepo { scan_path: PathBuf, #[serde(default)] default_branch: Option<String>, #[serde(default)] object_format: Option<String>,}
#[derive(Deserialize)]struct RawSecrets { sealed_key_file: PathBuf, #[serde(default = "default_master_key_env")] master_key_env: String,}
fn default_master_key_env() -> String { "KNOT_MASTER_KEY".to_string()}
#[derive(Deserialize)]struct RawAtproto { #[serde(default = "default_plc_directory")] plc_directory: String,}
impl Default for RawAtproto { fn default() -> Self { Self { plc_directory: default_plc_directory(), } }}
fn default_plc_directory() -> String { "https://plc.directory".to_string()}
#[derive(Deserialize)]struct RawOutput { root: PathBuf, #[serde(default = "default_keep_generations")] keep_generations: usize,}
fn default_keep_generations() -> usize { 3}
/// mirror crawl trigger endpoints (knotmirror admin or compatible).#[derive(Deserialize)]struct RawMirror { /// resync trigger URLs; knotmirror admin exposes /api/triggerRepoResync. #[serde(default)] resync_urls: Vec<String>, /// env var holding an optional bearer token for the trigger endpoints. #[serde(default)] auth_token_env: Option<String>,}
#[derive(Deserialize)]struct RawProfile { #[serde(default)] compare: Option<CompareProfile>, #[serde(default)] blame: Option<BlameProfile>, #[serde(default)] archives: Option<Vec<ArchiveFormat>>,}
impl Default for RawProfile { fn default() -> Self { Self { compare: None, blame: None, archives: None, } }}
/// the validated, typed form every command consumes.pub struct ValidatedConfig { pub config_path: PathBuf, pub hostname: KnotHostname, pub knot_did: knot_types::KnotId, pub owner: AccountDid, /// accounts activate re-publishes sh.tangled.repo records for, keyed by repo-owner DID. pub publish: Vec<PublishAccount>, pub scan_path: PathBuf, pub default_branch: BranchName, pub object_format: ObjectFormat, pub sealed_key_file: PathBuf, pub master_key_env: String, pub plc_directory: url::Url, pub output_root: PathBuf, pub keep_generations: usize, pub profile: Profile, pub mirror: Option<ValidatedMirror>, pub activate: ActivateConfig,}
/// one owner account activate may publish records as.#[derive(Debug, Clone)]pub struct PublishAccount { /// repo-owner DID this account publishes for. pub did: AccountDid, /// createSession identifier (handle or email). pub handle: String, /// env var holding the account's app password. pub password_env: String,}
#[derive(Debug, Clone)]pub struct ValidatedMirror { pub resync_urls: Vec<String>, pub auth_token_env: Option<String>,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]#[serde(rename_all = "kebab-case")]pub enum CompareProfile { /// default tip <-> every live branch tip, both directions. DefaultBothDirections,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, serde::Serialize)]#[serde(rename_all = "lowercase")]pub enum BlameProfile { #[default] All, Tips, Off,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]pub enum ArchiveFormat { #[serde(rename = "tar.gz")] TarGz, #[serde(rename = "zip")] Zip,}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct Profile { pub compare: CompareProfile, pub blame: BlameProfile, pub archives: &'static [ArchiveFormat],}
pub const DEFAULT_ARCHIVES: &[ArchiveFormat] = &[ArchiveFormat::TarGz, ArchiveFormat::Zip];
impl Default for Profile { fn default() -> Self { Self { compare: CompareProfile::DefaultBothDirections, blame: BlameProfile::All, archives: DEFAULT_ARCHIVES, } }}
impl ValidatedConfig { pub fn load(path: &Path) -> anyhow::Result<Self> { let raw = std::fs::read_to_string(path) .with_context(|| format!("read config {}", path.display()))?; let raw: RawConfig = toml::from_str(&raw).with_context(|| format!("parse config {}", path.display()))?; let mut cfg = Self::validated(raw).with_context(|| format!("validate config {}", path.display()))?; cfg.config_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); Ok(cfg) }
fn validated(raw: RawConfig) -> anyhow::Result<Self> { let hostname = KnotHostname::new(raw.knot.hostname).context("knot.hostname")?; let knot_did = hostname.knot_did(); let owner = AccountDid::new(raw.knot.owner).context("knot.owner")?; let default_branch = match raw.repo.default_branch { Some(branch) => BranchName::new(branch).context("repo.default_branch")?, None => BranchName::new("main".to_string()).expect("main is a valid branch name"), }; let object_format = match raw.repo.object_format.as_deref() { None => ObjectFormat::SHA1, Some(token) => ObjectFormat::from_capability(token) .with_context(|| format!("repo.object_format {token:?} is not sha1 or sha256"))?, }; let plc_directory = url::Url::parse(&raw.atproto.plc_directory) .context("atproto.plc_directory must be an absolute URL")?; if raw.output.keep_generations < 1 { anyhow::bail!("output.keep_generations must be at least 1"); } Ok(Self { config_path: PathBuf::from("knot-static.toml"), hostname, knot_did, owner, scan_path: raw.repo.scan_path, default_branch, object_format, sealed_key_file: raw.secrets.sealed_key_file, master_key_env: raw.secrets.master_key_env, plc_directory, output_root: raw.output.root, keep_generations: raw.output.keep_generations, mirror: raw.mirror.map(|mirror| ValidatedMirror { resync_urls: mirror.resync_urls, auth_token_env: mirror.auth_token_env, }), publish: raw .publish .into_iter() .map(|account| { Ok(PublishAccount { did: AccountDid::new(account.did).context("publish.did")?, handle: account.handle, password_env: account.password_env, }) }) .collect::<anyhow::Result<Vec<_>>>()?, activate: ActivateConfig { nginx_binary: raw .activate .nginx_binary .unwrap_or_else(|| "nginx".to_string()), reload_command: raw.activate.reload_command, listen: raw.activate.listen.unwrap_or_else(|| "8080".to_string()), }, profile: Profile { compare: raw .profile .compare .unwrap_or(CompareProfile::DefaultBothDirections), blame: raw.profile.blame.unwrap_or_default(), archives: DEFAULT_ARCHIVES, }, }) }
/// canonical Tangled host origin; DID-document service paths are stripped by /// Tangled's repoident, so the static knot serves XRPC at the root. /// load the master key from the environment. pub fn master_key(&self) -> anyhow::Result<knot_secrets::MasterKey> { knot_secrets::MasterKey::new( std::env::var(&self.master_key_env) .with_context(|| format!("read master key from {}", self.master_key_env))? .into_bytes(), ) .context("invalid master key") }
/// path to oauth secrets directory (<state_dir>/oauth). pub fn oauth_dir(&self) -> PathBuf { self.sealed_key_file .parent() .unwrap_or_else(|| Path::new(".")) .join("oauth") }
pub fn service_url(&self) -> anyhow::Result<url::Url> { let mut url = url::Url::parse(&format!("https://{}", self.hostname))?; url.set_path(""); Ok(url) }
/// fresh Layout projection of the configured repo store. pub fn layout(&self) -> anyhow::Result<Layout> { Layout::new(&self.scan_path) .with_default_branch(self.default_branch.clone()) .with_object_format(self.object_format) .reserving_meta(&self.knot_did) .context("construct repo layout") }}
#[cfg(test)]mod publish_config_tests { use super::*;
#[test] fn publish_accounts_parse_and_validate() { let raw = r#"[knot]hostname = "knot.test"owner = "did:plc:owner"
[repo]scan_path = "repos"
[secrets]sealed_key_file = "sealed-keys"
[output]root = "output"
[[publish]]did = "did:plc:ppspjougr2uxmvu6la66a35q"handle = "klbr.net"password_env = "KS_PW_A"
[[publish]]did = "did:plc:dfl62fgb7wtjj3fcbb72naae"handle = "ptr.pet""#; let raw_config: RawConfig = toml::from_str(raw).expect("parse"); let validated = ValidatedConfig::validated(raw_config).expect("validate"); assert_eq!(validated.publish.len(), 2); assert_eq!( validated.publish[0].did.as_str(), "did:plc:ppspjougr2uxmvu6la66a35q" ); assert_eq!(validated.publish[0].password_env, "KS_PW_A"); // omitted password_env falls back to the shared default assert_eq!( validated.publish[1].password_env, "KNOT_STATIC_PDS_APP_PASSWORD" ); }
#[test] fn publish_account_with_invalid_did_is_rejected() { let raw = r#"[knot]hostname = "knot.test"owner = "did:plc:owner"
[repo]scan_path = "repos"
[secrets]sealed_key_file = "sealed-keys"
[output]root = "output"
[[publish]]did = "not-a-did"handle = "x""#; let raw_config: RawConfig = toml::from_str(raw).expect("parse"); assert!(ValidatedConfig::validated(raw_config).is_err()); }}