Skip to content

Commit a07606b

Browse files
committed
fixes
1 parent d05f274 commit a07606b

11 files changed

Lines changed: 166 additions & 48 deletions

File tree

config-api-example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ db_sync_interval_sec = 1000
4545
subscription_restore_interval = 600
4646
subscription_expire_interval = 600
4747
traffic_persist_interval_sec = 3600
48+
connection_expire_interval = 600
4849

4950
[pg]
5051
host = "localhost"

docs/API.md

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -449,20 +449,71 @@ Endpoint'ы `/v1/*` используют шифрование RSA+AES, когд
449449
- **Body:** `GatewayConfigRequest`
450450
```json
451451
{
452-
"os_version": "...",
452+
"os_version": "ios",
453453
"app_version": "...",
454-
"app_language": "en",
454+
"app_language": "ru",
455455
"installation_uuid": "uuid",
456456
"user_country_code": "RU",
457-
"server_country_code": "DE",
457+
"server_country_code": "SWE",
458458
"service_type": "amnezia-free",
459459
"service_protocol": "awg",
460-
"auth_data": { "id": "uuid" },
461-
"public_key": "...",
462-
"connection_id": "uuid"
460+
"auth_data": {
461+
"id": "subscription-uuid"
462+
},
463+
"publicKey": "TTUZIyQdgxf9Yx4MiwzX6oICsh6ajjgxlWC8sVBMC8I=",
464+
"connection_id": "connection-uuid"
465+
}
466+
```
467+
- `publicKey` для `awg` можно не передавать / игнорировать: сервер сам берёт ключи из структуры connection.
468+
- `connection_id` опционален. Если передан — выбирается именно этот connection, иначе первый подходящий по стране и протоколу.
469+
470+
- **Response:** `200 OK``GatewayConfigResponse` (зашифрованный envelope)
471+
```json
472+
{
473+
"config": "<base64(gzip(inner_json))>",
474+
"config_version": 2,
475+
"supported_protocols": ["vless", "awg"],
476+
"api_config": {
477+
"server_country_code": "SWE",
478+
"service_protocol": "awg",
479+
"service_type": "amnezia-free",
480+
"user_country_code": "RU"
481+
},
482+
"service_info": {
483+
"name": "SWE",
484+
"type": "amnezia-free"
485+
}
486+
}
487+
```
488+
489+
Для `awg` поле `config` — это **gzip-сжатый** JSON, закодированный в base64 (стандартный алфавит с паддингом). После декодирования получается:
490+
491+
```json
492+
{
493+
"config_version": 2,
494+
"defaultContainer": "amnezia-awg",
495+
"description": "FRKN AWG",
496+
"dns1": "1.1.1.1",
497+
"dns2": "1.0.0.1",
498+
"hostName": "91.186.218.62",
499+
"name": "FRKN",
500+
"containers": [
501+
{
502+
"container": "amnezia-awg",
503+
"amnezia-awg": {
504+
"config": "[Interface]\nPrivateKey = Axhy7oMt1BfsuXw9qD3Nbkx5pVD/Yu/RPGzwqsjMlaY=\nAddress = 100.64.0.4/32\nMTU = 1280\nDNS = 1.1.1.1\nJc = 4\nJmin = 56\nJmax = 134\nS1 = 70\nS2 = 55\nH1 = 100000-200000\nH2 = 300000-400000\nH3 = 500000-600000\nH4 = 700000-800000\nS3 = 11\nS4 = 12\nI1 = <r 128>\n[Peer]\nPublicKey = hf0ZY6WULIRaBbeVtR8ox3y7LLEqocPB2DaSif2PTw0=\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 91.186.218.62:51820\nPersistentKeepalive = 25",
505+
"isThirdPartyConfig": true,
506+
"last_config": "{\"client_priv_key\":\"Axhy7oMt1BfsuXw9qD3Nbkx5pVD/Yu/RPGzwqsjMlaY=\",\"client_pub_key\":\"B6m2PYhhec2Pe+I50I63pdFB/bR6Psd77MhpWj4TlQE=\",\"client_ip\":\"100.64.0.4/32\",\"mtu\":\"1280\",\"server_pub_key\":\"hf0ZY6WULIRaBbeVtR8ox3y7LLEqocPB2DaSif2PTw0=\",\"psk_key\":\"\",\"port\":51820,\"hostName\":\"91.186.218.62\",\"persistent_keepalive\":\"25\",\"junkPacketCount\":\"4\",\"junkPacketMinSize\":\"56\",\"junkPacketMaxSize\":\"134\",\"initPacketJunkSize\":\"70\",\"responsePacketJunkSize\":\"55\",\"cookieReplyPacketJunkSize\":\"11\",\"transportPacketJunkSize\":\"12\",\"initPacketMagicHeader\":\"100000-200000\",\"responsePacketMagicHeader\":\"300000-400000\",\"underloadPacketMagicHeader\":\"500000-600000\",\"transportPacketMagicHeader\":\"700000-800000\",\"specialJunk1\":\"<r 128>\"}"
507+
}
508+
}
509+
]
463510
}
464511
```
465-
- **Response:** `200 OK``GatewayConfigResponse` с base64-конфигом Amnezia (зашифрован)
512+
513+
Важные моменты для AWG:
514+
- `config` содержит готовую INI-строку WireGuard/AmneziaWG с **реальным** клиентским приватным ключом.
515+
- `last_config` — JSON-строка с теми же ключами и параметрами обфускации.
516+
- `client_priv_key` и `client_pub_key` — это ключи из connection, сгенерированные сервером. Публичный ключ пира на сервере создаётся именно для этой пары.
466517

