From f9eeb6f4619bd955c2cb276f0a597e1e73bc7c2a Mon Sep 17 00:00:00 2001 From: ziabric Date: Sun, 19 Apr 2026 23:37:52 +0300 Subject: [PATCH] init --- .gitignore | 3 + CHANGELOG.md | 3 + README.md | 117 ++++++++++++ analysis_options.yaml | 30 ++++ bin/vpn_server.dart | 183 +++++++++++++++++++ data/vpn_links.db | Bin 0 -> 16384 bytes pubspec.lock | 405 ++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 16 ++ 8 files changed, 757 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 bin/vpn_server.dart create mode 100644 data/vpn_links.db create mode 100644 pubspec.lock create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fb017e --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# VPN Links HTTP Server + +Простой HTTP-сервер на Dart для хранения и выдачи VPN-ссылок через SQLite. + +## Что делает сервер + +- Поднимает HTTP-сервер на `0.0.0.0:8080`. +- Создает SQLite-базу `data/vpn_links.db` при первом запуске. +- Создает таблицу `connections`, если ее нет. +- Добавляет стартовые ссылки (seed) один раз через `INSERT OR IGNORE`. + +Формат ответа списка ссылок: + +```json +{ + "links": [ + "vless://...", + "vmess://...", + "trojan://..." + ] +} +``` + +## Установка и запуск + +1. Установить зависимости: + +```bash +dart pub get +``` + +2. Запустить сервер: + +```bash +dart run bin/vpn_server.dart +``` + +После запуска сервер доступен по адресу `http://127.0.0.1:8080`. + +## API + +### `GET /connections` + +Возвращает все ссылки из базы. + +Пример: + +```bash +curl http://127.0.0.1:8080/connections +``` + +Успешный ответ: `200 OK` + +```json +{ + "links": [ + "vless://example", + "trojan://example" + ] +} +``` + +### `POST /connections` + +Добавляет новую ссылку. + +Тело запроса: + +```json +{ + "url": "trojan://example-link" +} +``` + +Пример: + +```bash +curl -X POST http://127.0.0.1:8080/connections \ + -H "Content-Type: application/json" \ + -d '{"url":"trojan://example-link"}' +``` + +Ответы: +- `201 Created` — ссылка добавлена. +- `409 Conflict` — такая ссылка уже есть. +- `400 Bad Request` — неверный JSON или отсутствует `url`. + +### `DELETE /connections` + +Удаляет ссылку по точному значению `url`. + +Тело запроса: + +```json +{ + "url": "trojan://example-link" +} +``` + +Пример: + +```bash +curl -X DELETE http://127.0.0.1:8080/connections \ + -H "Content-Type: application/json" \ + -d '{"url":"trojan://example-link"}' +``` + +Ответы: +- `200 OK` — ссылка удалена. +- `404 Not Found` — ссылка не найдена. +- `400 Bad Request` — неверный JSON или отсутствует `url`. + +## Прочее поведение + +- Неизвестный путь: `404 Not Found`. +- Неподдерживаемый метод для `/connections`: `405 Method Not Allowed`. +- Заголовок `Allow` для `/connections`: `GET, POST, DELETE`. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/bin/vpn_server.dart b/bin/vpn_server.dart new file mode 100644 index 0000000..a06a382 --- /dev/null +++ b/bin/vpn_server.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:sqlite3/sqlite3.dart'; + +const seedLinks = [ + 'vless://adbb9513-991a-4d64-9b30-1bf2283e7ed8@93.77.185.114:444?security=reality&encryption=none&pbk=M_VX89rLtCxGh45cRzXITGBgV3HTxW5c2zOEvqHFbSs&headerType=none&fp=random&spx=%2F&type=tcp&sni=www.apple.com&sid=f33f#6uuknvqv', + 'vless://833a8f71-2b99-4e69-b39a-6d242c82fabb@147.45.145.102:444?security=reality&encryption=none&pbk=jQ7nhZoFKsFAwl8lhR4g5rBl_PT_-BA_lQmt1kG3EAs&headerType=&fp=random&spx=%2F&type=tcp&sni=ya.ru&sid=5a#vless_1-zxdrfypi4', +]; + +Future main() async { + final db = _initDatabase(); + + ProcessSignal.sigint.watch().listen((_) { + db.dispose(); + exit(0); + }); + + final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080); + print('Server started on http://${server.address.address}:${server.port}'); + + await for (final request in server) { + final isConnectionsRoute = request.uri.path == '/connections'; + + if (!isConnectionsRoute) { + request.response + ..statusCode = HttpStatus.notFound + ..write('Not Found'); + await request.response.close(); + continue; + } + + if (request.method == 'GET') { + final result = db.select('SELECT url FROM connections ORDER BY id ASC'); + final links = result.map((row) => row['url'] as String).toList(); + + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'links': links})); + await request.response.close(); + continue; + } + + if (request.method == 'POST') { + await _handleCreateConnection(request, db); + continue; + } + + if (request.method == 'DELETE') { + await _handleDeleteConnection(request, db); + continue; + } + + request.response + ..statusCode = HttpStatus.methodNotAllowed + ..headers.set(HttpHeaders.allowHeader, 'GET, POST, DELETE') + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Method Not Allowed'})); + await request.response.close(); + } +} + +Database _initDatabase() { + final dataDir = Directory('data'); + if (!dataDir.existsSync()) { + dataDir.createSync(recursive: true); + } + + final db = sqlite3.open('data/vpn_links.db'); + db.execute(''' + CREATE TABLE IF NOT EXISTS connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ) + '''); + + final insert = db.prepare( + 'INSERT OR IGNORE INTO connections(url, created_at) VALUES (?, ?)', + ); + final createdAt = DateTime.now().toUtc().toIso8601String(); + for (final link in seedLinks) { + insert.execute([link, createdAt]); + } + insert.dispose(); + + return db; +} + +Future _handleCreateConnection(HttpRequest request, Database db) async { + final body = await utf8.decoder.bind(request).join(); + final url = _extractUrl(body); + + if (url == null) { + request.response + ..statusCode = HttpStatus.badRequest + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Body must be JSON: {"url": "..."}'})); + await request.response.close(); + return; + } + + try { + db.execute( + 'INSERT INTO connections(url, created_at) VALUES (?, ?)', + [url, DateTime.now().toUtc().toIso8601String()], + ); + + request.response + ..statusCode = HttpStatus.created + ..headers.contentType = ContentType.json + ..write(jsonEncode({'message': 'Link added'})); + await request.response.close(); + } on SqliteException catch (e) { + if (e.extendedResultCode == 2067) { + request.response + ..statusCode = HttpStatus.conflict + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Link already exists'})); + await request.response.close(); + return; + } + + request.response + ..statusCode = HttpStatus.internalServerError + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Database error'})); + await request.response.close(); + } +} + +Future _handleDeleteConnection(HttpRequest request, Database db) async { + final body = await utf8.decoder.bind(request).join(); + final url = _extractUrl(body); + + if (url == null) { + request.response + ..statusCode = HttpStatus.badRequest + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Body must be JSON: {"url": "..."}'})); + await request.response.close(); + return; + } + + final delete = db.prepare('DELETE FROM connections WHERE url = ?'); + delete.execute([url]); + final changes = db.updatedRows; + delete.dispose(); + + if (changes == 0) { + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Link not found'})); + await request.response.close(); + return; + } + + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'message': 'Link deleted'})); + await request.response.close(); +} + +String? _extractUrl(String body) { + try { + final json = jsonDecode(body); + if (json is! Map) { + return null; + } + + final url = json['url']; + if (url is! String || url.trim().isEmpty) { + return null; + } + + return url.trim(); + } catch (_) { + return null; + } +} diff --git a/data/vpn_links.db b/data/vpn_links.db new file mode 100644 index 0000000000000000000000000000000000000000..14a9db627a63983c7a111f507f82da9bb80ce98f GIT binary patch literal 16384 zcmeI(OHblZ6ae5Ba2_UtJ2x(xkZCm9N*_RrA!9KGiHL&5Q8!H=7wHJ4wB@;&gz2vz^_{g~$X==9SrzbHYh`d+%*HU%ELZT-GX1h^S`Q?-(H^5Ss)^5=Rt85X2hJ z7|unI_~iL`G54$E8o^F_8;Ng3H2#T5Y$iVA0|W?w00@8p2!H?xfB*=900@8p2>heK zMfAni-X3`q8fF^>y*6%T^#Jwy$hDpiW5;z>(Ns!P3Kf-lwsyxgI%ps7+?}%I%dKRR z{Ft2I)9Sh|vO=fp21}=rC-+*Kpmx@&c{nyzu4-yYty35E@|jYSEsVkr5(W$Nl+}W%KR3>Bx(%a*`w**G8kq zvW$M%osadU@1}E0gD8f6HN)P(8Hq=T`1g2ZL=f?!C^EXVMsEHfg?$c!n-29vRQk+&qiX_)2_CuUhO&2qC$ z@i|cx-v-F)`%XB{<8~D{;$s>&;r`g0_0D;>>mu4S@AL0#S+{lDEuIENW!RS5t-5%Z z_6u$OLerUoqPJ_Ekh?DlsuIvGWZ1~p#vaPkO)u{ouHEg>fj7#(;fr)QyB=B|9k@<@ zY_NWx4jen5Hj?u~bdH&fY`;179Fb4)873t%oUHMwoWSR}3@fEGSuS%s{XpX4%Rh@Z zY}1s}oWRI3XE36j5gFMOQVeG{d0rAw7TMB~EU?)u%SpI)PW=1Y&-ANnN%kw@@u<`i z(^h?QUDis4yDQ;D8@)?g{N!96^iGQA<&)ayc7LdUI2^Ku=d}@Q&4{o=QxKZTOuv8c z4tj&%0IcJOIubu5;)jXt#P(`F;ZPL_fB*=900@8p2!H?xfB*=9z<&u$_1J?#uI_7t z$kdEIlwU6jT+LSlv)}*8#8(1;AV2^FKmY_l00ck)1V8`;KmY_l00dS;AVEfx=3.11.4 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..72cf6a6 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,16 @@ +name: vpn_server +description: A sample command-line application. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.11.4 + +# Add regular dependencies here. +dependencies: + path: ^1.9.0 + sqlite3: ^2.9.3 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6