Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/relay-frame-budget-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
---

feat(config): expose the frame budget and the per-IP cap as settings keys

The two bounds #959 added were `RelayConfig` fields only:
"pub frame_memory_budget_bytes: usize," (`crates/nostream-relay/src/relay.rs:66`) and
"pub max_connections_per_ip: usize," (`crates/nostream-relay/src/relay.rs:70`). A running
daemon could not change either number without a rebuild, which is what #989 is about.

Both are now settings the operator writes:

- `network.frameMemoryBudgetBytes` in `resources/default-settings.yaml` — the byte count every
socket's queued outbound payload is counted against.
- `limits.connection.maxConnectionsPerIp` in `resources/default-settings.yaml` — the live
sockets one source address may hold at once.

`RelayConfig::from_settings` reads both leaves, in the one place either is interpreted, and falls
back to the compiled default when a leaf is absent, zero, or above the ceiling that keeps an
operator's number out of the frame accounting:
" value > 0 && value <= MAX_SETTING_BOUND as u64" (`crates/nostream-relay/src/relay.rs:115`). The
daemon builds its config through it: "let mut relay_config = RelayConfig::from_settings(&settings);"
(`crates/nostreamd/src/main.rs:110`), keeping its one environment read. A value it replaces is not
silent: the constructor warns with the setting, the value, the ceiling and the default —
" \"a bound above its ceiling runs with its default\"" (`crates/nostream-relay/src/relay.rs:191`)
— and `nostreamd --check` and `nostream-cli config validate` print the same sentences before a relay
exists, from one place: "pub fn refusal_message(setting: &str, reason: BoundRefusal) -> String {"
(`crates/nostream-relay/src/relay.rs:169`).

The defaults document's values equal `RelayConfig::default()`'s, and a test in
`crates/nostream-relay/tests/config_defaults.rs` holds the two together. No validator rule is
added: the document is the validator's schema, so the two keys alone make an operator's file
valid. See ADR 0008 for what a zero means and why.

The TypeScript tree is untouched: no `src/` or `test/` file changes, so the published package
has no user-visible change.
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.client.subscription.maxFilters | Maximum number of filters per subscription. Defaults to 10. Disabled when set to zero. |
| limits.client.subscription.maxFilterValues | Maximum number of values allowed in each filter, counting its array criteria (`ids`, `authors`, `kinds`, `#<tag>`) together. Defaults to 2500. Disabled when set to zero. |
| limits.client.subscription.maxSubscriptions | Maximum number of subscriptions per connected client. Defaults to 10. Disabled when set to zero. |
| limits.connection.maxConnectionsPerIp | Maximum number of live sockets one source address may hold at once. Defaults to 64. Zero, or a value above 1099511627776, means the default. |
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
Expand Down Expand Up @@ -199,6 +200,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. |
| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. |
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
| network.frameMemoryBudgetBytes | The queued outbound payload bytes every socket of this relay shares. Unit: bytes. Defaults to 268435456 (256 MiB). Zero, or a value above 1099511627776 (1 TiB), means the default. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Settings appear to work

The published TypeScript CLI reads the shared defaults, so it accepts and saves both new keys. But the TypeScript relay never reads either one. Operators can receive a successful update while neither limit takes effect.

Mark both settings as Rust-only here and in the shared defaults comments.

Knowledge Base Used: Configuration, CLI, and deployment

| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/nostream-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ path = "src/main.rs"
clap.workspace = true
anyhow = { workspace = true }
nostream-config.workspace = true
nostream-relay.workspace = true
nostream-core.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
Expand Down
49 changes: 38 additions & 11 deletions crates/nostream-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use nostream_config::{
SettingsPaths, fit, load, load_defaults, load_document, load_user_settings, merge,
validate_document,
};
use nostream_relay::relay::{refusal_message, refused_bounds};
use serde_yaml::Value;

use crate::error::CliError;
Expand Down Expand Up @@ -57,31 +58,57 @@ pub fn validate(
};

