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 fetch`, `decdn bundle pull`); 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-channel 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; pool the client opens payment channels through
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
891const DEFAULT_CONFIG: &str = r#"# deCDN node configuration
911# CLI flags override values in this file.
912
913[identity]
914# data_dir = "~/.decdn"
915# region = "US"
916
917[network]
918# bind_port = 4433
919# Multiple relays give redundancy/failover; the node logs whether a relay connected
920# after bring-up but never fails on it (iroh retries in the background).
921# relay_urls = ["https://relay-a.example.", "https://relay-b.example."]
922# Operator-configurable address discovery (#818). Absent => n0-hosted pkarr/DNS.
923# Present => the node drops the n0 discovery leg and uses only what is set here.
924# [network.discovery]
925# pkarr_url = "https://pkarr.example./" # publish this node's signed address record here
926# dns_origin = "discovery.example." # resolve peers via DNS TXT under this origin
927# Static peer address book (fully offline; keyed by NodeId — 64-char lowercase hex):
928# [network.discovery.peers.0000000000000000000000000000000000000000000000000000000000000000]
929# relay_url = "https://relay.example./"
930# addrs = ["203.0.113.4:4433"]
931
932[blockchain]
933# rpc_url = "" # REQUIRED: Arbitrum Sepolia JSON-RPC URL
934# eth_keystore = "~/.decdn/keystore.json"
935# payment_pool_address = "" # REQUIRED: 0x-prefixed hex
936# capacity_bond_address = "" # REQUIRED: 0x-prefixed hex
937# 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).
938# 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
939# 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
940# origin_directory_cache_capacity = 4096 # lazy origin directory: max distinct namespaces held (LRU eviction); only consulted when origin_assignment_address is set; default 4096
941# publisher_registry_address = "" # OPTIONAL: 0x-prefixed hex; `decdn publish namespace create` target (#1029).
942# slash_judge_address = "" # REQUIRED: 0x-prefixed hex (EIP-712 verifyingContract, ADR 014)
943# 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).
944# content_blacklist_poll_interval_sec = 600 # blacklist watcher periodic replay + re-scope cadence (ADR 011); must be > 0; default 600s
945# 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
946# chain_id = 421614 # EIP-712 chain id; default Arbitrum Sepolia
947# rpc_watchdog_interval_sec = 30 # 0 disables the connectivity watchdog
948# 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)
949# 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
950# fee_shares_poll_interval_sec = 3600 # authoritative getShares() re-read cadence, safety net beside the SharesUpdated subscription (ADR 041); default 3600s, must be > 0
951# redeem_threshold_micro_usdc = 1000000 # seller redeems accrued vouchers on-chain at this µUSDC balance (#327); default 1 USDC
952# 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)
953# redeem_interval_secs = 300 # redeemer self-tick sweep cadence, the backstop beside the per-voucher hints (#327/#751); default 300s, must be > 0
954# 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
955# 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
956# 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
957# 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
958# CLI-only [blockchain] keys — consumed by `decdn appeal` / the client, NOT the daemon.
959# They live here because [blockchain] denies unknown fields and a node's node.toml is
960# shared with those CLIs, so a config that drives them must still pass `config validate`.
961# slash_appeal_address = "" # `decdn appeal slash` target (ADR 028)
962# usdc_address = "" # USDC settlement token clients spend on pool deposits (client resolves it from PaymentPool.usdc() at runtime)
963
964[cache]
965# cache_dir = "~/.decdn/cache"
966# 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.
967# 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)
968# max_blob_size_mb = 51200 # largest single blob admitted; unset => 50 GB capped to cache_size_mb. Must be <= cache_size_mb; 0 = unlimited
969# 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
970# pinned_hashes = [] # blob hashes (hex) exempted from LRU eviction (#276)
971# user_agent = "decdn-node/<version>" # User-Agent on HTTP origin pull-through (#435); default embeds the crate version
972# Pull-through origin (singular). Mutually exclusive with the plural [[cache.origins]] form below.
973# Empty => no pull-through; cache misses return NoOrigin.
974# [cache.origin]
975# kind = "http"
976# url = "https://origin.example/"
977# decompress = "auto" # optional; "auto" decompresses gzip/zstd, "strict" refuses non-identity encodings
978# — or a local filesystem origin (blobs at {path}/{hex[0..2]}/{hex}):
979# [cache.origin]
980# kind = "fs"
981# path = "/var/lib/decdn/origin"
982# — or an S3-compatible origin (AWS S3 / Cloudflare R2 / Backblaze B2 / MinIO):
983# [cache.origin]
984# kind = "s3"
985# bucket = "decdn-blobs"
986# region = "us-east-1" # required even with a custom endpoint_url (SigV4 signing)
987# endpoint_url = "https://<accountid>.r2.cloudflarestorage.com" # for R2/B2/MinIO; omit for AWS
988# path_style = true # required true for MinIO; AWS/R2 default to virtual-hosted-style
989# prefix = "blobs/" # optional key prefix; final key is {prefix}{hex[0..2]}/{hex}
990# decompress = "auto" # optional; mirrors the HTTP origin's knob
991# [cache.origin.credentials] # omit entirely to use the AWS default credential chain (env / ~/.aws / IAM role)
992# source = "default-chain" # or "static" with access_key_id = "..." and secret_access_key = "..."
993# Multi-origin fallback (#284), tried in order on a miss:
994# [[cache.origins]]
995# kind = "http"
996# url = "https://primary.example/"
997# Origin pull-through retry policy (#285); restart-required.
998# [cache.origin_retry]
999# max_retries = 3
1000# Per-origin circuit breaker (#963); restart-required. Fronts the retry loop: after
1001# failure_threshold consecutive origin-unavailable failures it trips OPEN and fast-fails
1002# every miss for cooldown_ms, then admits half_open_max_calls trial pulls. Structurally
1003# identical to [cache.origin_retry]. Set enabled = false (or failure_threshold = 0) to opt out.
1004# [cache.circuit_breaker]
1005# enabled = true
1006# failure_threshold = 5
1007# cooldown_ms = 30000
1008# half_open_max_calls = 1
1009# NOTE: the keys below belong to [cache], NOT to the [cache.origin_retry] table
1010# above — uncomment this header along with them or TOML will nest them wrongly.
1011# [cache]
1012# 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)
1013# 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)
1014# 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)
1015# 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)
1016# 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)
1017# 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)
1018# 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)
1019# eviction_high_water_pct = 90 # LRU driver evicts above this % of cache_size_mb (#1173); bounds [60,95]
1020# eviction_target_pct = 80 # LRU driver evicts down to this % (#1173); bounds [40,90], must be <= high_water-5
1021# eviction_per_sweep_budget = 16 # max LRU victims per tick before yielding (#1173); bounds [1,256]
1022# eviction_tick_secs = 1 # LRU driver wakeup cadence in seconds (#1173); bounds [1,60]
1023# max_probe_holds = 256 # probe eviction-hold budget (ADR 005 §Hold budget); 0 disables has_blob:true
1024# stake_lane_reserved_holds = 0 # hold slots reserved for node-to-node probes (#757, ADR 003 §Admission); 0 = off
1025# node_to_node_pull_through_enabled = false # paid cache-miss pull from upstream nodes (#831, ADR 001/022); OFF by default
1026# 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
1027# node_pull_probe_fanout = 5 # providers probed before ranking on a node-to-node pull (#831)
1028# node_pull_timeout_sec = 20 # per-upstream STREAM-OPEN timeout (connect/handshake/response) on a node-to-node miss; NOT the channel open, which has its own 5s budget. The overall pull-through deadline is derived from this, the channel-open budget, and the stall window, so every ranked upstream can be tried before falling back (#831, #859)
1029# 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 (167.5s at defaults); also the head start of each own-origin range-pull read's time budget (ADR 037)
1030# 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
1031# eviction_policy = "lru" # ADR 040: "lru" or "tinylfu"; restart-required
1032# admission_policy = "always" # ADR 040: "always" or "tinylfu"; restart-required
1033# [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
1034# sketch_bytes = 262144 # count-min sketch size, shared by admission and eviction; minimum 16384
1035# promotion_threshold = 2 # prior sightings before a probation member promotes to main
1036# probation_target_pct = 10 # % of cache_size_mb the probation segment is capped to; bounds [1,50]
1037# aging_halflife_sec = 600 # reserved: not yet consulted by the shipped sketch (fixed observation-count window)
1038# [cache.serve_economics] # refuse-to-serve economics (ADR 041)
1039# policy = "margin" # "off" or "margin"
1040# discount = 0.5 # (0.0, 1.0]; discount applied to the sell price for the margin gate
1041# n_max = 64 # max concurrent speculative-warming sources; >= 1
1042# warming_budget = 5000000 # per-source warming allowance, in payment base units; > 0
1043# warming_refill = 58 # per-source allowance refill rate, base units/sec; 0 disables time-based refill
1044
1045[payment]
1046# rate_per_mb = 10
1047# 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 (1 MiB)
1048# 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
1049# 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
1050# voucher_commit_interval_ms = 5000 # background flush period (ms) for durable voucher persistence (ADR 003); must be > 0; default 5000 (5s)
1051
1052[observability]
1053# log_level = "info"
1054# log_format = "pretty"
1055# metrics_port = 9090
1056# metrics_bind = "127.0.0.1" # IP the metrics HTTP server binds; default loopback only
1057# admin_port = 9191 # loopback-only; 0 disables (appendix-local-admin-http.md)
1058# otlp_endpoint = "http://localhost:4317" # OTLP gRPC collector (http:// only); enables span export
1059
1060[security]
1061# max_concurrent_handlers = 256 # global cap on in-flight QUIC handler tasks; 0 disables the cap
1062# per_source_rate_per_sec = 100.0 # per-source rate-limit refill (cells/sec); 0.0 disables the layer
1063# per_source_burst = 200 # per-source burst capacity; required > 0 when the rate is > 0
1064# max_tracked_sources = 4096 # cap on tracked sources in the keyed limiter; 0 = unbounded
1065
1066[load_shed]
1067# policy = "resource-pressure" # "resource-pressure" (default) or "always-admit"
1068# egress_budget_mbps = 0 # serving egress budget in Mbps; 0 disables the egress ceiling
1069# max_concurrent_serves_high = 256 # concurrency high-water mark; start shedding misses at/above
1070# max_concurrent_serves_low = 192 # concurrency low-water mark; resume at/below
1071# per_client_serve_cap = 32 # per-client concurrent-serve cap under pressure; 0 disables
1072
1073[dht.rate_limit]
1074# per_peer_rate_per_sec = 20.0 # per-peer (NodeId) sustained rate (ADR 022); 0.0 disables the layer
1075# per_peer_burst = 40 # per-peer burst capacity; required > 0 when the rate is > 0
1076# per_ip_rate_per_sec = 100.0 # per-IP sustained rate; 0.0 disables
1077# per_ip_burst = 200 # per-IP burst capacity
1078# global_rate_per_sec = 1000.0 # global inbound DHT sustained rate; 0.0 disables
1079# global_burst = 2000 # global inbound DHT burst capacity
1080# max_tracked_per_ip = 4096 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1081# max_tracked_per_peer = 4096 # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1082
1083[probe.rate_limit]
1084# per_peer_rate_per_sec = 5.0 # per-peer (NodeId) sustained rate (ADR 005); 0.0 disables the layer
1085# per_peer_burst = 5 # per-peer burst capacity; required > 0 when the rate is > 0
1086# per_ip_rate_per_sec = 50.0 # per-IP sustained rate; 0.0 disables
1087# per_ip_burst = 200 # per-IP burst capacity
1088# global_rate_per_sec = 1000.0 # global inbound probe sustained rate; 0.0 disables
1089# global_burst = 2000 # global inbound probe burst capacity
1090# max_tracked_per_ip = 4096 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1091# max_tracked_per_peer = 4096 # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1092
1093[receipts]
1094# max_file_bytes = 134217728 # rotate the download-receipt log at this size (#802); default 128 MiB
1095# retained_files = 4 # rotated backup receipt files retained (#802); 0 keeps none
1096
1097[content]
1098# ADR 011 local denylist — this operator's own removal lever, independent of
1099# governance. Entries take effect on `decdn node reload` (no restart) and bind
1100# only this node. This is the fastest removal path the protocol offers and the
1101# one sized to a sub-day statutory deadline (e.g. the EU TCO one-hour clock),
1102# because it is entirely within the order recipient's control. Refused
1103# requests are signed as Declined, which does not reveal whether the entry is
1104# local or on-chain.
1105#
1106# Hashes are bare 64-char lowercase hex — the same spelling as
1107# cache.pinned_hashes. An invalid entry FAILS startup rather than being skipped:
1108# a typo in a takedown must not silently leave content served.
1109# denied_hashes = ["0000000000000000000000000000000000000000000000000000000000000000"]
1110# denied_origins = ["0x000000000000000000000000000000000000dEaD"] # operator addresses whose channels are refused (the zero address is rejected)
1111
1112[client]
1113# `decdn fetch`/`decdn bundle pull` discovery pre-filter (independent of the
1114# node-scoped [network.discovery] address discovery above). Narrows which
1115# registered nodes are probed/discovered; never ranks them. Absent/empty =>
1116# no filter, every registered node is eligible.
1117# region_allowlist = ["US", "DE"]
1118"#;
1119
1120#[cfg(test)]
1121#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests;