From 584167a27863c8b1ca54fd007e9e3a780809c0c5 Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 16:41:08 +0800 Subject: [PATCH 1/4] fix(config): name the XHTTP range that exceeds its bounds --- crates/honk-config/src/node/xhttp.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/honk-config/src/node/xhttp.rs b/crates/honk-config/src/node/xhttp.rs index 1ca212b1a..714b1595f 100644 --- a/crates/honk-config/src/node/xhttp.rs +++ b/crates/honk-config/src/node/xhttp.rs @@ -568,13 +568,28 @@ impl XhttpOptions { /// Validate canonical options for immutable node admission. pub fn validate(&self) -> Result<(), &'static str> { - for (range, min, max) in [ - (self.x_padding_bytes, 1, 8192), - (self.sc_max_each_post_bytes, 1, 16 * 1024 * 1024), - (self.sc_min_posts_interval_ms, 0, 60_000), + for (range, min, max, error) in [ + ( + self.x_padding_bytes, + 1, + 8192, + "XHTTP xPaddingBytes must stay within 1-8192", + ), + ( + self.sc_max_each_post_bytes, + 1, + 16 * 1024 * 1024, + "XHTTP scMaxEachPostBytes must stay within 1-16777216", + ), + ( + self.sc_min_posts_interval_ms, + 0, + 60_000, + "XHTTP scMinPostsIntervalMs must stay within 0-60000", + ), ] { if range.min < min || range.min > range.max || range.max > max { - return Err("XHTTP range exceeds supported bounds"); + return Err(error); } } let path = self From eef5aa77eee8ffb5f470e771ac084bed29b9e0ad Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 16:41:08 +0800 Subject: [PATCH 2/4] fix(native-api): return the parser's reason for a rejected managed entry --- crates/honk-config/src/parser/source_edit.rs | 103 +++++++++++------- .../src/parser/source_edit/tests.rs | 34 ++++++ .../src/native_api/config/coordinator.rs | 12 +- .../src/native_api/config/tests/management.rs | 26 +++++ doc/en/reference/api.md | 2 + doc/zh/reference/api.md | 2 + 6 files changed, 134 insertions(+), 45 deletions(-) diff --git a/crates/honk-config/src/parser/source_edit.rs b/crates/honk-config/src/parser/source_edit.rs index 5942f48e2..391ef1e88 100644 --- a/crates/honk-config/src/parser/source_edit.rs +++ b/crates/honk-config/src/parser/source_edit.rs @@ -7,6 +7,7 @@ use super::cursor::{BodySyntax, Document}; use super::lexer::Source; use super::read::{self, Text}; use super::sources::SourceSnapshot; +use crate::diagnostic::Severity; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GroupField { @@ -33,9 +34,12 @@ impl GroupField { } } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Default, thiserror::Error)] #[error("source cannot represent the requested edit")] -pub struct SourceEditError; +pub struct SourceEditError { + /// Static parser/admission reason when the requested entry itself was rejected. + pub reason: Option<&'static str>, +} fn group_source_indices(documents: &[Document<'_>]) -> HashMap { let mut result = HashMap::new(); @@ -65,7 +69,7 @@ pub fn edit_group_source( &mut Vec::new(), false, ) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; let group = document .sections() .filter(|root| root.header() == "group") @@ -73,7 +77,7 @@ pub fn edit_group_source( .flatten() .filter(|group| read::block_header(group).is_some_and(|header| header.raw() == name)) .last() - .ok_or(SourceEditError)?; + .ok_or_else(SourceEditError::default)?; let mut edits: Vec<(Range, String)> = Vec::new(); let mut additions = String::new(); let newline = if source.content.contains("\r\n") { @@ -98,7 +102,7 @@ pub fn edit_group_source( .iter() .any(|(previous, _)| previous == field) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let fields: Vec<_> = group .body() @@ -153,7 +157,7 @@ pub fn edit_group_source( } edits.sort_unstable_by_key(|(range, _)| range.start); if edits.windows(2).any(|pair| pair[0].0.end > pair[1].0.start) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let mut output = source.content.to_string(); for (range, text) in edits.into_iter().rev() { @@ -164,7 +168,7 @@ pub fn edit_group_source( fn quote_scalar(value: &str, preferred: Option) -> Result { if value.chars().any(char::is_control) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } for quote in preferred.into_iter().chain(['\'', '"']) { let candidate = format!("{quote}{value}{quote}"); @@ -172,7 +176,7 @@ fn quote_scalar(value: &str, preferred: Option) -> Result Result { let (entry, config) = managed_entry("node", name, link, &[])?; let [node] = config.nodes.as_slice() else { - return Err(SourceEditError); + return Err(SourceEditError::default()); }; if node.name != name { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let document = managed_document(source)?; let sections = document @@ -198,12 +202,12 @@ pub fn append_node_source( let mut diagnostics = super::diagnostics::ParserDiagnostics::new(&mut notices, source.source.clone()); let nodes = super::entries::parse_node_section(§ions, &mut diagnostics) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; if nodes .iter() .any(|existing| existing.name == name || existing.id == node.id) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(append_entry(&document, "node", &entry)) } @@ -229,9 +233,9 @@ pub fn remove_node_source( duplicate |= target.replace(span.start..span.end).is_some(); } }) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; if duplicate { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(target.map(|range| { let mut output = source.content.to_string(); @@ -269,7 +273,7 @@ pub fn append_subscription_source( } let (entry, config) = managed_entry("subscription", name, url, &fields)?; let [subscription] = config.subscriptions.as_slice() else { - return Err(SourceEditError); + return Err(SourceEditError::default()); }; let defaults = crate::subscription::Subscription::default(); if subscription.name != name @@ -282,7 +286,7 @@ pub fn append_subscription_source( .ok() .is_none_or(|url| url.host_str().is_none()) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let document = managed_document(source)?; let sections = document @@ -298,9 +302,9 @@ pub fn append_subscription_source( &crate::assets::AssetsConfig::default(), &mut diagnostics, ) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; if subscriptions.iter().any(|existing| existing.name == name) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(append_entry(&document, "subscription", &entry)) } @@ -344,9 +348,9 @@ pub fn remove_subscription_source( } }, ) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; if ambiguous { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(target.map(|range| { let mut output = source.content.to_string(); @@ -393,7 +397,7 @@ fn managed_document(source: &SourceSnapshot) -> Result, SourceEditE &mut Vec::new(), false, ) - .map_err(|_| SourceEditError) + .map_err(|_| SourceEditError::default()) } /// A scalar `name: value` entry, or with `fields` a block whose `url` is `value`. @@ -404,15 +408,16 @@ fn managed_entry( fields: &[String], ) -> Result<(String, crate::Config), SourceEditError> { if name.trim().is_empty() { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let value = quote_scalar(value, None)?; - let quoted = quote_scalar(name, None).map_err(|_| SourceEditError)?; + let quoted = quote_scalar(name, None).map_err(|_| SourceEditError::default())?; let bare = name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(&byte)) .then(|| name.to_owned()); // A bare key that the section reads back differently (`mux`) keeps its quotes. + let mut reason = None; for key in bare.into_iter().chain([quoted]) { let entry = if fields.is_empty() { format!("{key}: {value}") @@ -423,11 +428,16 @@ fn managed_entry( } entry + "\n }" }; - let Ok(config) = super::parse_dae_config_with_detailed_diagnostics( + let mut diagnostics = Vec::new(); + let config = match super::parse_dae_config_with_detailed_diagnostics( &format!("{section} {{\n {entry}\n}}"), - &mut Vec::new(), - ) else { - continue; + &mut diagnostics, + ) { + Ok(config) => config, + Err(error) => { + reason = Some(error.diagnostic.message); + continue; + } }; let names: Vec<&str> = match section { "node" => config.nodes.iter().map(|node| node.name.as_str()).collect(), @@ -437,11 +447,22 @@ fn managed_entry( .map(|subscription| subscription.name.as_str()) .collect(), }; - if names == [name] && config.validate().is_ok() { - return Ok((entry, config)); + if names != [name] { + // A section skips an unreadable entry and records why as its last warning. + reason = diagnostics + .iter() + .rev() + .find(|diagnostic| diagnostic.severity != Severity::Info) + .map(|diagnostic| diagnostic.message) + .or(reason); + continue; + } + match config.validate_detailed() { + Ok(()) => return Ok((entry, config)), + Err(error) => reason = Some(error.diagnostic.message), } } - Err(SourceEditError) + Err(SourceEditError { reason }) } fn append_entry(document: &Document<'_>, section: &str, entry: &str) -> String { @@ -476,16 +497,16 @@ fn quote_dae_scalar(value: &str) -> Result { return Ok(quoted); } if value.chars().any(char::is_control) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } // dae preserves backslashes, so adding quote escapes would change the value. // Only use bare text when the canonical reader sees exactly one scalar. let candidate = format!("global {{\nvalue: {value}\n}}\n"); let document = plain_document(&candidate)?; let mut roots = document.sections(); - let root = roots.next().ok_or(SourceEditError)?; - let mut fields = root.body().ok_or(SourceEditError)?; - let field = fields.next().ok_or(SourceEditError)?; + let root = roots.next().ok_or_else(SourceEditError::default)?; + let mut fields = root.body().ok_or_else(SourceEditError::default)?; + let field = fields.next().ok_or_else(SourceEditError::default)?; if roots.next().is_some() || fields.next().is_some() || field.is_block() @@ -494,7 +515,7 @@ fn quote_dae_scalar(value: &str) -> Result { .kv() .is_some_and(|(key, parsed)| key.raw() == "value" && parsed.unquote().raw() == value) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(value.to_owned()) } @@ -517,7 +538,7 @@ pub fn strip_listener_secrets(content: &str) -> Result output.replace_range(range, ""); } if super::sources::contains_api_secret(&plain_document(&output)?) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } Ok(output) } @@ -531,7 +552,7 @@ pub fn restore_listener_secrets( ) -> Result { let document = plain_document(content)?; if super::sources::contains_api_secret(&document) { - return Err(SourceEditError); + return Err(SourceEditError::default()); } let newline = if content.contains("\r\n") { "\r\n" @@ -547,7 +568,7 @@ pub fn restore_listener_secrets( let (_, block) = blocks .iter() .rfind(|(name, _)| *name == api) - .ok_or(SourceEditError)?; + .ok_or_else(SourceEditError::default)?; let entry = format!("secret: {}", quote_dae_scalar(value)?); let close = block.span().end - 1; let edit = match own_line_start(content, close) { @@ -594,7 +615,7 @@ pub fn inline_sources(sources: &[SourceSnapshot]) -> Result Result, SourceEditError> { let source = crate::diagnostic::DiagnosticSources::new(None).root(); Document::parse_attempt(Source::new(content, source), &mut Vec::new(), false) - .map_err(|_| SourceEditError) + .map_err(|_| SourceEditError::default()) } fn listener_blocks<'d, 'a>( @@ -652,7 +673,7 @@ pub fn source_indices( &mut notices, false, ) - .map_err(|_| SourceEditError) + .map_err(|_| SourceEditError::default()) }) .collect::, _>>()?; let groups = group_source_indices(&documents); @@ -693,7 +714,7 @@ pub fn source_indices( } } }) - .map_err(|_| SourceEditError)?; + .map_err(|_| SourceEditError::default())?; let dns = documents .iter() .flat_map(Document::sections) diff --git a/crates/honk-config/src/parser/source_edit/tests.rs b/crates/honk-config/src/parser/source_edit/tests.rs index fbce20e65..81eec83f0 100644 --- a/crates/honk-config/src/parser/source_edit/tests.rs +++ b/crates/honk-config/src/parser/source_edit/tests.rs @@ -174,6 +174,40 @@ fn managed_appends_reject_injection_parse_skips_and_duplicate_identities() { ); } +#[test] +fn managed_node_rejection_keeps_the_static_parser_reason() { + let loaded = managed_source("node {}\n"); + let link = |extra: &str| { + format!( + "vless://00000000-0000-4000-8000-000000000001@example.test:443?type=xhttp&security=tls&extra={}", + url::form_urlencoded::byte_serialize(extra.as_bytes()).collect::() + ) + }; + for (extra, reason) in [ + ( + r#"{"xPaddingBytes":"0-10","headers":{"X-Secret":"private-value"}}"#, + "XHTTP xPaddingBytes must stay within 1-8192", + ), + ( + r#"{"scMaxEachPostBytes":0}"#, + "XHTTP scMaxEachPostBytes must stay within 1-16777216", + ), + ( + r#"{"scMinPostsIntervalMs":"10-60001"}"#, + "XHTTP scMinPostsIntervalMs must stay within 0-60000", + ), + ( + r#"{"headers":{"Host":"private-value"}}"#, + "invalid or protocol-managed XHTTP header", + ), + ] { + let error = append_node_source(&loaded.sources[0], "edge", &link(extra)).unwrap_err(); + assert_eq!(error.reason, Some(reason), "{extra}"); + } + let error = append_node_source(&loaded.sources[0], "", "socks5://127.0.0.1:1080").unwrap_err(); + assert_eq!(error.reason, None); +} + #[test] fn managed_node_deletion_uses_normalized_identity_and_keeps_nested_siblings() { let declaration = "'display # name': 'vless://00000000-0000-0000-0000-000000000001@example.test:443?security=tls&flow=xtls-rprx-vision-udp443'"; diff --git a/crates/honk-core/src/native_api/config/coordinator.rs b/crates/honk-core/src/native_api/config/coordinator.rs index 471ed4aff..2433d3646 100644 --- a/crates/honk-core/src/native_api/config/coordinator.rs +++ b/crates/honk-core/src/native_api/config/coordinator.rs @@ -381,9 +381,11 @@ impl Worker { if active.nodes.iter().any(|node| node.name == input.name) { return Err(management::conflict()); } - append_node_source(main, &input.name, &input.link).map_err(|_| { + append_node_source(main, &input.name, &input.link).map_err(|error| { management::unsupported_value( - "The main source cannot hold this node name and share link", + error + .reason + .unwrap_or("The main source cannot hold this node name and share link"), json!({"resource":"/nodes","fields":["name","link"]}), ) })? @@ -397,9 +399,11 @@ impl Worker { return Err(management::conflict()); } append_subscription_source(main, &input.name, &input.url, &input.options()) - .map_err(|_| { + .map_err(|error| { management::unsupported_value( - "The main source cannot hold this provider name, URL or user agent", + error.reason.unwrap_or( + "The main source cannot hold this provider name, URL or user agent", + ), json!({"resource":"/providers","fields":["name","url","user_agent"]}), ) })? diff --git a/crates/honk-core/src/native_api/config/tests/management.rs b/crates/honk-core/src/native_api/config/tests/management.rs index 51e41a289..bb791d4f4 100644 --- a/crates/honk-core/src/native_api/config/tests/management.rs +++ b/crates/honk-core/src/native_api/config/tests/management.rs @@ -110,6 +110,32 @@ async fn created_nodes_list_only_canonical_configured_stream_transport() { fixture.shutdown().await; } +#[tokio::test] +async fn rejected_node_links_report_the_static_parser_reason() { + let fixture = Fixture::new(Access::Admin, false).await; + let before = std::fs::read_to_string(fixture.path("main.dae")).unwrap(); + let link = "vless://b831381d-6324-4d53-ad4f-8cda48b30811@127.0.0.1:443?type=xhttp&security=tls&extra=%7B%22xPaddingBytes%22%3A%220-10%22%2C%22headers%22%3A%7B%22X-Secret%22%3A%22private-value%22%7D%7D"; + let response = create_node(&fixture, "bad-xhttp", link) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let text = response.text().await.unwrap(); + assert!(!text.contains("private-value"), "{text}"); + let body: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(body["error"]["code"], "unsupported_value"); + assert_eq!( + body["error"]["message"], + "XHTTP xPaddingBytes must stay within 1-8192" + ); + assert_eq!(body["error"]["details"]["written"], false); + assert_eq!( + std::fs::read_to_string(fixture.path("main.dae")).unwrap(), + before + ); + fixture.shutdown().await; +} + #[tokio::test] async fn node_management_commits_disk_catalog_and_generation_before_success() { let fixture = Fixture::new(Access::Admin, false).await; diff --git a/doc/en/reference/api.md b/doc/en/reference/api.md index 177c2f10b..3914cf227 100644 --- a/doc/en/reference/api.md +++ b/doc/en/reference/api.md @@ -10,6 +10,8 @@ The native contract, including authentication, node/provider management, geodata `Node.stream_transport` is an optional nullable string projected from accepted Trojan/VMess/VLESS configuration: `tcp`, `ws`, `grpc` or `xhttp` (including the `splithttp` alias). honk returns null when unrecognized or inapplicable. It describes configured transport, not negotiation or health; `protocol` and TCP/UDP health transport keep their existing meanings. It exposes no credentials, host, path, extra options or link. The fixture copies the doona contract bundle with its `read_only_reason` extension (SHA-256 `ef3e1ce08392c26d256b70277318d61a7ec932f4f5825ac8117d839944a33646`); this pin does not identify a published frontend release. +`POST /api/v1/nodes` accepts a link of 1–8192 characters; a longer declaration (for example XHTTP `extra` near its 16 KiB header bound) belongs in a configuration source edit. A link the engine rejects returns `422 unsupported_value` with the parser's static reason as `error.message`, such as `XHTTP xPaddingBytes must stay within 1-8192`, and `details.fields: ["name","link"]`; submitted names, links and option values are never echoed, and nothing is written. `POST /api/v1/providers` reports a rejected subscription entry the same way. Other refusals keep the generic message. + | Method | Path | Meaning | | --- | --- | --- | | GET | `/api` | Public discovery: authentication state and sign-in links for every caller; the fixed `/api/v1` base and all contract links for an admitted caller. | diff --git a/doc/zh/reference/api.md b/doc/zh/reference/api.md index bfff9734b..4ae0b4625 100644 --- a/doc/zh/reference/api.md +++ b/doc/zh/reference/api.md @@ -10,6 +10,8 @@ `Node.stream_transport` 是可选、可为 null 的字符串,投影已接受的 Trojan/VMess/VLESS 配置:`tcp`、`ws`、`grpc` 或 `xhttp`(包括 `splithttp` 别名)。无法识别或不适用时,honk 返回 null。该字段表示配置的传输方式,不表示协商结果或健康状态;`protocol` 与 TCP/UDP 健康传输字段的含义不变。它不暴露凭据、host、path、extra 选项或链接。测试 fixture 复制 doona 契约 bundle,保留其 `read_only_reason` 扩展(SHA-256 `ef3e1ce08392c26d256b70277318d61a7ec932f4f5825ac8117d839944a33646`);此固定版本不代表已发布的前端版本。 +`POST /api/v1/nodes` 接受 1–8192 个字符的链接;更长的声明(例如接近 16 KiB header 上限的 XHTTP `extra`)应通过编辑配置源写入。引擎拒绝的链接返回 `422 unsupported_value`,`error.message` 为解析器给出的静态原因,例如 `XHTTP xPaddingBytes must stay within 1-8192`,`details.fields` 为 `["name","link"]`;不会回显提交的名称、链接或选项值,也不会写入任何内容。`POST /api/v1/providers` 以同样方式报告被拒绝的订阅条目。其他拒绝仍使用通用消息。 + | 方法 | 路径 | 含义 | | --- | --- | --- | | GET | `/api` | 公共 discovery:所有请求都能获得认证状态与登录 links;已放行的请求另外获得固定 `/api/v1` base 与全部契约 links。 | From 8cf9a8ff2bac2cdb67638a7d7dc0cd7aa7b5fcf4 Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 16:41:08 +0800 Subject: [PATCH 3/4] fix(config): reserve accepted share-link warnings for legacy codes --- .agents/rules/configuration.md | 1 + crates/honk-config/src/share_link.rs | 11 ++++++++++- doc/en/reference/nodes.md | 2 ++ doc/zh/reference/nodes.md | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.agents/rules/configuration.md b/.agents/rules/configuration.md index a610be4ac..bf19ec95c 100644 --- a/.agents/rules/configuration.md +++ b/.agents/rules/configuration.md @@ -8,6 +8,7 @@ Only documented format: original **dae syntax**, `{ include { ... } global { ... - VLESS permits UDP/443 by default, including base Vision; users block QUIC through routing. Xray `Udp443Policy` defaults to `Allow`, while explicit `Reject` and UDP-disabled remain terminal. `Skip` selects protocol fallback without an implicit Vision port gate. Normalize the `xtls-rprx-vision-udp443` input spelling to base Vision before identity/runtime reuse; never let that spelling override explicit packet policy. - VLESS `vless_mode` has been removed: reject its raw presence, including `null`, rather than interpreting an alias. Static entries reject the candidate config; subscriptions salvage other valid entries, but an all-old-mode cached body cannot restore offline. The same flat field on a non-VLESS serde record remains only a compatibility artifact; do not change VMess behavior or identity through VLESS cleanup. Normalize before `Node::derive_id`; every admitted VLESS node is re-derived with a new ID at this upgrade, not only nodes with an explicit old mode. Name-based persisted Selector choices and valid TCP-v4 delays no older than 24 hours can survive; delay restoration is not liveness restoration. - Share-link VLESS failures carry `DetailedConfigError` from their origin; schema fields and safe reasons are not reconstructed from legacy prose. Public legacy APIs project only at the exit. `node/wire.rs` derives flat serialization from its complete borrowed output view; input raw-presence validation remains separate. +- A share link that parses warns only with `legacy-*` codes; `Node::parse_share_link` debug-asserts it at its single warning sink. Native clients validate one `node {}` declaration in `syntax` mode and read any other warning as a dropped entry, so a new warning for an accepted link needs a `legacy-*` code. `source_edit` managed entries carry the static parser or admission reason in `SourceEditError.reason`; native node/provider creation returns it as `error.message`, never a submitted value. - VLESS identity separates plaintext from TLS while keeping REALITY's effective transport independent of a redundant TLS flag. Share-link `security`/`tls` claims coalesce only when equal and reject contradictions. VLESS and Trojan map REALITY intent through shared TLS options; unsupported share-link schemes reject that intent instead of silently using ordinary TLS. - Shared XHTTP configuration, strict typed Xray extras and flat serde glue live in `node/xhttp.rs` through `StreamTransportOptions.xhttp`; `share_link/xhttp.rs` owns URI/VMess XHTTP claims. XHTTP-aware adapters use `options/vocab::xhttp_stream_transport`, while legacy records/sing-box use `stream_transport`. URI/VMess/flat/Mihomo adapters normalize paths, headers, ranges and H2 ALPN before identity; immutable admission rejects noncanonical direct nodes through the shared stream/TLS checks. Only XHTTP IDs add accepted request/pacing options and effective TLS posture; old transport IDs remain unchanged. Supported extra fields are strict, duplicate-aware and value-redacted. Xray-shaped `downloadSettings` (URI/VMess) and panel `download-settings` (Clash) share `XrayDownload` into `XhttpOptions.download` without inheritance, and admission validates the TLS-only `Node::xhttp_download_view`; XMUX passes only all-zero or at Xray's implicit defaults, and server-only options are type-checked and dropped. Other unsupported placement fields reject by raw presence, never by whether a value looks active. Official sing-box and client-record XHTTP remain unsupported. See the bilingual node reference for the H2 profile and bounds. diff --git a/crates/honk-config/src/share_link.rs b/crates/honk-config/src/share_link.rs index e54a8ac98..85051b742 100644 --- a/crates/honk-config/src/share_link.rs +++ b/crates/honk-config/src/share_link.rs @@ -81,7 +81,16 @@ impl Node { source: &crate::diagnostic::SourceRef, emit: &mut impl FnMut(crate::diagnostic::DetailedDiagnostic), ) -> Result { - let mut node = Self::decode_share_link(link, source, emit)?; + // A node entry that parses only ever warns with `legacy-*` codes, so native + // clients read any other warning on the entry as the entry being dropped. + let mut node = Self::decode_share_link(link, source, &mut |diagnostic| { + debug_assert!( + diagnostic.code.starts_with("legacy-"), + "share-link warning {} must use a legacy-* code", + diagnostic.code + ); + emit(diagnostic); + })?; node.normalize_stream_transport().map_err(|message| { DetailedConfigError::new( ErrorCategory::Validation, diff --git a/doc/en/reference/nodes.md b/doc/en/reference/nodes.md index 6adacde1e..4875f7415 100644 --- a/doc/en/reference/nodes.md +++ b/doc/en/reference/nodes.md @@ -25,6 +25,8 @@ In quoted tags and links, a backslash escapes the next character when locating t A malformed recognized link is dropped with a warning. Known invalid fields retain a specific safe reason and schema path; unknown parse failures use `invalid-node-entry`. Diagnostics carry the original node-entry ordinal and source location, never the raw link, value, or node name. Data entrypoints return them without logging; plain entrypoints report them once. An unknown scheme or removed VLESS `vless_mode` is a hard configuration error. A standalone `mux:` or `mux=` line is also rejected; VLESS packet and carrier choices belong in that link's exact `packetEncoding=`, `mux=`, and `udp=` query parameters. +A link that is accepted only ever warns with `legacy-*` codes. Any other warning on a node entry means the entry was dropped, so a client can validate a single `node { name: 'link' }` declaration in `syntax` mode and tell a skipped link from an accepted one by code alone. + In the legacy `ss://base64(method:password@host:port)` form, decoded credentials are literal text, not URL-decoded: `%20` stays `%20`, and `?`, `/`, `#`, `:` and `@` remain password characters. The last `@` separates the endpoint; the userinfo may itself be `base64(method:password)`. A decoded payload without `@`, or credentials that cannot supply a method and password, is rejected. URL userinfo forms still percent-decode their credentials. The endpoint, path, query (including `/?plugin=...`) and fragment keep the usual URL handling. ## Node identity diff --git a/doc/zh/reference/nodes.md b/doc/zh/reference/nodes.md index 69e4abfac..36a861716 100644 --- a/doc/zh/reference/nodes.md +++ b/doc/zh/reference/nodes.md @@ -25,6 +25,8 @@ VMess JSON 的 `ps` 备注缺失或为空时,先使用 `vmess-{host}` 通过 协议已识别但格式错误的链接会被丢弃并产生 warning。已知的无效字段保留具体的安全原因及 schema 路径;未知解析失败使用 `invalid-node-entry`。诊断携带原始节点条目序号与来源位置,不包含原始链接、值或节点名称。数据接口返回诊断而不记录日志;普通接口只报告一次。未知协议或已删除的 VLESS `vless_mode` 属于配置硬错误。独立的 `mux:` 或 `mux=` 行也会被拒绝;VLESS 数据包与 carrier 选择必须写在该链接精确的 `packetEncoding=`、`mux=` 和 `udp=` query 参数中。 +被接受的链接只会产生 `legacy-*` 代码的 warning。节点条目上的其他任何 warning 都表示该条目已被丢弃,因此客户端可以用 `syntax` 模式校验单个 `node { name: 'link' }` 声明,仅凭代码区分被跳过与被接受的链接。 + 旧版 `ss://base64(method:password@host:port)` 格式按字面值读取解码后的凭据,不做 URL 解码:`%20` 保持为 `%20`,`?`、`/`、`#`、`:` 和 `@` 仍是密码字符。最后一个 `@` 分隔凭据与端点;userinfo 本身也可以是 `base64(method:password)`。解码后的载荷缺少 `@`,或凭据无法解析为方法与密码时,拒绝解析。URL userinfo 格式仍按百分号解码凭据。端点、路径、query(包括 `/?plugin=...`)和 fragment 继续按 URL 处理。 ## 节点身份 From 1b67dba27020d5e27dea449f8222f10464b56410 Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 17:03:45 +0800 Subject: [PATCH 4/4] fix(pr-140): synchronize node documentation corpus ranges --- crates/honk-config/conformance/manifest.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/honk-config/conformance/manifest.toml b/crates/honk-config/conformance/manifest.toml index 0135f2998..6b4d84129 100644 --- a/crates/honk-config/conformance/manifest.toml +++ b/crates/honk-config/conformance/manifest.toml @@ -1766,7 +1766,7 @@ reason = "honk accepts its documented dialect; the pinned dae parser rejects thi [[case]] id = "honk-doc-en-reference-nodes-02" -source = { file = "doc/en/reference/nodes.md", lines = "181-184" } +source = { file = "doc/en/reference/nodes.md", lines = "183-186" } wrap = "" layer = "structure" honk = "accept" @@ -1777,7 +1777,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-en-reference-nodes-03" -source = { file = "doc/en/reference/nodes.md", lines = "271-273" } +source = { file = "doc/en/reference/nodes.md", lines = "273-275" } wrap = "" layer = "structure" honk = "accept" @@ -1788,7 +1788,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-en-reference-nodes-04" -source = { file = "doc/en/reference/nodes.md", lines = "324-329" } +source = { file = "doc/en/reference/nodes.md", lines = "326-331" } wrap = "" layer = "structure" honk = "accept" @@ -1799,7 +1799,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-en-reference-nodes-05" -source = { file = "doc/en/reference/nodes.md", lines = "204-206" } +source = { file = "doc/en/reference/nodes.md", lines = "206-208" } wrap = "" layer = "structure" honk = "accept" @@ -2083,7 +2083,7 @@ reason = "honk accepts its documented dialect; the pinned dae parser rejects thi [[case]] id = "honk-doc-zh-reference-nodes-02" -source = { file = "doc/zh/reference/nodes.md", lines = "180-183" } +source = { file = "doc/zh/reference/nodes.md", lines = "182-185" } wrap = "" layer = "structure" honk = "accept" @@ -2094,7 +2094,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-zh-reference-nodes-03" -source = { file = "doc/zh/reference/nodes.md", lines = "270-272" } +source = { file = "doc/zh/reference/nodes.md", lines = "272-274" } wrap = "" layer = "structure" honk = "accept" @@ -2105,7 +2105,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-zh-reference-nodes-04" -source = { file = "doc/zh/reference/nodes.md", lines = "323-328" } +source = { file = "doc/zh/reference/nodes.md", lines = "325-330" } wrap = "" layer = "structure" honk = "accept" @@ -2116,7 +2116,7 @@ unknown_sections = 0 [[case]] id = "honk-doc-zh-reference-nodes-05" -source = { file = "doc/zh/reference/nodes.md", lines = "203-205" } +source = { file = "doc/zh/reference/nodes.md", lines = "205-207" } wrap = "" layer = "structure" honk = "accept"