upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.rs100
-rw-r--r--src/http/nip11.rs53
-rw-r--r--src/sync/mod.rs6
-rw-r--r--src/sync/relay_connection.rs22
4 files changed, 122 insertions, 59 deletions
diff --git a/src/config.rs b/src/config.rs
index 94035e4..c4a7b6c 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,6 +1,9 @@
1use anyhow::Result; 1use anyhow::{Context, Result};
2use clap::{Parser, ValueEnum}; 2use clap::{Parser, ValueEnum};
3use nostr_sdk::prelude::*;
3use serde::{Deserialize, Serialize}; 4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
4 7
5/// Database backend type for the relay 8/// Database backend type for the relay
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] 9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
@@ -40,9 +43,18 @@ pub struct Config {
40 #[arg(long, env = "NGIT_DOMAIN")] 43 #[arg(long, env = "NGIT_DOMAIN")]
41 pub domain: String, 44 pub domain: String,
42 45
43 /// Owner's npub (optional, for relay info in NIP-11) 46 /// Relay operator's nsec (private key) for signing and authentication
44 #[arg(long, env = "NGIT_OWNER_NPUB")] 47 ///
45 pub owner_npub: Option<String>, 48 /// Used for:
49 /// - NIP-11 relay information document (pubkey field derived from this nsec)
50 /// - NIP-42 authentication when syncing from other relays
51 /// - Future: signing events, WoT-based rate limiting of syncing relays
52 ///
53 /// If not provided via CLI/env, will be loaded from/saved to `.relay-owner.nsec` file
54 /// in the current directory. If the file doesn't exist, a new key will be generated
55 /// and saved automatically.
56 #[arg(long, env = "NGIT_RELAY_OWNER_NSEC")]
57 pub relay_owner_nsec: Option<String>,
46 58
47 /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay") 59 /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay")
48 #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")] 60 #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")]
@@ -127,6 +139,9 @@ pub struct Config {
127} 139}
128 140
129impl Config { 141impl Config {
142 /// Path to the relay owner key file
143 const RELAY_OWNER_KEY_FILE: &'static str = ".relay-owner.nsec";
144
130 /// Load configuration from CLI args, environment variables, and defaults. 145 /// Load configuration from CLI args, environment variables, and defaults.
131 /// 146 ///
132 /// Priority (highest to lowest): 147 /// Priority (highest to lowest):
@@ -139,11 +154,66 @@ impl Config {
139 dotenvy::dotenv().ok(); 154 dotenvy::dotenv().ok();
140 155
141 // Parse CLI args (clap automatically handles env var fallback) 156 // Parse CLI args (clap automatically handles env var fallback)
142 let config = Self::parse(); 157 let mut config = Self::parse();
158
159 // If relay_owner_nsec not provided, load from file or generate
160 if config.relay_owner_nsec.is_none() {
161 config.relay_owner_nsec = Some(Self::load_or_generate_relay_owner_key()?);
162 }
143 163
144 Ok(config) 164 Ok(config)
145 } 165 }
146 166
167 /// Load relay owner key from file, or generate and save a new one
168 fn load_or_generate_relay_owner_key() -> Result<String> {
169 let key_path = PathBuf::from(Self::RELAY_OWNER_KEY_FILE);
170
171 // Try to load existing key
172 if key_path.exists() {
173 let nsec = fs::read_to_string(&key_path)
174 .context("Failed to read relay owner key file")?
175 .trim()
176 .to_string();
177
178 // Validate it's a valid nsec
179 Keys::parse(&nsec).context("Invalid nsec in relay owner key file")?;
180
181 tracing::info!(
182 "Loaded relay owner key from {}",
183 key_path.display()
184 );
185 return Ok(nsec);
186 }
187
188 // Generate new key
189 let keys = Keys::generate();
190 let nsec = keys.secret_key().to_bech32()?;
191
192 // Save to file
193 fs::write(&key_path, &nsec)
194 .context("Failed to write relay owner key file")?;
195
196 tracing::info!(
197 "Generated new relay owner key and saved to {}",
198 key_path.display()
199 );
200
201 Ok(nsec)
202 }
203
204 /// Get the relay owner's Keys object
205 pub fn relay_owner_keys(&self) -> Result<Keys> {
206 let nsec = self.relay_owner_nsec.as_ref()
207 .context("relay_owner_nsec not set (should be set by Config::load())")?;
208 Keys::parse(nsec).context("Invalid relay_owner_nsec")
209 }
210
211 /// Get the relay owner's public key (npub format) for NIP-11
212 pub fn relay_owner_npub(&self) -> Result<String> {
213 let keys = self.relay_owner_keys()?;
214 Ok(keys.public_key().to_bech32()?)
215 }
216
147 /// Get relay name (defaults to "${domain} grasp relay" if not set) 217 /// Get relay name (defaults to "${domain} grasp relay" if not set)
148 pub fn relay_name(&self) -> String { 218 pub fn relay_name(&self) -> String {
149 self.relay_name_override 219 self.relay_name_override
@@ -167,9 +237,13 @@ impl Config {
167 /// Create config for testing 237 /// Create config for testing
168 #[cfg(test)] 238 #[cfg(test)]
169 pub fn for_testing() -> Self { 239 pub fn for_testing() -> Self {
240 // Generate a test key deterministically for consistent tests
241 let keys = Keys::generate();
242 let nsec = keys.secret_key().to_bech32().expect("Failed to generate test nsec");
243
170 Self { 244 Self {
171 domain: "localhost:8080".to_string(), 245 domain: "localhost:8080".to_string(),
172 owner_npub: Some("npub1test".to_string()), 246 relay_owner_nsec: Some(nsec),
173 relay_name_override: Some("test relay".to_string()), 247 relay_name_override: Some("test relay".to_string()),
174 relay_description: "test description".to_string(), 248 relay_description: "test description".to_string(),
175 git_data_path: "./test_data/git".to_string(), 249 git_data_path: "./test_data/git".to_string(),
@@ -256,12 +330,14 @@ mod tests {
256 } 330 }
257 331
258 #[test] 332 #[test]
259 fn test_owner_npub_optional() { 333 fn test_relay_owner_keys() {
260 let config = Config { 334 let config = Config::for_testing();
261 owner_npub: None, 335 let keys = config.relay_owner_keys().expect("Should have valid keys");
262 ..Config::for_testing() 336 let npub = config.relay_owner_npub().expect("Should derive npub");
263 }; 337
264 assert!(config.owner_npub.is_none()); 338 // Verify the npub matches the keys
339 assert_eq!(npub, keys.public_key().to_bech32().unwrap());
340 assert!(npub.starts_with("npub1"));
265 } 341 }
266 342
267 #[test] 343 #[test]
diff --git a/src/http/nip11.rs b/src/http/nip11.rs
index 7df8306..cf31cf3 100644
--- a/src/http/nip11.rs
+++ b/src/http/nip11.rs
@@ -59,7 +59,7 @@ impl RelayInformationDocument {
59 Self { 59 Self {
60 name: config.relay_name(), 60 name: config.relay_name(),
61 description: config.relay_description.clone(), 61 description: config.relay_description.clone(),
62 pubkey: config.owner_npub.clone(), 62 pubkey: config.relay_owner_npub().ok(),
63 contact: None, // Could be added to config if needed 63 contact: None, // Could be added to config if needed
64 supported_nips: vec![ 64 supported_nips: vec![
65 1, // NIP-01: Basic protocol flow 65 1, // NIP-01: Basic protocol flow
@@ -93,30 +93,21 @@ mod tests {
93 93
94 #[test] 94 #[test]
95 fn test_relay_information_document_structure() { 95 fn test_relay_information_document_structure() {
96 let config = Config { 96 let mut config = Config::for_testing();
97 domain: "relay.example.com".to_string(), 97 config.domain = "relay.example.com".to_string();
98 owner_npub: Some("npub1test".to_string()), 98 config.relay_name_override = Some("Test Relay".to_string());
99 relay_name_override: Some("Test Relay".to_string()), 99 config.relay_description = "A test relay".to_string();
100 relay_description: "A test relay".to_string(),
101 git_data_path: "./data/git".to_string(),
102 relay_data_path: "./data/relay".to_string(),
103 bind_address: "127.0.0.1:8080".to_string(),
104 database_backend: crate::config::DatabaseBackend::Memory,
105 metrics_enabled: true,
106 metrics_connection_per_ip_abuse_threshold: 10,
107 metrics_top_n_repos: 10,
108 sync_bootstrap_relay_url: None,
109 sync_max_backoff_secs: 3600,
110 sync_disconnect_check_interval_secs: 60,
111 sync_base_backoff_secs: 5,
112 sync_disable_negentropy: false,
113 };
114 100
115 let doc = RelayInformationDocument::from_config(&config); 101 let doc = RelayInformationDocument::from_config(&config);
116 102
117 assert_eq!(doc.name, "Test Relay"); 103 assert_eq!(doc.name, "Test Relay");
118 assert_eq!(doc.description, "A test relay"); 104 assert_eq!(doc.description, "A test relay");
119 assert_eq!(doc.pubkey, Some("npub1test".to_string())); 105
106 // Verify pubkey is present and is a valid npub
107 assert!(doc.pubkey.is_some());
108 let pubkey = doc.pubkey.unwrap();
109 assert!(pubkey.starts_with("npub1"));
110
120 assert!(doc.supported_nips.contains(&1)); 111 assert!(doc.supported_nips.contains(&1));
121 assert!(doc.supported_nips.contains(&11)); 112 assert!(doc.supported_nips.contains(&11));
122 assert!(doc.supported_nips.contains(&34)); 113 assert!(doc.supported_nips.contains(&34));
@@ -132,24 +123,10 @@ mod tests {
132 123
133 #[test] 124 #[test]
134 fn test_relay_information_document_json() { 125 fn test_relay_information_document_json() {
135 let config = Config { 126 let mut config = Config::for_testing();
136 domain: "relay.example.com".to_string(), 127 config.domain = "relay.example.com".to_string();
137 owner_npub: Some("npub1test".to_string()), 128 config.relay_name_override = Some("Test Relay".to_string());
138 relay_name_override: Some("Test Relay".to_string()), 129 config.relay_description = "A test relay".to_string();
139 relay_description: "A test relay".to_string(),
140 git_data_path: "./data/git".to_string(),
141 relay_data_path: "./data/relay".to_string(),
142 bind_address: "127.0.0.1:8080".to_string(),
143 database_backend: crate::config::DatabaseBackend::Memory,
144 metrics_enabled: true,
145 metrics_connection_per_ip_abuse_threshold: 10,
146 metrics_top_n_repos: 10,
147 sync_bootstrap_relay_url: None,
148 sync_max_backoff_secs: 3600,
149 sync_disconnect_check_interval_secs: 60,
150 sync_base_backoff_secs: 5,
151 sync_disable_negentropy: false,
152 };
153 130
154 let doc = RelayInformationDocument::from_config(&config); 131 let doc = RelayInformationDocument::from_config(&config);
155 let json = doc.to_json().expect("Failed to serialize to JSON"); 132 let json = doc.to_json().expect("Failed to serialize to JSON");
diff --git a/src/sync/mod.rs b/src/sync/mod.rs
index 280f857..6da2644 100644
--- a/src/sync/mod.rs
+++ b/src/sync/mod.rs
@@ -1396,8 +1396,12 @@ impl SyncManager {
1396 async fn register_relay(&mut self, relay_url: String) { 1396 async fn register_relay(&mut self, relay_url: String) {
1397 // Create RelayConnection if not exists 1397 // Create RelayConnection if not exists
1398 if !self.connections.contains_key(&relay_url) { 1398 if !self.connections.contains_key(&relay_url) {
1399 // Get relay owner keys for NIP-42 authentication
1400 let keys = self.config.relay_owner_keys()
1401 .expect("relay_owner_keys should be available");
1402
1399 let connection = 1403 let connection =
1400 RelayConnection::new_with_database(relay_url.clone(), Arc::clone(&self.database)); 1404 RelayConnection::new_with_database(relay_url.clone(), Arc::clone(&self.database), keys);
1401 self.connections.insert(relay_url.clone(), connection); 1405 self.connections.insert(relay_url.clone(), connection);
1402 tracing::debug!(relay = %relay_url, "Registered new relay connection"); 1406 tracing::debug!(relay = %relay_url, "Registered new relay connection");
1403 } 1407 }
diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs
index 21d864d..d0090c8 100644
--- a/src/sync/relay_connection.rs
+++ b/src/sync/relay_connection.rs
@@ -106,9 +106,10 @@ impl RelayConnection {
106 /// 106 ///
107 /// # Arguments 107 /// # Arguments
108 /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com") 108 /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com")
109 pub fn new(url: String) -> Self { 109 /// * `keys` - Cryptographic keys for NIP-42 authentication (typically the relay operator's keys)
110 pub fn new(url: String, keys: Keys) -> Self {
110 let normalized_url = Self::normalize_url(&url); 111 let normalized_url = Self::normalize_url(&url);
111 let client = Client::default(); 112 let client = Client::new(keys);
112 Self { 113 Self {
113 url: normalized_url, 114 url: normalized_url,
114 client, 115 client,
@@ -122,9 +123,10 @@ impl RelayConnection {
122 /// # Arguments 123 /// # Arguments
123 /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com") 124 /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com")
124 /// * `database` - Shared database for local event comparison during negentropy sync 125 /// * `database` - Shared database for local event comparison during negentropy sync
125 pub fn new_with_database(url: String, database: SharedDatabase) -> Self { 126 /// * `keys` - Cryptographic keys for NIP-42 authentication (typically the relay operator's keys)
127 pub fn new_with_database(url: String, database: SharedDatabase, keys: Keys) -> Self {
126 let normalized_url = Self::normalize_url(&url); 128 let normalized_url = Self::normalize_url(&url);
127 let client = Client::default(); 129 let client = Client::new(keys);
128 Self { 130 Self {
129 url: normalized_url, 131 url: normalized_url,
130 client, 132 client,
@@ -553,19 +555,22 @@ mod tests {
553 555
554 #[test] 556 #[test]
555 fn test_new_normalizes_url() { 557 fn test_new_normalizes_url() {
556 let conn = RelayConnection::new("relay.example.com".to_string()); 558 let keys = Keys::generate();
559 let conn = RelayConnection::new("relay.example.com".to_string(), keys);
557 assert_eq!(conn.url(), "wss://relay.example.com"); 560 assert_eq!(conn.url(), "wss://relay.example.com");
558 } 561 }
559 562
560 #[test] 563 #[test]
561 fn test_new_preserves_wss_scheme() { 564 fn test_new_preserves_wss_scheme() {
562 let conn = RelayConnection::new("wss://relay.example.com".to_string()); 565 let keys = Keys::generate();
566 let conn = RelayConnection::new("wss://relay.example.com".to_string(), keys);
563 assert_eq!(conn.url(), "wss://relay.example.com"); 567 assert_eq!(conn.url(), "wss://relay.example.com");
564 } 568 }
565 569
566 #[test] 570 #[test]
567 fn test_new_preserves_ws_scheme() { 571 fn test_new_preserves_ws_scheme() {
568 let conn = RelayConnection::new("ws://relay.example.com".to_string()); 572 let keys = Keys::generate();
573 let conn = RelayConnection::new("ws://relay.example.com".to_string(), keys);
569 assert_eq!(conn.url(), "ws://relay.example.com"); 574 assert_eq!(conn.url(), "ws://relay.example.com");
570 } 575 }
571 576
@@ -573,7 +578,8 @@ mod tests {
573 fn test_new_with_database_normalizes_url() { 578 fn test_new_with_database_normalizes_url() {
574 // This test just verifies the URL normalization works 579 // This test just verifies the URL normalization works
575 // We can't easily test with_database without a real database 580 // We can't easily test with_database without a real database
576 let conn = RelayConnection::new("git.shakespeare.diy".to_string()); 581 let keys = Keys::generate();
582 let conn = RelayConnection::new("git.shakespeare.diy".to_string(), keys);
577 assert_eq!(conn.url(), "wss://git.shakespeare.diy"); 583 assert_eq!(conn.url(), "wss://git.shakespeare.diy");
578 } 584 }
579 585