diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index a50960c..225d1ce 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: ## production 交付观测边界 -项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网字面地址,不跟随重定向,并限制请求时间与响应大小。 +项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。 `get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。 diff --git a/src-tauri/src/commands/delivery_status.rs b/src-tauri/src/commands/delivery_status.rs index 708f63e..c48a37f 100644 --- a/src-tauri/src/commands/delivery_status.rs +++ b/src-tauri/src/commands/delivery_status.rs @@ -4,7 +4,12 @@ //! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。 use std::{ - collections::BTreeMap, io::Read, net::IpAddr, path::Path, process::Command, time::Duration, + collections::BTreeMap, + io::{self, Read}, + net::{IpAddr, SocketAddr, ToSocketAddrs}, + path::Path, + process::Command, + time::Duration, }; use serde::{Deserialize, Serialize}; @@ -170,41 +175,63 @@ fn validate_probe_url(raw: &str) -> Result<(), String> { } let ip_host = host.trim_start_matches('[').trim_end_matches(']'); if let Ok(ip) = ip_host.parse::() { - let blocked = match ip { - IpAddr::V4(ip) => { - ip.is_private() - || ip.is_loopback() - || ip.is_link_local() - || ip.is_multicast() - || ip.is_unspecified() - } - IpAddr::V6(ip) => { - ip.to_ipv4_mapped().is_some_and(|mapped| { - mapped.is_private() - || mapped.is_loopback() - || mapped.is_link_local() - || mapped.is_multicast() - || mapped.is_unspecified() - }) || ip.is_loopback() - || ip.is_unique_local() - || ip.is_unicast_link_local() - || ip.is_multicast() - || ip.is_unspecified() - } - }; - if blocked { + if !is_public_probe_ip(ip) { return Err("探针不允许本机或内网 IP".into()); } } Ok(()) } +fn is_public_probe_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_multicast() + && !ip.is_unspecified() + && !ip.is_broadcast() + && octets[0] != 0 + && !(octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(ip) => { + if let Some(ipv4) = ip.to_ipv4() { + return is_public_probe_ip(IpAddr::V4(ipv4)); + } + !ip.is_loopback() + && !ip.is_unique_local() + && !ip.is_unicast_link_local() + && !ip.is_multicast() + && !ip.is_unspecified() + } + } +} + +fn retain_public_probe_addresses( + addresses: impl IntoIterator, +) -> io::Result> { + let safe: Vec<_> = addresses + .into_iter() + .filter(|address| is_public_probe_ip(address.ip())) + .collect(); + if safe.is_empty() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "探针域名未解析到允许的公网地址", + )); + } + Ok(safe) +} + /// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。 pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence { if let Err(detail) = validate_probe_url(&probe.url) { return ProbeEvidence::unavailable(detail); } let agent = ureq::AgentBuilder::new() + .try_proxy_from_env(false) + .resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?)) .redirects(0) .timeout(Duration::from_secs(4)) .timeout_connect(Duration::from_secs(3)) @@ -721,6 +748,20 @@ delivery: assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok()); } + #[test] + fn probe_dns_resolver_never_returns_private_addresses() { + let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap(); + let private: SocketAddr = "192.168.1.1:443".parse().unwrap(); + let carrier_nat: SocketAddr = "100.64.0.1:443".parse().unwrap(); + let public: SocketAddr = "8.8.8.8:443".parse().unwrap(); + assert!(retain_public_probe_addresses([loopback, private, carrier_nat]).is_err()); + assert_eq!( + retain_public_probe_addresses([loopback, public, private]).unwrap(), + vec![public] + ); + assert!(validate_probe_url("http://100.64.0.1/version").is_err()); + } + #[test] fn missing_profile_is_not_configured_and_probe_body_is_strict() { let empty = std::env::temp_dir().join(format!(