myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 55580439c805c02f8c0b8267b52e8910f2b8d1e8
parent 9ea8e896c06c7f9074a74bc480a3e8d6d46a2c5f
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 14:12:38 +0000

config: add canonical env rendering

Diffstat:
Msrc/config.rs | 381+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 381 insertions(+), 0 deletions(-)

diff --git a/src/config.rs b/src/config.rs @@ -401,6 +401,295 @@ impl MycConfig { Self::from_env_str_with_source(value, Path::new("<inline>")) } + pub fn to_env_string(&self) -> Result<String, MycError> { + self.validate()?; + + let mut lines = Vec::new(); + push_env_line( + &mut lines, + "MYC_SERVICE_INSTANCE_NAME", + self.service.instance_name.as_str(), + ); + push_env_line( + &mut lines, + "MYC_LOGGING_FILTER", + self.logging.filter.as_str(), + ); + push_optional_path_env_line( + &mut lines, + "MYC_LOGGING_OUTPUT_DIR", + self.logging.output_dir.as_ref(), + ); + push_env_line( + &mut lines, + "MYC_LOGGING_STDOUT", + self.logging.stdout.to_string(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_STATE_DIR", + self.paths.state_dir.display().to_string(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_SIGNER_IDENTITY_BACKEND", + self.paths.signer_identity_backend.as_str(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_SIGNER_IDENTITY_PATH", + self.paths.signer_identity_path.display().to_string(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID", + self.paths.signer_identity_keyring_account_id.as_deref(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME", + self.paths.signer_identity_keyring_service_name.as_str(), + ); + push_optional_path_env_line( + &mut lines, + "MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH", + self.paths.signer_identity_profile_path.as_ref(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_USER_IDENTITY_BACKEND", + self.paths.user_identity_backend.as_str(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_USER_IDENTITY_PATH", + self.paths.user_identity_path.display().to_string(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID", + self.paths.user_identity_keyring_account_id.as_deref(), + ); + push_env_line( + &mut lines, + "MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME", + self.paths.user_identity_keyring_service_name.as_str(), + ); + push_optional_path_env_line( + &mut lines, + "MYC_PATHS_USER_IDENTITY_PROFILE_PATH", + self.paths.user_identity_profile_path.as_ref(), + ); + push_env_line( + &mut lines, + "MYC_AUDIT_DEFAULT_READ_LIMIT", + self.audit.default_read_limit.to_string(), + ); + push_env_line( + &mut lines, + "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES", + self.audit.max_active_file_bytes.to_string(), + ); + push_env_line( + &mut lines, + "MYC_AUDIT_MAX_ARCHIVED_FILES", + self.audit.max_archived_files.to_string(), + ); + push_env_line( + &mut lines, + "MYC_OBSERVABILITY_ENABLED", + self.observability.enabled.to_string(), + ); + push_env_line( + &mut lines, + "MYC_OBSERVABILITY_BIND_ADDR", + self.observability.bind_addr.to_string(), + ); + push_env_line( + &mut lines, + "MYC_DISCOVERY_ENABLED", + self.discovery.enabled.to_string(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_DOMAIN", + self.discovery.domain.as_deref(), + ); + push_env_line( + &mut lines, + "MYC_DISCOVERY_HANDLER_IDENTIFIER", + self.discovery.handler_identifier.as_str(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_APP_IDENTITY_BACKEND", + self.discovery + .app_identity_backend + .map(MycIdentityBackend::as_str), + ); + push_optional_path_env_line( + &mut lines, + "MYC_DISCOVERY_APP_IDENTITY_PATH", + self.discovery.app_identity_path.as_ref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID", + self.discovery.app_identity_keyring_account_id.as_deref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME", + self.discovery.app_identity_keyring_service_name.as_deref(), + ); + push_optional_path_env_line( + &mut lines, + "MYC_DISCOVERY_APP_IDENTITY_PROFILE_PATH", + self.discovery.app_identity_profile_path.as_ref(), + ); + push_env_line( + &mut lines, + "MYC_DISCOVERY_PUBLIC_RELAYS", + self.discovery.public_relays.join(","), + ); + push_env_line( + &mut lines, + "MYC_DISCOVERY_PUBLISH_RELAYS", + self.discovery.publish_relays.join(","), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE", + self.discovery.nostrconnect_url_template.as_deref(), + ); + push_optional_path_env_line( + &mut lines, + "MYC_DISCOVERY_NIP05_OUTPUT_PATH", + self.discovery.nip05_output_path.as_ref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_METADATA_NAME", + self.discovery.metadata.name.as_deref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_METADATA_DISPLAY_NAME", + self.discovery.metadata.display_name.as_deref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_METADATA_ABOUT", + self.discovery.metadata.about.as_deref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_METADATA_WEBSITE", + self.discovery.metadata.website.as_deref(), + ); + push_optional_string_env_line( + &mut lines, + "MYC_DISCOVERY_METADATA_PICTURE", + self.discovery.metadata.picture.as_deref(), + ); + push_env_line( + &mut lines, + "MYC_POLICY_CONNECTION_APPROVAL", + match self.policy.connection_approval { + MycConnectionApproval::NotRequired => "not_required", + MycConnectionApproval::ExplicitUser => "explicit_user", + MycConnectionApproval::Deny => "deny", + }, + ); + push_env_line( + &mut lines, + "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS", + self.policy.trusted_client_pubkeys.join(","), + ); + push_env_line( + &mut lines, + "MYC_POLICY_DENIED_CLIENT_PUBKEYS", + self.policy.denied_client_pubkeys.join(","), + ); + push_env_line( + &mut lines, + "MYC_POLICY_PERMISSION_CEILING", + self.policy.permission_ceiling.to_string(), + ); + push_env_line( + &mut lines, + "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS", + self.policy + .allowed_sign_event_kinds + .iter() + .map(u16::to_string) + .collect::<Vec<_>>() + .join(","), + ); + push_optional_string_env_line( + &mut lines, + "MYC_POLICY_AUTH_URL", + self.policy.auth_url.as_deref(), + ); + push_env_line( + &mut lines, + "MYC_POLICY_AUTH_PENDING_TTL_SECS", + self.policy.auth_pending_ttl_secs.to_string(), + ); + push_optional_u64_env_line( + &mut lines, + "MYC_POLICY_AUTHORIZED_TTL_SECS", + self.policy.auth_authorized_ttl_secs, + ); + push_optional_u64_env_line( + &mut lines, + "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS", + self.policy.reauth_after_inactivity_secs, + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_ENABLED", + self.transport.enabled.to_string(), + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS", + self.transport.connect_timeout_secs.to_string(), + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_RELAYS", + self.transport.relays.join(","), + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_DELIVERY_POLICY", + self.transport.delivery_policy.as_str(), + ); + push_optional_usize_env_line( + &mut lines, + "MYC_TRANSPORT_DELIVERY_QUORUM", + self.transport.delivery_quorum, + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS", + self.transport.publish_max_attempts.to_string(), + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS", + self.transport.publish_initial_backoff_millis.to_string(), + ); + push_env_line( + &mut lines, + "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS", + self.transport.publish_max_backoff_millis.to_string(), + ); + + Ok(lines.join("\n") + "\n") + } + pub fn validate(&self) -> Result<(), MycError> { if self.service.instance_name.trim().is_empty() { return Err(MycError::InvalidConfig( @@ -586,6 +875,34 @@ impl MycConfig { } } +fn push_env_line(lines: &mut Vec<String>, key: &str, value: impl ToString) { + lines.push(format!("{key}={}", value.to_string())); +} + +fn push_optional_string_env_line(lines: &mut Vec<String>, key: &str, value: Option<&str>) { + if let Some(value) = value { + push_env_line(lines, key, value); + } +} + +fn push_optional_path_env_line(lines: &mut Vec<String>, key: &str, value: Option<&PathBuf>) { + if let Some(value) = value { + push_env_line(lines, key, value.display().to_string()); + } +} + +fn push_optional_u64_env_line(lines: &mut Vec<String>, key: &str, value: Option<u64>) { + if let Some(value) = value { + push_env_line(lines, key, value.to_string()); + } +} + +fn push_optional_usize_env_line(lines: &mut Vec<String>, key: &str, value: Option<usize>) { + if let Some(value) = value { + push_env_line(lines, key, value.to_string()); + } +} + fn parse_env_entries(value: &str, path: &Path) -> Result<Vec<(String, String, usize)>, MycError> { let mut seen = BTreeSet::new(); let mut entries = Vec::new(); @@ -1783,4 +2100,68 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery Some(PathBuf::from("/var/lib/myc/public/.well-known/nostr.json")) ); } + + #[test] + fn env_renderer_roundtrips_current_config_surface() { + let config = MycConfig::from_env_str( + r#" +MYC_SERVICE_INSTANCE_NAME=myc-dev +MYC_LOGGING_FILTER=debug,myc=trace +MYC_LOGGING_OUTPUT_DIR=/tmp/myc logs +MYC_LOGGING_STDOUT=false +MYC_PATHS_STATE_DIR=/tmp/myc state +MYC_PATHS_SIGNER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/ignored-signer.json +MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111 +MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer +MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=/tmp/signer-profile.json +MYC_PATHS_USER_IDENTITY_BACKEND=filesystem +MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json +MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user +MYC_AUDIT_DEFAULT_READ_LIMIT=50 +MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 +MYC_AUDIT_MAX_ARCHIVED_FILES=3 +MYC_OBSERVABILITY_ENABLED=true +MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=myc.example.com +MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main +MYC_DISCOVERY_APP_IDENTITY_BACKEND=filesystem +MYC_DISCOVERY_APP_IDENTITY_PATH=/tmp/myc-app.json +MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery +MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.discovery.example.com +MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.publish.example.com +MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect> +MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json +MYC_DISCOVERY_METADATA_NAME=myc +MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza +MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer +MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com +MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png +MYC_POLICY_CONNECTION_APPROVAL=not_required +MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111 +MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222 +MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1 +MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7 +MYC_POLICY_AUTH_URL=https://auth.example.com/challenge +MYC_POLICY_AUTH_PENDING_TTL_SECS=300 +MYC_POLICY_AUTHORIZED_TTL_SECS=3600 +MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 +MYC_TRANSPORT_ENABLED=true +MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 +MYC_TRANSPORT_RELAYS=wss://relay.example.com,wss://relay2.example.com +MYC_TRANSPORT_DELIVERY_POLICY=quorum +MYC_TRANSPORT_DELIVERY_QUORUM=2 +MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=4 +MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS=100 +MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 + "#, + ) + .expect("config"); + + let rendered = config.to_env_string().expect("render env"); + let reparsed = MycConfig::from_env_str(&rendered).expect("reparse rendered env"); + + assert_eq!(reparsed, config); + } }