From 8c1f5aed7098131aa5bcdc6899438d2a27a46e91 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 11:23:10 +0400 Subject: [PATCH 1/9] fix(payments,api): restore webhook deduplication, fix cryptobot amounts and eliminate panics --- crates/api/src/catalog_routes.rs | 22 ++- crates/api/src/pay_routes.rs | 14 +- crates/api/src/profile_workflow.rs | 2 +- crates/payments/src/providers/cryptobot.rs | 156 ++++++++++++++- crates/payments/src/providers/generic_http.rs | 41 +++- crates/payments/tests/webhook_regression.rs | 183 ++++++++++++++++++ crates/sub/src/policy.rs | 3 +- devtools/check-docs.py | 8 +- 8 files changed, 400 insertions(+), 29 deletions(-) create mode 100644 crates/payments/tests/webhook_regression.rs diff --git a/crates/api/src/catalog_routes.rs b/crates/api/src/catalog_routes.rs index 3ab59af..eb9de56 100644 --- a/crates/api/src/catalog_routes.rs +++ b/crates/api/src/catalog_routes.rs @@ -622,7 +622,8 @@ async fn validate_host_body(st:&AppState,b:&HostBody)->Result<()> { if let Some(id)=options.get("xray_template_id"){sn_sub::overrides::validate_templates(&st.pool,&json!({"xray_json":id})).await?;} for (key,table) in [("node_ids","nodes"),("exclude_squad_ids","squads")] { if let Some(ids)=options[key].as_array(){for id in ids { - let exists:bool=sqlx::query_scalar(&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE id=$1)")).bind(id.as_i64().unwrap()).fetch_one(&st.pool).await?; + let Some(node_id) = id.as_i64() else { return Err(Error::bad("Идентификаторы должны быть целыми числами")); }; + let exists:bool=sqlx::query_scalar(&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE id=$1)")).bind(node_id).fetch_one(&st.pool).await?; if !exists{return Err(Error::bad("Выбранная нода или сквад больше не существует"))} }} } @@ -838,6 +839,25 @@ mod правка_хоста { assert_eq!(чистое(поле), None, "{имя}"); } } + + #[test] + fn host_options_node_ids_rejects_non_integers_without_panic() { + let b = тело(r#"{"options": {"node_ids": ["abc", 123, null, false], "exclude_squad_ids": ["bad", 456]}}"#); + assert!(b.options.is_some()); + let opts = b.options.unwrap(); + assert!(sn_sub::overrides::validate_host(&opts, false).is_err()); + for key in ["node_ids", "exclude_squad_ids"] { + let ids = opts[key].as_array().unwrap(); + let mut errors = 0; + for id in ids { + let Some(_node_id) = id.as_i64() else { + errors += 1; + continue; + }; + } + assert!(errors > 0, "should safely detect non-integers without panic"); + } + } } #[cfg(test)]mod language_tests { diff --git a/crates/api/src/pay_routes.rs b/crates/api/src/pay_routes.rs index 0051ce8..feb73ac 100644 --- a/crates/api/src/pay_routes.rs +++ b/crates/api/src/pay_routes.rs @@ -45,7 +45,7 @@ async fn webhook( st.payments.refresh(&st.pool).await; let Some(provider) = st.payments.get(&provider_id) else { - log_webhook(&st, &provider_id, &raw, false, None, Some("нет такого модуля")).await; + log_webhook(&st, &provider_id, &raw, false, None, None, Some("нет такого модуля")).await; return Ok(Json(json!({ "ok": false, "error": "неизвестный модуль" }))); }; @@ -73,12 +73,12 @@ async fn webhook( match st.payments.apply_outcome(&st.pool, &provider_id, &outcome).await { Ok(pid) => { - log_webhook(&st, &provider_id, &outcome.raw, true, pid, None).await; + log_webhook(&st, &provider_id, &outcome.raw, true, pid, outcome.external_event_id.as_deref(), None).await; tracing::info!(provider = provider_id, payment = ?pid, "оплата применена"); Ok(Json(json!({ "ok": true }))) } Err(e) => { - log_webhook(&st, &provider_id, &outcome.raw, true, None, Some(&e.to_string())).await; + log_webhook(&st, &provider_id, &outcome.raw, true, None, outcome.external_event_id.as_deref(), Some(&e.to_string())).await; tracing::warn!(provider = provider_id, error = %e, "не удалось применить оплату"); Err(e) } @@ -87,7 +87,7 @@ async fn webhook( Err(e) => { // Неверная подпись — самый важный случай для журнала: // это либо ошибка настройки, либо попытка подделать оплату. - log_webhook(&st, &provider_id, &raw, false, None, Some(&e.to_string())).await; + log_webhook(&st, &provider_id, &raw, false, None, None, Some(&e.to_string())).await; tracing::warn!(provider = provider_id, error = %e, "вебхук отклонён"); Err(e) } @@ -100,17 +100,19 @@ async fn log_webhook( payload: &Value, signature_ok: bool, payment_id: Option, + external_id: Option<&str>, error: Option<&str>, ) { let _ = sqlx::query( - "INSERT INTO payment_webhooks (provider, payload, signature_ok, processed, payment_id, error) - VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO payment_webhooks (provider, payload, signature_ok, processed, payment_id, external_id, error) + VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(provider) .bind(payload) .bind(signature_ok) .bind(payment_id.is_some()) .bind(payment_id) + .bind(external_id) .bind(error) .execute(&st.pool) .await; diff --git a/crates/api/src/profile_workflow.rs b/crates/api/src/profile_workflow.rs index 3060b4e..d5c3cfb 100644 --- a/crates/api/src/profile_workflow.rs +++ b/crates/api/src/profile_workflow.rs @@ -389,7 +389,7 @@ async fn trial_get( ) -> Result> { let trial:Option=sqlx::query_scalar("SELECT to_jsonb(t)||jsonb_build_object('config',p.config,'current_version',p.version) FROM profile_trials t JOIN config_profiles p ON p.id=t.candidate_id WHERE t.profile_id=$1 AND t.state='testing'").bind(id).fetch_optional(&st.pool).await?; if let Some(mut t) = trial { - let c = t["candidate_id"].as_i64().unwrap(); + let c = t["candidate_id"].as_i64().ok_or_else(|| Error::Internal("candidate_id missing".into()))?; t["nodes"] = json!(node_status(&st, c).await?); Ok(Json(t)) } else { diff --git a/crates/payments/src/providers/cryptobot.rs b/crates/payments/src/providers/cryptobot.rs index 64b46f4..99487e8 100644 --- a/crates/payments/src/providers/cryptobot.rs +++ b/crates/payments/src/providers/cryptobot.rs @@ -71,6 +71,33 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } +fn parse_minor_units(v: &serde_json::Value) -> Option { + if let Ok(minor) = super::hosted::amount_minor(v) { + return Some(minor); + } + let raw = match v { + serde_json::Value::String(s) => s.trim(), + _ => return None, + }; + let (whole, fraction) = raw.split_once('.').unwrap_or((raw, "")); + let fraction_trimmed = fraction.trim_end_matches('0'); + if fraction_trimmed.len() <= 2 + && !whole.is_empty() + && whole.bytes().all(|b| b.is_ascii_digit()) + && fraction.bytes().all(|b| b.is_ascii_digit()) + { + let w = whole.parse::().ok()?; + let f = if fraction_trimmed.is_empty() { + 0 + } else { + fraction_trimmed.parse::().ok()? * if fraction_trimmed.len() == 1 { 10 } else { 1 } + }; + w.checked_mul(100)?.checked_add(f) + } else { + None + } +} + #[async_trait] impl PaymentProvider for CryptoBot { fn accepts_http_webhooks(&self) -> bool { true } @@ -183,11 +210,23 @@ impl PaymentProvider for CryptoBot { .as_str() .and_then(|s| s.parse::().ok()); - let amount_minor = payload["paid_amount"] - .as_str() - .or_else(|| payload["amount"].as_str()) - .and_then(|s| s.parse::().ok()) - .map(|f| (f * 100.0).round() as i64); + let fiat = payload["fiat"].as_str().filter(|s| !s.trim().is_empty()); + let is_fiat = payload["currency_type"].as_str() == Some("fiat") || fiat.is_some(); + + let raw_amount = if is_fiat { + &payload["amount"] + } else { + payload + .get("paid_amount") + .filter(|v| !v.is_null() && v.as_str() != Some("")) + .unwrap_or(&payload["amount"]) + }; + + let amount_minor = parse_minor_units(raw_amount); + + let currency = fiat + .or_else(|| payload["asset"].as_str().filter(|s| !s.trim().is_empty())) + .map(String::from); Ok(WebhookOutcome { external_event_id: v["update_id"].as_i64().map(|v| v.to_string()), @@ -195,7 +234,7 @@ impl PaymentProvider for CryptoBot { provider_txid: payload["invoice_id"].as_i64().map(|v| v.to_string()), status, amount_minor, - currency: payload["fiat"].as_str().map(String::from), + currency, error: None, raw: v, }) @@ -276,4 +315,109 @@ mod tests { h.insert("crypto-pay-api-signature".to_string(), sig); assert!(cb.signature_valid(&h, body)); } + + #[tokio::test] + async fn fiat_webhook_extracts_amount_and_fiat_currency() { + let token = "test-token"; + let body = json!({ + "update_id": 101, + "payload": { + "invoice_id": 999, + "status": "paid", + "currency_type": "fiat", + "fiat": "USD", + "amount": "19.99", + "paid_amount": "0.005123", + "paid_asset": "TON", + "payload": "42" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = with_token(token); + let mut h = HashMap::new(); + h.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&h, &bytes).await.unwrap(); + assert_eq!(outcome.external_event_id.as_deref(), Some("101")); + assert_eq!(outcome.payment_id, Some(42)); + assert_eq!(outcome.provider_txid.as_deref(), Some("999")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(1999)); + assert_eq!(outcome.currency.as_deref(), Some("USD")); + } + + #[tokio::test] + async fn crypto_webhook_falls_back_to_asset_when_fiat_missing() { + let token = "test-token"; + let body = json!({ + "update_id": 102, + "payload": { + "invoice_id": 1000, + "status": "paid", + "currency_type": "crypto", + "asset": "USDT", + "fiat": null, + "amount": "15.00", + "paid_amount": "15.00", + "payload": "43" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = with_token(token); + let mut h = HashMap::new(); + h.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&h, &bytes).await.unwrap(); + assert_eq!(outcome.external_event_id.as_deref(), Some("102")); + assert_eq!(outcome.payment_id, Some(43)); + assert_eq!(outcome.provider_txid.as_deref(), Some("1000")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(1500)); + assert_eq!(outcome.currency.as_deref(), Some("USDT")); + } + + #[tokio::test] + async fn crypto_webhook_handles_trailing_zeros_and_empty_fiat() { + let token = "test-token"; + let body = json!({ + "update_id": 103, + "payload": { + "invoice_id": 1001, + "status": "paid", + "currency_type": "crypto", + "asset": "TON", + "fiat": "", + "amount": "15.00", + "paid_amount": "15.000000", + "payload": "44" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = with_token(token); + let mut h = HashMap::new(); + h.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&h, &bytes).await.unwrap(); + assert_eq!(outcome.external_event_id.as_deref(), Some("103")); + assert_eq!(outcome.payment_id, Some(44)); + assert_eq!(outcome.provider_txid.as_deref(), Some("1001")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(1500)); + assert_eq!(outcome.currency.as_deref(), Some("TON")); + } } diff --git a/crates/payments/src/providers/generic_http.rs b/crates/payments/src/providers/generic_http.rs index e55169a..d2daedb 100644 --- a/crates/payments/src/providers/generic_http.rs +++ b/crates/payments/src/providers/generic_http.rs @@ -269,19 +269,12 @@ impl PaymentProvider for GenericHttp { .get(&header_name) .ok_or_else(|| Error::bad(format!("нет заголовка {header_name}")))?; + let signature_bytes = hex::decode(got.trim()) + .map_err(|_| Error::Forbidden)?; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .map_err(|_| Error::Internal("плохой секрет".into()))?; mac.update(body); - let expected = hex::encode(mac.finalize().into_bytes()); - - // Сравнение постоянного времени: по времени ответа подпись подбирается. - let ok = got.len() == expected.len() - && got - .bytes() - .zip(expected.bytes()) - .fold(0u8, |a, (x, y)| a | (x ^ y)) - == 0; - if !ok { + if mac.verify_slice(&signature_bytes).is_err() { return Err(Error::Forbidden); } @@ -450,6 +443,34 @@ mod tests { assert_eq!(out.amount_minor, Some(1999)); assert_eq!(out.currency.as_deref(), Some("RUB")); } + + #[tokio::test] + async fn подпись_в_верхнем_регистре_принимается() { + let secret = "s3cret"; + let body = br#"{"order":"77","state":"PAID","amount":"19.99","currency":"RUB"}"#; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let sig = hex::encode(mac.finalize().into_bytes()).to_uppercase(); + + let p = cfg(&[ + ("webhook_secret", secret), + ("webhook_signature_header", "x-signature"), + ("webhook_payment_id_path", "order"), + ("webhook_status_path", "state"), + ("webhook_success_value", "paid"), + ("webhook_amount_path", "amount"), + ("webhook_currency_path", "currency"), + ]); + let mut h = HashMap::new(); + h.insert("x-signature".to_string(), sig); + + let out = p.handle_webhook(&h, body).await.expect("подпись в верхнем регистре верная"); + assert_eq!(out.payment_id, Some(77)); + assert_eq!(out.status, PaymentStatus::Success); + assert_eq!(out.amount_minor, Some(1999)); + assert_eq!(out.currency.as_deref(), Some("RUB")); + } + #[tokio::test] async fn signed_success_requires_precise_amount_currency_and_order() { let p = cfg(&[("webhook_secret","test"),("webhook_payment_id_path","order"), diff --git a/crates/payments/tests/webhook_regression.rs b/crates/payments/tests/webhook_regression.rs new file mode 100644 index 0000000..13bb0de --- /dev/null +++ b/crates/payments/tests/webhook_regression.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use serde_json::json; +use sn_payments::{PaymentProvider, PaymentStatus}; +use sn_payments::providers::cryptobot::CryptoBot; +use sn_payments::providers::generic_http::GenericHttp; + +type HmacSha256 = Hmac; + +fn cryptobot_with_token(token: &str) -> CryptoBot { + let cb = CryptoBot::new(); + let mut m = serde_json::Map::new(); + m.insert("token".into(), json!(token)); + cb.settings().unwrap().replace(m); + cb +} + +fn generic_http_cfg(pairs: &[(&str, &str)]) -> GenericHttp { + let p = GenericHttp::new(); + let mut m = serde_json::Map::new(); + for (k, v) in pairs { + m.insert(k.to_string(), json!(v)); + } + p.settings().unwrap().replace(m); + p +} + +#[tokio::test] +async fn cryptobot_fiat_invoice_extracts_amount_rather_than_paid_crypto_amount() { + let token = "test-secret-token"; + let body = json!({ + "update_id": 99991, + "payload": { + "invoice_id": 123456, + "status": "paid", + "currency_type": "fiat", + "fiat": "USD", + "amount": "25.50", + "paid_amount": "0.00789123", + "paid_asset": "TON", + "payload": "100" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = cryptobot_with_token(token); + let mut headers = HashMap::new(); + headers.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&headers, &bytes).await.expect("valid signature"); + assert_eq!(outcome.external_event_id.as_deref(), Some("99991")); + assert_eq!(outcome.payment_id, Some(100)); + assert_eq!(outcome.provider_txid.as_deref(), Some("123456")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(2550)); + assert_eq!(outcome.currency.as_deref(), Some("USD")); +} + +#[tokio::test] +async fn cryptobot_crypto_invoice_falls_back_to_asset_when_fiat_null() { + let token = "test-secret-token"; + let body = json!({ + "update_id": 99992, + "payload": { + "invoice_id": 123457, + "status": "paid", + "currency_type": "crypto", + "asset": "USDT", + "fiat": null, + "amount": "10.00", + "paid_amount": "10.00", + "payload": "101" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = cryptobot_with_token(token); + let mut headers = HashMap::new(); + headers.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&headers, &bytes).await.expect("valid signature"); + assert_eq!(outcome.external_event_id.as_deref(), Some("99992")); + assert_eq!(outcome.payment_id, Some(101)); + assert_eq!(outcome.provider_txid.as_deref(), Some("123457")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(1000)); + assert_eq!(outcome.currency.as_deref(), Some("USDT")); +} + +#[tokio::test] +async fn generic_http_webhook_accepts_both_lowercase_and_uppercase_hmac() { + let secret = "webhook-secret-key-123"; + let body = br#"{"order":"555","state":"PAID","amount":"49.90","currency":"EUR"}"#; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let raw_sig = hex::encode(mac.finalize().into_bytes()); + + let p = generic_http_cfg(&[ + ("webhook_secret", secret), + ("webhook_signature_header", "x-signature"), + ("webhook_payment_id_path", "order"), + ("webhook_status_path", "state"), + ("webhook_success_value", "paid"), + ("webhook_amount_path", "amount"), + ("webhook_currency_path", "currency"), + ]); + + // 1. Lowercase hex signature + let mut headers_lower = HashMap::new(); + headers_lower.insert("x-signature".to_string(), raw_sig.to_lowercase()); + let outcome_lower = p.handle_webhook(&headers_lower, body).await.expect("lowercase signature accepted"); + assert_eq!(outcome_lower.payment_id, Some(555)); + assert_eq!(outcome_lower.status, PaymentStatus::Success); + assert_eq!(outcome_lower.amount_minor, Some(4990)); + assert_eq!(outcome_lower.currency.as_deref(), Some("EUR")); + + // 2. Uppercase hex signature + let mut headers_upper = HashMap::new(); + headers_upper.insert("x-signature".to_string(), raw_sig.to_uppercase()); + let outcome_upper = p.handle_webhook(&headers_upper, body).await.expect("uppercase signature accepted"); + assert_eq!(outcome_upper.payment_id, Some(555)); + assert_eq!(outcome_upper.status, PaymentStatus::Success); + assert_eq!(outcome_upper.amount_minor, Some(4990)); + assert_eq!(outcome_upper.currency.as_deref(), Some("EUR")); + + // 3. Invalid signature rejected + let mut headers_invalid = HashMap::new(); + headers_invalid.insert("x-signature".to_string(), "badf00d".to_string()); + assert!(p.handle_webhook(&headers_invalid, body).await.is_err()); + + // 4. Mixed-case and whitespace-padded signature accepted + let mut headers_mixed = HashMap::new(); + let mixed_sig = format!(" {} ", raw_sig.chars().enumerate().map(|(i, c)| if i % 2 == 0 { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }).collect::()); + headers_mixed.insert("x-signature".to_string(), mixed_sig); + let outcome_mixed = p.handle_webhook(&headers_mixed, body).await.expect("mixed-case whitespace signature accepted"); + assert_eq!(outcome_mixed.payment_id, Some(555)); + assert_eq!(outcome_mixed.amount_minor, Some(4990)); +} + +#[tokio::test] +async fn cryptobot_crypto_invoice_with_trailing_zeros_and_empty_fiat() { + let token = "test-secret-token"; + let body = json!({ + "update_id": 99993, + "payload": { + "invoice_id": 123458, + "status": "paid", + "currency_type": "crypto", + "asset": "TON", + "fiat": "", + "amount": "10.00", + "paid_amount": "10.000000", + "payload": "102" + } + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let secret = Sha256::digest(token.as_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).unwrap(); + mac.update(&bytes); + let sig = hex::encode(mac.finalize().into_bytes()); + + let cb = cryptobot_with_token(token); + let mut headers = HashMap::new(); + headers.insert("crypto-pay-api-signature".to_string(), sig); + + let outcome = cb.handle_webhook(&headers, &bytes).await.expect("valid signature"); + assert_eq!(outcome.external_event_id.as_deref(), Some("99993")); + assert_eq!(outcome.payment_id, Some(102)); + assert_eq!(outcome.provider_txid.as_deref(), Some("123458")); + assert_eq!(outcome.status, PaymentStatus::Success); + assert_eq!(outcome.amount_minor, Some(1000)); + assert_eq!(outcome.currency.as_deref(), Some("TON")); +} + diff --git a/crates/sub/src/policy.rs b/crates/sub/src/policy.rs index 2d5d7ec..992d661 100644 --- a/crates/sub/src/policy.rs +++ b/crates/sub/src/policy.rs @@ -146,7 +146,7 @@ pub fn render_template(code:&str,body:&str,hosts:&[HostEntry],title:&str)->Resul } if code=="singbox"{ let generated=formats::render_singbox(hosts,title);let outs=generated["outbounds"].clone(); - let dynamic=json!(outs.as_array().unwrap().iter().filter(|o|o.get("server").is_some()).collect::>()); + let dynamic=json!(outs.as_array().map(|a|a.iter().filter(|o|o.get("server").is_some()).collect::>()).unwrap_or_default()); if body.contains("{{SERVERS}}")||body.contains("{{OUTBOUNDS}}"){expand(&mut v,&dynamic,&outs,&names,title)}else{ let template=v;v=generated;deep_merge(&mut v,&template); let mut merged=outs.as_array().cloned().unwrap_or_default(); @@ -190,6 +190,7 @@ mod tests{ #[test]fn title_is_structurally_escaped(){let out=render_template("xray_json",r#"{"title":"{{TITLE}}","list":{{SERVERS}}}"#,&hosts(),"A\"\nB").unwrap();let v:Value=serde_json::from_str(&out).unwrap();assert_eq!(v["title"],"A\"\nB");assert_eq!(v["list"].as_array().unwrap().len(),2);} #[test]fn yaml_templates_apply_for_every_family(){for code in ["clash","mihomo","stash"]{let out=render_template(code,"mixed-port: 8888\nproxies: {{SERVERS}}\nproxy-groups:\n - name: Select\n type: select\n proxies: {{PROXY_NAMES}}\nrules:\n - MATCH,Select\n",&hosts(),"title").unwrap();let v:Value=serde_yaml_ng::from_str(&out).unwrap();assert_eq!(v["mixed-port"],8888);assert_eq!(v["proxies"].as_array().unwrap().len(),2);assert_eq!(v["proxy-groups"][0]["proxies"].as_array().unwrap().len(),2);}} #[test]fn singbox_applies_dns_and_retains_servers(){let out=render_template("singbox",r#"{"log":{"level":"debug"},"dns":{"servers":[{"type":"udp","server":"9.9.9.9"}]}}"#,&hosts(),"title").unwrap();let v:Value=serde_json::from_str(&out).unwrap();assert_eq!(v["log"]["level"],"debug");assert_eq!(v["outbounds"].as_array().unwrap().iter().filter(|o|o["server"].is_string()).count(),2);} + #[test]fn singbox_handles_missing_outbounds_without_panic(){let out=render_template("singbox",r#"{"log":{"level":"debug"}}"#,&hosts(),"title").unwrap();let v:Value=serde_json::from_str(&out).unwrap();assert_eq!(v["log"]["level"],"debug");} #[test]fn balanced_xray_has_unique_real_outbounds(){let out=render_template("xray_json",r#"{"stealthnet":{"mode":"balanced"},"routing":{"balancers":[{"tag":"auto","selector":["proxy-"]}]}}"#,&hosts(),"Auto").unwrap();let v:Value=serde_json::from_str(&out).unwrap();assert_eq!(v.as_array().unwrap().len(),1);assert_eq!(v[0]["remarks"],"Auto");assert_eq!(v[0]["outbounds"][0]["tag"],"proxy-0");assert_eq!(v[0]["outbounds"][1]["tag"],"proxy-1");assert!(v[0].get("stealthnet").is_none());} #[test]fn text_templates_are_encoded_once(){let b="{{TITLE}}\n{{SERVERS}}";let out=render_template("base64",b,&hosts(),"T").unwrap();let plain=String::from_utf8(STANDARD.decode(out).unwrap()).unwrap();assert!(plain.starts_with("T\nvless://"));assert_eq!(plain.lines().count(),3);} #[test]fn routing_and_settings_reject_invalid_data(){assert!(validate_routing("happ://routing/onadd/!!!!").is_err());let l=format!("happ://routing/onadd/{}",STANDARD.encode(r#"{"Name":"Мой VPN","DirectSites":["example.com"]}"#));assert!(validate_routing(&l).is_ok());assert!(validate_routing("happ://routing/off").is_ok());assert!(validate_settings(json!({"subscription.remark_expired":["Окончено","Продлите доступ"]}).as_object().unwrap()).is_ok());assert!(validate_settings(json!({"subscription.remark_expired":[false]}).as_object().unwrap()).is_err());} diff --git a/devtools/check-docs.py b/devtools/check-docs.py index 718f506..f2cad2d 100644 --- a/devtools/check-docs.py +++ b/devtools/check-docs.py @@ -9,16 +9,16 @@ ru={str(p.relative_to(ROOT/'docs/ru')) for p in (ROOT/'docs/ru').rglob('*.md')} en={str(p.relative_to(ROOT/'docs/en')) for p in (ROOT/'docs/en').rglob('*.md')} if ru!=en:errors.append('Language page mismatch: '+str(ru^en)) -raw=(ROOT/'web/help.js').read_text().split('const SECTION_HELP = ',1)[1].split('\n};',1)[0]+'\n}' +raw=(ROOT/'web/help.js').read_text(encoding="utf-8").split('const SECTION_HELP = ',1)[1].split('\n};',1)[0]+'\n}' section_ids=set(json.loads(raw))|{'getting-started','cabinet'} for lang in ('ru','en'): actual={p.stem for p in (ROOT/f'docs/{lang}/sections').glob('*.md')} if actual!=section_ids:errors.append(f'{lang}: missing/extra sections {actual^section_ids}') for id in section_ids: - if f'(sections/{id}.md)' not in (ROOT/f'docs/{lang}/README.md').read_text():errors.append(f'{lang}: section not indexed: {id}') + if f'(sections/{id}.md)' not in (ROOT/f'docs/{lang}/README.md').read_text(encoding="utf-8"):errors.append(f'{lang}: section not indexed: {id}') for path in paths: if not path.is_file():errors.append('Missing file: '+str(path.relative_to(ROOT)));continue - text=path.read_text() + text=path.read_text(encoding="utf-8") # Ignore fenced examples; validate Markdown destinations and HTML image/link URLs. text=re.sub(r'```.*?```','',text,flags=re.S) links=re.findall(r'\]\(([^)]+)\)',text)+re.findall(r'(?:src|href)="([^"]+)"',text) @@ -33,7 +33,7 @@ except ET.ParseError as e:errors.append(f'{svg.name}: invalid SVG: {e}') for png in (ROOT/'docs/media').glob('*.png'): if not png.read_bytes().startswith(b'\x89PNG\r\n\x1a\n'):errors.append(f'{png.name}: invalid PNG') -if 'GNU AFFERO GENERAL PUBLIC LICENSE' not in (ROOT/'LICENSE').read_text():errors.append('Full license text missing') +if 'GNU AFFERO GENERAL PUBLIC LICENSE' not in (ROOT/'LICENSE').read_text(encoding="utf-8"):errors.append('Full license text missing') if errors: print('\n'.join(errors));sys.exit(1) print(f'Documentation checked: {len(paths)} pages, {len(section_ids)} sections in RU/EN, local links and media valid.') From 805db280f96745108ab445f1dd1350eabf32897d Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 12:28:16 +0400 Subject: [PATCH 2/9] feat(web): support dynamic theme auto-detection and add cabinet help section with parity tests --- .gitignore | 1 + Makefile | 2 +- devtools/tests/help-parity.test.cjs | 60 +++++++++++++++++++++++++++++ web/help-en.js | 1 + web/help.js | 6 +++ web/theme.js | 40 +++++++++++++------ 6 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 devtools/tests/help-parity.test.cjs diff --git a/.gitignore b/.gitignore index 90a6647..a9cdf72 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /devtools/tests/* !/devtools/tests/profile-form.test.cjs !/devtools/tests/test_release_version.py +!/devtools/tests/help-parity.test.cjs /docs/design/ /docs/repository-preparation.md __pycache__/ diff --git a/Makefile b/Makefile index ba5f8b4..592609f 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,6 @@ doctor: test-release: @python3 devtools/release_version.py --check @python3 -m unittest discover -s devtools/tests -p 'test_*.py' -v - @node --test devtools/tests/profile-form.test.cjs + @node --test devtools/tests/profile-form.test.cjs devtools/tests/help-parity.test.cjs @python3 -m unittest discover -s deploy/tests -v @bash -n install.sh update.sh deploy/migrate.sh diff --git a/devtools/tests/help-parity.test.cjs b/devtools/tests/help-parity.test.cjs new file mode 100644 index 0000000..38317bb --- /dev/null +++ b/devtools/tests/help-parity.test.cjs @@ -0,0 +1,60 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '../..'); + +function extractKeys(text) { + const match = text.match(/SECTION_HELP(?:_EN)?\s*=\s*\{([\s\S]*?)\};/); + if (!match) return []; + const keys = []; + const re = /["']([a-zA-Z0-9_-]+)["']\s*:/g; + let m; + while ((m = re.exec(match[1])) !== null) { + keys.push(m[1]); + } + return keys; +} + +test('bilingual parity between RU and EN section help entries', () => { + const helpRu = fs.readFileSync(path.join(root, 'web', 'help.js'), 'utf8'); + const helpEn = fs.readFileSync(path.join(root, 'web', 'help-en.js'), 'utf8'); + + const ruKeys = extractKeys(helpRu); + const enKeys = extractKeys(helpEn); + + // 'getting-started' is registered in help.js dynamically + const ruSet = new Set([...ruKeys, 'getting-started']); + const enSet = new Set(enKeys); + + const missingInEn = [...ruSet].filter(k => !enSet.has(k)); + const missingInRu = [...enSet].filter(k => !ruSet.has(k)); + + assert.deepEqual(missingInEn, [], 'Help entries present in RU but missing in EN'); + assert.deepEqual(missingInRu, [], 'Help entries present in EN but missing in RU'); +}); + +test('all registered pages have a help drawer entry', () => { + const helpRu = fs.readFileSync(path.join(root, 'web', 'help.js'), 'utf8'); + const ruKeys = extractKeys(helpRu); + const allHelpKeys = new Set([...ruKeys, 'getting-started']); + + const webFiles = fs.readdirSync(path.join(root, 'web')).filter(f => f.endsWith('.js')); + const registeredPages = new Set(); + for (const file of webFiles) { + const content = fs.readFileSync(path.join(root, 'web', file), 'utf8'); + const re = /registerPage\(\{\s*id:\s*['"]([a-zA-Z0-9_-]+)['"]/g; + let pm; + while ((pm = re.exec(content)) !== null) { + registeredPages.add(pm[1]); + } + } + + // 'login' and '404' are system screens without top navigation or drawer + const expectedWithoutHelp = new Set(['login', '404']); + const missingHelp = [...registeredPages].filter(p => !allHelpKeys.has(p) && !expectedWithoutHelp.has(p)); + + assert.deepEqual(missingHelp, [], 'Registered pages lacking a help drawer entry'); +}); diff --git a/web/help-en.js b/web/help-en.js index 4fc5b3c..5acb3f2 100644 --- a/web/help-en.js +++ b/web/help-en.js @@ -11,6 +11,7 @@ const SECTION_HELP_EN={ 'broadcasts':['Messaging your audience','Broadcasts send messages from your Telegram bot to a selected customer segment.','Write the message and optionally attach a photo → choose the audience → save the draft → send yourself a test → schedule or start delivery. Photo captions allow 1024 characters; text-only messages allow 4096.','Starting delivery sends messages to real people. Canceling stops the remaining queue but does not delete delivered messages. Telegram does not report read receipts to bots.'], 'providers':['Setting up payments','Providers create invoices and confirm payments. Available options include Stars, CryptoBot, manual payment and a configurable HTTP provider.','Choose the project currency → configure provider keys → enable supported currencies → set plan prices → make a test purchase.','A configured key does not prove that money was received. A custom API may require a Rust module and rebuild. Automatic charging requires provider support.'], 'bot':['Managing your Telegram bot','Configure menus, greetings, images, documents, tickets, referrals and buttons without changing code.','Set the bot token in the service environment → check the bot → configure text and links → save → open /start.','Settings may take up to 30 seconds to refresh. For mandatory channel membership, the bot must be a channel administrator. The customer website is installed separately.'], +'cabinet':['Web cabinet and Mini App','Branding, plan showcase, legal links and client cabinet server deployment.','Configure texts and enable publication under Branding → save → under Servers, add a server and run the installation command. For Telegram Mini App, link the server and enable the button in bot settings.','The cabinet requires a valid HTTPS domain. The cabinet server has no direct access to the panel database. Showcase changes take effect immediately after saving.'], 'support':['Customer conversations','Bot tickets are linked to the customer record. Replies from the panel are sent to the customer through Telegram.','Open the ticket → review the conversation and subscription status → reply → close when resolved.','Closing a ticket preserves its history. If a reply is not delivered, check the bot and whether the customer blocked it.'], 'hosts':['What customers see in their server list','A host is a published location: its name, address, port and profile inbound. A node is the machine running that inbound.','Create a profile and connect a node first. Then add a host, select the inbound, enter the public address and port, and enable the host.','Reality keys come from the profile. Check host bindings after replacing an inbound. Disabling a host hides the location from subscriptions; it does not stop the server port.'], 'profiles':["Profiles and template library", "A profile stores the complete server JSON. Its inbounds connect to nodes, hosts and internal squads. Library templates are independent starting points.", "New profile → ready-made scenario → plain-language parameters → validation. For REALITY, enter a camouflage website and connection port; its server name and missing keys are prepared automatically. Advanced settings and full JSON import remain available. Review effects on hosts and squads before changes. Rehearse on a free test node first if needed.", "Applied confirms the agent started Xray; verify VPN connectivity with a client. History loads previous JSON into the draft without automatically restoring deleted bindings."], diff --git a/web/help.js b/web/help.js index ab43777..719f0fb 100644 --- a/web/help.js +++ b/web/help.js @@ -56,6 +56,12 @@ const SECTION_HELP = { "Установите токен бота в окружении сервиса → нажмите «Проверить бота» → настройте тексты и ссылки → сохраните → откройте /start.", "Настройки подхватываются с задержкой до 30 секунд. Для обязательной подписки на канал бот должен быть его администратором. Отдельный веб-кабинет — другой компонент." ], + "cabinet": [ + "Веб-кабинет и Mini App", + "Брендинг, витрина тарифов, ссылки на документы и размещение серверов личного кабинета.", + "В разделе «Брендинг» настройте тексты и включите публикацию → сохраните → в разделе «Серверы» добавьте сервер и выполните команду установки. Для Telegram Mini App привяжите сервер и включите кнопку в настройках бота.", + "Кабинету требуется валидный HTTPS-домен. Сервер кабинета не имеет прямого доступа к базе данных панели. Изменение настроек витрины вступает в силу сразу после сохранения." + ], "support": [ "Переписка с клиентом", "Обращения из бота связаны с клиентской карточкой. Ответ из панели отправляется клиенту в Telegram.", diff --git a/web/theme.js b/web/theme.js index 285ef1f..df45962 100644 --- a/web/theme.js +++ b/web/theme.js @@ -1,9 +1,8 @@ -/* Тема оформления: светлая по умолчанию, тёмная по выбору. +/* Тема оформления: системная по умолчанию (светлая/тёмная), с возможностью ручного переключения. * * Файл подключается первым и до отрисовки: если выставлять атрибут * позже, человек с тёмной темой успевает увидеть вспышку белого экрана. - * Пока выбор не сделан, атрибута нет вовсе — тогда решает настройка - * системы через prefers-color-scheme в таблице стилей. + * Пока выбор не сделан вручную, решает настройка системы через prefers-color-scheme. */ 'use strict'; @@ -13,19 +12,24 @@ function storedTheme(){ try { return localStorage.getItem(THEME_KEY); } catch (_) { return null; } } +function prefersDark(){ + try { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; } catch (_) { return false; } +} + +function isDark(){ + const s = storedTheme(); + if (s === 'dark') return true; + if (s === 'light') return false; + return prefersDark(); +} + function applyTheme(t){ const root = document.documentElement; - root.setAttribute('data-theme', t === 'dark' ? 'dark' : 'light'); - // Системные части окна — полосы прокрутки, поле ввода браузера — - // красятся по этому свойству. Без него светлая панель получает - // тёмную полосу прокрутки, и это сразу заметно. - root.style.colorScheme = isDark() ? 'dark' : 'light'; + const dark = t === 'dark' || (t !== 'light' && prefersDark()); + root.setAttribute('data-theme', dark ? 'dark' : 'light'); + root.style.colorScheme = dark ? 'dark' : 'light'; } -/* По умолчанию светлая: панель для работы днём, и тёмная — выбор, а не - догадка по настройке операционной системы. */ -function isDark(){ return storedTheme() === 'dark'; } - function setTheme(t){ try { localStorage.setItem(THEME_KEY, t); } catch (_) {} applyTheme(t); @@ -36,3 +40,15 @@ function setTheme(t){ function toggleTheme(){ setTheme(isDark() ? 'light' : 'dark'); } applyTheme(storedTheme()); + +try { + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (!storedTheme()) { + applyTheme(null); + if (typeof route === 'function') route(); + } + }); + } +} catch (_) {} + From a518f9bd64b06f065ec8aff6732e672c1b80e3d4 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 14:44:08 +0400 Subject: [PATCH 3/9] perf(api,worker): delegate auth rate limit expiry to worker and add window_start index --- crates/api/src/cabinet.rs | 2 -- crates/api/src/security.rs | 3 --- crates/worker/src/main.rs | 31 +++++++++++++++++++++++++ db/migrations/049_auth_limits_index.sql | 2 ++ 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 db/migrations/049_auth_limits_index.sql diff --git a/crates/api/src/cabinet.rs b/crates/api/src/cabinet.rs index 07b7a5e..932d189 100644 --- a/crates/api/src/cabinet.rs +++ b/crates/api/src/cabinet.rs @@ -118,8 +118,6 @@ async fn throttle(st:&AppState,h:&HeaderMap,install:uuid::Uuid,action:&str,max:i let key=hex::encode(token_hash(&format!("{install}:{ip}:{action}"))); let window=chrono::Utc::now().timestamp()/seconds*seconds; let count:i32=sqlx::query_scalar("INSERT INTO cabinet_auth_limits(bucket,window_start,hits) VALUES($1,$2,1) ON CONFLICT(bucket,window_start) DO UPDATE SET hits=cabinet_auth_limits.hits+1 RETURNING hits").bind(key).bind(window).fetch_one(&st.pool).await?; - // All windows are bounded to <= 1 hour. Keep two days, not an unbounded log. - sqlx::query("DELETE FROM cabinet_auth_limits WHERE window_start<$1").bind(chrono::Utc::now().timestamp()-172800).execute(&st.pool).await?; if count>max{return Err(Error::TooManyRequests);}Ok(()) } fn csrf(token:&str)->String{hex::encode(token_hash(&format!("cabinet-csrf:{token}")))} diff --git a/crates/api/src/security.rs b/crates/api/src/security.rs index 18e013d..b1befd3 100644 --- a/crates/api/src/security.rs +++ b/crates/api/src/security.rs @@ -50,9 +50,6 @@ async fn consume(st: &AppState, identity: &str, limit: i32, seconds: i64) -> Res .bind(now / seconds * seconds) .fetch_one(&st.pool) .await?; - // Indexed expiry keeps anonymous attempts from growing into an indefinite log. - sqlx::query("DELETE FROM admin_auth_limits WHERE window_start<$1") - .bind(now - 3600).execute(&st.pool).await?; if count > limit { return Err(Error::TooManyRequests); } Ok(()) } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index a4cc8a2..2a13e0d 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -88,6 +88,10 @@ async fn main() -> Result<()> { tracing::warn!(error = %e, "не почистил отчёты"); let _=sn_core::alerts::incident(&pool,"job:prune_reports","Очистка отчётов",Some("Фоновая задача завершилась с ошибкой. Подробности в журнале сервиса.")).await; } else { let _=sn_core::alerts::incident(&pool,"job:prune_reports","Очистка отчётов",None).await; } + if let Err(e) = prune_auth_limits(&pool).await { + tracing::warn!(error = %e, "не почистил лимиты авторизации"); + let _=sn_core::alerts::incident(&pool,"job:prune_auth_limits","Очистка лимитов авторизации",Some("Фоновая задача завершилась с ошибкой. Подробности в журнале сервиса.")).await; + } else { let _=sn_core::alerts::incident(&pool,"job:prune_auth_limits","Очистка лимитов авторизации",None).await; } if let Err(e) = ensure_partitions(&pool).await { tracing::error!(error = %e, "обслуживание партиций"); let _=sn_core::alerts::incident(&pool,"job:ensure_partitions","Обслуживание базы",Some("Фоновая задача завершилась с ошибкой. Подробности в журнале сервиса.")).await; @@ -191,6 +195,33 @@ async fn prune_reports(pool: &Pool) -> Result<()> { Ok(()) } +/// Удаляет устаревшие временные окна рейт-лимитинга авторизации. +/// +/// Очистка вынесена из горячих путей `consume()` и `throttle()`, чтобы исключить +/// лишние `DELETE` и блокировки строк при каждом запросе входа или кабинета. +async fn prune_auth_limits(pool: &Pool) -> Result<()> { + let now = Utc::now().timestamp(); + // Ограничения для входа в панель администратора: окна до 5 минут, храним 1 час. + let a = sqlx::query("DELETE FROM admin_auth_limits WHERE window_start < $1") + .bind(now - 3600) + .execute(pool) + .await?; + // Ограничения для кабинета клиента: окна до 1 часа, храним 2 суток. + let c = sqlx::query("DELETE FROM cabinet_auth_limits WHERE window_start < $1") + .bind(now - 172800) + .execute(pool) + .await?; + + if a.rows_affected() + c.rows_affected() > 0 { + tracing::info!( + admin = a.rows_affected(), + cabinet = c.rows_affected(), + "устаревшие лимиты авторизации удалены" + ); + } + Ok(()) +} + async fn ensure_partitions(pool: &Pool) -> Result<()> { // Раз в сутки достаточно; вызов идемпотентен, лишний раз не навредит. let hour = Utc::now().format("%H").to_string(); diff --git a/db/migrations/049_auth_limits_index.sql b/db/migrations/049_auth_limits_index.sql new file mode 100644 index 0000000..16a4ceb --- /dev/null +++ b/db/migrations/049_auth_limits_index.sql @@ -0,0 +1,2 @@ +-- Индекс по времени окна для быстрой периодической очистки лимитов кабинета воркером +CREATE INDEX IF NOT EXISTS cabinet_auth_limits_window_idx ON cabinet_auth_limits(window_start); From b9e05c8f50a30d76d09584f5baa01d91cce2e15c Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 14:56:01 +0400 Subject: [PATCH 4/9] fix(sub,web): serialize device admission to prevent hwid race and polish manual checkout ux --- crates/api/src/sub_routes.rs | 30 +++++++++++++++++++++--------- crates/sub/src/main.rs | 28 ++++++++++++++++++---------- web/app/app.js | 9 +++++---- web/app/customer-en.js | 8 +++++++- web/app/customer-ui.js | 9 +++++---- 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/crates/api/src/sub_routes.rs b/crates/api/src/sub_routes.rs index e239c14..0ffc254 100644 --- a/crates/api/src/sub_routes.rs +++ b/crates/api/src/sub_routes.rs @@ -329,27 +329,37 @@ async fn register_device( return Err(Error::bad("пустой идентификатор устройства")); } + // Serialize device admission for this client. Counting first and inserting + // later lets concurrent new HWIDs all consume the same remaining slot. + let mut tx = st.pool.begin().await?; + let client_id: i64 = sqlx::query_scalar( + "SELECT id FROM clients WHERE short_id = $1 AND deleted_at IS NULL FOR UPDATE", + ) + .bind(&short_id) + .fetch_optional(&mut *tx) + .await? + .ok_or(Error::NotFound)?; + let row = sqlx::query( - "SELECT c.id, - COALESCE(s.device_limit, 0) AS device_limit, - (SELECT count(*) FROM devices d WHERE d.client_id = c.id) AS used, - EXISTS (SELECT 1 FROM devices d WHERE d.client_id = c.id AND d.hwid = $2) AS known + "SELECT COALESCE(s.device_limit, 0) AS device_limit, + (SELECT count(*) FROM devices d WHERE d.client_id = $1) AS used, + EXISTS (SELECT 1 FROM devices d WHERE d.client_id = $1 AND d.hwid = $2) AS known FROM clients c LEFT JOIN subscriptions s ON s.client_id = c.id AND s.is_current - WHERE c.short_id = $1 AND c.deleted_at IS NULL", + WHERE c.id = $1", ) - .bind(&short_id) + .bind(client_id) .bind(&hwid) - .fetch_optional(&st.pool) + .fetch_optional(&mut *tx) .await? .ok_or(Error::NotFound)?; - let client_id: i64 = row.get("id"); let limit: i32 = row.get("device_limit"); let used: i64 = row.get("used"); let known: bool = row.get("known"); if !known && limit > 0 && used >= limit as i64 { + tx.commit().await?; return Ok(Json(json!({ "over_limit": true, "used": used, "limit": limit }))); } @@ -367,9 +377,11 @@ async fn register_device( .bind(body.platform.map(|s| s.chars().take(64).collect::())) .bind(body.model.map(|s| s.chars().take(64).collect::())) .bind(body.app_version.map(|s| s.chars().take(32).collect::())) - .execute(&st.pool) + .execute(&mut *tx) .await?; + tx.commit().await?; + Ok(Json(json!({ "over_limit": false, "used": used + if known { 0 } else { 1 }, "limit": limit }))) } diff --git a/crates/sub/src/main.rs b/crates/sub/src/main.rs index d1a4532..86739f3 100644 --- a/crates/sub/src/main.rs +++ b/crates/sub/src/main.rs @@ -1125,23 +1125,29 @@ pub enum DeviceVerdict { async fn register_device(st: &SubState, short_id: &str, dev: &DeviceInfo) -> DeviceVerdict { match &st.source { Source::Db(pool) => { + let Ok(mut tx) = pool.begin().await else { return DeviceVerdict::Allow }; + let client_id: std::result::Result, _> = sqlx::query_scalar( + "SELECT id FROM clients WHERE short_id = $1 AND deleted_at IS NULL FOR UPDATE", + ) + .bind(short_id) + .fetch_optional(&mut *tx) + .await; + let Ok(Some(client_id)) = client_id else { return DeviceVerdict::Allow }; let row = sqlx::query( - "SELECT c.id, - COALESCE(s.device_limit, 0) AS device_limit, - (SELECT count(*) FROM devices d WHERE d.client_id = c.id) AS used, + "SELECT COALESCE(s.device_limit, 0) AS device_limit, + (SELECT count(*) FROM devices d WHERE d.client_id = $1) AS used, EXISTS (SELECT 1 FROM devices d - WHERE d.client_id = c.id AND d.hwid = $2) AS known + WHERE d.client_id = $1 AND d.hwid = $2) AS known FROM clients c LEFT JOIN subscriptions s ON s.client_id = c.id AND s.is_current - WHERE c.short_id = $1 AND c.deleted_at IS NULL", + WHERE c.id = $1", ) - .bind(short_id) + .bind(client_id) .bind(&dev.hwid) - .fetch_optional(pool) + .fetch_optional(&mut *tx) .await; let Ok(Some(row)) = row else { return DeviceVerdict::Allow }; - let client_id: i64 = row.get("id"); let limit: i32 = row.get("device_limit"); let used: i64 = row.get("used"); let known: bool = row.get("known"); @@ -1150,7 +1156,7 @@ async fn register_device(st: &SubState, short_id: &str, dev: &DeviceInfo) -> Dev return DeviceVerdict::OverLimit { used, limit }; } - let _ = sqlx::query( + let inserted = sqlx::query( "INSERT INTO devices (client_id, hwid, platform, model, app_version) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (client_id, hwid) DO UPDATE @@ -1164,9 +1170,11 @@ async fn register_device(st: &SubState, short_id: &str, dev: &DeviceInfo) -> Dev .bind(&dev.platform) .bind(&dev.model) .bind(&dev.app_version) - .execute(pool) + .execute(&mut *tx) .await; + if inserted.is_ok() { let _ = tx.commit().await; } + DeviceVerdict::Allow } Source::Api { panel_url, token, http } => { diff --git a/web/app/app.js b/web/app/app.js index f65a98d..0a9ea7b 100644 --- a/web/app/app.js +++ b/web/app/app.js @@ -469,7 +469,7 @@ async function drawShop(n) { } if (n !== generation) return; currency = shop.currency; - root.innerHTML = CT.html`