let mut issues = validate_document(&merged, &defaults);
// The typed value the document fits, kept rather than parsed again: it is what the daemon
// would load, and it is what the range report walks.
let mut fitted = None;
if issues.is_empty() {
// The document walk grades JavaScript types; the typed schema is stricter. A document
// the walk approves but the daemon cannot load (a fractional integer, for one) is
// refused here, with the field named, so `config validate` never blesses a config the
// daemon will discard.
if let Err(issue) = fit(&merged, &defaults) {
issues.push(issue);
match fit(&merged, &defaults) {
Ok(settings) => fitted = Some(settings),
Err(issue) => issues.push(issue),
}
}

// The daemon bounds two settings at run time and ignores a value it cannot use. That is not a
// schema fault, so it is not an issue and not fatal — but this door and the daemon's `--check`
// are the two read-only ways an operator looks, so the verdict says it here too, in the words
// `refusal_message` produces for both (ADR 0008). A document the walk refused has no typed
// value to walk — `fit` is what produces one, and it is not reached — so this door is silent
// about the bound on a rejected document, exactly as the daemon's gate is, and for the same
// reason the gate's comment gives.
let refused: Vec<String> = fitted
.as_ref()
.map(|settings| {
refused_bounds(settings)
.into_iter()
.map(|(setting, reason)| refusal_message(setting, reason))
.collect()
})
.unwrap_or_default();

if issues.is_empty() {
let mut verdict = "settings valid: 0 issues".to_owned();
for line in refused {
verdict.push_str("; ");
verdict.push_str(&line);
}
return Ok(CommandOutput {
lines: vec!["settings valid: 0 issues".to_owned()],
lines: vec![verdict],
exit_code: 0,
});
}

// A refused document is reported too, and only when there is a typed value to walk (the gate's
// `settings_gate.rs:41-43` comment says the same, from the daemon's side).
let mut lines: Vec<String> = issues.iter().map(|issue| issue.to_string()).collect();
lines.extend(refused);
lines.push(format!("settings invalid: {} issues", issues.len()));

Ok(CommandOutput {
lines: issues
.iter()
.map(|issue| issue.to_string())
.chain(std::iter::once(format!(
"settings invalid: {} issues",
issues.len()
)))
.collect(),
lines,
exit_code: 2,
})
}
Expand Down
58 changes: 58 additions & 0 deletions crates/nostream-cli/tests/config_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,61 @@ fn the_paths_resolver_honours_the_config_dir() {
);
assert_eq!(paths.config_base_dir, directory.path().to_path_buf());
}

#[test]
fn config_validate_says_which_bound_setting_the_relay_will_not_use() {
// The CLI door and `nostreamd --check` are the two read-only ways onto the same validator;
// a bound the relay replaces must not be silent on one of them. Zero and an over-ceiling
// value are different facts and get different sentences.
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join("settings.yaml");
fs::write(
&path,
"network:\n frameMemoryBudgetBytes: 0\nlimits:\n connection:\n maxConnectionsPerIp: 1099511627777\n",
)
.expect("write");

let output =
config::validate(&workspace_root(), Some(directory.path()), Some(&path)).expect("validate");
assert_eq!(output.exit_code, 0);
let verdict = output.lines.join("\n");
assert!(
verdict.contains(
"network.frameMemoryBudgetBytes: 0 means the default, and the relay runs with it"
),
"verdict: {verdict}"
);
assert!(
verdict.contains(
"limits.connection.maxConnectionsPerIp: above the 1099511627776 a bound accepts; the key's value is ignored"
),
"verdict: {verdict}"
);
}

#[test]
fn config_validate_reports_the_schema_fault_and_not_a_bound_it_cannot_read() {
// The same mixed document through the other door: the bound is not described, because a
// rejected document has no typed value to walk, and the exit code is the schema verdict.
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join("settings.yaml");
fs::write(
&path,
"network:\n frameMemoryBudgetBytes: 1099511627777\nlimits:\n rateLimiter:\n strategy: random\n",
)
.expect("write");

let output =
config::validate(&workspace_root(), Some(directory.path()), Some(&path)).expect("validate");
assert_eq!(output.exit_code, 2);
let text = output.lines.join("\n");
assert!(
text.contains("strategy must be ewma or sliding_window"),
"{text}"
);
assert!(!text.contains("above the"), "no range line: {text}");
assert_eq!(
output.lines.last().map(String::as_str),
Some("settings invalid: 1 issues")
);
}
12 changes: 12 additions & 0 deletions crates/nostream-config/src/guided.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
Number,
SafeNonNegativeInteger,
),
validated(
"Frame memory budget (bytes, 0 means the default)",
"network.frameMemoryBudgetBytes",
Number,
SafeNonNegativeInteger,
),
validated(
"Worker count",
"workers.count",
Expand Down Expand Up @@ -263,6 +269,12 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
Number,
SafeNonNegativeInteger,
),
validated(
"Max connections per IP (sockets, 0 means the default)",
"limits.connection.maxConnectionsPerIp",
Number,
SafeNonNegativeInteger,
),
validated(
"Max filters per subscription",
"limits.client.subscription.maxFilters",
Expand Down
4 changes: 4 additions & 0 deletions crates/nostream-config/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub struct Network {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_payload_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_memory_budget_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_ip_header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_proxies: Option<Vec<String>>,
Expand Down Expand Up @@ -306,6 +308,8 @@ pub struct ConnectionLimits {
pub ip_whitelist: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_blacklist: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections_per_ip: Option<u64>,
}

/// `limits.invoice` (`src/@types/settings.ts` `InvoiceLimits`).
Expand Down
1 change: 1 addition & 0 deletions crates/nostream-relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tracing.workspace = true

[dev-dependencies]
base64.workspace = true
serde_yaml.workspace = true

[lints]
workspace = true
Loading
Loading