467518
---
468519

src/bin/api/http/admin.html

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,24 @@ <h4>Create connection</h4>
500500
}
501501
}
502502

503+
function formatMetricValue(name, value) {
504+
if (value == null) return '-';
505+
if (name.startsWith('sys.cpu.')) return value.toFixed(1) + '%';
506+
if (name.startsWith('sys.loadavg_')) return value.toFixed(2);
507+
if (name.startsWith('sys.mem_') || name.match(/^sys\.disk\.[^.]+\.(used|total)_bytes$/)) {
508+
return formatBytes(value);
509+
}
510+
if (name.startsWith('sys.disk.') && name.endsWith('.usage_percent')) return value.toFixed(1) + '%';
511+
if (name.match(/^net\.[^.]+\.(rx|tx|total_rx|total_tx)_bps$/)) return formatBits(value);
512+
if (name.startsWith('user.traffic.')) {
513+
if (name.endsWith('.online')) return Math.round(value).toString();
514+
return formatBytes(value);
515+
}
516+
if (name.match(/^net\.inbound\.[^.]+\.(uplink|downlink)$/)) return formatBytes(value);
517+
if (name.match(/^net\.inbound\.[^.]+\.connections$/)) return Math.round(value).toString();
518+
return value.toFixed(2);
519+
}
520+
503521
function renderNodeMetrics(nodeId, data) {
504522
if (!data.metrics || !data.metrics.length) {
505523
return '<p class="small">No metrics available</p>';
@@ -536,10 +554,12 @@ <h4>Overview</h4>
536554
<h4 style="margin-top:1rem">All series</h4>
537555
<table class="small">
538556
<tr><th>Metric</th><th>Latest</th><th>Points</th></tr>
539-
${data.metrics.map(m => {
540-
const last = m.points[m.points.length - 1];
541-
return `<tr><td>${m.name}</td><td>${last ? last.y.toFixed(2) : '-'}</td><td>${m.points.length}</td></tr>`;
542-
}).join('')}
557+
${data.metrics
558+
.filter(m => !m.name.startsWith('user.traffic.'))
559+
.map(m => {
560+
const last = m.points[m.points.length - 1];
561+
return `<tr><td>${m.name}</td><td>${last ? formatMetricValue(m.name, last.y) : '-'}</td><td>${m.points.length}</td></tr>`;
562+
}).join('')}
543563
</table>
544564
`;
545565
}
@@ -551,20 +571,20 @@ <h4 style="margin-top:1rem">All series</h4>
551571
const netTx = data.metrics.filter(m => m.name.match(/^net\..+\.tx_bps$/));
552572
const loadSeries = data.metrics.filter(m => m.name.startsWith('sys.loadavg_'));
553573

554-
const merge = (series) => {
574+
const merge = (series, multiplier = 1) => {
555575
const map = new Map();
556576
series.forEach(s => {
557577
s.points.forEach(p => {
558578
const v = map.get(p.x) || 0;
559-
map.set(p.x, v + p.y);
579+
map.set(p.x, v + p.y * multiplier);
560580
});
561581
});
562582
return Array.from(map.entries()).sort((a, b) => a[0] - b[0]).map(([x, y]) => ({ x, y }));
563583
};
564584

565585
drawChart(`chart-${nodeId}-cpu`, merge(cpuSeries), 'CPU %', '#1a1a2e');
566586
drawChart(`chart-${nodeId}-mem`, merge(memSeries), 'Memory used', '#2563eb');
567-
drawChart(`chart-${nodeId}-net`, merge([...netRx, ...netTx]), 'bps ×8', '#16a34a');
587+
drawChart(`chart-${nodeId}-net`, merge([...netRx, ...netTx], 8), 'Network bps', '#16a34a');
568588
drawChart(`chart-${nodeId}-load`, merge(loadSeries), 'Load', '#d97706');
569589
}
570590

src/bin/api/http/crypto.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,20 @@ use openssl::symm::{decrypt as aes_decrypt, encrypt as aes_encrypt, Cipher};
55
use std::sync::Arc;
66
use warp::{Filter, Rejection};
77

8-
/// AES-контекст, полученный при расшифровке запроса. Используется для шифрования ответа.
8+
/// AES context obtained while decrypting the request. Used to encrypt the response.
99
#[derive(Debug, Clone)]
1010
pub struct AesContext {
1111
pub key: Vec<u8>,
1212
pub iv: Vec<u8>,
1313
}
1414

15-
/// Ошибка шифрования/расшифровки AGW.
15+
/// AGW encryption/decryption error.
1616
#[derive(Debug)]
1717
pub struct AgwCryptoError(pub String);
1818
impl warp::reject::Reject for AgwCryptoError {}
1919

20-
/// Расшифровывает зашифрованный запрос. Если тело plain JSON, возвращает его как есть
21-
/// с пустым AES-контекстом.
20+
/// Decrypts an encrypted request. If the body is plain JSON, returns it as-is
21+
/// with an empty AES context.
2222
pub fn decrypt_request(
2323
private_key: &PKey<Private>,
2424
body: serde_json::Value,
@@ -60,7 +60,7 @@ pub fn decrypt_request(
6060
.get("aes_iv")
6161
.and_then(|v| v.as_str())
6262
.ok_or_else(|| AgwCryptoError("aes_iv missing".to_string()))?;
63-
// aes_salt передаётся клиентом, но не используется для вывода ключа.
63+
// aes_salt is sent by the client but is not used for key derivation.
6464

6565
let aes_key = base64::engine::general_purpose::STANDARD
6666
.decode(aes_key_b64)
@@ -75,7 +75,7 @@ pub fn decrypt_request(
7575
let aes_iv = base64::engine::general_purpose::STANDARD
7676
.decode(aes_iv_b64)
7777
.map_err(|e| AgwCryptoError(format!("aes_iv base64 decode failed: {e}")))?;
78-
// Клиент передаёт 32 байта IV, но AES-256-CBC использует только первые 16.
78+
// The client sends 32 bytes of IV, but AES-256-CBC only uses the first 16.
7979
let aes_iv: Vec<u8> = aes_iv.into_iter().take(16).collect();
8080
if aes_iv.len() != 16 {
8181
return Err(AgwCryptoError(format!(
@@ -103,14 +103,14 @@ pub fn decrypt_request(
103103
))
104104
}
105105

106-
/// Шифрует ответ тем же AES-256-CBC с PKCS#7 padding.
106+
/// Encrypts the response with the same AES-256-CBC using PKCS#7 padding.
107107
pub fn encrypt_response(ctx: &AesContext, plaintext: &[u8]) -> Result<Vec<u8>, AgwCryptoError> {
108108
aes_encrypt(Cipher::aes_256_cbc(), &ctx.key, Some(&ctx.iv), plaintext)
109109
.map_err(|e| AgwCryptoError(format!("AES encryption failed: {e}")))
110110
}
111111

112-
/// Warp-фильтр: автоматически определяет зашифрованное тело и расшифровывает его.
113-
/// Если приватный ключ не настроен, а тело зашифровано — возвращает ошибку.
112+
/// Warp filter: automatically detects an encrypted body and decrypts it.
113+
/// Returns an error if the body is encrypted but no private key is configured.
114114
pub fn with_agw_decryption<T>(
115115
private_key: Option<Arc<PKey<Private>>>,
116116
) -> impl Filter<Extract = (T, Option<AesContext>), Error = Rejection> + Clone
@@ -138,7 +138,7 @@ where
138138
.untuple_one()
139139
}
140140

141-
/// Шифрует исходящий ответ, если был AES-контекст. Иначе возвращает ответ как есть.
141+
/// Encrypts the outgoing response if an AES context exists. Otherwise returns the response as-is.
142142
pub async fn encrypt_gateway_reply(
143143
response: warp::reply::Response,
144144
aes_ctx: Option<AesContext>,

src/bin/api/http/filters.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ pub fn with_email_store(
7070
warp::any().map(move || email_store.clone())
7171
}
7272

73-
/// Аутентификация либо service token, либо admin token.
74-
/// Используется для management endpoint'ов, которые админка использует с admin token.
73+
/// Authentication using either a service token or an admin token.
74+
/// Used for management endpoints that the admin panel calls with an admin token.
7575
pub fn with_service_or_admin_auth(
7676
service_token: Arc<String>,
7777
admin_token: String,
@@ -94,7 +94,7 @@ pub fn with_service_or_admin_auth(
9494
.untuple_one()
9595
}
9696

97-
/// Аутентификация премиум-пользователя по Bearer-токену (premium_token).
97+
/// Premium user authentication via Bearer token (premium_token).
9898
pub fn with_premium_auth<T, C, S>(
9999
mem_sync: MemSync<T, C, S>,
100100
) -> impl Filter<Extract = (S,), Error = warp::Rejection> + Clone

src/bin/api/http/handlers/amnezia.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,18 @@ pub struct GatewayConfigResponse {
203203
}
204204

205205
// ============================================================================
206-
// Хелперы
206+
// Helpers
207207
// ============================================================================
208208

209-
/// Извлекает subscription_id из auth_data.
209+
/// Extracts subscription_id from auth_data.
210210
fn extract_subscription_id(auth_data: &serde_json::Value) -> Option<uuid::Uuid> {
211211
auth_data
212212
.get("id")
213213
.and_then(|v| v.as_str())
214214
.and_then(|s| uuid::Uuid::parse_str(s).ok())
215215
}
216216

217-
/// Проверяет, соответствует ли тег протоколу из запроса клиента.
217+
/// Checks whether the tag matches the protocol from the client request.
218218
fn proto_matches(tag: Tag, protocol: &str) -> bool {
219219
match protocol {
220220
"awg" => tag == Tag::AmneziaWg,
@@ -229,7 +229,7 @@ fn proto_matches(tag: Tag, protocol: &str) -> bool {
229229
}
230230
}
231231

232-
/// Возвращает человекочитаемое название инбаунда по тегу.
232+
/// Returns a human-readable inbound name for the given tag.
233233
fn inbound_label(tag: Tag) -> &'static str {
234234
match tag {
235235
Tag::VlessTcpReality => "VLESS TCP Reality",
@@ -245,8 +245,8 @@ fn inbound_label(tag: Tag) -> &'static str {
245245
}
246246
}
247247

248-
/// Возвращает список connection'ов для заданного протокола.
249-
/// Для каждой онлайн-ноды с нужным инбаундом ищет matching connection подписки.
248+
/// Returns the list of connections for the given protocol.
249+
/// For each online node with the required inbound, finds a matching connection of the subscription.
250250
fn connections_for_protocol<N, C>(
251251
nodes: &N,
252252
protocol: &str,
@@ -271,7 +271,7 @@ where
271271
if !proto_matches(tag, protocol) {
272272
continue;
273273
}
274-
// connection можно сопоставить только с нодой, у которой есть точно такой же inbound
274+
// A connection can only be matched with a node that has exactly the same inbound.
275275
if !node.inbounds.values().any(|i| i.tag == tag) {
276276
continue;
277277
}
@@ -311,7 +311,7 @@ where
311311
result
312312
}
313313

314-
/// Возвращает уникальный список стран из connection'ов (для available_countries).
314+
/// Returns a unique list of countries from the connections (for available_countries).
315315
fn available_countries_from_connections(conns: &[GatewayConnection]) -> Vec<GatewayCountry> {
316316
let mut seen = std::collections::HashSet::new();
317317
let mut countries = Vec::new();
@@ -328,7 +328,7 @@ fn available_countries_from_connections(conns: &[GatewayConnection]) -> Vec<Gate
328328
countries
329329
}
330330

331-
/// Строит Amnezia server config для AWG.
331+
/// Builds the Amnezia server config for AWG.
332332
fn build_awg_server_config(
333333
ini_config: &str,
334334
client_priv_key: &str,
@@ -411,7 +411,7 @@ fn build_awg_server_config(
411411
})
412412
}
413413

414-
/// Строит Amnezia server config для VLESS/XRay из реального inbound.
414+
/// Builds the Amnezia server config for VLESS/XRay from the real inbound.
415415
fn build_vless_server_config(
416416
inbound: &fcore::Inbound,
417417
conn_id: &uuid::Uuid,
@@ -662,7 +662,7 @@ fn build_vless_server_config(
662662
}
663663

664664
// ============================================================================
665-
// Обработчики
665+
// Handlers
666666
// ============================================================================
667667

668668
pub async fn gateway_services_handler<N, C, S>(
@@ -684,7 +684,7 @@ where
684684
{
685685
let mem = memory.memory.read().await;
686686

687-
// Если передан subscription_id, берём реальный end_date из подписки и её connections
687+
// If subscription_id is provided, use the real subscription end_date and its connections.
688688
let sub_id = req.auth_data.as_ref().and_then(extract_subscription_id);
689689
let end_date = sub_id
690690
.and_then(|sub_id| mem.subscriptions.find_by_id(&sub_id))
@@ -798,7 +798,7 @@ where
798798
let conns = mem.connections.get_by_subscription_id(&sub_id);
799799
let active_devices = conns.as_ref().map(|c| c.len() as i64).unwrap_or(0);
800800

801-
// Собираем issued_configs из реальных connections
801+
// Build issued_configs from real connections.
802802
let issued_configs: Vec<GatewayIssuedConfig> = conns
803803
.unwrap_or_default()
804804
.into_iter()
@@ -940,7 +940,7 @@ where
940940
continue;
941941
}
942942

943-
// Нода должна иметь точно такой же inbound, как у connection
943+
// The node must have exactly the same inbound as the connection.
944944
let has_inbound = node.inbounds.values().any(|i| i.tag == conn_tag);
945945
if !has_inbound {
946946
continue;

src/bin/api/http/handlers/subscription.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ where
187187
.refer_code
188188
.unwrap_or_else(|| get_uuid_last_octet_simple(&sub_id));
189189

190-
// Проверяем, что реферальный код существует (если указан и это не системный код).
190+
// Verify that the referral code exists (if provided and it is not a system code).
191191
if let Some(ref_by) = req.referred_by.clone() {
192192
let mem = memory.memory.read().await;
193193
if !system_refer_codes.iter().any(|c| c == &ref_by)

src/bin/api/http/handlers/trial.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ where
5454
let ref_by = req.referred_by.clone().unwrap_or_else(|| "WEB".to_string());
5555
let sub_id = uuid::Uuid::new_v4();
5656

57-
// Проверяем, что реферальный код существует (если указан и это не системный код).
57+
// Verify that the referral code exists (if provided and it is not a system code).
5858
if let Some(ref_by_code) = req.referred_by.clone() {
5959
let mem = memory.memory.read().await;
6060
if !system_refer_codes.iter().any(|c| c == &ref_by_code)

src/bin/api/tasks.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ where
6868
.connections
6969
.iter()
7070
.filter_map(|(id, conn)| {
71+
// Connections that belong to a subscription are managed by
72+
// cleanup_expired_subscriptions / restore_subscriptions via the
73+
// sync bus. Only standalone connections with their own expires_at
74+
// should be cleaned up here.
75+
if conn.get_subscription_id().is_some() {
76+
return None;
77+
}
78+
7179
if let Some(expires_at) = conn.get_expires_at() {
7280
if expires_at <= now && !conn.get_deleted() {
7381
Some((*id, conn.clone()))

0 commit comments

Comments
 (0)