Тарифы

После оплаты доступ включится автоматически.

${ + root.innerHTML = CT.html`

Тарифы

После подтверждения оплаты доступ включится.

${ shop.tariffs .filter((t) => t.prices.some((p) => p.currency === currency)) .map((t) => { @@ -507,7 +507,7 @@ function buySheet(id, selectedPeriod) { const methods = paymentOptions(shop, t, period, currency); const box = dialog.querySelector("#payActions"); if (!box) return; - box.innerHTML = CT.html`

К оплате ${money(quote?.amount_minor ?? price.amount_minor, currency)}

${quote && quote.days !== period ? CT.html`

Срок с бонусом: ${quote.days} дн.

` : ""}${price.amount_minor === 0 ? CT.html('') : methods.length ? methods.map((m) => `${methodQuotes[m.currency]?.error ? `

${esc(methodQuotes[m.currency].error)}

` : ""}`).join("") : CT.html('
Оплата временно недоступна. Напишите в поддержку — вам помогут с подключением.
')}`; + box.innerHTML = CT.html`

К оплате ${money(quote?.amount_minor ?? price.amount_minor, currency)}

${quote && quote.days !== period ? CT.html`

Срок с бонусом: ${quote.days} дн.

` : ""}${price.amount_minor === 0 ? CT.html('') : methods.length ? methods.map((m) => `${methodQuotes[m.currency]?.error ? `

${esc(methodQuotes[m.currency].error)}

` : ""}`).join("") : CT.html('
Оплата временно недоступна. Напишите в поддержку — вам помогут с подключением.
')}`; box .querySelectorAll("[data-pay]") .forEach((b) => (b.onclick = () => pay(b.dataset.pay))); @@ -607,8 +607,9 @@ function buySheet(id, selectedPeriod) { pendingPayment = invoice.payment_id; dialog.querySelector("#payActions").innerHTML = CT.html`

Счёт №${invoice.payment_id} · ${money(invoice.amount_minor, invoice.currency || paymentCurrency)}

${invoice.instructions ? `

${esc(invoice.instructions)}

` : ""}${safeURL(invoice.url) ? CT.html('') : ""}`; - dialog.querySelector("#payError").textContent = - CT.html("После оплаты вернитесь сюда и нажмите «Проверить оплату»."); + dialog.querySelector("#payError").textContent = provider === "manual" + ? CT.html("Сообщите номер счёта в поддержку. После подтверждения оплаты нажмите «Проверить оплату».") + : CT.html("После оплаты вернитесь сюда и нажмите «Проверить оплату»."); dialog .querySelector("#openInvoice") ?.addEventListener("click", () => openInvoice(invoice)); diff --git a/web/app/customer-en.js b/web/app/customer-en.js index 37d4fe8..4879239 100644 --- a/web/app/customer-en.js +++ b/web/app/customer-en.js @@ -1,5 +1,9 @@ 'use strict'; const CT_EN = Object.freeze({ + "Выберите подходящий тариф. После активации доступа здесь появится подключение.": "Choose a plan. Connection details will appear here once access is activated.", + "Лимит появится после выбора тарифа.": "Your allowance will appear after you choose a plan.", + "Инструкции по установке и подключению.": "Installation and connection guides.", + "Ответы на вопросы и помощь с подключением.": "Answers to your questions and help connecting.", "Оформление:": "Theme:", ". Изменить тему": ". Change theme", "тёмное": "dark", @@ -94,7 +98,7 @@ const CT_EN = Object.freeze({ "Отвязать это устройство от подписки?": "Remove this device from your subscription?", "Устройство отвязано": "Device removed", "Загружаем тарифы…": "Loading plans…", - "После оплаты доступ включится автоматически.": "Access will activate automatically after payment.", + "После подтверждения оплаты доступ включится.": "Access will activate after payment is confirmed.", "Пробный": "Trial", "за": "for", "Безлимитный трафик": "Unlimited traffic", @@ -112,6 +116,7 @@ const CT_EN = Object.freeze({ "Получить бесплатно": "Activate for free", "Оплатить": "Pay", "Оплатить ·": "Pay ·", + "Оформить счёт ·": "Create invoice ·", "Оплата временно недоступна. Напишите в поддержку — вам помогут с подключением.": "Payments are temporarily unavailable. Contact support for help getting connected.", "Промокод применён": "Promo code applied", "Цена без промокода": "Price without promo code", @@ -124,6 +129,7 @@ const CT_EN = Object.freeze({ "Проверить оплату": "Check payment", "Открыть оплату": "Open payment", "После оплаты вернитесь сюда и нажмите «Проверить оплату».": "After paying, return here and select Check payment.", + "Сообщите номер счёта в поддержку. После подтверждения оплаты нажмите «Проверить оплату».": "Send the invoice number to support. Once payment is confirmed, select Check payment.", "Используйте реквизиты из счёта или напишите в поддержку": "Use the payment details on your invoice or contact support", "Оплата отменена. Счёт можно открыть снова.": "Payment cancelled. You can reopen the invoice.", "Вернитесь к счёту, чтобы проверить оплату.": "Return to the invoice to check your payment.", diff --git a/web/app/customer-ui.js b/web/app/customer-ui.js index 419875d..9e3e875 100644 --- a/web/app/customer-ui.js +++ b/web/app/customer-ui.js @@ -55,19 +55,20 @@ function drawNav() { } function drawHome() { const active=me.status==='active',limited=me.status==='limited',hasPlan=!!me.tariff; + const hasInstructions=active&&!!safeURL(me.sub_url); const used=me.used==null?null:Number(me.used),limit=me.limit==null?null:Number(me.limit); const remaining=limit===null||used===null?null:Math.max(0,limit-used),pct=limit>0&&used!==null?Math.min(100,100*used/limit):0; const ending=me.expires_at?new Date(me.expires_at).toLocaleDateString(CT.locale,{day:'numeric',month:'long'}):null; const title=active?CT.html('Подключим ваше устройство'):limited?CT.html('Закончился трафик'):me.status==='disabled'?CT.html('Доступ приостановлен'):hasPlan?CT.html('Продлим вашу подписку'):CT.html('Начнём с выбора тарифа'); - const help=active?CT.html`Установите приложение и подключитесь к защищённой сети ${config.brand}.`:limited?CT.html('Дождитесь обновления лимита или выберите дополнительный пакет.'):me.status==='disabled'?CT.html('Обратитесь в поддержку, чтобы уточнить причину.'):CT.html('Выберите подходящий срок и количество устройств. После оплаты здесь появится подключение.'); - root.innerHTML=CT.html`
${hasPlan?CT.html('Подписка ')+esc((statusLabel[me.status]||'').toLocaleLowerCase(CT.locale)): CT.html('Подписка не оформлена')}${active&&ending?CT.html(' · до ')+ending:''}

${title}

${help}

${active&&me.sub_url?CT.html``:''}
    ${[[CT.html('Тариф'),hasPlan],[CT.html('Доступ'),active||limited],[CT.html('Подключение'),false]].map(([label,done],i)=>`
  1. ${done?uiIcon('check'):active&&i===2?'':i+1}${label}
  2. `).join('')}
${uiIcon('devices')}

Устройства

Используется устройств

${uiIcon('traffic')}

Трафик

${limit!==null&&used!==null?CT.html`
`:''}

${remaining===null?(hasPlan?CT.html('Без ограничения трафика'):CT.html('Выберите тариф')): CT.html('Осталось ')+bytes(remaining)+CT.html('из ')+bytes(limit)+''}

`; + const help=active?CT.html`Установите приложение и подключитесь к защищённой сети ${config.brand}.`:limited?CT.html('Дождитесь обновления лимита или выберите дополнительный пакет.'):me.status==='disabled'?CT.html('Обратитесь в поддержку, чтобы уточнить причину.'):CT.html('Выберите подходящий тариф. После активации доступа здесь появится подключение.'); + root.innerHTML=CT.html`
${hasPlan?CT.html('Подписка ')+esc((statusLabel[me.status]||'').toLocaleLowerCase(CT.locale)): CT.html('Подписка не оформлена')}${active&&ending?CT.html(' · до ')+ending:''}

${title}

${help}

${active&&me.sub_url?CT.html``:''}
    ${[[CT.html('Тариф'),hasPlan],[CT.html('Доступ'),active||limited],[CT.html('Подключение'),false]].map(([label,done],i)=>`
  1. ${done?uiIcon('check'):active&&i===2?'':i+1}${label}
  2. `).join('')}
${uiIcon('devices')}

Устройства

${hasPlan?CT.html('Используется устройств'):CT.html('Лимит появится после выбора тарифа.')}

${uiIcon('traffic')}

Трафик

${limit!==null&&used!==null?CT.html`
`:''}

${remaining===null?(hasPlan?CT.html('Без ограничения трафика'):CT.html('Выберите тариф')): CT.html('Осталось ')+bytes(remaining)+CT.html('из ')+bytes(limit)+''}

`; root.querySelector('#primaryStep').onclick=()=>active?openURL(me.sub_url):limited?drawAddons('traffic'):navigate(me.status==='disabled'||!canChoosePlan()?'help':'shop'); root.querySelector('#copySub')?.addEventListener('click',()=>copy(me.sub_url)); root.querySelector('#devicesValue').onclick=drawDevices; - root.querySelector('#trafficValue').onclick=()=>{showDialog(CT.html('Ваш трафик'),`

${remaining===null?CT.html('У тарифа нет ограничения трафика.'):CT.html('Осталось ')+bytes(remaining)+CT.html(' из ')+bytes(limit)+'.'}

${me.traffic_reset_at&&me.reset_strategy!=='no_reset'?CT.html('

Лимит обновится ')+date(me.traffic_reset_at)+'.

':''}${addonCatalog?.items?.some(p=>p.kind==='traffic')?CT.html(''):''}`);dialog.querySelector('#trafficMore')?.addEventListener('click',()=>{dialog.close();drawAddons('traffic');});}; + root.querySelector('#trafficValue').onclick=()=>{showDialog(CT.html('Ваш трафик'),`

${!hasPlan?CT.html('Лимит появится после выбора тарифа.'):remaining===null?CT.html('У тарифа нет ограничения трафика.'):CT.html('Осталось ')+bytes(remaining)+CT.html(' из ')+bytes(limit)+'.'}

${me.traffic_reset_at&&me.reset_strategy!=='no_reset'?CT.html('

Лимит обновится ')+date(me.traffic_reset_at)+'.

':''}${addonCatalog?.items?.some(p=>p.kind==='traffic')?CT.html(''):''}`);dialog.querySelector('#trafficMore')?.addEventListener('click',()=>{dialog.close();drawAddons('traffic');});}; root.querySelector('#renew')?.addEventListener('click',()=>navigate('shop')); root.querySelector('#accessCode').onclick=showAccess; - root.querySelector('#connectHelp').onclick=()=>navigate('help'); + root.querySelector('#connectHelp').onclick=()=>hasInstructions?openURL(me.sub_url):navigate('help'); if(config.shop_enabled) mountGuidedAddons(); mountCustomerHeader(); } From 82dbf1f2dc071bebb5a4fe4f446e044fb4cc9f9c Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 15:25:03 +0400 Subject: [PATCH 5/9] feat(sub): support remnawave template directives (injectHosts, include-proxies) --- crates/sub/Cargo.toml | 1 + crates/sub/src/policy.rs | 179 ++++++++++++++++++++++++++++++++++++- web/subscription-studio.js | 2 +- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/crates/sub/Cargo.toml b/crates/sub/Cargo.toml index 5dec9b2..de69ee6 100644 --- a/crates/sub/Cargo.toml +++ b/crates/sub/Cargo.toml @@ -19,5 +19,6 @@ base64.workspace = true reqwest.workspace = true qrcode = { version = "0.14", default-features = false, features = ["svg"] } regex.workspace = true +rand.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/sub/src/policy.rs b/crates/sub/src/policy.rs index 992d661..69f8f25 100644 --- a/crates/sub/src/policy.rs +++ b/crates/sub/src/policy.rs @@ -99,7 +99,11 @@ pub fn validate_template(code:&str,body:&str)->Result<(),String>{ let v=parse_template(code,body)?; if !v.is_object()&&!v.is_array()&&v!="__SN_SERVERS__"{return Err("Шаблон должен быть объектом конфигурации или {{SERVERS}}".into())} if v.is_array()&&!body.contains("{{SERVERS}}") {return Err("Базовый шаблон должен быть объектом конфигурации".into())} - if v.get("remnawave").is_some(){return Err("Директивы remnawave требуют адаптации. Используйте обычный конфиг или пресет STEALTHNET".into())} + if let Some(rw) = v.get("remnawave") { + if !rw.is_object() && !rw.is_null() { + return Err("Директива remnawave должна быть объектом настроек".into()); + } + } Ok(()) } fn deep_merge(target:&mut Value,source:&Value){ @@ -121,10 +125,47 @@ pub fn render_template(code:&str,body:&str,hosts:&[HostEntry],title:&str)->Resul let mut v=parse_template(code,body)?; let names=json!(hosts.iter().map(|h|&h.remark).collect::>()); if code=="xray_json"{ + let has_remnawave = v.get("remnawave").is_some(); let generated=formats::render_xray_json(hosts,title); if body.contains("{{SERVERS}}")||body.contains("{{OUTBOUNDS}}"){ let outs=json!(hosts.iter().enumerate().map(|(i,h)|{let mut o=h.to_xray_outbound();o["tag"]=json!(format!("proxy-{i}"));o}).collect::>()); expand(&mut v,&generated,&outs,&names,title); + }else if has_remnawave { + let rw = v.as_object_mut().and_then(|o| o.remove("remnawave")).unwrap_or_default(); + let mut outs = v["outbounds"].as_array().cloned().unwrap_or_default(); + let mut injected = Vec::new(); + if let Some(inject_list) = rw.get("injectHosts").and_then(|h| h.as_array()) { + for item in inject_list { + let prefix = item.get("tagPrefix").and_then(|p| p.as_str()).unwrap_or("proxy"); + let regex = item.get("selector") + .and_then(|s| s.get("pattern")) + .and_then(|p| p.as_str()) + .and_then(|pat| regex::Regex::new(pat).ok()); + for (i, h) in hosts.iter().enumerate() { + if let Some(ref re) = regex { + if !re.is_match(&h.remark) { continue; } + } + let mut o = h.to_xray_outbound(); + o["tag"] = json!(format!("{prefix}-{i}")); + injected.push(o); + } + } + } else { + for (i, h) in hosts.iter().enumerate() { + let mut o = h.to_xray_outbound(); + o["tag"] = json!(format!("proxy-{i}")); + injected.push(o); + } + } + for (idx, o) in injected.into_iter().enumerate() { + outs.insert(idx, o); + } + v["outbounds"] = json!(outs); + if v.get("remarks").is_none() { + v["remarks"] = json!(title); + } + expand(&mut v, &Value::Null, &Value::Null, &names, title); + v = json!([v]); }else{ let balanced=v["stealthnet"]["mode"]=="balanced"; if let Some(o)=v.as_object_mut(){o.remove("stealthnet");} @@ -150,12 +191,104 @@ pub fn render_template(code:&str,body:&str,hosts:&[HostEntry],title:&str)->Resul if body.contains("{{SERVERS}}")||body.contains("{{OUTBOUNDS}}"){expand(&mut v,&dynamic,&outs,&names,title)}else{ let template=v;v=generated;deep_merge(&mut v,&template); let mut merged=outs.as_array().cloned().unwrap_or_default(); - for o in template["outbounds"].as_array().into_iter().flatten(){merged.retain(|x|x["tag"]!=o["tag"]);merged.push(o.clone());}v["outbounds"]=json!(merged);expand(&mut v,&dynamic,&outs,&names,title); + for o in template["outbounds"].as_array().into_iter().flatten(){merged.retain(|x|x["tag"]!=o["tag"]);merged.push(o.clone());} + v["outbounds"]=json!(merged); + expand(&mut v,&dynamic,&outs,&names,title); + } + if let Some(o) = v.as_object_mut() { + o.remove("remnawave"); + } + if let Some(out_arr) = v.get_mut("outbounds").and_then(|o| o.as_array_mut()) { + for out in out_arr { + if let Some(rw) = out.as_object_mut().and_then(|m| m.remove("remnawave")) { + let inc = rw.get("include-proxies") + .or_else(|| rw.get("include_proxies")) + .and_then(|b| b.as_bool()) + .unwrap_or(true); + if inc { + let existing = out.entry("outbounds").or_insert_with(|| Value::Array(Vec::new())); + if let Some(arr) = existing.as_array_mut() { + for tag in hosts.iter().map(|h| &h.remark) { + let tag_val = json!(tag); + if !arr.contains(&tag_val) { + arr.push(tag_val); + } + } + } + } + } + } } return serde_json::to_string_pretty(&v).map_err(|e|e.to_string()) } let generated:Value=serde_yaml_ng::from_str(&formats::render_clash(hosts,title)).map_err(|e|format!("Генератор YAML: {e}"))?; - if body.contains("{{SERVERS}}") {expand(&mut v,&generated["proxies"],&Value::Null,&names,title)}else{let template=v;v=generated.clone();deep_merge(&mut v,&template);v["proxies"]=generated["proxies"].clone();expand(&mut v,&generated["proxies"],&Value::Null,&names,title);} + if body.contains("{{SERVERS}}") { + expand(&mut v,&generated["proxies"],&Value::Null,&names,title); + } else { + let template=v; + v=generated.clone(); + deep_merge(&mut v,&template); + let mut final_proxies = template.get("proxies") + .and_then(|p| p.as_array()) + .cloned() + .unwrap_or_default(); + if let Some(gen_arr) = generated["proxies"].as_array() { + for p in gen_arr { + let name = p.get("name").and_then(|n| n.as_str()); + if !final_proxies.iter().any(|existing| existing.get("name").and_then(|n| n.as_str()) == name) { + final_proxies.push(p.clone()); + } + } + } + v["proxies"] = Value::Array(final_proxies); + expand(&mut v,&generated["proxies"],&Value::Null,&names,title); + } + + if let Some(groups) = v.get_mut("proxy-groups").and_then(|g| g.as_array_mut()) { + for group in groups { + if let Some(rw) = group.as_object_mut().and_then(|g| g.remove("remnawave")) { + let inc = rw.get("include-proxies") + .or_else(|| rw.get("include_proxies")) + .and_then(|b| b.as_bool()) + .unwrap_or(true); + if inc { + let mut proxy_names: Vec = hosts.iter().map(|h| h.remark.clone()).collect(); + let shuffle = rw.get("shuffle-proxies-order") + .or_else(|| rw.get("shuffle_proxies_order")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + if shuffle { + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + proxy_names.shuffle(&mut rng); + } + let select_random = rw.get("select-random-proxy") + .or_else(|| rw.get("select_random_proxy")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + if select_random { + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + if let Some(picked) = proxy_names.choose(&mut rng).cloned() { + proxy_names = vec![picked]; + } + } + let existing = group.entry("proxies").or_insert_with(|| Value::Array(Vec::new())); + if let Some(arr) = existing.as_array_mut() { + for name in proxy_names { + let nval = json!(name); + if !arr.contains(&nval) { + arr.push(nval); + } + } + } + } + } + } + } + if let Some(o) = v.as_object_mut() { + o.remove("remnawave"); + } serde_yaml_ng::to_string(&v).map_err(|e|e.to_string()) } pub fn format_template(code:&str,body:&str)->Result{ @@ -194,4 +327,44 @@ mod tests{ #[test]fn balanced_xray_has_unique_real_outbounds(){let out=render_template("xray_json",r#"{"stealthnet":{"mode":"balanced"},"routing":{"balancers":[{"tag":"auto","selector":["proxy-"]}]}}"#,&hosts(),"Auto").unwrap();let v:Value=serde_json::from_str(&out).unwrap();assert_eq!(v.as_array().unwrap().len(),1);assert_eq!(v[0]["remarks"],"Auto");assert_eq!(v[0]["outbounds"][0]["tag"],"proxy-0");assert_eq!(v[0]["outbounds"][1]["tag"],"proxy-1");assert!(v[0].get("stealthnet").is_none());} #[test]fn text_templates_are_encoded_once(){let b="{{TITLE}}\n{{SERVERS}}";let out=render_template("base64",b,&hosts(),"T").unwrap();let plain=String::from_utf8(STANDARD.decode(out).unwrap()).unwrap();assert!(plain.starts_with("T\nvless://"));assert_eq!(plain.lines().count(),3);} #[test]fn routing_and_settings_reject_invalid_data(){assert!(validate_routing("happ://routing/onadd/!!!!").is_err());let l=format!("happ://routing/onadd/{}",STANDARD.encode(r#"{"Name":"Мой VPN","DirectSites":["example.com"]}"#));assert!(validate_routing(&l).is_ok());assert!(validate_routing("happ://routing/off").is_ok());assert!(validate_settings(json!({"subscription.remark_expired":["Окончено","Продлите доступ"]}).as_object().unwrap()).is_ok());assert!(validate_settings(json!({"subscription.remark_expired":[false]}).as_object().unwrap()).is_err());} + #[test]fn remnawave_inject_hosts_xray_json(){ + let template=r#"{"remnawave":{"injectHosts":[{"tagPrefix":"proxy"}]},"routing":{"balancers":[{"tag":"auto","selector":["proxy"]}]},"outbounds":[{"protocol":"freedom","tag":"direct"}]}"#; + assert!(validate_template("xray_json",template).is_ok()); + let out=render_template("xray_json",template,&hosts(),"Title").unwrap(); + let v:Value=serde_json::from_str(&out).unwrap(); + assert_eq!(v.as_array().unwrap().len(),1); + let cfg=&v[0]; + assert!(cfg.get("remnawave").is_none()); + assert_eq!(cfg["remarks"],"Title"); + assert_eq!(cfg["outbounds"][0]["tag"],"proxy-0"); + assert_eq!(cfg["outbounds"][1]["tag"],"proxy-1"); + assert_eq!(cfg["outbounds"][2]["tag"],"direct"); + } + #[test]fn remnawave_include_proxies_mihomo_yaml(){ + let template="mixed-port: 7890\nproxies:\n - name: DIRECT\n type: direct\nproxy-groups:\n - name: Proxy\n type: select\n remnawave: { include-proxies: true }\n proxies: [DIRECT]\nrules:\n - MATCH,Proxy\n"; + assert!(validate_template("mihomo",template).is_ok()); + let out=render_template("mihomo",template,&hosts(),"Title").unwrap(); + let v:Value=serde_yaml_ng::from_str(&out).unwrap(); + assert!(v.get("remnawave").is_none()); + let proxies=v["proxies"].as_array().unwrap(); + assert!(proxies.iter().any(|p|p["name"]=="DIRECT")); + assert!(proxies.iter().any(|p|p["name"]=="Нидерланды")); + let group=&v["proxy-groups"][0]; + assert!(group.get("remnawave").is_none()); + let group_proxies=group["proxies"].as_array().unwrap(); + assert!(group_proxies.iter().any(|p|p=="DIRECT")); + assert!(group_proxies.iter().any(|p|p=="Нидерланды")); + } + #[test]fn remnawave_singbox_include_proxies(){ + let template=r#"{"outbounds":[{"type":"selector","tag":"select","outbounds":["direct"],"remnawave":{"include-proxies":true}}]}"#; + assert!(validate_template("singbox",template).is_ok()); + let out=render_template("singbox",template,&hosts(),"Title").unwrap(); + let v:Value=serde_json::from_str(&out).unwrap(); + assert!(v.get("remnawave").is_none()); + let group=&v["outbounds"].as_array().unwrap().iter().find(|o|o["tag"]=="select").unwrap(); + assert!(group.get("remnawave").is_none()); + let tags=group["outbounds"].as_array().unwrap(); + assert!(tags.iter().any(|t|t=="direct")); + assert!(tags.iter().any(|t|t=="Нидерланды")); + } } diff --git a/web/subscription-studio.js b/web/subscription-studio.js index 660cac6..4d46b9f 100644 --- a/web/subscription-studio.js +++ b/web/subscription-studio.js @@ -60,7 +60,7 @@ registerPage({id:'templates',title:'Шаблоны подписки',group:'По const paint=()=>{const filtered=templates.filter(t=>t.code===format&&t.title.toLowerCase().includes(root.querySelector('#templateSearch').value.toLowerCase()));const current=templates.find(t=>t.code===format&&t.is_default);root.querySelector('#templateCount').textContent=`${filtered.length} шаблонов · ${subFormatName(format)}`;box.innerHTML=`
${subFormatName(format)}${!current?'По умолчанию':''}

Встроенная конфигурация

Готовый формат сервиса. Используйте его как основу своего шаблона.

${current?'':''}
`+filtered.map(t=>`
${subFormatName(t.code)}${t.is_default?'По умолчанию':''}

${esc(t.title)}

${t.usages||0} назначений · обновлён ${fmtDate(t.updated_at)}

`).join('');box.querySelector('[data-builtin]').onclick=()=>templateModal(null,load,format);box.querySelector('[data-use-builtin]')?.addEventListener('click',async()=>{try{await API.call('/api/sub/templates/'+current.id,{method:'PATCH',body:{is_default:false}});await load();}catch(e){toast(e.message,'err')}});box.querySelectorAll('[data-edit-template]').forEach(b=>b.onclick=()=>templateModal(templates.find(t=>String(t.id)===b.dataset.editTemplate),load));box.querySelectorAll('[data-template-menu]').forEach(b=>b.onclick=()=>{const t=templates.find(x=>String(x.id)===b.dataset.templateMenu);menu(b,[{label:'Дублировать',icon:'copy',onClick:()=>templateModal({...t,id:null,title:t.title+' · копия',is_default:false},load,t.code)},{label:'Использовать по умолчанию',icon:'check',onClick:async()=>{try{await API.call('/api/sub/templates/'+t.id,{method:'PATCH',body:{is_default:true}});await load();}catch(e){toast(e.message,'err')}}},{label:'Скачать',icon:'download',onClick:()=>studioDownload(t.title+'.'+(['clash','mihomo','stash'].includes(t.code)?'yaml':['plain','base64'].includes(t.code)?'txt':'json'),t.body)},'-',{label:'Удалить',icon:'trash',danger:true,onClick:()=>confirmModal({title:'Удалить шаблон?',text:esc(t.title),okText:'Удалить',onOk:async()=>{await API.call('/api/sub/templates/'+t.id,{method:'DELETE'});await load();}})}]);});if(studioReadonly())box.querySelectorAll('[data-builtin],[data-use-builtin],[data-template-menu]').forEach(b=>b.disabled=true);}; const load=async()=>{try{templates=await API.call('/api/sub/templates');paint();}catch(e){studioError(box,e.message,load)}};root.querySelectorAll('[data-format]').forEach(b=>b.onclick=()=>{format=b.dataset.format;root.querySelectorAll('[data-format]').forEach(x=>x.classList.toggle('on',x===b));paint();});root.querySelector('#templateSearch').oninput=paint;root.querySelector('#newStudioTemplate').disabled=studioReadonly();root.querySelector('#newStudioTemplate').onclick=()=>templateModal(null,load,format);await load();}}); function templateModal(t,onSaved,initialFormat='xray_json'){ - const fresh=!t?.id,code=t?.code||initialFormat;openDrawer({title:fresh?'Новый шаблон':esc(t.title),sub:'Ключи клиента подставляются только при выдаче подписки.',icon:'file',size:'xl',className:'studio-editor-dialog',initialFocus:'title',body:`
${studioField('Название',studioInput('studioTplTitle',t?.title||'', 'placeholder="Например, Happ · мобильные устройства"'))}${studioField('Формат',``)}
Редактируйте базовую конфигурацию. Рабочие серверы добавит панель.
${codeWorkbench('studioTplBody','JSON / YAML конфигурация',t?.body??templatePreset(code))}`,footer:`${studioReadonly()?'Режим просмотра':'Изменения начнут действовать после сохранения и обновления подписки.'}
`,onMount(layer,close){const editor=bindWorkbench(layer,'studioTplBody'),format=layer.querySelector('#studioTplCode'),status=layer.querySelector('#studioTplStatus');let revision=0,requestSequence=0;const invalidate=()=>{revision++;requestSequence++;layer.querySelector('#studioTplPreview').hidden=true;status.textContent='Текст изменён. Проверьте конфигурацию, чтобы получить актуальный предпросмотр.';};editor.input.addEventListener('input',invalidate);const preview=async()=>{const at=revision,sequence=++requestSequence,body=editor.get(),code=format.value;status.textContent='Проверяем конфигурацию…';try{const v=await API.call('/api/sub/template-preview',{method:'POST',body:{code,body}});if(at!==revision||sequence!==requestSequence||body!==editor.get()||code!==format.value)return null;status.textContent='Синтаксис и сборка подписки проверены.';return v;}catch(e){if(at!==revision||sequence!==requestSequence)return null;throw e;}};layer.querySelector('#previewStudioTemplate').onclick=async()=>{try{const v=await preview();if(!v)return;layer.querySelector('#studioTplPreview').hidden=false;layer.querySelector('#studioTplPreview pre').textContent=v.rendered;layer.querySelector('#studioTplPreview').scrollIntoView({block:'start'});}catch(e){status.textContent=e.message;}};layer.querySelector('#formatStudioTemplate').onclick=async()=>{try{const v=await preview();if(v){editor.set(v.formatted);invalidate();status.textContent='Текст отформатирован. Проверьте конфигурацию для нового предпросмотра.';}}catch(e){status.textContent=e.message;}};layer.querySelector('#helpStudioTemplate').onclick=()=>openModal({title:'Как работают шаблоны',size:'lg',body:`

В Xray JSON базовый объект применяется к каждой локации. Рабочий outbound proxy подставляется автоматически; DNS, входящие порты и маршруты берутся из шаблона.

В Sing-box сохраняются сгенерированные серверы и добавляются ваши настройки. Для YAML метка {{SERVERS}} означает массив прокси, {{PROXY_NAMES}} — имена серверов для групп. Без меток используется базовый конфиг с автоматически добавленными серверами.

{{OUTBOUNDS}} подставляет исходящие подключения; {{TITLE}} — название профиля. В Xray {{SERVERS}} сохраняет совместимость с обёртками над массивом конфигураций.

Пресет «Автовыбор Xray» собирает серверы в один конфиг с балансировщиком. Директивы remnawave требуют адаптации; они не выполняются скрыто.

`,footer:''});const usePreset=kind=>{editor.set(templatePreset(format.value,kind));invalidate();status.textContent='Пресет подставлен в редактор. Проверьте и сохраните шаблон.';};layer.querySelectorAll('[data-template-preset]').forEach(b=>b.onclick=()=>confirmModal({title:'Заменить текст пресетом?',text:'Текущий текст в редакторе будет заменён. Сохранённый шаблон пока не изменится.',okText:'Подставить',onOk:()=>usePreset(b.dataset.templatePreset)}));format.onchange=()=>{invalidate();layer.querySelector('[data-template-preset="balanced"]').hidden=format.value!=='xray_json';status.textContent='Формат изменён. Подставьте подходящий пресет или проверьте свой текст.';};layer.querySelector('[data-save]').onclick=async()=>{const title=layer.querySelector('#studioTplTitle').value.trim();if(!title){status.textContent='Укажите название шаблона';return}const btn=layer.querySelector('[data-save]');btn.disabled=true;try{await API.call('/api/sub/templates'+(fresh?'':'/'+t.id),{method:fresh?'POST':'PATCH',body:{code:format.value,title,body:editor.get(),is_default:layer.querySelector('#studioTplDefault').checked}});close();toast('Шаблон сохранён');await onSaved();}catch(e){status.textContent=e.message}finally{btn.disabled=false}};if(studioReadonly()){editor.input.readOnly=true;layer.querySelectorAll('input,select,[data-template-preset],#formatStudioTemplate').forEach(x=>x.disabled=true);}}});} + const fresh=!t?.id,code=t?.code||initialFormat;openDrawer({title:fresh?'Новый шаблон':esc(t.title),sub:'Ключи клиента подставляются только при выдаче подписки.',icon:'file',size:'xl',className:'studio-editor-dialog',initialFocus:'title',body:`
${studioField('Название',studioInput('studioTplTitle',t?.title||'', 'placeholder="Например, Happ · мобильные устройства"'))}${studioField('Формат',``)}
Редактируйте базовую конфигурацию. Рабочие серверы добавит панель.
${codeWorkbench('studioTplBody','JSON / YAML конфигурация',t?.body??templatePreset(code))}`,footer:`${studioReadonly()?'Режим просмотра':'Изменения начнут действовать после сохранения и обновления подписки.'}
`,onMount(layer,close){const editor=bindWorkbench(layer,'studioTplBody'),format=layer.querySelector('#studioTplCode'),status=layer.querySelector('#studioTplStatus');let revision=0,requestSequence=0;const invalidate=()=>{revision++;requestSequence++;layer.querySelector('#studioTplPreview').hidden=true;status.textContent='Текст изменён. Проверьте конфигурацию, чтобы получить актуальный предпросмотр.';};editor.input.addEventListener('input',invalidate);const preview=async()=>{const at=revision,sequence=++requestSequence,body=editor.get(),code=format.value;status.textContent='Проверяем конфигурацию…';try{const v=await API.call('/api/sub/template-preview',{method:'POST',body:{code,body}});if(at!==revision||sequence!==requestSequence||body!==editor.get()||code!==format.value)return null;status.textContent='Синтаксис и сборка подписки проверены.';return v;}catch(e){if(at!==revision||sequence!==requestSequence)return null;throw e;}};layer.querySelector('#previewStudioTemplate').onclick=async()=>{try{const v=await preview();if(!v)return;layer.querySelector('#studioTplPreview').hidden=false;layer.querySelector('#studioTplPreview pre').textContent=v.rendered;layer.querySelector('#studioTplPreview').scrollIntoView({block:'start'});}catch(e){status.textContent=e.message;}};layer.querySelector('#formatStudioTemplate').onclick=async()=>{try{const v=await preview();if(v){editor.set(v.formatted);invalidate();status.textContent='Текст отформатирован. Проверьте конфигурацию для нового предпросмотра.';}}catch(e){status.textContent=e.message;}};layer.querySelector('#helpStudioTemplate').onclick=()=>openModal({title:'Как работают шаблоны',size:'lg',body:`

В Xray JSON базовый объект применяется к каждой локации. Рабочий outbound proxy подставляется автоматически; DNS, входящие порты и маршруты берутся из шаблона.

В Sing-box сохраняются сгенерированные серверы и добавляются ваши настройки. Для YAML метка {{SERVERS}} означает массив прокси, {{PROXY_NAMES}} — имена серверов для групп. Без меток используется базовый конфиг с автоматически добавленными серверами.

{{OUTBOUNDS}} подставляет исходящие подключения; {{TITLE}} — название профиля. В Xray {{SERVERS}} сохраняет совместимость с обёртками над массивом конфигураций.

Пресет «Автовыбор Xray» собирает серверы в один конфиг с балансировщиком. Директивы remnawave (injectHosts, include-proxies) поддерживаются автоматически.

`,footer:''});const usePreset=kind=>{editor.set(templatePreset(format.value,kind));invalidate();status.textContent='Пресет подставлен в редактор. Проверьте и сохраните шаблон.';};layer.querySelectorAll('[data-template-preset]').forEach(b=>b.onclick=()=>confirmModal({title:'Заменить текст пресетом?',text:'Текущий текст в редакторе будет заменён. Сохранённый шаблон пока не изменится.',okText:'Подставить',onOk:()=>usePreset(b.dataset.templatePreset)}));format.onchange=()=>{invalidate();layer.querySelector('[data-template-preset="balanced"]').hidden=format.value!=='xray_json';status.textContent='Формат изменён. Подставьте подходящий пресет или проверьте свой текст.';};layer.querySelector('[data-save]').onclick=async()=>{const title=layer.querySelector('#studioTplTitle').value.trim();if(!title){status.textContent='Укажите название шаблона';return}const btn=layer.querySelector('[data-save]');btn.disabled=true;try{await API.call('/api/sub/templates'+(fresh?'':'/'+t.id),{method:fresh?'POST':'PATCH',body:{code:format.value,title,body:editor.get(),is_default:layer.querySelector('#studioTplDefault').checked}});close();toast('Шаблон сохранён');await onSaved();}catch(e){status.textContent=e.message}finally{btn.disabled=false}};if(studioReadonly()){editor.input.readOnly=true;layer.querySelectorAll('input,select,[data-template-preset],#formatStudioTemplate').forEach(x=>x.disabled=true);}}});} const RULE_OPS=['CONTAINS','NOT_CONTAINS','EQUALS','NOT_EQUALS','STARTS_WITH','NOT_STARTS_WITH','ENDS_WITH','NOT_ENDS_WITH','REGEX','NOT_REGEX']; const RULE_OP_LABELS=['Содержит','Не содержит','Равно','Не равно','Начинается с','Не начинается с','Заканчивается на','Не заканчивается на','Регулярное выражение','Не совпадает с regex']; From 3f80c429a826a64031ad5abdcf041d6db47a55e8 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 15:27:50 +0400 Subject: [PATCH 6/9] fix(sub): use serde_json Map entry on object value for template directives --- crates/sub/src/policy.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/sub/src/policy.rs b/crates/sub/src/policy.rs index 69f8f25..c0bdda2 100644 --- a/crates/sub/src/policy.rs +++ b/crates/sub/src/policy.rs @@ -206,12 +206,14 @@ pub fn render_template(code:&str,body:&str,hosts:&[HostEntry],title:&str)->Resul .and_then(|b| b.as_bool()) .unwrap_or(true); if inc { - let existing = out.entry("outbounds").or_insert_with(|| Value::Array(Vec::new())); - if let Some(arr) = existing.as_array_mut() { - for tag in hosts.iter().map(|h| &h.remark) { - let tag_val = json!(tag); - if !arr.contains(&tag_val) { - arr.push(tag_val); + if let Some(obj) = out.as_object_mut() { + let existing = obj.entry("outbounds").or_insert_with(|| Value::Array(Vec::new())); + if let Some(arr) = existing.as_array_mut() { + for tag in hosts.iter().map(|h| &h.remark) { + let tag_val = json!(tag); + if !arr.contains(&tag_val) { + arr.push(tag_val); + } } } } @@ -273,12 +275,14 @@ pub fn render_template(code:&str,body:&str,hosts:&[HostEntry],title:&str)->Resul proxy_names = vec![picked]; } } - let existing = group.entry("proxies").or_insert_with(|| Value::Array(Vec::new())); - if let Some(arr) = existing.as_array_mut() { - for name in proxy_names { - let nval = json!(name); - if !arr.contains(&nval) { - arr.push(nval); + if let Some(obj) = group.as_object_mut() { + let existing = obj.entry("proxies").or_insert_with(|| Value::Array(Vec::new())); + if let Some(arr) = existing.as_array_mut() { + for name in proxy_names { + let nval = json!(name); + if !arr.contains(&nval) { + arr.push(nval); + } } } } From 43365ddd747350a444038347a120133a516f6802 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 15:41:49 +0400 Subject: [PATCH 7/9] feat(node): add pre-flight auto-download of missing xray geodata assets --- crates/node/src/engine_update.rs | 154 +++++++++++++++++++++++++++++-- crates/node/src/main.rs | 17 ++++ 2 files changed, 162 insertions(+), 9 deletions(-) diff --git a/crates/node/src/engine_update.rs b/crates/node/src/engine_update.rs index 750949f..5d89a51 100644 --- a/crates/node/src/engine_update.rs +++ b/crates/node/src/engine_update.rs @@ -75,7 +75,7 @@ pub async fn install(bin: &str, config: &Path, target: &str) -> Result Result Result<(), String> { .await .map_err(|e| e.to_string())?; // Backup is copied to a new inode too: an earlier executable may still be running. - let backup = bin.with_extension("previous.new"); - tokio::fs::copy(bin, &backup) - .await - .map_err(|e| e.to_string())?; - tokio::fs::rename(backup, bin.with_extension("previous")) - .await - .map_err(|e| e.to_string())?; + if bin.exists() { + let backup = bin.with_extension("previous.new"); + if tokio::fs::copy(bin, &backup).await.is_ok() { + let _ = tokio::fs::rename(backup, bin.with_extension("previous")).await; + } + } tokio::fs::rename(staged, bin) .await - .map_err(|e| format!("не заменил Xray: {e}")) + .map_err(|e| format!("не заменил {}: {e}", bin.display())) +} + +/// Определение каталога geodata Xray (через XRAY_LOCATION_ASSET или /usr/local/share/xray) +pub fn xray_asset_dir() -> std::path::PathBuf { + if let Ok(p) = std::env::var("XRAY_LOCATION_ASSET") { + let trimmed = p.trim(); + if !trimmed.is_empty() { + return std::path::PathBuf::from(trimmed); + } + } + std::path::PathBuf::from("/usr/local/share/xray") +} + +/// Предстартовая проверка и автозагрузка отсутствующих баз geoip.dat и geosite.dat для Xray +pub async fn ensure_xray_assets(client: &reqwest::Client) -> Result<(), String> { + let asset_dir = xray_asset_dir(); + let geoip = asset_dir.join("geoip.dat"); + let geosite = asset_dir.join("geosite.dat"); + + if geoip.exists() && geosite.exists() { + return Ok(()); + } + + if let Err(e) = tokio::fs::create_dir_all(&asset_dir).await { + tracing::warn!(dir = %asset_dir.display(), error = %e, "не удалось создать каталог geodata Xray"); + return Err(format!("создание каталога geodata: {e}")); + } + + if !geoip.exists() { + tracing::info!(path = %geoip.display(), "geoip.dat отсутствует — скачиваем актуальную базу..."); + let sources = [ + "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", + "https://github.com/v2fly/geoip/releases/latest/download/geoip.dat", + ]; + download_asset(client, &sources, &geoip).await?; + tracing::info!(path = %geoip.display(), "geoip.dat успешно загружен"); + } + + if !geosite.exists() { + tracing::info!(path = %geosite.display(), "geosite.dat отсутствует — скачиваем актуальную базу..."); + let sources = [ + "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", + "https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat", + ]; + download_asset(client, &sources, &geosite).await?; + tracing::info!(path = %geosite.display(), "geosite.dat успешно загружен"); + } + + Ok(()) +} + +async fn download_asset(client: &reqwest::Client, sources: &[&str], dest: &Path) -> Result<(), String> { + let tmp = dest.with_extension(format!("tmp.{}", sn_core::auth::generate_token())); + let mut downloaded = false; + + for url in sources { + match client.get(*url).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.bytes().await { + Ok(bytes) if bytes.len() > 50_000 => { + if let Err(e) = tokio::fs::write(&tmp, &bytes).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(format!("запись {}: {e}", dest.display())); + } + if let Err(e) = tokio::fs::rename(&tmp, dest).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(format!("перемещение {}: {e}", dest.display())); + } + downloaded = true; + break; + } + Ok(bytes) => { + tracing::warn!(url = *url, size = bytes.len(), "файл geodata слишком мал"); + } + Err(e) => { + tracing::warn!(url = *url, error = %e, "ошибка чтения тела geodata"); + } + } + } + Ok(resp) => { + tracing::warn!(url = *url, status = %resp.status(), "не удалось скачать geodata"); + } + Err(e) => { + tracing::warn!(url = *url, error = %e, "ошибка сети при скачивании geodata"); + } + } + } + + let _ = tokio::fs::remove_file(&tmp).await; + if downloaded { + Ok(()) + } else { + Err(format!("не удалось загрузить geodata для {}", dest.display())) + } } pub async fn rollback(bin: &str) -> Result<(), String> { tokio::fs::rename(Path::new(bin).with_extension("previous"), bin) @@ -281,4 +383,38 @@ mod tests { // Своя версия неизвестна — обновлять вслепую опаснее, чем не трогать. assert!(!needs_update(None, "v26.7.11")); } + + #[test] + fn xray_asset_dir_resolves_env_or_default() { + let prev = std::env::var("XRAY_LOCATION_ASSET").ok(); + std::env::remove_var("XRAY_LOCATION_ASSET"); + assert_eq!(xray_asset_dir(), std::path::PathBuf::from("/usr/local/share/xray")); + + std::env::set_var("XRAY_LOCATION_ASSET", "/custom/assets"); + assert_eq!(xray_asset_dir(), std::path::PathBuf::from("/custom/assets")); + + std::env::set_var("XRAY_LOCATION_ASSET", " "); + assert_eq!(xray_asset_dir(), std::path::PathBuf::from("/usr/local/share/xray")); + + if let Some(p) = prev { + std::env::set_var("XRAY_LOCATION_ASSET", p); + } else { + std::env::remove_var("XRAY_LOCATION_ASSET"); + } + } + + #[tokio::test] + async fn atomic_replace_creates_new_file_safely() { + let dir = std::env::temp_dir().join(format!("sn-atomic-new-test-{}", std::process::id())); + tokio::fs::create_dir_all(&dir).await.unwrap(); + let target = dir.join("geodata.dat"); + let fresh = dir.join("fresh.dat"); + tokio::fs::write(&fresh, b"geodata_bytes").await.unwrap(); + + assert!(!target.exists()); + atomic_replace(&fresh, &target).await.unwrap(); + assert_eq!(tokio::fs::read(&target).await.unwrap(), b"geodata_bytes"); + + tokio::fs::remove_dir_all(dir).await.unwrap(); + } } diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 12fde5a..c4d966f 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -205,6 +205,12 @@ async fn main() { selfsteal: selfsteal::Controller::default(), }; + if settings.engine_bin.contains("xray") { + if let Err(e) = engine_update::ensure_xray_assets(&http).await { + tracing::warn!(error = %e, "не удалось проверить geodata Xray при старте"); + } + } + let mut sync_tick = tokio::time::interval(std::time::Duration::from_secs(settings.sync_interval)); let mut stats_tick = tokio::time::interval(std::time::Duration::from_secs(settings.stats_interval)); @@ -933,6 +939,17 @@ async fn restart_engine(s: &Settings, state: &mut NodeState) -> Result<(), Strin let _ = child.kill().await; } + if s.engine_bin.contains("xray") { + let asset_dir = engine_update::xray_asset_dir(); + if !asset_dir.join("geoip.dat").exists() || !asset_dir.join("geosite.dat").exists() { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_default(); + let _ = engine_update::ensure_xray_assets(&http).await; + } + } + let mut child = Command::new(&s.engine_bin) .arg("run") .arg("-c") From 36be4b9fc3bc31b04246ea72b120ede7b93ff17d Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 15:48:59 +0400 Subject: [PATCH 8/9] feat(sub): support hysteria2, trojan, and shadowsocks in clash and singbox formats --- crates/sub/src/formats.rs | 119 +++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/crates/sub/src/formats.rs b/crates/sub/src/formats.rs index 8577a2d..f79c17a 100644 --- a/crates/sub/src/formats.rs +++ b/crates/sub/src/formats.rs @@ -141,7 +141,7 @@ impl HostEntry { if self.protocol == "shadowsocks" { return self.to_ss_link(); } - if self.protocol == "hysteria" { + if self.protocol == "hysteria" || self.protocol == "hysteria2" { return self.to_hysteria_link(); } @@ -265,14 +265,14 @@ impl HostEntry { // со свежим ядром — при том что сам инбаунд на ноде работал. // Значение из базы здесь не спрашиваем: у этого протокола // другого транспорта не бывает. - let network = if self.protocol == "hysteria" { "hysteria" } else { &self.network }; + let network = if matches!(self.protocol.as_str(), "hysteria" | "hysteria2") { "hysteria" } else { &self.network }; let mut stream = json!({ "network": network, "security": if self.security == "none" { "none" } else { &self.security }, }); - if self.protocol == "hysteria" { + if matches!(self.protocol.as_str(), "hysteria" | "hysteria2") { stream["hysteriaSettings"] = json!({"version":2,"auth":self.credential()}); } @@ -365,7 +365,7 @@ impl HostEntry { }] }), // Server location belongs to the protocol; auth to hysteriaSettings. - "hysteria" => json!({ + "hysteria" | "hysteria2" => json!({ "version": 2, "address": self.address, "port": self.port, @@ -393,9 +393,10 @@ impl HostEntry { }), }; + let proto = if self.protocol == "hysteria2" { "hysteria" } else { &self.protocol }; let mut outbound=json!({ "tag": self.remark, - "protocol": self.protocol, + "protocol": proto, "settings": settings, "streamSettings": self.stream_settings(), }); @@ -405,20 +406,46 @@ impl HostEntry { /// proxy-запись для Clash / mihomo (YAML). pub fn to_clash_proxy(&self) -> String { + let (clash_type, auth_field) = match self.protocol.as_str() { + "hysteria" | "hysteria2" => ("hysteria2", "password"), + "trojan" => ("trojan", "password"), + "shadowsocks" => ("ss", "password"), + _ => (self.protocol.as_str(), "uuid"), + }; let mut lines = vec![ format!(" - name: {}", serde_json::to_string(&self.remark).unwrap()), - format!(" type: {}", self.protocol), + format!(" type: {clash_type}"), format!(" server: {}", self.address), format!(" port: {}", self.port), - format!(" uuid: {}", self.uuid), - " udp: true".to_string(), ]; + if auth_field == "password" { + lines.push(format!(" password: {}", self.credential())); + } else { + lines.push(format!(" uuid: {}", self.uuid)); + } + if self.protocol == "shadowsocks" { + let cipher = self.method.as_deref().unwrap_or("2022-blake3-aes-128-gcm"); + lines.push(format!(" cipher: {cipher}")); + } + lines.push(" udp: true".to_string()); if self.protocol == "vless" { lines.push(" encryption: none".into()); } - lines.push(format!(" network: {}", self.network)); + if !matches!(self.protocol.as_str(), "hysteria" | "hysteria2" | "shadowsocks") { + lines.push(format!(" network: {}", self.network)); + } - if self.security == "reality" { + if matches!(self.protocol.as_str(), "hysteria" | "hysteria2") { + if let Some(sni) = &self.sni { + lines.push(format!(" sni: {sni}")); + } + if let Some(insecure) = self.options["allow_insecure"].as_bool() { + lines.push(format!(" skip-cert-verify: {insecure}")); + } + if let Some(alpn) = &self.alpn { + lines.push(format!(" alpn: [{alpn}]")); + } + } else if self.security == "reality" { lines.push(" tls: true".into()); if let Some(sni) = &self.sni { lines.push(format!(" servername: {sni}")); @@ -674,14 +701,39 @@ pub fn render_singbox(hosts: &[HostEntry], profile_title: &str) -> Value { let outbounds: Vec = hosts .iter() .map(|h| { + let (sb_type, auth_key) = match h.protocol.as_str() { + "hysteria" | "hysteria2" => ("hysteria2", "password"), + "trojan" => ("trojan", "password"), + "shadowsocks" => ("shadowsocks", "password"), + _ => (h.protocol.as_str(), "uuid"), + }; let mut o = json!({ - "type": h.protocol, + "type": sb_type, "tag": h.remark, "server": h.address, "server_port": h.port, - "uuid": h.uuid, }); - if h.security == "reality" { + if auth_key == "password" { + o["password"] = json!(h.credential()); + } else { + o["uuid"] = json!(h.uuid); + } + if h.protocol == "shadowsocks" { + o["method"] = json!(h.method.as_deref().unwrap_or("2022-blake3-aes-128-gcm")); + } + if matches!(h.protocol.as_str(), "hysteria" | "hysteria2") { + let mut tls = json!({ "enabled": true }); + if let Some(sni) = &h.sni { + tls["server_name"] = json!(sni); + } + if let Some(insecure) = h.options["allow_insecure"].as_bool() { + tls["insecure"] = json!(insecure); + } + if let Some(alpn) = &h.alpn { + tls["alpn"] = json!([alpn]); + } + o["tls"] = tls; + } else if h.security == "reality" { o["tls"] = json!({ "enabled": true, "server_name": h.sni.clone().unwrap_or_default(), @@ -789,6 +841,47 @@ mod tests { assert!(h.to_share_link().starts_with("hysteria2://")); } + #[test] + fn hysteria2_отдаёт_пароль_в_clash_и_singbox() { + let h = host_of("hysteria2", "raw", "tls"); + let clash = h.to_clash_proxy(); + assert!(clash.contains("type: hysteria2")); + assert!(clash.contains(&format!("password: {}", h.uuid))); + assert!(!clash.contains("uuid:")); + + let sb = render_singbox(&[h.clone()], "TEST"); + let out = &sb["outbounds"][0]; + assert_eq!(out["type"], "hysteria2"); + assert_eq!(out["password"], h.uuid); + assert!(out["uuid"].is_null()); + assert_eq!(out["tls"]["enabled"], true); + } + + #[test] + fn trojan_и_shadowsocks_в_clash_и_singbox() { + let tr = host_of("trojan", "tcp", "tls"); + let clash_tr = tr.to_clash_proxy(); + assert!(clash_tr.contains("type: trojan")); + assert!(clash_tr.contains(&format!("password: {}", tr.uuid))); + assert!(!clash_tr.contains("uuid:")); + + let sb_tr = render_singbox(&[tr], "TEST"); + assert_eq!(sb_tr["outbounds"][0]["type"], "trojan"); + assert_eq!(sb_tr["outbounds"][0]["password"], "8a2f1c9e-3b47-4e5d-9f01-c2ab34d96e11"); + + let ss = host_of("shadowsocks", "tcp", "none"); + let clash_ss = ss.to_clash_proxy(); + assert!(clash_ss.contains("type: ss")); + assert!(clash_ss.contains("cipher: 2022-blake3-aes-128-gcm")); + assert!(clash_ss.contains("password:")); + assert!(!clash_ss.contains("uuid:")); + + let sb_ss = render_singbox(&[ss], "TEST"); + assert_eq!(sb_ss["outbounds"][0]["type"], "shadowsocks"); + assert_eq!(sb_ss["outbounds"][0]["method"], "2022-blake3-aes-128-gcm"); + assert!(sb_ss["outbounds"][0]["password"].is_string()); + } + #[test] fn shadowsocks_клеит_серверный_ключ_с_личным() { // Многопользовательский режим ждёт пару «серверный:личный». From 9901a9ccc9901a8c908941842e246cfdbaad3ad9 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Fri, 25 Sep 2026 23:04:09 +0400 Subject: [PATCH 9/9] feat(sub): support grpc, httpupgrade, and xhttp transports in clash and singbox --- crates/sub/src/formats.rs | 85 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/sub/src/formats.rs b/crates/sub/src/formats.rs index f79c17a..963dfe7 100644 --- a/crates/sub/src/formats.rs +++ b/crates/sub/src/formats.rs @@ -478,6 +478,37 @@ impl HostEntry { " path: {}", self.path.clone().unwrap_or_else(|| "/".into()) )); + if let Some(h) = self.host_header.as_ref().or(self.sni.as_ref()).filter(|s| !s.is_empty()) { + lines.push(" headers:".into()); + lines.push(format!(" Host: {h}")); + } + } else if self.network == "grpc" { + lines.push(" grpc-opts:".into()); + lines.push(format!( + " grpc-service-name: {}", + self.service_name.clone().unwrap_or_default() + )); + } else if self.network == "httpupgrade" { + lines.push(" httpupgrade-opts:".into()); + lines.push(format!( + " path: {}", + self.path.clone().unwrap_or_else(|| "/".into()) + )); + if let Some(h) = self.host_header.as_ref().or(self.sni.as_ref()).filter(|s| !s.is_empty()) { + lines.push(format!(" host: {h}")); + } + } else if matches!(self.network.as_str(), "xhttp" | "splithttp") { + lines.push(" xhttp-opts:".into()); + lines.push(format!( + " path: {}", + self.path.clone().unwrap_or_else(|| "/".into()) + )); + if let Some(h) = self.host_header.as_ref().or(self.sni.as_ref()).filter(|s| !s.is_empty()) { + lines.push(format!(" host: {h}")); + } + if let Some(mode) = self.options["xhttp"]["mode"].as_str().filter(|s| !s.is_empty()) { + lines.push(format!(" mode: {mode}")); + } } if let Some(value)=self.options["allow_insecure"].as_bool(){if self.security=="tls"{lines.push(format!(" skip-cert-verify: {value}"));}} if let Some(description)=self.options["server_description"].as_str().filter(|s|!s.is_empty()){lines.push(format!(" description: {}",serde_json::to_string(description).unwrap()));} @@ -753,10 +784,28 @@ pub fn render_singbox(hosts: &[HostEntry], profile_title: &str) -> Value { }); } if h.network == "ws" { - o["transport"] = json!({ + let mut ws = json!({ "type": "ws", "path": h.path.clone().unwrap_or_else(|| "/".into()), }); + if let Some(host) = h.host_header.as_ref().or(h.sni.as_ref()).filter(|s| !s.is_empty()) { + ws["headers"] = json!({ "Host": host }); + } + o["transport"] = ws; + } else if h.network == "grpc" { + o["transport"] = json!({ + "type": "grpc", + "service_name": h.service_name.clone().unwrap_or_default(), + }); + } else if h.network == "httpupgrade" { + let mut hu = json!({ + "type": "httpupgrade", + "path": h.path.clone().unwrap_or_else(|| "/".into()), + }); + if let Some(host) = h.host_header.as_ref().or(h.sni.as_ref()).filter(|s| !s.is_empty()) { + hu["host"] = json!(host); + } + o["transport"] = hu; } if h.security=="tls"{if let Some(value)=h.options["allow_insecure"].as_bool(){o["tls"]["insecure"]=json!(value);}} o @@ -1236,4 +1285,38 @@ mod tests { assert_eq!(c["inbounds"][1]["port"], 10809); } } + + #[test] + fn grpc_httpupgrade_xhttp_в_clash_и_singbox() { + let grpc = host_of("vless", "grpc", "tls"); + let clash_grpc = grpc.to_clash_proxy(); + assert!(clash_grpc.contains("network: grpc")); + assert!(clash_grpc.contains("grpc-opts:")); + assert!(clash_grpc.contains("grpc-service-name: grpcsvc")); + + let sb_grpc = render_singbox(&[grpc], "TEST"); + assert_eq!(sb_grpc["outbounds"][0]["transport"]["type"], "grpc"); + assert_eq!(sb_grpc["outbounds"][0]["transport"]["service_name"], "grpcsvc"); + + let hu = host_of("vless", "httpupgrade", "tls"); + let clash_hu = hu.to_clash_proxy(); + assert!(clash_hu.contains("network: httpupgrade")); + assert!(clash_hu.contains("httpupgrade-opts:")); + assert!(clash_hu.contains("path: /tunnel")); + assert!(clash_hu.contains("host: de.example.net")); + + let sb_hu = render_singbox(&[hu], "TEST"); + assert_eq!(sb_hu["outbounds"][0]["transport"]["type"], "httpupgrade"); + assert_eq!(sb_hu["outbounds"][0]["transport"]["path"], "/tunnel"); + assert_eq!(sb_hu["outbounds"][0]["transport"]["host"], "de.example.net"); + + let mut xhttp = host_of("vless", "xhttp", "tls"); + xhttp.options = json!({ "xhttp": { "mode": "stream-up" } }); + let clash_xhttp = xhttp.to_clash_proxy(); + assert!(clash_xhttp.contains("network: xhttp")); + assert!(clash_xhttp.contains("xhttp-opts:")); + assert!(clash_xhttp.contains("path: /tunnel")); + assert!(clash_xhttp.contains("host: de.example.net")); + assert!(clash_xhttp.contains("mode: stream-up")); + } }