xray-tools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ uses: ./.github/workflows/test.yml
13
+
14
+ publish:
15
+ name: Publish
16
+ needs: test
17
+ runs-on: ubuntu-latest
18
+ environment:
19
+ name: pypi
20
+ url: https://pypi.org/p/xray-tools
21
+ permissions:
22
+ id-token: write
23
+ contents: read
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Set version from the tag
32
+ run: |
33
+ version="${GITHUB_REF_NAME#v}"
34
+ sed -i "s/^__version__ = \".*\"/__version__ = \"${version}\"/" xray_tools/__init__.py
35
+
36
+ - name: Build
37
+ run: |
38
+ pip install build
39
+ python -m build
40
+
41
+ - name: Verify version matches the tag
42
+ run: |
43
+ version="${GITHUB_REF_NAME#v}"
44
+ shopt -s nullglob
45
+ wheels=(dist/*-"${version}"-*.whl)
46
+ sdists=(dist/*-"${version}".tar.gz)
47
+ if (( ${#wheels[@]} == 0 || ${#sdists[@]} == 0 )); then
48
+ echo "Expected version ${version} in dist/, got:"
49
+ ls -la dist
50
+ exit 1
51
+ fi
52
+
53
+ - name: Publish to PyPI
54
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,34 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Python ${{ matrix.python-version }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+
27
+ - name: Install Poetry
28
+ run: pipx install poetry
29
+
30
+ - name: Install dependencies
31
+ run: poetry install --with test --no-interaction --no-ansi
32
+
33
+ - name: Run tests
34
+ run: poetry run pytest
@@ -0,0 +1,35 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Test and type-check caches
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ .coverage
23
+ .coverage.*
24
+ htmlcov/
25
+ coverage.xml
26
+
27
+ # Secrets
28
+ .env
29
+ .env.*
30
+
31
+ # Editors and OS
32
+ .idea/
33
+ .vscode/
34
+ *.swp
35
+ .DS_Store
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.5
2
+ Name: xray-tools
3
+ Version: 0.1.0
4
+ Summary: Pure Python library for Xray-core configuration
5
+ Project-URL: Repository, https://github.com/shizacat/xray-tools
6
+ Author: Alexey Matveev
7
+ License-Expression: MIT
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: pydantic<3.0.0,>=2.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Xray tools — Python library
15
+
16
+ Create and manage [Xray-core](https://github.com/XTLS/Xray-core) configuration: inbounds, outbounds, routing and protocol settings. Pure Python implementation.
17
+
18
+ Links:
19
+ - https://github.com/XTLS/Xray-core
20
+ - https://xtls.github.io/config/
21
+
22
+ # Install
23
+
24
+ ```bash
25
+ pip install xray-tools
26
+ ```
27
+
28
+ # Development
29
+
30
+ Run tests:
31
+ ```bash
32
+ pytest
33
+ ```
@@ -0,0 +1,20 @@
1
+ # Xray tools — Python library
2
+
3
+ Create and manage [Xray-core](https://github.com/XTLS/Xray-core) configuration: inbounds, outbounds, routing and protocol settings. Pure Python implementation.
4
+
5
+ Links:
6
+ - https://github.com/XTLS/Xray-core
7
+ - https://xtls.github.io/config/
8
+
9
+ # Install
10
+
11
+ ```bash
12
+ pip install xray-tools
13
+ ```
14
+
15
+ # Development
16
+
17
+ Run tests:
18
+ ```bash
19
+ pytest
20
+ ```
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "xray-tools"
3
+ dynamic = ["version"]
4
+ description = "Pure Python library for Xray-core configuration"
5
+ authors = [{ name = "Alexey Matveev" }]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "pydantic (>=2.0,<3.0.0)",
11
+ ]
12
+ classifiers = [
13
+ "Intended Audience :: Developers",
14
+ "Topic :: Software Development :: Libraries :: Python Modules",
15
+ ]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/shizacat/xray-tools"
19
+
20
+ [tool.hatch.version]
21
+ path = "xray_tools/__init__.py"
22
+
23
+ [tool.poetry]
24
+ package-mode = false
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ black = "^22.10.0"
28
+
29
+ [tool.poetry.group.test.dependencies]
30
+ pytest = "^6.2.5"
31
+ pytest-mock = "^3.6.1"
32
+
33
+ [tool.black]
34
+ target-version = ["py310"]
35
+
36
+ [tool.isort]
37
+ py_version = 310
38
+ profile = "black"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
File without changes
@@ -0,0 +1,223 @@
1
+ import json
2
+
3
+ import pytest
4
+ from pytest import fixture
5
+ from pydantic import ValidationError
6
+
7
+ from xray_tools import config
8
+ from xray_tools import config as xray_config
9
+ from xray_tools import protocols
10
+
11
+
12
+ # --- Fixture ---
13
+
14
+ @fixture
15
+ def xray_config_ex1() -> xray_config.XRayConfig:
16
+ return config.XRayConfig.from_config_file("""
17
+ {
18
+ "log": {"loglevel": "error"},
19
+ "inbounds": [
20
+ {
21
+ "port": 443,
22
+ "protocol": "vless",
23
+ "tag": "vless_tls",
24
+ "settings": {
25
+ "clients": [
26
+ {
27
+ "id": "{{ xray.uuid }}",
28
+ "email": "user@{{ ansible_fqdn }}",
29
+ "flow": "xtls-rprx-vision"
30
+ }
31
+ ],
32
+ "decryption": "none"
33
+ },
34
+ "streamSettings": {
35
+ "network": "tcp",
36
+ "security": "reality",
37
+ "realitySettings": {
38
+ "show": false,
39
+ "dest": "www.microsoft.com:443",
40
+ "xver": 0,
41
+ "serverNames": [
42
+ "www.microsoft.com"
43
+ ],
44
+ "privateKey": "{{ xray.x25519_private }}",
45
+ "minClientVer": "",
46
+ "maxClientVer": "",
47
+ "maxTimeDiff": 0,
48
+ "shortIds": [
49
+ "{{ xray.short_id }}"
50
+ ]
51
+ }
52
+ },
53
+ "sniffing": {
54
+ "enabled": true,
55
+ "destOverride": [
56
+ "http",
57
+ "tls"
58
+ ]
59
+ }
60
+ }
61
+ ]
62
+ }
63
+ """)
64
+
65
+
66
+ # --- Tests ---
67
+
68
+
69
+ def test_create_empty_config():
70
+ obj = config.XRayConfig()
71
+
72
+ # get config
73
+ c = obj.to_config_file()
74
+ assert isinstance(c, str)
75
+
76
+ # get config with indent
77
+ c = obj.to_config_file(indent=4)
78
+ assert isinstance(c, str)
79
+
80
+
81
+ def test_config_create_from_file(xray_config_ex1: xray_config.XRayConfig):
82
+ """
83
+ Check type of config
84
+ """
85
+ assert isinstance(xray_config_ex1, config.XRayConfig)
86
+ assert isinstance(
87
+ xray_config_ex1.inbounds[0].settings, protocols.ProtocolSettingsVless)
88
+
89
+
90
+ def test_config_to_config_file(xray_config_ex1: xray_config.XRayConfig):
91
+ """
92
+ Check as create dump
93
+ """
94
+ dump = xray_config_ex1.to_config_file()
95
+ assert isinstance(dump, str)
96
+ dump_dict = json.loads(dump)
97
+ assert len(dump_dict["inbounds"][0]["settings"]["clients"]) == 1
98
+ assert dump_dict["inbounds"][0]["settings"]["decryption"] == "none"
99
+
100
+
101
+ def test_config_create_fill_01():
102
+ """
103
+ Создаем и заполняем конфиг
104
+ """
105
+ config = xray_config.XRayConfig(
106
+ inbounds=[
107
+ xray_config.Inbound(
108
+ settings=protocols.ProtocolSettingsVless(),
109
+ )
110
+ ]
111
+ )
112
+ config.inbounds[0].settings.client_add(email="test@test.ru")
113
+ assert isinstance(config.to_config_file(), str)
114
+
115
+
116
+ def test_log_crete_default():
117
+ r = config.Log()
118
+ assert isinstance(r, config.Log)
119
+
120
+
121
+ def test_stream_settings():
122
+ r = config.StreamSettings()
123
+ assert isinstance(r, config.StreamSettings)
124
+
125
+
126
+ def test_inbound():
127
+ r = config.Inbound(settings=protocols.ProtocolSettingsVless())
128
+ assert isinstance(r, config.Inbound)
129
+ assert r.protocol == protocols.InboundProtocol.vless
130
+
131
+
132
+ def test_outbound():
133
+ r = config.Outbound()
134
+ assert isinstance(r, config.Outbound)
135
+
136
+
137
+ def test_stats():
138
+ r = xray_config.Stats()
139
+ assert isinstance(r, xray_config.Stats)
140
+
141
+ # Проверяем, что stats добавился как пустой словарь
142
+ obj = config.XRayConfig(stats=xray_config.Stats())
143
+ cfg = json.loads(obj.to_config_file())
144
+ assert cfg["stats"] == {}
145
+
146
+ # Check, that stats is not included in config
147
+ obj = config.XRayConfig()
148
+ cfg = json.loads(obj.to_config_file())
149
+ assert "stats" not in cfg
150
+
151
+
152
+ def test_api():
153
+ r = xray_config.Api(tag="api")
154
+ assert isinstance(r, xray_config.Api)
155
+
156
+ # Проверяем, что api не включен в config
157
+ obj = config.XRayConfig()
158
+ cfg = json.loads(obj.to_config_file())
159
+ assert "api" not in cfg
160
+
161
+ # Проверяем, что api добавился в config
162
+ obj = config.XRayConfig(
163
+ api=xray_config.Api(
164
+ tag="api",
165
+ services=[xray_config.ApiService.StatsService]
166
+ )
167
+ )
168
+ cfg = json.loads(obj.to_config_file())
169
+ assert cfg["api"]["tag"] == "api"
170
+
171
+
172
+ def test_policy():
173
+ r = xray_config.Policy()
174
+ assert isinstance(r, xray_config.Policy)
175
+
176
+ # Check level key format, raise ValidationError
177
+ with pytest.raises(ValidationError):
178
+ xray_config.Policy(levels={"test": {}})
179
+
180
+ # Check level key correct format
181
+ r = xray_config.Policy(levels={"0": xray_config.PolicyLevel()})
182
+ assert isinstance(r, xray_config.Policy)
183
+
184
+ # Проверяем, что policy не включен в config
185
+ obj = config.XRayConfig()
186
+ cfg = json.loads(obj.to_config_file())
187
+ assert "policy" not in cfg
188
+
189
+ # Проверяем, что policy добавился в config
190
+ obj = config.XRayConfig(
191
+ policy=xray_config.Policy(
192
+ levels={
193
+ "0": xray_config.PolicyLevel(
194
+ statsUserUplink=True,
195
+ )
196
+ }
197
+ )
198
+ )
199
+ cfg = json.loads(obj.to_config_file())
200
+ assert cfg["policy"]["levels"]["0"]["statsUserUplink"] is True
201
+
202
+
203
+ def test_ws_settings_roundtrip():
204
+ """path/host у WebSocket не теряются при разборе и сборке конфига."""
205
+ import json as _json
206
+ from xray_tools.config import XRayConfig as _XRayConfig
207
+
208
+ raw = {
209
+ "inbounds": [{
210
+ "listen": "127.0.0.1", "port": 10443, "protocol": "vless",
211
+ "settings": {"clients": [], "decryption": "none"},
212
+ "streamSettings": {
213
+ "network": "ws", "security": "none",
214
+ "wsSettings": {"path": "/media/stream/abc",
215
+ "host": "cloud.example.org"},
216
+ },
217
+ }],
218
+ }
219
+ cfg = _XRayConfig.model_validate_json(_json.dumps(raw))
220
+ out = _json.loads(cfg.to_config_file())
221
+ ws = out["inbounds"][0]["streamSettings"]["wsSettings"]
222
+ assert ws["path"] == "/media/stream/abc"
223
+ assert ws["host"] == "cloud.example.org"
@@ -0,0 +1,30 @@
1
+ from xray_tools import protocols
2
+
3
+
4
+ def test_protocol_vless_add_remove_client():
5
+ obj = protocols.ProtocolSettingsVless()
6
+
7
+ # Add
8
+ c = obj.client_add(email="test@example.com")
9
+ assert len(obj.clients) == 1
10
+ assert isinstance(c, protocols.ClientVless)
11
+ assert c == obj.clients[0]
12
+
13
+ # Remove
14
+ obj.client_remove(c.id)
15
+ assert len(obj.clients) == 0
16
+
17
+
18
+ def test_protocol_vless_add_client_more_than_one():
19
+ """При повторном добавлении клиента не добавлять его
20
+ при этом не должно быть исключения
21
+ """
22
+ obj = protocols.ProtocolSettingsVless()
23
+
24
+ # Add
25
+ c = obj.client_add(client_id="test", email="test@example.com")
26
+ c = obj.client_add(client_id="test", email="test@example.com")
27
+ c = obj.client_add(client_id="test", email="test@example.com")
28
+ assert isinstance(c, protocols.ClientVless)
29
+ assert len(obj.clients) == 1
30
+ assert c == obj.clients[0]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class BaseModelConfig(BaseModel):
9
+ """
10
+ Общая модель для всех конфигов
11
+ """
12
+
13
+ def model_dump(self, *args, **kwargs) -> Dict:
14
+ """Return dump config
15
+
16
+ Features:
17
+ - exclude none
18
+ - enum as value
19
+ """
20
+ kwargs.setdefault("exclude_none", True)
21
+ # Ensures enums are dumped as values in python mode
22
+ kwargs.setdefault("mode", "python")
23
+ r = super().model_dump(*args, **kwargs)
24
+ return r
25
+
26
+ def model_dump_json(self, *args, **kwargs) -> str:
27
+ """Return dump config as json"""
28
+ kwargs.setdefault("exclude_none", True)
29
+ return super().model_dump_json(*args, **kwargs)
@@ -0,0 +1,741 @@
1
+ """
2
+ Example:
3
+
4
+ {
5
+ "log": {
6
+ "loglevel": "info"
7
+ },
8
+ "routing": {
9
+ "rules": [],
10
+ "domainStrategy": "AsIs"
11
+ },
12
+ "inbounds": [
13
+ {
14
+ "port": 443,
15
+ "protocol": "vless",
16
+ "tag": "vless_tls",
17
+ "settings": {
18
+ "clients": [
19
+ {
20
+ "id": "{{ xray.uuid }}",
21
+ "email": "user@{{ ansible_fqdn }}",
22
+ "flow": "xtls-rprx-vision"
23
+ }
24
+ ],
25
+ "decryption": "none"
26
+ },
27
+ "streamSettings": {
28
+ "network": "tcp",
29
+ "security": "reality",
30
+ "realitySettings": {
31
+ "show": false,
32
+ "dest": "www.microsoft.com:443",
33
+ "xver": 0,
34
+ "serverNames": [
35
+ "www.microsoft.com"
36
+ ],
37
+ "privateKey": "{{ xray.x25519_private }}",
38
+ "minClientVer": "",
39
+ "maxClientVer": "",
40
+ "maxTimeDiff": 0,
41
+ "shortIds": [
42
+ "{{ xray.short_id }}"
43
+ ]
44
+ }
45
+ },
46
+ "sniffing": {
47
+ "enabled": true,
48
+ "destOverride": [
49
+ "http",
50
+ "tls"
51
+ ]
52
+ }
53
+ }
54
+ ],
55
+ "outbounds": [
56
+ {
57
+ "protocol": "freedom",
58
+ "tag": "direct"
59
+ },
60
+ {
61
+ "protocol": "blackhole",
62
+ "tag": "block"
63
+ }
64
+ ]
65
+ }
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import json
71
+ from enum import Enum
72
+ from typing import TextIO, Optional, List, Dict, Type, ClassVar, Union
73
+
74
+ # from pydantic import BaseModel, Field, root_validator
75
+ from pydantic import (
76
+ BaseModel,
77
+ Field,
78
+ ConfigDict,
79
+ RootModel,
80
+ field_validator,
81
+ model_validator,
82
+ constr
83
+ )
84
+
85
+ from .protocols import (
86
+ InboundProtocol,
87
+ ProtocolSettingsBase,
88
+ ProtocolSettingsVless,
89
+ ProtocolSettingsDokodemoDoor,
90
+ )
91
+ from .base import BaseModelConfig
92
+
93
+
94
+ # Enums
95
+
96
+
97
+ class LogLevel(str, Enum):
98
+ """
99
+ Log level
100
+ "debug" | "info" | "warning" | "error" | "none"
101
+ """
102
+
103
+ debug = "debug"
104
+ info = "info"
105
+ warning = "warning"
106
+ error = "error"
107
+ none = "none"
108
+
109
+
110
+ class Network(str, Enum):
111
+ tcp = "tcp"
112
+ kcp = "kcp"
113
+ ws = "ws"
114
+ http = "http"
115
+ domainsocket = "domainsocket"
116
+ quic = "quic"
117
+ grpc = "grpc"
118
+
119
+
120
+ class NetworkRoutingRule(str, Enum):
121
+ tcp = "tcp"
122
+ udp = "udp"
123
+ tcp_udp = "tcp,udp"
124
+
125
+
126
+ class Security(str, Enum):
127
+ none = "none"
128
+ tls = "tls"
129
+ xtls = "xtls"
130
+ reality = "reality"
131
+
132
+
133
+ class DomainStrategy(str, Enum):
134
+ """
135
+ DomainStrategy
136
+ """
137
+
138
+ AsIs = "AsIs"
139
+ IPIfNonMatch = "IPIfNonMatch"
140
+ IPOnDemand = "IPOnDemand"
141
+
142
+
143
+ class DomainMatcher(str, Enum):
144
+ """
145
+ DomainMatcher
146
+ """
147
+
148
+ hybrid = "hybrid"
149
+ linear = "linear"
150
+
151
+
152
+ class SniffingDest(str, Enum):
153
+ """
154
+ SniffingDest
155
+ """
156
+
157
+ http = "http"
158
+ tls = "tls"
159
+ quic = "quic"
160
+ fakedns = "fakedns"
161
+ fakedns_others = "fakedns+others"
162
+
163
+
164
+ class ApiService(str, Enum):
165
+ """
166
+ ApiService
167
+ """
168
+
169
+ HandlerService = "HandlerService"
170
+ LoggerService = "LoggerService"
171
+ StatsService = "StatsService"
172
+
173
+
174
+ # Classes
175
+
176
+
177
+ class Stats(BaseModelConfig):
178
+ """
179
+ Включает сбор статистики
180
+ """
181
+
182
+
183
+ class Api(BaseModelConfig):
184
+ """
185
+ API interface configuration provides a set
186
+ of APIs based on gRPC for remote invocation.
187
+
188
+ Example
189
+ {
190
+ "api": {
191
+ "tag": "api",
192
+ "listen": "127.0.0.1:8080",
193
+ "services": ["HandlerService", "LoggerService", "StatsService"]
194
+ }
195
+ }
196
+ """
197
+ tag: str = Field(
198
+ ..., description="The identifier of this inbound connection"
199
+ )
200
+ listen: Optional[str] = Field(
201
+ None,
202
+ description="The listening address. Example: 127.0.0.1:8080"
203
+ )
204
+ services: List[ApiService] = Field(
205
+ default_factory=list,
206
+ description=(
207
+ "The list of services to be enabled. "
208
+ "Available services: HandlerService, LoggerService, StatsService"
209
+ ),
210
+ )
211
+
212
+
213
+ class Log(BaseModelConfig):
214
+ access: Optional[str] = Field(None, description="путь к файлу")
215
+ error: Optional[str] = Field(None, description="путь к файлу")
216
+ loglevel: LogLevel = Field(LogLevel.none)
217
+ dnsLog: bool = Field(False)
218
+
219
+
220
+ # Ограничиваем ключи только строками, которые соответствуют числу
221
+ PolicyLevelKey = constr(pattern=r'^\d+$')
222
+
223
+
224
+ class PolicyLevel(BaseModelConfig):
225
+ """
226
+ Level Policy Object
227
+ """
228
+ handshake: Optional[int] = Field(
229
+ None, description="Handshake timeout in seconds"
230
+ )
231
+ connIdle: Optional[int] = Field(
232
+ None, description="Idle timeout in seconds"
233
+ )
234
+ uplinkOnly: Optional[int] = Field(
235
+ None, description="Uplink only timeout in seconds"
236
+ )
237
+ downlinkOnly: Optional[int] = Field(
238
+ None, description="Downlink only timeout in seconds"
239
+ )
240
+ statsUserUplink: Optional[bool] = Field(
241
+ None, description="Enable statistics collection for user uplink"
242
+ )
243
+ statsUserDownlink: Optional[bool] = Field(
244
+ None, description="Enable statistics collection for user downlink"
245
+ )
246
+ statsUserOnline: Optional[bool] = Field(
247
+ None, description="Enable statistics collection for user online"
248
+ )
249
+ bufferSize: Optional[int] = Field(
250
+ None, description="Buffer size in bytes"
251
+ )
252
+
253
+
254
+ class PolicySystem(BaseModelConfig):
255
+ """Policy System"""
256
+ statsInboundUplink: Optional[bool] = Field(
257
+ None, description="Enable statistics collection for inbound uplink"
258
+ )
259
+ statsInboundDownlink: Optional[bool] = Field(
260
+ None, description="Enable statistics collection for inbound downlink"
261
+ )
262
+ statsOutboundUplink: Optional[bool] = Field(
263
+ None, description="Enable statistics collection for outbound uplink"
264
+ )
265
+ statsOutboundDownlink: Optional[bool] = Field(
266
+ None, description="Enable statistics collection for outbound downlink"
267
+ )
268
+
269
+
270
+ class Policy(BaseModelConfig):
271
+ """
272
+ Local Policy
273
+ Local policy can be used to set different policy settings
274
+ for different user levels
275
+
276
+ {
277
+ "policy": {
278
+ "levels": {
279
+ "0": {
280
+ "handshake": 4,
281
+ "connIdle": 300,
282
+ "uplinkOnly": 2,
283
+ "downlinkOnly": 5,
284
+ "statsUserUplink": false,
285
+ "statsUserDownlink": false,
286
+ "statsUserOnline": false,
287
+ "bufferSize": 4
288
+ }
289
+ },
290
+ "system": {
291
+ "statsInboundUplink": false,
292
+ "statsInboundDownlink": false,
293
+ "statsOutboundUplink": false,
294
+ "statsOutboundDownlink": false
295
+ }
296
+ }
297
+ }
298
+ """
299
+ levels: Dict[PolicyLevelKey, PolicyLevel] = Field(
300
+ default_factory=dict,
301
+ description=(
302
+ "Local policy can be used to set different policy settings "
303
+ "for different user levels"
304
+ "Key: '0', '1', ..."
305
+ ),
306
+ )
307
+ system: PolicySystem = Field(
308
+ default_factory=PolicySystem,
309
+ description="System policy settings"
310
+ )
311
+
312
+
313
+ class Certificate(BaseModel):
314
+ """
315
+ Certificate
316
+ """
317
+
318
+ ocspStapling: int = 3600
319
+ oneTimeLoading: bool = False
320
+ usage: str = "encipherment"
321
+ buildChain: bool = False
322
+ certificateFile: str = ""
323
+ keyFile: str = ""
324
+ certificate: List[str] = Field(default_factory=list)
325
+ key: List[str] = Field(default_factory=list)
326
+
327
+
328
+ class TLSSettings(BaseModel):
329
+ """ """
330
+
331
+ serverName: str = Field(
332
+ "", description="Specifies the domain of the server-side certificate"
333
+ )
334
+ rejectUnknownSni: bool = Field(
335
+ False,
336
+ description=(
337
+ "When true, the server rejects TLS handshakes if the SNI received "
338
+ "does not match domains specified in the certificate. "
339
+ ),
340
+ )
341
+ allowInsecure: bool = Field(False)
342
+ alpn: List[str] = Field(
343
+ ["h2", "http/1.1"],
344
+ description=(
345
+ "An array of strings specifying the ALPN values used "
346
+ "in TLS handshakes"
347
+ ),
348
+ )
349
+ minVersion: str = Field("1.2")
350
+ maxVersion: str = Field("1.3")
351
+ cipherSuites: str = Field(
352
+ "",
353
+ description=(
354
+ "Specifies a list of supported cryptographic suites, "
355
+ "with names of each separated by a colon."
356
+ ),
357
+ )
358
+ disableSystemRoot: bool = Field(
359
+ False,
360
+ description=(
361
+ "Whether to disable the CA certificates provided by "
362
+ "the operating system."
363
+ ),
364
+ )
365
+ enableSessionResumption: bool = Field(
366
+ False,
367
+ description=(
368
+ "When false, the session_ticket extension will not be "
369
+ "included in ClientHello"
370
+ ),
371
+ )
372
+ fingerprint: str = Field(
373
+ "",
374
+ description=(
375
+ "Specifies the fingerprint of the TLS Client Hello message"
376
+ ),
377
+ )
378
+ pinnedPeerCertificateChainSha256: List[str] = Field(
379
+ default_factory=list,
380
+ description=(
381
+ "Specifies the SHA256 hash values of the certificate chain "
382
+ "of the remote server, using the standard encoding format."
383
+ ),
384
+ )
385
+ masterKeyLog: str = Field(
386
+ "", description=("Path to the (Pre-)Master-Secret log file.")
387
+ )
388
+ certificates: List[Certificate] = Field(
389
+ default_factory=list,
390
+ description=(
391
+ "A list of certificates, each representing a single certificate "
392
+ "(fullchain recommended)."
393
+ ),
394
+ )
395
+
396
+
397
+ class TCPSettings(BaseModel):
398
+ acceptProxyProtocol: bool = False
399
+ header: Dict[str, str] = Field({"type": "none"})
400
+
401
+
402
+ class KCPSettings(BaseModel):
403
+ pass
404
+
405
+
406
+ class WSSettings(BaseModel):
407
+ acceptProxyProtocol: bool = False
408
+ path: str = Field("/", description="HTTP path of the WebSocket")
409
+ host: str = Field("", description="HTTP Host header sent by the client")
410
+ headers: Dict[str, str] = Field(default_factory=dict)
411
+
412
+
413
+ class HttpSettings(BaseModel):
414
+ pass
415
+
416
+
417
+ class GrpcSettings(BaseModel):
418
+ pass
419
+
420
+
421
+ class HTTPUpgradeSettings(BaseModel):
422
+ pass
423
+
424
+
425
+ class SplitHTTPSettings(BaseModel):
426
+ pass
427
+
428
+
429
+ class ProxySettings(BaseModel):
430
+ tag: str
431
+
432
+
433
+ class RealitySettings(BaseModel):
434
+ show: bool = Field(False)
435
+ dest: str | None = Field(None)
436
+ xver: int = Field(0)
437
+ serverName: str | None = Field(None)
438
+ serverNames: List[str] = Field(default_factory=list)
439
+ privateKey: str = Field("")
440
+ minClientVer: str = Field("")
441
+ maxClientVer: str = Field("")
442
+ maxTimeDiff: int = Field(0)
443
+ shortIds: List[str] = Field(default_factory=list)
444
+ shortId: str | None = Field(None)
445
+ fingerprint: str | None = Field(None)
446
+ publicKey: str | None = Field(None)
447
+
448
+
449
+ class SockOpt(BaseModel):
450
+ """ """
451
+
452
+ mark: int = 0
453
+ tcpFastOpen: bool = False
454
+ tproxy: str = "off"
455
+ domainStrategy: str = "AsIs"
456
+ dialerProxy: str = ""
457
+ acceptProxyProtocol: bool = False
458
+ tcpKeepAliveInterval: int = 0
459
+ tcpcongestion: str = "bbr"
460
+ interface: str = "wg0"
461
+ tcpMptcp: bool = False
462
+ tcpNoDelay: bool = False
463
+
464
+
465
+ class StreamSettings(BaseModelConfig):
466
+ """
467
+ StreamSettings corresponds to the streamSettings property
468
+ in the inbound or outbound config.
469
+ Each inbound or outbound can be configured with different
470
+ transports and can use streamSettings to specify local configs.
471
+
472
+ """
473
+
474
+ network: Network = Field(
475
+ Network.tcp,
476
+ description=(
477
+ "The underlying protocol of the transport "
478
+ "used by the data stream of the connection"
479
+ ),
480
+ )
481
+ security: Security = Field(
482
+ Security.none,
483
+ description="Whether to enable transport layer encryption",
484
+ )
485
+ tlsSettings: TLSSettings | None = Field(
486
+ None, description="Configures vanilla TLS"
487
+ )
488
+ tcpSettings: TCPSettings | None = Field(None, description="Configures TCP")
489
+ kcpSettings: KCPSettings | None = Field(
490
+ default=None, description="Configures KCP"
491
+ )
492
+ wsSettings: WSSettings | None = Field(
493
+ None, description="Configures WebSocket"
494
+ )
495
+ httpSettings: HttpSettings | None = Field(
496
+ None, description="Configures HTTP"
497
+ )
498
+ grpcSettings: GrpcSettings | None = Field(
499
+ None, description="Configures gRPC"
500
+ )
501
+ httpupgradeSettings: HTTPUpgradeSettings | None = Field(
502
+ None, description="Configures HTTP"
503
+ )
504
+ splithttpSettings: SplitHTTPSettings | None = Field(
505
+ None, description="Configures HTTP"
506
+ )
507
+ realitySettings: RealitySettings | None = Field(
508
+ None, description="Configures Reality"
509
+ )
510
+ sockopt: SockOpt | None = Field(None)
511
+
512
+
513
+ class Sniffing(BaseModelConfig):
514
+ enabled: bool = True
515
+ destOverride: List[SniffingDest] = Field(
516
+ default_factory=list,
517
+ description=(
518
+ "When the traffic is of a specified type, reset the destination "
519
+ "of the current connection to the target address included "
520
+ "in the list."
521
+ ),
522
+ )
523
+ metadataOnly: bool = False
524
+ domainsExcluded: List[str] = Field(
525
+ default_factory=list, description="A list of domain names."
526
+ )
527
+ routeOnly: bool = False
528
+
529
+
530
+ class Allocate(BaseModel):
531
+ strategy: str = "always"
532
+ refresh: int = 5
533
+ concurrency: int = 3
534
+
535
+
536
+ class Mux(BaseModel):
537
+ enabled: bool = False
538
+ concurrency: int = 8
539
+
540
+
541
+ class RoutingRule(BaseModelConfig):
542
+ domainMatcher: DomainMatcher = Field(DomainMatcher.hybrid)
543
+ type: str = "field"
544
+ domain: List[str] = Field(default_factory=list)
545
+ ip: List[str] = Field(default_factory=list)
546
+ port: Optional[str] = Field(None, description="The target port range")
547
+ sourcePort: Optional[str] = Field(None)
548
+ network: Optional[NetworkRoutingRule] = Field(None)
549
+ source: List[str] = Field(default_factory=list)
550
+ user: List[str] = Field(default_factory=list)
551
+ inboundTag: List[str] = Field(default_factory=list)
552
+ protocol: List[InboundProtocol] = Field(default_factory=list)
553
+ attrs: Dict[str, str] = Field(default_factory=dict)
554
+ outboundTag: Optional[str] = Field(None)
555
+ balancerTag: Optional[str] = Field(None)
556
+
557
+
558
+ class RoutingBalancer(BaseModelConfig):
559
+ tag: str = Field("")
560
+ selector: List[str] = Field(default_factory=list)
561
+ fallbackTag: str = Field("")
562
+ strategy: Dict[str, str] = Field(default_factory=dict)
563
+
564
+
565
+ class Routing(BaseModelConfig):
566
+ domainStrategy: DomainStrategy = Field(
567
+ DomainStrategy.AsIs, description="The domain name resolution strategy"
568
+ )
569
+ domainMatcher: DomainMatcher = Field(
570
+ DomainMatcher.hybrid, description="The domain name matching algorithm"
571
+ )
572
+ rules: List[RoutingRule] = Field(default_factory=list)
573
+ balancers: List[RoutingBalancer] = Field(default_factory=list)
574
+
575
+
576
+ class Inbound(BaseModelConfig):
577
+ """
578
+ Inbound connections are used to receive incoming data
579
+ and the available protocols are listed in
580
+ """
581
+
582
+ _protocol_settings_map: ClassVar[
583
+ dict[InboundProtocol, Type[ProtocolSettingsBase]]
584
+ ] = {
585
+ InboundProtocol.vless: ProtocolSettingsVless,
586
+ InboundProtocol.dokodemo_door: ProtocolSettingsDokodemoDoor,
587
+ }
588
+
589
+ listen: str = Field(
590
+ "0.0.0.0",
591
+ description=(
592
+ "The listening address, "
593
+ "either an IP address or a Unix domain socket."
594
+ ),
595
+ )
596
+ port: int | str = Field(1080, description="The listening port")
597
+ # Fill from settings model
598
+ protocol: InboundProtocol = Field(
599
+ ..., description="The connection protocol name"
600
+ )
601
+ settings: Union[
602
+ ProtocolSettingsBase,
603
+ ProtocolSettingsVless,
604
+ ProtocolSettingsDokodemoDoor
605
+ ] = Field(
606
+ default_factory=ProtocolSettingsBase,
607
+ description=(
608
+ "The specific configuration content depends on the protocol"
609
+ ),
610
+ )
611
+ streamSettings: StreamSettings = Field(
612
+ default_factory=StreamSettings,
613
+ description=(
614
+ "The underlying transport method is how the current Xray node "
615
+ "interfaces with other nodes."
616
+ ),
617
+ )
618
+ tag: str | None = Field(
619
+ None,
620
+ description=(
621
+ "The identifier of this inbound connection, used to locate "
622
+ "this connection in other configurations."
623
+ ),
624
+ )
625
+ sniffing: Sniffing = Field(
626
+ default_factory=Sniffing,
627
+ description=(
628
+ "Configures sniffing, which is used to determine the protocol "
629
+ "of the current connection."
630
+ ),
631
+ )
632
+ allocate: Allocate = Field(
633
+ default_factory=Allocate,
634
+ description=(
635
+ "Specifies the specific settings for port allocation when "
636
+ "multiple ports are set up"
637
+ ),
638
+ )
639
+
640
+ @model_validator(mode="before")
641
+ @classmethod
642
+ def fill_protocol(cls, values: Dict[str, object]) -> Dict[str, object]:
643
+ settings = values.get("settings", {})
644
+ protocol = values.get("protocol")
645
+
646
+ # Apply settings class based on protocol
647
+ settings_class = cls._protocol_settings_map.get(protocol)
648
+ if settings_class and isinstance(settings, dict):
649
+ values["settings"] = settings_class(**settings)
650
+
651
+ # Ensure protocol is consistent
652
+ if isinstance(values.get("settings"), ProtocolSettingsBase):
653
+ values["protocol"] = values["settings"].protocol.value
654
+ elif isinstance(settings, dict):
655
+ settings["protocol"] = protocol
656
+
657
+ return values
658
+
659
+
660
+ class Outbound(BaseModelConfig):
661
+ sendThrough: str = Field(
662
+ "0.0.0.0", description="The IP address used to send data."
663
+ )
664
+ protocol: InboundProtocol = Field(
665
+ InboundProtocol.freedom, description="The connection protocol name"
666
+ )
667
+ settings: Dict = Field(default_factory=dict)
668
+ tag: str | None = Field(
669
+ None,
670
+ description=(
671
+ "The identifier of this inbound connection, used to locate "
672
+ "this connection in other configurations."
673
+ ),
674
+ )
675
+ streamSettings: StreamSettings | None = Field(
676
+ None,
677
+ description=(
678
+ "The underlying transport method is how the current Xray node "
679
+ "interfaces with other nodes."
680
+ ),
681
+ )
682
+ proxySettings: ProxySettings | None = Field(
683
+ None, description="The outbound proxy configuration."
684
+ )
685
+ mux: Mux | None = Field(
686
+ None,
687
+ description=(
688
+ "The Mux feature distributes the data of multiple TCP connections "
689
+ "on a single TCP connection."
690
+ ),
691
+ )
692
+
693
+
694
+ class XRayConfig(BaseModelConfig):
695
+ """
696
+ Содержит полную конфигурацию XRay сервера
697
+ """
698
+ stats: Optional[Stats] = Field(
699
+ None, description="Включает сбор статистики")
700
+ api: Optional[Api] = Field(
701
+ None, description="API interface configuration provides a set of APIs")
702
+ policy: Optional[Policy] = Field(
703
+ None,
704
+ description="Local policy can be used to set different policy settings"
705
+ )
706
+ log: Log = Field(default_factory=Log)
707
+ routing: Routing = Field(
708
+ default_factory=Routing,
709
+ description=(
710
+ "Configures routing. Specify rules to route connections "
711
+ "through different outbounds."
712
+ ),
713
+ )
714
+ inbounds: List[Inbound] = Field(
715
+ default_factory=list,
716
+ description=(
717
+ "Inbound connections are used to receive incoming data "
718
+ "and the available protocols are listed in"
719
+ ),
720
+ )
721
+ outbounds: List[Outbound] = Field(
722
+ default_factory=list,
723
+ description=("An array of outbound connection configurations."),
724
+ )
725
+
726
+ @classmethod
727
+ def from_config_file(cls, content: TextIO) -> XRayConfig:
728
+ """
729
+ Создает объект конфигурации из файла. Config format is json.
730
+
731
+ Args:
732
+ content: Содержимое файла конфигурации
733
+ """
734
+ return cls.model_validate_json(content)
735
+
736
+ def to_config_file(self, indent=None) -> str:
737
+ """
738
+ Возвращает отрендеренный конфигурационный файл.
739
+ Json dump
740
+ """
741
+ return self.model_dump_json(indent=indent, exclude_none=True)
@@ -0,0 +1,127 @@
1
+ """
2
+ Describe diffent protocols
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from enum import Enum
9
+ from typing import TextIO, Optional, List, Dict, Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from .base import BaseModelConfig
14
+
15
+
16
+ def gen_uuid() -> str:
17
+ return str(uuid.uuid4())
18
+
19
+
20
+ class InboundProtocol(str, Enum):
21
+ """
22
+ Inbound connections are used to receive incoming data
23
+ and the available protocols are listed in
24
+ """
25
+
26
+ tcp = "tcp"
27
+ kcp = "kcp"
28
+ ws = "ws"
29
+ h2 = "h2"
30
+ quic = "quic"
31
+ grpc = "grpc"
32
+ dokodemo = "dokodemo"
33
+ dokodemo_door = "dokodemo-door"
34
+ freedom = "freedom"
35
+ blackhole = "blackhole"
36
+ socks = "socks"
37
+ http = "http"
38
+ mtproto = "mtproto"
39
+ tuic = "tuic"
40
+ vless = "vless"
41
+ vmess = "vmess"
42
+ trojan = "trojan"
43
+ shadowsocks = "shadowsocks"
44
+ reality = "reality"
45
+ wireguard = "wireguard"
46
+
47
+
48
+ class ProtocolSettingsBase(BaseModelConfig, BaseModel):
49
+ """
50
+ Base class for protocol settings
51
+ """
52
+
53
+ protocol: InboundProtocol = Field(..., exclude=True, description="Protocol")
54
+
55
+ def client_add(self, client_id: Optional[str] = None, **kwargs):
56
+ """
57
+ Add a client to the protocol
58
+ """
59
+ raise NotImplementedError()
60
+
61
+ def client_remove(self, client_id: str):
62
+ """
63
+ Remove a client from the protocol
64
+ """
65
+ raise NotImplementedError()
66
+
67
+
68
+ class VlessSettingFlow(str, Enum):
69
+ xtls_rprx_vision = "xtls-rprx-vision"
70
+
71
+
72
+ class ClientVless(BaseModelConfig):
73
+ id: str = Field(
74
+ default_factory=gen_uuid, description="Unique ID of the client"
75
+ )
76
+ level: int = Field(0, description="Level of the client")
77
+ email: str = Field(..., description="Email of the client")
78
+ flow: Optional[VlessSettingFlow] = Field(
79
+ None, description="Flow control mode used to select the XTLS algorithm"
80
+ )
81
+
82
+ def __eq__(self, other: ClientVless):
83
+ if not isinstance(other, ClientVless):
84
+ return NotImplemented
85
+ return self.id == other.id and self.email == other.email
86
+
87
+
88
+ class ProtocolSettingsVless(ProtocolSettingsBase):
89
+ protocol: InboundProtocol = Field(InboundProtocol.vless, exclude=True)
90
+
91
+ clients: List[ClientVless] = Field(
92
+ default_factory=list, description="List of clients"
93
+ )
94
+ decryption: str = Field("none", description="Decryption method")
95
+ fallbacks: List[Dict] = Field(
96
+ default_factory=list,
97
+ description=(
98
+ "An array that contains a series of powerful fallback "
99
+ "configurations"
100
+ ),
101
+ )
102
+
103
+ def client_add(self, client_id: Optional[str] = None, **kwargs):
104
+ """
105
+ Add a client to the protocol
106
+ """
107
+ if client_id is not None:
108
+ kwargs["id"] = client_id
109
+ client = ClientVless(**kwargs)
110
+ if client not in self.clients:
111
+ self.clients.append(client)
112
+ return client
113
+
114
+ def client_remove(self, client_id: str):
115
+ """
116
+ Remove a client from the protocol
117
+ """
118
+ self.clients = [
119
+ client for client in self.clients if client.id != client_id
120
+ ]
121
+
122
+
123
+ class ProtocolSettingsDokodemoDoor(ProtocolSettingsBase):
124
+ protocol: InboundProtocol = Field(
125
+ InboundProtocol.dokodemo_door, exclude=True)
126
+
127
+ address: str = Field(..., description="Address to bind to")