1use anyhow::Context;
5use decdn_common::{cli, config};
6
7use crate::known_chains;
8
9pub fn config_validate(
20 config_path: Option<&std::path::Path>,
21 args: &cli::ConfigValidateArgs,
22) -> anyhow::Result<()> {
23 let (resolved, notices) = config::resolve_config(config_path, &args.run)?;
24 let source = effective_source(config_path, cli::common::default_config_path)?;
25 let mut stdout = std::io::stdout().lock();
26 write_validate_summary(&mut stdout, source.as_deref(), &resolved, ¬ices)
27 .map_err(|e| anyhow::anyhow!("failed to write summary: {e}"))
28}
29
30pub fn effective_source(
38 config_path: Option<&std::path::Path>,
39 default: impl FnOnce() -> Option<std::path::PathBuf>,
40) -> anyhow::Result<Option<std::path::PathBuf>> {
41 if let Some(p) = config_path {
42 return Ok(Some(p.to_path_buf()));
43 }
44 let Some(default) = default() else {
45 return Ok(None);
46 };
47 match default.try_exists() {
48 Ok(true) => Ok(Some(default)),
49 Ok(false) => Ok(None),
50 Err(e) => Err(anyhow::anyhow!(
51 "cannot determine whether default config {} exists: {e}",
52 default.display()
53 )),
54 }
55}
56
57#[allow(clippy::too_many_lines)]
64pub fn write_validate_summary<W: std::io::Write>(
65 w: &mut W,
66 source: Option<&std::path::Path>,
67 resolved: &config::ResolvedConfig,
68 notices: &[config::ConfigNotice],
69) -> std::io::Result<()> {
70 writeln!(w, "config valid")?;
71 match source {
72 Some(path) => writeln!(w, " source: {}", path.display())?,
73 None => writeln!(
74 w,
75 " source: (defaults + env only, no config file)"
76 )?,
77 }
78 writeln!(
79 w,
80 " data_dir: {}",
81 resolved.identity.data_dir.display()
82 )?;
83 if let Some(region) = &resolved.identity.region {
84 writeln!(w, " region: {region}")?;
85 }
86 writeln!(
87 w,
88 " bind_port: {}",
89 resolved.network.bind_port
90 )?;
91 for relay in &resolved.network.relay_urls {
97 writeln!(
98 w,
99 " relay_url: {}",
100 decdn_common::redact::redact_userinfo(relay)
101 )?;
102 }
103 let discovery = &resolved.network.discovery;
115 if let Some(pkarr) = &discovery.pkarr_url {
116 writeln!(
117 w,
118 " discovery.pkarr_url: {}",
119 decdn_common::redact::redact_userinfo(pkarr)
120 )?;
121 }
122 if let Some(origin) = &discovery.dns_origin {
123 writeln!(w, " discovery.dns_origin: {origin}")?;
124 }
125 if !discovery.peers.is_empty() {
126 writeln!(w, " discovery.peers: {}", discovery.peers.len())?;
127 }
128 writeln!(
129 w,
130 " rpc_url: <redacted> ({} chars)",
131 resolved.blockchain.rpc_url.len()
132 )?;
133 writeln!(
134 w,
135 " eth_keystore: {}",
136 resolved.blockchain.eth_keystore.display()
137 )?;
138 writeln!(
139 w,
140 " payment_pool_address: {}",
141 resolved.blockchain.payment_pool_address
142 )?;
143 writeln!(
144 w,
145 " capacity_bond_address: {}",
146 resolved.blockchain.capacity_bond_address
147 )?;
148 writeln!(
149 w,
150 " origin_assignment_address: {}",
151 resolved
152 .blockchain
153 .origin_assignment_address
154 .as_deref()
155 .unwrap_or("(unset — origin directory empty: pull-through gate and FIND_VALUE fallback find no origins)")
156 )?;
157 writeln!(
158 w,
159 " publisher_registry_address: {}",
160 resolved
161 .blockchain
162 .publisher_registry_address
163 .as_deref()
164 .unwrap_or("(unset — origin directory empty: pull-through gate and FIND_VALUE fallback find no origins)")
165 )?;
166 writeln!(
167 w,
168 " content_blacklist_address: {}",
169 resolved
170 .blockchain
171 .content_blacklist_address
172 .as_deref()
173 .unwrap_or("(unset — REQUIRED: the node refuses to start without it)")
174 )?;
175 writeln!(
176 w,
177 " content_blacklist_poll_interval_sec: {}",
178 resolved.blockchain.content_blacklist_poll_interval_sec
179 )?;
180 writeln!(
181 w,
182 " chain_staleness_grace_sec: {}",
183 resolved.blockchain.chain_staleness_grace_sec
184 )?;
185 writeln!(
186 w,
187 " rpc_watchdog_interval_sec: {}",
188 resolved.blockchain.rpc_watchdog_interval_sec
189 )?;
190 writeln!(
191 w,
192 " event_poll_interval_ms: {}",
193 resolved.blockchain.event_poll_interval_ms
194 )?;
195 writeln!(
196 w,
197 " get_logs_max_block_span: {}",
198 resolved.blockchain.get_logs_max_block_span
199 )?;
200 writeln!(
201 w,
202 " fee_shares_poll_interval_sec: {}",
203 resolved.blockchain.fee_shares_poll_interval_sec
204 )?;
205 writeln!(
206 w,
207 " redeem_threshold_micro_usdc: {}",
208 resolved.blockchain.redeem_threshold_micro_usdc
209 )?;
210 writeln!(
211 w,
212 " redeem_interval_secs: {}",
213 resolved.blockchain.redeem_interval_secs
214 )?;
215 writeln!(
216 w,
217 " buyer_working_deposit_micro_usdc: {}",
218 resolved.blockchain.buyer_working_deposit_micro_usdc
219 )?;
220 writeln!(
221 w,
222 " buyer_max_approve: {}",
223 resolved.blockchain.buyer_max_approve
224 )?;
225 writeln!(
226 w,
227 " pool_min_remaining_deposit_micro_usdc: {}",
228 resolved.blockchain.pool_min_remaining_deposit_micro_usdc
229 )?;
230 writeln!(
231 w,
232 " pool_floor_signer_live_windows: {}",
233 resolved.blockchain.pool_floor_signer_live_windows
234 )?;
235 writeln!(
236 w,
237 " cache_dir: {}",
238 resolved.cache.cache_dir.display()
239 )?;
240 writeln!(
241 w,
242 " cache_size_mb: {}",
243 resolved.cache.cache_size_mb
244 )?;
245 writeln!(
246 w,
247 " max_blob_size_mb: {}",
248 resolved.cache.max_blob_size_mb
249 )?;
250 match resolved.cache.max_rate_per_mb {
251 0 => writeln!(w, " max_rate_per_mb: unlimited (buyer)")?,
252 n => writeln!(w, " max_rate_per_mb: {n} (buyer ceiling)")?,
253 }
254 match resolved.cache.gc_interval_sec {
255 0 => writeln!(w, " gc_interval_sec: disabled")?,
256 n => writeln!(w, " gc_interval_sec: {n}")?,
257 }
258 match resolved.cache.fs_rescan_interval_sec {
259 0 => writeln!(
260 w,
261 " fs_rescan_interval_sec: disabled (startup + reload only)"
262 )?,
263 n => writeln!(w, " fs_rescan_interval_sec: {n}")?,
264 }
265 writeln!(
266 w,
267 " eviction: high_water_pct={}, target_pct={}, per_sweep_budget={}, tick_secs={}",
268 resolved.cache.eviction_high_water_pct,
269 resolved.cache.eviction_target_pct,
270 resolved.cache.eviction_per_sweep_budget,
271 resolved.cache.eviction_tick_secs
272 )?;
273 if resolved.cache.node_to_node_pull_through_enabled {
274 writeln!(
275 w,
276 " node_to_node_pull: enabled (probe_fanout={}, pull_timeout_sec={}, stall_window_sec={}, min_throughput_bps={})",
277 resolved.cache.node_pull_probe_fanout,
278 resolved.cache.node_pull_timeout_sec,
279 resolved.cache.node_pull_stall_window_sec,
280 resolved.cache.node_pull_min_throughput_bps
281 )?;
282 } else {
283 writeln!(w, " node_to_node_pull: disabled")?;
284 }
285 if resolved.cache.relay_foreign_namespaces {
286 writeln!(w, " relay_foreign_namespaces: true")?;
287 } else {
288 writeln!(w, " relay_foreign_namespaces: false (origin-only node)")?;
289 }
290 writeln!(
291 w,
292 " rate_per_mb: {}",
293 resolved.payment.rate_per_mb
294 )?;
295 writeln!(
296 w,
297 " log_level: {}",
298 resolved.observability.log_level
299 )?;
300 writeln!(
301 w,
302 " metrics_port: {}",
303 resolved.observability.metrics_port
304 )?;
305 writeln!(
306 w,
307 " metrics_bind: {}",
308 resolved.observability.metrics_bind
309 )?;
310 match resolved.observability.admin_port {
311 Some(p) => writeln!(w, " admin_port: {p}")?,
312 None => writeln!(w, " admin_port: disabled")?,
313 }
314 if let Some(otlp) = &resolved.observability.otlp_endpoint {
317 writeln!(
318 w,
319 " otlp_endpoint: <redacted> ({} chars)",
320 otlp.len()
321 )?;
322 }
323 writeln!(
327 w,
328 " security.max_concurrent_handlers: {}",
329 resolved.security.max_concurrent_handlers
330 )?;
331 writeln!(
332 w,
333 " security.per_source_rate_per_sec: {}",
334 resolved.security.per_source_rate_per_sec
335 )?;
336 writeln!(
337 w,
338 " security.per_source_burst: {}",
339 resolved.security.per_source_burst
340 )?;
341 writeln!(
342 w,
343 " dht.per_peer_rate_per_sec: {}",
344 resolved.dht.per_peer_rate_per_sec
345 )?;
346 writeln!(
347 w,
348 " dht.global_rate_per_sec: {}",
349 resolved.dht.global_rate_per_sec
350 )?;
351 writeln!(
352 w,
353 " probe.per_peer_rate_per_sec: {}",
354 resolved.probe.per_peer_rate_per_sec
355 )?;
356 writeln!(
357 w,
358 " probe.global_rate_per_sec: {}",
359 resolved.probe.global_rate_per_sec
360 )?;
361 writeln!(
368 w,
369 " receipts.log_path: {}",
370 resolved
371 .identity
372 .data_dir
373 .join(config::RECEIPT_LOG_FILE)
374 .display()
375 )?;
376 writeln!(
377 w,
378 " receipts.max_file_bytes: {}",
379 resolved.receipts.max_file_bytes
380 )?;
381 writeln!(
382 w,
383 " receipts.retained_files: {}",
384 resolved.receipts.retained_files
385 )?;
386 writeln!(
392 w,
393 " content.denied_hashes: {}",
394 resolved.content.denied_hashes.len()
395 )?;
396 writeln!(
397 w,
398 " content.denied_origins: {}",
399 resolved.content.denied_origins.len()
400 )?;
401 write_notices(w, notices)
402}
403
404fn write_notices<W: std::io::Write>(
412 w: &mut W,
413 notices: &[config::ConfigNotice],
414) -> std::io::Result<()> {
415 if notices.is_empty() {
416 return Ok(());
417 }
418 writeln!(w, "notices ({}):", notices.len())?;
419 for notice in notices {
420 let level = match notice.level {
421 config::ConfigNoticeLevel::Warn => "warning",
422 config::ConfigNoticeLevel::Info => "info",
423 };
424 writeln!(w, " - {level}: {}: {}", notice.field, notice.message)?;
425 }
426 Ok(())
427}
428
429#[derive(Debug)]
438pub enum Role {
439 Relay,
442 Origin(OriginSpec),
445 Client,
448}
449
450#[derive(Debug)]
455pub enum OriginSpec {
456 Http(decdn_config_types::OriginUrl),
458 Fs(std::path::PathBuf),
460 S3 {
463 bucket: Option<String>,
466 },
467}
468
469fn resolve_role(args: &cli::ConfigInitArgs) -> anyhow::Result<Role> {
472 if args.client {
473 return Ok(Role::Client);
474 }
475 match args.origin.as_deref() {
476 Some(raw) => Ok(Role::Origin(parse_origin_spec(raw)?)),
477 None => Ok(Role::Relay),
478 }
479}
480
481fn parse_origin_spec(raw: &str) -> anyhow::Result<OriginSpec> {
490 if raw == "s3" {
491 return Ok(OriginSpec::S3 { bucket: None });
492 }
493 let url = url::Url::parse(raw).context(
497 "invalid --origin value (expected an http(s):// or file:/// URL, `s3`, or s3://<bucket>)",
498 )?;
499 match url.scheme() {
500 "http" | "https" => Ok(OriginSpec::Http(decdn_config_types::parse_origin_url(raw)?)),
501 "file" => {
502 let path = url.to_file_path().map_err(|()| {
505 anyhow::anyhow!(
506 "--origin file:// URL must name a local absolute path \
507 (file:///var/lib/decdn/origin)"
508 )
509 })?;
510 Ok(OriginSpec::Fs(path))
511 }
512 "s3" => {
513 let bucket = url
514 .host_str()
515 .ok_or_else(|| anyhow::anyhow!("--origin s3:// URL must name a bucket"))?
516 .to_string();
517 decdn_common::config::validate_s3_bucket_name(&bucket)
518 .with_context(|| format!("invalid --origin s3 bucket `{bucket}`"))?;
519 anyhow::ensure!(
520 url.path().is_empty() || url.path() == "/",
521 "--origin s3://<bucket> takes no key prefix; set the commented `prefix` key \
522 in the generated [cache.origin] section instead"
523 );
524 Ok(OriginSpec::S3 {
525 bucket: Some(bucket),
526 })
527 }
528 other => anyhow::bail!(
529 "unsupported --origin scheme {other:?}: pass an http(s):// or file:/// URL, `s3`, \
530 or s3://<bucket>"
531 ),
532 }
533}
534
535pub fn config_init(args: &cli::ConfigInitArgs) -> anyhow::Result<()> {
537 let output = args
538 .output
539 .as_deref()
540 .map(cli::common::expand_tilde)
541 .or_else(cli::default_config_path)
542 .ok_or_else(|| anyhow::anyhow!("cannot determine config path: home dir not found"))?;
543
544 if output.exists() && !args.force {
545 anyhow::bail!(
546 "config file already exists at {}; use --force to overwrite",
547 output.display()
548 );
549 }
550
551 if let Some(parent) = output.parent() {
552 std::fs::create_dir_all(parent)
553 .map_err(|e| anyhow::anyhow!("failed to create directory {}: {e}", parent.display()))?;
554 }
555
556 let role = resolve_role(args)?;
557 let chain = known_chains::resolve(args.chain.as_deref())?;
558 let contents = render_config(chain, &role)?;
559
560 std::fs::write(&output, &contents)
561 .map_err(|e| anyhow::anyhow!("failed to write config file {}: {e}", output.display()))?;
562
563 let role_word = match &role {
564 Role::Relay => "relay-node",
565 Role::Origin(_) => "origin-node",
566 Role::Client => "client",
567 };
568 let key_gen = match &role {
571 Role::Client => "decdn key-gen --output-dir ~/.decdn/client",
572 Role::Relay | Role::Origin(_) => "decdn key-gen",
573 };
574 match chain {
575 Some(c) => println!(
576 "wrote {} {role_word} config to {} (run `{key_gen}` to create the keystore)",
577 c.label,
578 output.display()
579 ),
580 None => println!(
581 "wrote blank {role_word} config template to {}",
582 output.display()
583 ),
584 }
585 if let Role::Origin(spec) = &role {
586 let backend = match spec {
587 OriginSpec::Http(url) => decdn_config_types::redact_for_log(url.as_url()),
588 OriginSpec::Fs(path) => format!("fs {}", path.display()),
589 OriginSpec::S3 { bucket: Some(b) } => format!("s3 bucket {b}"),
590 OriginSpec::S3 { bucket: None } => {
591 "s3 (edit the MUST-EDIT bucket in [cache.origin])".to_string()
592 }
593 };
594 println!(
595 "origin backend: {backend} — cache.relay_foreign_namespaces derives to false \
596 (origin-only)"
597 );
598 }
599 Ok(())
600}
601
602fn render_config(chain: Option<&known_chains::KnownChain>, role: &Role) -> anyhow::Result<String> {
611 if let Role::Client = role {
612 let Some(chain) = chain else {
613 return Ok(CLIENT_CONFIG.to_string());
614 };
615 let filled = render_client_blockchain_section(chain)?;
616 return splice_section(CLIENT_CONFIG, &filled, "\n[blockchain]\n", None);
618 }
619 let base = match chain {
620 None => DEFAULT_CONFIG.to_string(),
621 Some(chain) => {
622 let filled = render_blockchain_section(chain)?;
623 splice_section(
624 DEFAULT_CONFIG,
625 &filled,
626 "\n[blockchain]\n",
627 Some("\n[cache]\n"),
628 )?
629 }
630 };
631 match role {
632 Role::Origin(spec) => insert_origin_block(&base, spec),
633 Role::Relay | Role::Client => Ok(base),
634 }
635}
636
637fn render_blockchain_section(chain: &known_chains::KnownChain) -> anyhow::Result<String> {
639 let a = chain.addresses()?;
640 Ok(format!(
641 "[blockchain]\n\
642 # Baked in for {label} (chain {chain_id}) from the shipped deployment\n\
643 # manifest (contracts/deployments/{chain_id}.json). Ready to run as-is;\n\
644 # `eth_keystore` defaults to <data_dir>/keystore.json — create it with\n\
645 # `decdn key-gen`. Point `rpc_url` at your own provider for production.\n\
646 rpc_url = \"{rpc}\"\n\
647 chain_id = {chain_id}\n\
648 # eth_keystore = \"~/.decdn/keystore.json\" # defaults to <data_dir>/keystore.json\n\
649 # --- Contract addresses (from deployments/{chain_id}.json) ---\n\
650 payment_pool_address = \"{payment_pool}\"\n\
651 capacity_bond_address = \"{capacity_bond}\"\n\
652 slash_judge_address = \"{slash_judge}\"\n\
653 content_blacklist_address = \"{content_blacklist}\"\n\
654 origin_assignment_address = \"{origin_assignment}\"\n\
655 publisher_registry_address = \"{publisher_registry}\"\n\
656 slash_appeal_address = \"{slash_appeal}\"\n\
657 usdc_address = \"{usdc}\"\n\
658 # Optional tuning knobs (economics, poll intervals) are commented in the\n\
659 # blank template — see `decdn config init --chain none`.\n\
660 \n",
661 label = chain.label,
662 chain_id = chain.chain_id,
663 rpc = chain.public_rpc,
664 payment_pool = a.payment_pool,
665 capacity_bond = a.capacity_bond,
666 slash_judge = a.slash_judge,
667 content_blacklist = a.content_blacklist,
668 origin_assignment = a.origin_assignment,
669 publisher_registry = a.publisher_registry,
670 slash_appeal = a.slash_appeal,
671 usdc = a.usdc,
672 ))
673}
674
675fn render_client_blockchain_section(chain: &known_chains::KnownChain) -> anyhow::Result<String> {
682 let a = chain.addresses()?;
683 Ok(format!(
684 "[blockchain]\n\
685 # Baked in for {label} (chain {chain_id}) from the shipped deployment\n\
686 # manifest (contracts/deployments/{chain_id}.json). The client consumes the\n\
687 # first block of keys; the remaining contract addresses are daemon/operator\n\
688 # keys it ignores, kept active so this file also passes `decdn config\n\
689 # validate`. Ready to fetch as-is once the keystore exists (`decdn key-gen\n\
690 # --output-dir ~/.decdn/client`) and holds USDC. Point `rpc_url` at your own\n\
691 # provider for production.\n\
692 rpc_url = \"{rpc}\"\n\
693 chain_id = {chain_id}\n\
694 # eth_keystore = \"~/.decdn/client/keystore.json\" # defaults to <data_dir>/keystore.json\n\
695 payment_pool_address = \"{payment_pool}\"\n\
696 usdc_address = \"{usdc}\"\n\
697 # buyer_working_deposit_micro_usdc = 10000000 # deposit when OPENING a pool and refill target; must be > 0; default 10 USDC\n\
698 # buyer_max_approve = false # exact deposit-sized USDC approvals (client default); true opts into an unlimited standing approval\n\
699 # --- Daemon/operator addresses (ignored by the client) ---\n\
700 capacity_bond_address = \"{capacity_bond}\"\n\
701 slash_judge_address = \"{slash_judge}\"\n\
702 content_blacklist_address = \"{content_blacklist}\"\n\
703 origin_assignment_address = \"{origin_assignment}\"\n\
704 publisher_registry_address = \"{publisher_registry}\"\n\
705 slash_appeal_address = \"{slash_appeal}\"\n",
706 label = chain.label,
707 chain_id = chain.chain_id,
708 rpc = chain.public_rpc,
709 payment_pool = a.payment_pool,
710 usdc = a.usdc,
711 capacity_bond = a.capacity_bond,
712 slash_judge = a.slash_judge,
713 content_blacklist = a.content_blacklist,
714 origin_assignment = a.origin_assignment,
715 publisher_registry = a.publisher_registry,
716 slash_appeal = a.slash_appeal,
717 ))
718}
719
720fn splice_section(
727 template: &str,
728 filled: &str,
729 start_header: &str,
730 end_header: Option<&str>,
731) -> anyhow::Result<String> {
732 let start = template
733 .find(start_header)
734 .map(|i| i + 1)
735 .with_context(|| {
736 format!(
737 "template is missing the {} section header",
738 start_header.trim()
739 )
740 })?;
741 let rest = template
742 .get(start..)
743 .context("template section slice out of bounds")?;
744 let end = match end_header {
745 Some(header) => {
746 let rel = rest.find(header).map(|i| i + 1).with_context(|| {
747 format!(
748 "template is missing the {} section header after {}",
749 header.trim(),
750 start_header.trim()
751 )
752 })?;
753 start + rel
754 }
755 None => template.len(),
756 };
757 let head = template
758 .get(..start)
759 .context("template head slice out of bounds")?;
760 let tail = template
761 .get(end..)
762 .context("template tail slice out of bounds")?;
763 Ok(format!("{head}{filled}{tail}"))
764}
765
766fn insert_origin_block(template: &str, spec: &OriginSpec) -> anyhow::Result<String> {
777 let at = template
778 .find("\n[payment]\n")
779 .map(|i| i + 1)
780 .context("rendered config is missing the [payment] section header")?;
781 let head = template
782 .get(..at)
783 .context("rendered config head slice out of bounds")?;
784 let tail = template
785 .get(at..)
786 .context("rendered config tail slice out of bounds")?;
787 let backend = match spec {
788 OriginSpec::Http(url) => format!(
792 "[cache.origin]\n\
793 kind = \"http\"\n\
794 url = \"{url}\"\n"
795 ),
796 OriginSpec::Fs(path) => {
797 let path = path
798 .to_str()
799 .context("--origin file:// path is not valid UTF-8, so it cannot be written to a TOML config")?;
800 format!(
801 "[cache.origin]\n\
802 kind = \"fs\"\n\
803 path = {}\n",
804 toml_basic_string(path)?
805 )
806 }
807 OriginSpec::S3 { bucket } => {
808 let (bucket_line, edit_note) = match bucket {
809 Some(b) => (format!("bucket = {}", toml_basic_string(b)?), ""),
810 None => (
811 "bucket = \"your-origin-bucket\" # MUST-EDIT: your bucket name".to_string(),
812 " Edit the MUST-EDIT bucket, then",
813 ),
814 };
815 format!(
816 "# S3-compatible origin (AWS S3 / Cloudflare R2 / Backblaze B2 / MinIO).{edit_note}\n\
817 # confirm `region`; for non-AWS providers uncomment `endpoint_url` (and\n\
818 # `path_style = true` for MinIO). Credentials default to the AWS chain\n\
819 # (env / ~/.aws / IAM role) — uncomment [cache.origin.credentials] to override.\n\
820 [cache.origin]\n\
821 kind = \"s3\"\n\
822 {bucket_line}\n\
823 region = \"us-east-1\" # required even with a custom endpoint_url (SigV4 signing)\n\
824 # endpoint_url = \"https://<accountid>.r2.cloudflarestorage.com\" # for R2/B2/MinIO; omit for AWS\n\
825 # path_style = true # required true for MinIO\n\
826 # prefix = \"blobs/\" # optional key prefix; final key is {{prefix}}{{hex[0..2]}}/{{hex}}\n\
827 # decompress = \"auto\" # \"auto\" decompresses gzip/zstd, \"strict\" refuses non-identity encodings\n\
828 # [cache.origin.credentials]\n\
829 # source = \"default-chain\" # or \"static\" with access_key_id = \"...\" and secret_access_key = \"...\"\n"
830 )
831 }
832 };
833 let block = format!(
834 "# --- Origin backend (written by `decdn config init --origin`) ---\n\
835 # An active origin backend makes this an ORIGIN node: the runtime derives\n\
836 # relay_foreign_namespaces = false (serve own namespaces only). Uncomment the\n\
837 # next line to override the derived value — set true to also relay foreign\n\
838 # content for pay while running an origin backend.\n\
839 # relay_foreign_namespaces = false\n\
840 {backend}\
841 \n"
842 );
843 Ok(format!("{head}{block}{tail}"))
844}
845
846fn toml_basic_string(value: &str) -> anyhow::Result<String> {
854 anyhow::ensure!(
855 !value.chars().any(char::is_control),
856 "value contains a control character, which cannot be written to a TOML config"
857 );
858 Ok(format!(
859 "\"{}\"",
860 value.replace('\\', "\\\\").replace('"', "\\\"")
861 ))
862}
863
864const CLIENT_CONFIG: &str = r#"# deCDN client configuration — fetch-only consumer.
873# A client fetches and pays (`decdn get`); it needs an
874# identity, chain coordinates, and USDC to deposit. The cache/origin/serving
875# sections a `decdn-node` daemon reads do not apply and are omitted.
876# CLI flags override values in this file.
877
878[identity]
879# data_dir = "~/.decdn/client" # holds the eth keystore and the buyer pool store
880
881[blockchain]
882# rpc_url = "" # REQUIRED: JSON-RPC URL for the payment chain
883# chain_id = 421614 # EIP-712 chain id; default Arbitrum Sepolia
884# eth_keystore = "~/.decdn/client/keystore.json" # defaults to <data_dir>/keystore.json; create with `decdn key-gen --output-dir ~/.decdn/client`
885# payment_pool_address = "" # REQUIRED: 0x-prefixed hex; PaymentPool contract the client opens its pool on
886# usdc_address = "" # USDC settlement token deposits spend
887# buyer_working_deposit_micro_usdc = 10000000 # deposit when OPENING a pool and refill target; must be > 0; default 10 USDC
888# buyer_max_approve = false # exact deposit-sized USDC approvals (client default); true opts into an unlimited standing approval
889
890# [self_update]
891# check = true # once a day, print a notice when a newer signed release exists; `decdn self-update` applies it
892"#;
893
894const DEFAULT_CONFIG: &str = r#"# deCDN node configuration
914# CLI flags override values in this file.
915
916[identity]
917# data_dir = "~/.decdn"
918# region = "US"
919
920[network]
921# bind_port = 4433
922# Multiple relays give redundancy/failover; the node logs whether a relay connected
923# after bring-up but never fails on it (iroh retries in the background).
924# relay_urls = ["https://relay-a.example.", "https://relay-b.example."]
925# Operator-configurable address discovery (#818). Absent => n0-hosted pkarr/DNS.
926# Present => the node drops the n0 discovery leg and uses only what is set here.
927# [network.discovery]
928# pkarr_url = "https://pkarr.example./" # publish this node's signed address record here
929# dns_origin = "discovery.example." # resolve peers via DNS TXT under this origin
930# Static peer address book (fully offline; keyed by NodeId — 64-char lowercase hex):
931# [network.discovery.peers.0000000000000000000000000000000000000000000000000000000000000000]
932# relay_url = "https://relay.example./"
933# addrs = ["203.0.113.4:4433"]
934
935[blockchain]
936# rpc_url = "" # REQUIRED: Arbitrum Sepolia JSON-RPC URL
937# eth_keystore = "~/.decdn/keystore.json"
938# payment_pool_address = "" # REQUIRED: 0x-prefixed hex
939# capacity_bond_address = "" # REQUIRED: 0x-prefixed hex
940# origin_assignment_address = "" # OPTIONAL: 0x-prefixed hex; `decdn publish assign` target, and the chain-backed origin directory for cache-miss pull-through fallback (ADR 022).
941# origin_directory_positive_ttl_sec = 300 # lazy origin directory: how long a resolved, non-empty getOrigins() set is served before a re-read; only consulted when origin_assignment_address is set; 0 disables caching; default 300s
942# origin_directory_negative_ttl_sec = 30 # lazy origin directory: how long "namespace has no origins" is cached — the DoS bound against permissionless/free namespace creation; only consulted when origin_assignment_address is set; 0 disables caching; default 30s
943# origin_directory_cache_capacity = 4096 # lazy origin directory: max distinct namespaces held (LRU eviction); only consulted when origin_assignment_address is set; default 4096
944# publisher_registry_address = "" # OPTIONAL: 0x-prefixed hex; `decdn publish namespace create` target (#1029).
945# slash_judge_address = "" # REQUIRED: 0x-prefixed hex (EIP-712 verifyingContract, ADR 014)
946# content_blacklist_address = "" # REQUIRED: 0x-prefixed hex; deployed ContentBlacklist (ADR 011/031). Absent => startup fails before any ALPN accepts; the zero address is rejected (it is a fail-open compliance trap).
947# content_blacklist_poll_interval_sec = 600 # blacklist watcher periodic replay + re-scope cadence (ADR 011); must be > 0; default 600s
948# chain_staleness_grace_sec = 1800 # stop serving after this long without a successful chain read (ADR 011); guards deny-set/pool/signer-cap staleness; must be > 0; set large to opt out; default 1800s
949# chain_id = 421614 # EIP-712 chain id; default Arbitrum Sepolia
950# rpc_watchdog_interval_sec = 30 # 0 disables the connectivity watchdog
951# event_poll_interval_ms = 7000 # eth_getLogs tick cadence for chain watchers + pending-tx receipt polling (#1011/#1106); default 7000ms, min 250ms (lower for a local anvil)
952# get_logs_max_block_span = 10000 # max blocks per chain-watcher eth_getLogs request; set to your RPC provider's range limit (the poller also shrinks the window when the provider rejects a range; set it to the lowest span the poller reaches while rejections keep coming); default 10000, must be > 0
953# fee_shares_poll_interval_sec = 3600 # authoritative getShares() re-read cadence, safety net beside the SharesUpdated subscription (ADR 041); default 3600s, must be > 0
954# redeem_threshold_micro_usdc = 1000000 # seller redeems accrued vouchers on-chain at this µUSDC balance (#327); default 1 USDC
955# redeem_max_vouchers_per_tx = 300 # max vouchers per redeemMany tx; the redeemer chunks a sweep to stay under the block gas limit (default 300)
956# redeem_interval_secs = 300 # redeemer self-tick sweep cadence, the backstop beside the per-voucher hints (#327/#751); default 300s, must be > 0
957# buyer_working_deposit_micro_usdc = 10000000 # deposit when OPENING a pool and refill target on reuse or mid-transfer shortfall; must be > 0; default 10 USDC
958# buyer_max_approve = true # unlimited USDC approval for PaymentPool (#744); node default true, decdn client default false (exact deposit-sized approval); set true on the client to opt into unlimited
959# pool_min_remaining_deposit_micro_usdc = 1000000 # refundable floor M the node keeps in reserve on a pool it serves (ADR 003 § Sizing); default 1 USDC
960# pool_floor_signer_live_windows = 8 # per-signer LIVE concurrency cap k, in ramp-start credit windows: the most live un-vouchered floor reservation one capability signer may hold against a pool, k × one window (ADR 003 § Pool solvency); recycles as streams pay; lower-clamped to one window; default 8
961# CLI-only [blockchain] keys — consumed by `decdn appeal` / the client, NOT the daemon.
962# They live here because [blockchain] denies unknown fields and a node's node.toml is
963# shared with those CLIs, so a config that drives them must still pass `config validate`.
964# slash_appeal_address = "" # `decdn appeal slash` target (ADR 028)
965# usdc_address = "" # USDC settlement token clients spend on pool deposits (client resolves it from PaymentPool.usdc() at runtime)
966
967[cache]
968# cache_dir = "~/.decdn/cache"
969# cache_size_mb = 102400 # UPPER bound on the cache; the eviction driver clamps it down to keep disk_headroom_mb free on the volume (#1930). Set it large to let free disk size the cache.
970# disk_headroom_mb = 8192 # free disk (MiB) the eviction driver keeps unused on the cache_dir volume, defended against ANY process (#1930); default 8 GiB. 0 opts out (only cache_size_mb binds)
971# max_blob_size_mb = 51200 # cap on the bytes of one blob held (an own-origin blob over it is refused; a pull-through serves any offset while the bytes held fit); unset => 50 GB capped to cache_size_mb. Must be <= cache_size_mb; 0 = unlimited
972# max_rate_per_mb = 0 # buyer-side per-MB rate ceiling for paid cache-miss pulls (USDC base units); 0 = unlimited (#1375). Refuses a provider quote above the lower of this and the candidate's probe rate, before paying. A buyer-side ceiling only; the node never bounds its own sell rate
973# pinned_hashes = [] # blob hashes (hex) exempted from LRU eviction (#276)
974# user_agent = "decdn-node/<version>" # User-Agent on HTTP origin pull-through (#435); default embeds the crate version
975# Pull-through origin (singular). Mutually exclusive with the plural [[cache.origins]] form below.
976# Empty => no pull-through; cache misses return NoOrigin.
977# [cache.origin]
978# kind = "http"
979# url = "https://origin.example/"
980# decompress = "auto" # optional; "auto" decompresses gzip/zstd, "strict" refuses non-identity encodings
981# — or a local filesystem origin (blobs at {path}/{hex[0..2]}/{hex}):
982# [cache.origin]
983# kind = "fs"
984# path = "/var/lib/decdn/origin"
985# — or an S3-compatible origin (AWS S3 / Cloudflare R2 / Backblaze B2 / MinIO):
986# [cache.origin]
987# kind = "s3"
988# bucket = "decdn-blobs"
989# region = "us-east-1" # required even with a custom endpoint_url (SigV4 signing)
990# endpoint_url = "https://<accountid>.r2.cloudflarestorage.com" # for R2/B2/MinIO; omit for AWS
991# path_style = true # required true for MinIO; AWS/R2 default to virtual-hosted-style
992# prefix = "blobs/" # optional key prefix; final key is {prefix}{hex[0..2]}/{hex}
993# decompress = "auto" # optional; mirrors the HTTP origin's knob
994# [cache.origin.credentials] # omit entirely to use the AWS default credential chain (env / ~/.aws / IAM role)
995# source = "default-chain" # or "static" with access_key_id = "..." and secret_access_key = "..."
996# Multi-origin fallback (#284), tried in order on a miss:
997# [[cache.origins]]
998# kind = "http"
999# url = "https://primary.example/"
1000# Origin pull-through retry policy (#285); restart-required.
1001# [cache.origin_retry]
1002# max_retries = 3
1003# Per-origin circuit breaker (#963); restart-required. Fronts the retry loop: after
1004# failure_threshold consecutive origin-unavailable failures it trips OPEN and fast-fails
1005# every miss for cooldown_ms, then admits half_open_max_calls trial pulls. Structurally
1006# identical to [cache.origin_retry]. Set enabled = false (or failure_threshold = 0) to opt out.
1007# [cache.circuit_breaker]
1008# enabled = true
1009# failure_threshold = 5
1010# cooldown_ms = 30000
1011# half_open_max_calls = 1
1012# NOTE: the keys below belong to [cache], NOT to the [cache.origin_retry] table
1013# above — uncomment this header along with them or TOML will nest them wrongly.
1014# [cache]
1015# gc_interval_sec = 300 # iroh-blobs GC sweep cadence; 0 disables (#518). NOTE: the eviction driver only drops GC protection, so with 0 it can never reclaim disk and cache_size_mb is unenforceable (#1173)
1016# fs_rescan_interval_sec = 60 # re-walk the fs origin + re-check pins into the origin-held index, so a file dropped into the origin becomes probe-answerable and DHT-announced within one interval (#1130); 0 disables the timer (startup and `decdn node reload` still rescan)
1017# origin_probe_ttl_sec = 15 # TTL for a memoised positive live-origin probe answer — a hash absent from the fs/pins index falls back to a HEAD/HeadObject against the http/s3 origin, a present answer cached this long (#1130 pt3)
1018# origin_probe_negative_ttl_sec = 2 # TTL for a memoised negative (absent) live-origin probe answer; short on purpose so a stale absent cannot hide newly-available own content for long (#1130 pt3)
1019# origin_probe_fault_ttl_sec = 5 # TTL for a memoised fault (backend outage) live-origin probe answer; the memo is per hash, so this collapses one hash's repeats rather than a namespace's load. Must satisfy origin_probe_negative_ttl_sec <= this <= origin_probe_ttl_sec: patient enough to beat the negative TTL on a retried hash, eager enough that a recovered origin stops refusing clients soon (#1130 pt3, #1789 item 6)
1020# origin_probe_timeout_ms = 2000 # per-probe ceiling on the live-origin HEAD/HeadObject; on timeout the probe answers has_blob:false and the miss is memoised absent for one negative TTL (#1130 pt3)
1021# origin_probe_memo_capacity = 4096 # max distinct hashes held in the live-origin probe memo; bounds memo memory under a random-hash probe flood (#1130 pt3)
1022# eviction_high_water_pct = 90 # LRU driver evicts above this % of cache_size_mb (#1173); bounds [60,95]
1023# eviction_target_pct = 80 # LRU driver evicts down to this % (#1173); bounds [40,90], must be <= high_water-5
1024# eviction_per_sweep_budget = 16 # max LRU victims per tick before yielding (#1173); bounds [1,256]
1025# eviction_tick_secs = 1 # LRU driver wakeup cadence in seconds (#1173); bounds [1,60]
1026# max_probe_holds = 256 # probe eviction-hold budget (ADR 005 §Hold budget); 0 disables has_blob:true
1027# stake_lane_reserved_holds = 0 # hold slots reserved for node-to-node probes (#757, ADR 003 §Admission); 0 = off
1028# node_to_node_pull_through_enabled = false # paid cache-miss pull from upstream nodes (#831, ADR 001/022); OFF by default
1029# relay_foreign_namespaces = true # relay content this node's own backend does not hold (#1759); default is role-derived — origin-only (false) when a [cache.origin]/[[cache.origins]] backend is configured, relay (true) when none is; set explicitly to override
1030# node_pull_probe_fanout = 5 # providers probed before ranking on a node-to-node pull (#831)
1031# node_pull_timeout_sec = 20 # per-upstream STREAM-OPEN timeout (connect/handshake/response) on a node-to-node miss; NOT the pool open, which has its own 5s budget. The overall pull-through deadline is derived from this, the pool-open budget, and the stall window, so every ranked upstream can be tried before falling back (#831, #859)
1032# node_pull_stall_window_sec = 20 # per-upstream THROUGHPUT-FLOOR window while streaming (#1797); bytes are counted off the stream sub-frame, so it trips only when throughput falls below the floor — not on a large blob or a big frame. Budgeted per candidate, so raising it raises the worst-case client wait ~3x (168s at defaults); also the head start of each own-origin range-pull read's time budget (ADR 037)
1033# node_pull_min_throughput_bps = 4096 # minimum sustained upstream throughput (bytes/sec) over node_pull_stall_window_sec (#1797); catches a slow-drip wedge. 0 = idle detection only (one byte per window). The abort is requester-local and does not score the upstream's reputation; also the average throughput each own-origin range-pull read ({H}.obao4 or one data window) must sustain after that head start, 0 = those reads unbounded
1034# eviction_policy = "lru" # ADR 040: "lru" or "tinylfu"; restart-required
1035# admission_policy = "always" # ADR 040: "always" or "tinylfu"; restart-required
1036# [cache.tinylfu] # W-TinyLFU tuning (ADR 040); promotion_threshold/probation_target_pct apply only under a "tinylfu" selector, but sketch_bytes also sizes the serve_economics "margin" heat estimator (the default), so it is live on a stock node
1037# sketch_bytes = 262144 # count-min sketch size, shared by admission and eviction; minimum 16384
1038# promotion_threshold = 2 # prior sightings before a probation member promotes to main
1039# probation_target_pct = 10 # % of cache_size_mb the probation segment is capped to; bounds [1,50]
1040# aging_halflife_sec = 600 # reserved: not yet consulted by the shipped sketch (fixed observation-count window)
1041# [cache.serve_economics] # refuse-to-serve economics (ADR 041)
1042# policy = "margin" # "off" or "margin"
1043# discount = 0.5 # (0.0, 1.0]; discount applied to the sell price for the margin gate
1044# n_max = 64 # max concurrent speculative-warming sources; >= 1
1045# warming_budget = 5000000 # per-source warming allowance, in payment base units; > 0
1046# warming_refill = 58 # per-source allowance refill rate, base units/sec; 0 disables time-based refill
1047
1048[payment]
1049# rate_per_mb = 10
1050# credit_max = 67108864 # downstream credit-window ceiling in bytes (ADR 003 §Credit window); the window ramps toward this cap as the stream pays; default 64 MiB; floored at one chunk plus one chunk group (1 MiB + 16 KiB)
1051# credit_ramp_divisor = 2 # ramp divisor (ADR 003 §Credit window); window is paid/credit_ramp_divisor, capped at credit_max; 0 opens the full ceiling immediately
1052# frame_target_bytes = 1048576 # serve-path wire-frame target in bytes (ADR 005 §cdn/client/v1); node-local, never negotiated; clamped down to the credit window's remaining room; must be in 1..=1048576 (one payment chunk); default 1 MiB
1053# voucher_commit_interval_ms = 5000 # background flush period (ms) for durable voucher persistence (ADR 003); must be > 0; default 5000 (5s)
1054
1055[observability]
1056# log_level = "info"
1057# log_format = "pretty"
1058# metrics_port = 9090
1059# metrics_bind = "127.0.0.1" # IP the metrics HTTP server binds; default loopback only
1060# admin_port = 9191 # loopback-only; 0 disables (appendix-local-admin-http.md)
1061# otlp_endpoint = "http://localhost:4317" # OTLP gRPC collector (http:// only); enables span export
1062
1063[security]
1064# max_concurrent_handlers = 256 # global cap on in-flight QUIC handler tasks; 0 disables the cap
1065# per_source_rate_per_sec = 100.0 # per-source rate-limit refill (cells/sec); 0.0 disables the layer
1066# per_source_burst = 200 # per-source burst capacity; required > 0 when the rate is > 0
1067# max_tracked_sources = 4096 # cap on tracked sources in the keyed limiter; 0 = unbounded
1068
1069[load_shed]
1070# policy = "resource-pressure" # "resource-pressure" (default) or "always-admit"
1071# egress_budget_mbps = 0 # serving egress budget in Mbps; 0 disables the egress ceiling
1072# max_concurrent_serves_high = 256 # concurrency high-water mark; start shedding misses at/above
1073# max_concurrent_serves_low = 192 # concurrency low-water mark; resume at/below
1074# per_client_serve_cap = 32 # per-client concurrent-serve cap under pressure; 0 disables
1075
1076[dht.rate_limit]
1077# per_peer_rate_per_sec = 20.0 # per-peer (NodeId) sustained rate (ADR 022); 0.0 disables the layer
1078# per_peer_burst = 40 # per-peer burst capacity; required > 0 when the rate is > 0
1079# per_ip_rate_per_sec = 100.0 # per-IP sustained rate; 0.0 disables
1080# per_ip_burst = 200 # per-IP burst capacity
1081# global_rate_per_sec = 1000.0 # global inbound DHT sustained rate; 0.0 disables
1082# global_burst = 2000 # global inbound DHT burst capacity
1083# max_tracked_per_ip = 4096 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1084# max_tracked_per_peer = 4096 # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1085
1086[probe.rate_limit]
1087# per_peer_rate_per_sec = 5.0 # per-peer (NodeId) sustained rate (ADR 005); 0.0 disables the layer
1088# per_peer_burst = 5 # per-peer burst capacity; required > 0 when the rate is > 0
1089# per_ip_rate_per_sec = 50.0 # per-IP sustained rate; 0.0 disables
1090# per_ip_burst = 200 # per-IP burst capacity
1091# global_rate_per_sec = 1000.0 # global inbound probe sustained rate; 0.0 disables
1092# global_burst = 2000 # global inbound probe burst capacity
1093# max_tracked_per_ip = 4096 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1094# max_tracked_per_peer = 4096 # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1095
1096[receipts]
1097# max_file_bytes = 134217728 # rotate the download-receipt log at this size (#802); default 128 MiB
1098# retained_files = 4 # rotated backup receipt files retained (#802); 0 keeps none
1099
1100[content]
1101# ADR 011 local denylist — this operator's own removal lever, independent of
1102# governance. Entries take effect on `decdn node reload` (no restart) and bind
1103# only this node. This is the fastest removal path the protocol offers and the
1104# one sized to a sub-day statutory deadline (e.g. the EU TCO one-hour clock),
1105# because it is entirely within the order recipient's control. Refused
1106# requests are signed as Declined, which does not reveal whether the entry is
1107# local or on-chain.
1108#
1109# Hashes are bare 64-char lowercase hex — the same spelling as
1110# cache.pinned_hashes. An invalid entry FAILS startup rather than being skipped:
1111# a typo in a takedown must not silently leave content served.
1112# denied_hashes = ["0000000000000000000000000000000000000000000000000000000000000000"]
1113# denied_origins = ["0x000000000000000000000000000000000000dEaD"] # operator addresses whose pools are refused (the zero address is rejected)
1114
1115[client]
1116# `decdn get` discovery pre-filter (independent of the
1117# node-scoped [network.discovery] address discovery above). Narrows which
1118# registered nodes are probed/discovered; never ranks them. Absent/empty =>
1119# no filter, every registered node is eligible.
1120# region_allowlist = ["US", "DE"]
1121
1122[self_update]
1123# Once a day, after a command's own work, `decdn` checks for a newer signed
1124# release and prints one line on stderr when it finds one. `false` turns the
1125# check off; `DECDN_SELF_UPDATE_CHECK=0` does the same for one shell.
1126# check = true
1127"#;
1128
1129#[cfg(test)]
1130#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests;