py-mihomo-trojan-interface 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.
- py_mihomo_trojan_interface-0.1.0/.gitignore +218 -0
- py_mihomo_trojan_interface-0.1.0/LICENSE +21 -0
- py_mihomo_trojan_interface-0.1.0/PKG-INFO +97 -0
- py_mihomo_trojan_interface-0.1.0/README.md +55 -0
- py_mihomo_trojan_interface-0.1.0/pyproject.toml +37 -0
- py_mihomo_trojan_interface-0.1.0/src/mihomo_trojan_interface/__init__.py +3 -0
- py_mihomo_trojan_interface-0.1.0/src/mihomo_trojan_interface/__main__.py +7 -0
- py_mihomo_trojan_interface-0.1.0/src/mihomo_trojan_interface/cli.py +313 -0
- py_mihomo_trojan_interface-0.1.0/src/mihomo_trojan_interface/config.py +504 -0
- py_mihomo_trojan_interface-0.1.0/tests/test_config.py +46 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGN_2015
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-mihomo-trojan-interface
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Launch mihomo with an administrator-elevated Trojan config generated from a share URL.
|
|
5
|
+
Author: GGN_2015
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 GGN_2015
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: administrator,clash,mihomo,proxy,trojan
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: MacOS
|
|
33
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
34
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
38
|
+
Classifier: Topic :: System :: Systems Administration
|
|
39
|
+
Requires-Python: >=3.9
|
|
40
|
+
Requires-Dist: py-admin-launch>=0.1.3
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# py-mihomo-trojan-interface
|
|
44
|
+
|
|
45
|
+
Generate a mihomo YAML config from a `trojan://` share URL, relaunch with
|
|
46
|
+
administrator privileges through `py-admin-launch`, and start mihomo with the
|
|
47
|
+
generated config.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Prefer passing the Trojan URL through stdin, an environment variable, or a file
|
|
58
|
+
so the full URL does not stay in shell history.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
printf '%s' 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>' | \
|
|
62
|
+
mihomo-trojan \
|
|
63
|
+
--mihomo /path/to/mihomo \
|
|
64
|
+
--country-mmdb /path/to/Country.mmdb \
|
|
65
|
+
--trojan-url-stdin
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
PowerShell example:
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
$env:MIHOMO_TROJAN_URL = 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>'
|
|
72
|
+
mihomo-trojan `
|
|
73
|
+
--mihomo C:\path\to\mihomo.exe `
|
|
74
|
+
--country-mmdb C:\path\to\Country.mmdb `
|
|
75
|
+
--trojan-url-env MIHOMO_TROJAN_URL
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
You can also pass the URL directly when needed:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
mihomo-trojan \
|
|
82
|
+
--mihomo /path/to/mihomo \
|
|
83
|
+
--country-mmdb /path/to/Country.mmdb \
|
|
84
|
+
--trojan-url 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The launcher passes the mihomo data directory with `-d` and the generated YAML
|
|
88
|
+
with `-f`. If `--data-dir` is not provided and the mmdb file is named
|
|
89
|
+
`Country.mmdb`, the file's parent directory is used as the mihomo data
|
|
90
|
+
directory.
|
|
91
|
+
|
|
92
|
+
Useful options:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
mihomo-trojan --help
|
|
96
|
+
mihomo-trojan --dry-run --mihomo /path/to/mihomo --country-mmdb /path/to/Country.mmdb --trojan-url-env MIHOMO_TROJAN_URL
|
|
97
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# py-mihomo-trojan-interface
|
|
2
|
+
|
|
3
|
+
Generate a mihomo YAML config from a `trojan://` share URL, relaunch with
|
|
4
|
+
administrator privileges through `py-admin-launch`, and start mihomo with the
|
|
5
|
+
generated config.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Prefer passing the Trojan URL through stdin, an environment variable, or a file
|
|
16
|
+
so the full URL does not stay in shell history.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
printf '%s' 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>' | \
|
|
20
|
+
mihomo-trojan \
|
|
21
|
+
--mihomo /path/to/mihomo \
|
|
22
|
+
--country-mmdb /path/to/Country.mmdb \
|
|
23
|
+
--trojan-url-stdin
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
PowerShell example:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
$env:MIHOMO_TROJAN_URL = 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>'
|
|
30
|
+
mihomo-trojan `
|
|
31
|
+
--mihomo C:\path\to\mihomo.exe `
|
|
32
|
+
--country-mmdb C:\path\to\Country.mmdb `
|
|
33
|
+
--trojan-url-env MIHOMO_TROJAN_URL
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You can also pass the URL directly when needed:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mihomo-trojan \
|
|
40
|
+
--mihomo /path/to/mihomo \
|
|
41
|
+
--country-mmdb /path/to/Country.mmdb \
|
|
42
|
+
--trojan-url 'trojan://<secret>@<host>:<port>?type=tcp&sni=<sni>#<name>'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The launcher passes the mihomo data directory with `-d` and the generated YAML
|
|
46
|
+
with `-f`. If `--data-dir` is not provided and the mmdb file is named
|
|
47
|
+
`Country.mmdb`, the file's parent directory is used as the mihomo data
|
|
48
|
+
directory.
|
|
49
|
+
|
|
50
|
+
Useful options:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
mihomo-trojan --help
|
|
54
|
+
mihomo-trojan --dry-run --mihomo /path/to/mihomo --country-mmdb /path/to/Country.mmdb --trojan-url-env MIHOMO_TROJAN_URL
|
|
55
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "py-mihomo-trojan-interface"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Launch mihomo with an administrator-elevated Trojan config generated from a share URL."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "GGN_2015" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mihomo", "clash", "trojan", "proxy", "administrator"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
"Operating System :: POSIX :: Linux",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Topic :: Internet :: Proxy Servers",
|
|
26
|
+
"Topic :: System :: Systems Administration",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"py-admin-launch>=0.1.3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
mihomo-trojan = "mihomo_trojan_interface.cli:main"
|
|
34
|
+
py-mihomo-trojan-interface = "mihomo_trojan_interface.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/mihomo_trojan_interface"]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ctypes
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import (
|
|
14
|
+
find_running_mihomo_processes,
|
|
15
|
+
flush_windows_dns,
|
|
16
|
+
generate_config,
|
|
17
|
+
write_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_admin() -> bool:
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
try:
|
|
24
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
geteuid = getattr(os, "geteuid", None)
|
|
28
|
+
return bool(geteuid is not None and geteuid() == 0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="mihomo-trojan",
|
|
34
|
+
description="Generate a mihomo Trojan YAML config, elevate privileges, and launch mihomo.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("trojan_url", nargs="?", help="trojan:// share URL")
|
|
37
|
+
parser.add_argument("--mihomo", required=True, help="path to the mihomo executable")
|
|
38
|
+
parser.add_argument("--country-mmdb", required=True, help="path to Country.mmdb")
|
|
39
|
+
parser.add_argument("--trojan-url", dest="trojan_url_option", help="trojan:// share URL")
|
|
40
|
+
parser.add_argument("--trojan-url-file", help="read the trojan:// share URL from a text file")
|
|
41
|
+
parser.add_argument("--trojan-url-env", help="read the trojan:// share URL from an environment variable")
|
|
42
|
+
parser.add_argument("--trojan-url-stdin", action="store_true", help="read the trojan:// share URL from stdin")
|
|
43
|
+
parser.add_argument("--config", help="output mihomo YAML path; defaults to <data-dir>/mihomo-trojan.yaml")
|
|
44
|
+
parser.add_argument("--data-dir", help="mihomo data directory passed to mihomo with -d")
|
|
45
|
+
parser.add_argument("--mixed-port", type=int, default=7890, help="mihomo mixed proxy port")
|
|
46
|
+
parser.add_argument("--controller", default="127.0.0.1:9090", help="external-controller address")
|
|
47
|
+
parser.add_argument("--no-tun", action="store_true", help="disable TUN in generated config")
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--keep-server-domain",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="keep the original server domain instead of resolving it to IPv4",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("--server-ip", action="append", default=[], help="pin a server IPv4 address; repeatable")
|
|
54
|
+
parser.add_argument("--connect-ip", default="", help="IPv4 address to use in proxies[].server")
|
|
55
|
+
parser.add_argument("--resolve-timeout", type=float, default=5.0, help="DNS/connect timeout in seconds")
|
|
56
|
+
parser.add_argument("--resolve-dns-server", default="223.5.5.5", help="DNS server used while generating")
|
|
57
|
+
parser.add_argument("--strict-cert", action="store_true", help="set skip-cert-verify to false")
|
|
58
|
+
parser.add_argument("--interface-name", default="", help="physical outbound interface name")
|
|
59
|
+
parser.add_argument("--node-name", default="", help="override proxy node name")
|
|
60
|
+
parser.add_argument("--host-alias", action="append", default=[], help="additional CNAME/host to pin; repeatable")
|
|
61
|
+
parser.add_argument("--allow-running", action="store_true", help="continue even when another mihomo process is found")
|
|
62
|
+
parser.add_argument("--no-flush-dns", action="store_true", help="skip ipconfig /flushdns on Windows")
|
|
63
|
+
parser.add_argument("--mihomo-arg", action="append", default=[], help="extra argument passed to mihomo; repeatable")
|
|
64
|
+
parser.add_argument("--no-wait-mihomo", action="store_true", help="start mihomo in the background")
|
|
65
|
+
parser.add_argument("--dry-run", action="store_true", help="write the config but do not start mihomo")
|
|
66
|
+
parser.add_argument("--no-elevate", action="store_true", help=argparse.SUPPRESS)
|
|
67
|
+
parser.add_argument("--delete-trojan-url-file", action="store_true", help=argparse.SUPPRESS)
|
|
68
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_trojan_url(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str:
|
|
73
|
+
sources = [
|
|
74
|
+
value is not None
|
|
75
|
+
for value in [
|
|
76
|
+
args.trojan_url,
|
|
77
|
+
args.trojan_url_option,
|
|
78
|
+
args.trojan_url_file,
|
|
79
|
+
args.trojan_url_env,
|
|
80
|
+
]
|
|
81
|
+
]
|
|
82
|
+
sources.append(args.trojan_url_stdin)
|
|
83
|
+
if sum(1 for used in sources if used) != 1:
|
|
84
|
+
parser.error("provide exactly one trojan URL source")
|
|
85
|
+
|
|
86
|
+
if args.trojan_url is not None:
|
|
87
|
+
link = args.trojan_url
|
|
88
|
+
elif args.trojan_url_option is not None:
|
|
89
|
+
link = args.trojan_url_option
|
|
90
|
+
elif args.trojan_url_file is not None:
|
|
91
|
+
path = Path(args.trojan_url_file)
|
|
92
|
+
try:
|
|
93
|
+
link = path.read_text(encoding="utf-8").strip()
|
|
94
|
+
finally:
|
|
95
|
+
if args.delete_trojan_url_file:
|
|
96
|
+
try:
|
|
97
|
+
path.unlink()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
pass
|
|
100
|
+
elif args.trojan_url_env is not None:
|
|
101
|
+
link = os.environ.get(args.trojan_url_env, "").strip()
|
|
102
|
+
if not link:
|
|
103
|
+
parser.error(f"environment variable {args.trojan_url_env!r} is empty or missing")
|
|
104
|
+
else:
|
|
105
|
+
link = sys.stdin.read().strip()
|
|
106
|
+
|
|
107
|
+
if not link:
|
|
108
|
+
parser.error("trojan URL is empty")
|
|
109
|
+
return link
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_secret_temp_file(secret: str) -> Path:
|
|
113
|
+
fd, name = tempfile.mkstemp(prefix="mihomo-trojan-url-", suffix=".txt")
|
|
114
|
+
path = Path(name)
|
|
115
|
+
try:
|
|
116
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
117
|
+
handle.write(secret)
|
|
118
|
+
handle.write("\n")
|
|
119
|
+
try:
|
|
120
|
+
path.chmod(0o600)
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
except BaseException:
|
|
124
|
+
try:
|
|
125
|
+
path.unlink()
|
|
126
|
+
finally:
|
|
127
|
+
raise
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_executable(value: str) -> Path:
|
|
132
|
+
candidate = Path(value).expanduser()
|
|
133
|
+
if candidate.exists():
|
|
134
|
+
return candidate.resolve()
|
|
135
|
+
|
|
136
|
+
resolved = shutil.which(value)
|
|
137
|
+
if resolved:
|
|
138
|
+
return Path(resolved).resolve()
|
|
139
|
+
raise FileNotFoundError(f"mihomo executable not found: {value}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_existing_file(value: str, label: str) -> Path:
|
|
143
|
+
path = Path(value).expanduser()
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
raise FileNotFoundError(f"{label} not found: {value}")
|
|
146
|
+
return path.resolve()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def prepare_data_dir(country_mmdb: Path, requested_data_dir: str | None) -> Path:
|
|
150
|
+
if requested_data_dir:
|
|
151
|
+
data_dir = Path(requested_data_dir).expanduser().resolve()
|
|
152
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
target = data_dir / "Country.mmdb"
|
|
154
|
+
if country_mmdb.resolve() != target.resolve():
|
|
155
|
+
shutil.copy2(country_mmdb, target)
|
|
156
|
+
return data_dir
|
|
157
|
+
|
|
158
|
+
if country_mmdb.name == "Country.mmdb":
|
|
159
|
+
return country_mmdb.parent.resolve()
|
|
160
|
+
|
|
161
|
+
data_dir = Path(tempfile.gettempdir()) / "mihomo-trojan-interface"
|
|
162
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
shutil.copy2(country_mmdb, data_dir / "Country.mmdb")
|
|
164
|
+
return data_dir.resolve()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def config_path_for(args: argparse.Namespace, data_dir: Path) -> Path:
|
|
168
|
+
if args.config:
|
|
169
|
+
return Path(args.config).expanduser().resolve()
|
|
170
|
+
return data_dir / "mihomo-trojan.yaml"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def child_argv_from_args(args: argparse.Namespace, trojan_url_file: Path) -> list[str]:
|
|
174
|
+
child = [
|
|
175
|
+
"--mihomo",
|
|
176
|
+
str(args.mihomo),
|
|
177
|
+
"--country-mmdb",
|
|
178
|
+
str(args.country_mmdb),
|
|
179
|
+
"--trojan-url-file",
|
|
180
|
+
str(trojan_url_file),
|
|
181
|
+
"--delete-trojan-url-file",
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
if args.config:
|
|
185
|
+
child.extend(["--config", args.config])
|
|
186
|
+
if args.data_dir:
|
|
187
|
+
child.extend(["--data-dir", args.data_dir])
|
|
188
|
+
child.extend(["--mixed-port", str(args.mixed_port)])
|
|
189
|
+
child.extend(["--controller", args.controller])
|
|
190
|
+
if args.no_tun:
|
|
191
|
+
child.append("--no-tun")
|
|
192
|
+
if args.keep_server_domain:
|
|
193
|
+
child.append("--keep-server-domain")
|
|
194
|
+
for value in args.server_ip:
|
|
195
|
+
child.extend(["--server-ip", value])
|
|
196
|
+
if args.connect_ip:
|
|
197
|
+
child.extend(["--connect-ip", args.connect_ip])
|
|
198
|
+
child.extend(["--resolve-timeout", str(args.resolve_timeout)])
|
|
199
|
+
child.extend(["--resolve-dns-server", args.resolve_dns_server])
|
|
200
|
+
if args.strict_cert:
|
|
201
|
+
child.append("--strict-cert")
|
|
202
|
+
if args.interface_name:
|
|
203
|
+
child.extend(["--interface-name", args.interface_name])
|
|
204
|
+
if args.node_name:
|
|
205
|
+
child.extend(["--node-name", args.node_name])
|
|
206
|
+
for value in args.host_alias:
|
|
207
|
+
child.extend(["--host-alias", value])
|
|
208
|
+
if args.allow_running:
|
|
209
|
+
child.append("--allow-running")
|
|
210
|
+
if args.no_flush_dns:
|
|
211
|
+
child.append("--no-flush-dns")
|
|
212
|
+
for value in args.mihomo_arg:
|
|
213
|
+
child.extend(["--mihomo-arg", value])
|
|
214
|
+
if args.no_wait_mihomo:
|
|
215
|
+
child.append("--no-wait-mihomo")
|
|
216
|
+
if args.dry_run:
|
|
217
|
+
child.append("--dry-run")
|
|
218
|
+
return child
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def relaunch_as_admin(args: argparse.Namespace, trojan_url: str) -> int:
|
|
222
|
+
from py_admin_launch import launch
|
|
223
|
+
|
|
224
|
+
args.mihomo = str(resolve_executable(args.mihomo))
|
|
225
|
+
args.country_mmdb = str(resolve_existing_file(args.country_mmdb, "Country.mmdb"))
|
|
226
|
+
secret_path = write_secret_temp_file(trojan_url)
|
|
227
|
+
command = [
|
|
228
|
+
sys.executable,
|
|
229
|
+
"-m",
|
|
230
|
+
"mihomo_trojan_interface",
|
|
231
|
+
*child_argv_from_args(args, secret_path),
|
|
232
|
+
"--no-elevate",
|
|
233
|
+
]
|
|
234
|
+
try:
|
|
235
|
+
result = launch(command, cwd=os.getcwd(), wait=True)
|
|
236
|
+
except BaseException:
|
|
237
|
+
try:
|
|
238
|
+
secret_path.unlink()
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
raise
|
|
242
|
+
return 0 if result.returncode is None else int(result.returncode)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_mihomo(command: list[str], cwd: Path, no_wait: bool) -> int:
|
|
246
|
+
if no_wait:
|
|
247
|
+
process = subprocess.Popen(command, cwd=str(cwd))
|
|
248
|
+
print(f"Started mihomo with PID {process.pid}.")
|
|
249
|
+
return 0
|
|
250
|
+
return subprocess.run(command, cwd=str(cwd), check=False).returncode
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(args: argparse.Namespace, trojan_url: str) -> int:
|
|
254
|
+
mihomo = resolve_executable(args.mihomo)
|
|
255
|
+
country_mmdb = resolve_existing_file(args.country_mmdb, "Country.mmdb")
|
|
256
|
+
|
|
257
|
+
if not args.allow_running:
|
|
258
|
+
running = find_running_mihomo_processes()
|
|
259
|
+
if running:
|
|
260
|
+
print("mihomo appears to be running; refusing to generate a competing config.", file=sys.stderr)
|
|
261
|
+
print("Use --allow-running to override this check.", file=sys.stderr)
|
|
262
|
+
return 2
|
|
263
|
+
|
|
264
|
+
if not args.no_flush_dns:
|
|
265
|
+
if flush_windows_dns():
|
|
266
|
+
print("Flushed Windows DNS cache.")
|
|
267
|
+
elif sys.platform.startswith("win"):
|
|
268
|
+
print("Warning: failed to flush Windows DNS cache.", file=sys.stderr)
|
|
269
|
+
|
|
270
|
+
data_dir = prepare_data_dir(country_mmdb, args.data_dir)
|
|
271
|
+
config_path = config_path_for(args, data_dir)
|
|
272
|
+
generated = generate_config(
|
|
273
|
+
trojan_url,
|
|
274
|
+
mixed_port=args.mixed_port,
|
|
275
|
+
controller=args.controller,
|
|
276
|
+
enable_tun=not args.no_tun,
|
|
277
|
+
keep_server_domain=args.keep_server_domain,
|
|
278
|
+
server_ips=args.server_ip,
|
|
279
|
+
connect_ip=args.connect_ip,
|
|
280
|
+
resolve_timeout=args.resolve_timeout,
|
|
281
|
+
resolve_dns_server=args.resolve_dns_server,
|
|
282
|
+
skip_cert_verify=not args.strict_cert,
|
|
283
|
+
interface_name=args.interface_name,
|
|
284
|
+
node_name=args.node_name,
|
|
285
|
+
host_aliases=args.host_alias,
|
|
286
|
+
)
|
|
287
|
+
write_config(config_path, generated.content)
|
|
288
|
+
|
|
289
|
+
print(f"Wrote mihomo config: {config_path}")
|
|
290
|
+
print(f"Using mihomo data dir: {data_dir}")
|
|
291
|
+
if args.dry_run:
|
|
292
|
+
print("Dry run complete; mihomo was not started.")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
command = [str(mihomo), "-d", str(data_dir), "-f", str(config_path), *args.mihomo_arg]
|
|
296
|
+
print("Starting mihomo with administrator privileges.")
|
|
297
|
+
return run_mihomo(command, data_dir, args.no_wait_mihomo)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main(argv: list[str] | None = None) -> int:
|
|
301
|
+
parser = build_parser()
|
|
302
|
+
args = parser.parse_args(argv)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
trojan_url = read_trojan_url(args, parser)
|
|
306
|
+
if not args.no_elevate and not is_admin():
|
|
307
|
+
return relaunch_as_admin(args, trojan_url)
|
|
308
|
+
return run(args, trojan_url)
|
|
309
|
+
except KeyboardInterrupt:
|
|
310
|
+
return 130
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
313
|
+
return 1
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_GOOGLE_RULES = [
|
|
18
|
+
"DOMAIN-SUFFIX,google.com.hk,Proxy",
|
|
19
|
+
"DOMAIN-SUFFIX,google.com,Proxy",
|
|
20
|
+
"DOMAIN-SUFFIX,pki.goog,Proxy",
|
|
21
|
+
"DOMAIN-SUFFIX,googletrustservices.com,Proxy",
|
|
22
|
+
"DOMAIN-SUFFIX,googleapis.com,Proxy",
|
|
23
|
+
"DOMAIN-SUFFIX,gstatic.com,Proxy",
|
|
24
|
+
"DOMAIN-SUFFIX,googleusercontent.com,Proxy",
|
|
25
|
+
"DOMAIN-SUFFIX,googlevideo.com,Proxy",
|
|
26
|
+
"DOMAIN-SUFFIX,ggpht.com,Proxy",
|
|
27
|
+
"DOMAIN-SUFFIX,ytimg.com,Proxy",
|
|
28
|
+
"DOMAIN-SUFFIX,youtube.com,Proxy",
|
|
29
|
+
"DOMAIN-KEYWORD,google,Proxy",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TrojanLink:
|
|
35
|
+
name: str
|
|
36
|
+
password: str
|
|
37
|
+
host: str
|
|
38
|
+
port: int
|
|
39
|
+
sni: str
|
|
40
|
+
network: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ResolvedTrojanConfig:
|
|
45
|
+
content: str
|
|
46
|
+
node: TrojanLink
|
|
47
|
+
resolved_ips: list[str]
|
|
48
|
+
connect_ip: str
|
|
49
|
+
host_aliases: list[str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def yaml_quote(value: object) -> str:
|
|
53
|
+
text = str(value)
|
|
54
|
+
text = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
55
|
+
return f'"{text}"'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def yaml_bool(value: bool) -> str:
|
|
59
|
+
return "true" if value else "false"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def first_query_value(query: dict[str, list[str]], key: str, default: str = "") -> str:
|
|
63
|
+
values = query.get(key)
|
|
64
|
+
return values[0] if values else default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_trojan_link(link: str) -> TrojanLink:
|
|
68
|
+
link = link.strip().strip('"').strip("'")
|
|
69
|
+
parsed = urlsplit(link)
|
|
70
|
+
|
|
71
|
+
if parsed.scheme.lower() != "trojan":
|
|
72
|
+
raise ValueError("Only trojan:// links are supported.")
|
|
73
|
+
if not parsed.hostname:
|
|
74
|
+
raise ValueError("Trojan link is missing server hostname.")
|
|
75
|
+
if not parsed.username:
|
|
76
|
+
raise ValueError("Trojan link is missing password.")
|
|
77
|
+
|
|
78
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
|
79
|
+
name = unquote(parsed.fragment) if parsed.fragment else parsed.hostname
|
|
80
|
+
password = unquote(parsed.username)
|
|
81
|
+
host = parsed.hostname
|
|
82
|
+
port = parsed.port or 443
|
|
83
|
+
sni = first_query_value(query, "sni", host)
|
|
84
|
+
network = first_query_value(query, "type", "tcp").lower() or "tcp"
|
|
85
|
+
|
|
86
|
+
return TrojanLink(
|
|
87
|
+
name=name,
|
|
88
|
+
password=password,
|
|
89
|
+
host=host,
|
|
90
|
+
port=port,
|
|
91
|
+
sni=sni,
|
|
92
|
+
network=network,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unique_preserve_order(values: Iterable[str]) -> list[str]:
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
result: list[str] = []
|
|
99
|
+
for value in values:
|
|
100
|
+
if value and value not in seen:
|
|
101
|
+
seen.add(value)
|
|
102
|
+
result.append(value)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_ip_address(host: str) -> bool:
|
|
107
|
+
try:
|
|
108
|
+
ipaddress.ip_address(host)
|
|
109
|
+
return True
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ipv4_from_getaddrinfo(host: str) -> list[str]:
|
|
115
|
+
try:
|
|
116
|
+
infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM)
|
|
117
|
+
except OSError:
|
|
118
|
+
return []
|
|
119
|
+
return unique_preserve_order(info[4][0] for info in infos)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_ipv4(host: str, timeout: float = 5.0, dns_server: str = "223.5.5.5") -> list[str]:
|
|
123
|
+
if is_ip_address(host):
|
|
124
|
+
return [host] if "." in host else []
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
completed = subprocess.run(
|
|
128
|
+
["nslookup", host, dns_server],
|
|
129
|
+
check=False,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
encoding="utf-8",
|
|
134
|
+
errors="ignore",
|
|
135
|
+
)
|
|
136
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
137
|
+
return _ipv4_from_getaddrinfo(host)
|
|
138
|
+
|
|
139
|
+
output = completed.stdout + "\n" + completed.stderr
|
|
140
|
+
ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", output)
|
|
141
|
+
result = unique_preserve_order(ip for ip in ips if is_ip_address(ip) and ip != dns_server)
|
|
142
|
+
return result or _ipv4_from_getaddrinfo(host)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_cname_aliases(host: str, timeout: float = 5.0, dns_server: str = "223.5.5.5") -> list[str]:
|
|
146
|
+
if is_ip_address(host):
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
completed = subprocess.run(
|
|
151
|
+
["nslookup", "-type=CNAME", host, dns_server],
|
|
152
|
+
check=False,
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
timeout=timeout,
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
errors="ignore",
|
|
158
|
+
)
|
|
159
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
output = completed.stdout + "\n" + completed.stderr
|
|
163
|
+
aliases: list[str] = []
|
|
164
|
+
for pattern in [
|
|
165
|
+
r"canonical name\s*=\s*([^\s]+)",
|
|
166
|
+
r"Aliases:\s*([^\s]+)",
|
|
167
|
+
]:
|
|
168
|
+
aliases.extend(match.rstrip(".") for match in re.findall(pattern, output, flags=re.IGNORECASE))
|
|
169
|
+
return [alias for alias in unique_preserve_order(aliases) if alias.lower() != host.lower()]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def tcp_connects(ip: str, port: int, timeout: float = 3.0) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
with socket.create_connection((ip, port), timeout=timeout):
|
|
175
|
+
return True
|
|
176
|
+
except OSError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def tls_connect_latency(ip: str, port: int, sni: str, timeout: float = 4.0) -> float | None:
|
|
181
|
+
start = time.monotonic()
|
|
182
|
+
try:
|
|
183
|
+
with socket.create_connection((ip, port), timeout=timeout) as raw:
|
|
184
|
+
context = ssl._create_unverified_context()
|
|
185
|
+
with context.wrap_socket(raw, server_hostname=sni):
|
|
186
|
+
return time.monotonic() - start
|
|
187
|
+
except OSError:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def choose_connect_ip(ips: list[str], port: int, sni: str, timeout: float) -> str:
|
|
192
|
+
candidates: list[tuple[float, str]] = []
|
|
193
|
+
for ip in ips:
|
|
194
|
+
latency = tls_connect_latency(ip, port, sni, min(timeout, 4.0))
|
|
195
|
+
if latency is not None:
|
|
196
|
+
candidates.append((latency, ip))
|
|
197
|
+
if candidates:
|
|
198
|
+
return min(candidates)[1]
|
|
199
|
+
|
|
200
|
+
tcp_candidates: list[tuple[float, str]] = []
|
|
201
|
+
for ip in ips:
|
|
202
|
+
start = time.monotonic()
|
|
203
|
+
if tcp_connects(ip, port, min(timeout, 3.0)):
|
|
204
|
+
tcp_candidates.append((time.monotonic() - start, ip))
|
|
205
|
+
if tcp_candidates:
|
|
206
|
+
return min(tcp_candidates)[1]
|
|
207
|
+
return ips[0] if ips else ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def find_running_mihomo_processes() -> list[str]:
|
|
211
|
+
if sys.platform.startswith("win"):
|
|
212
|
+
return _find_running_mihomo_processes_windows()
|
|
213
|
+
return _find_running_mihomo_processes_posix()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _find_running_mihomo_processes_windows() -> list[str]:
|
|
217
|
+
try:
|
|
218
|
+
completed = subprocess.run(
|
|
219
|
+
[
|
|
220
|
+
"powershell",
|
|
221
|
+
"-NoProfile",
|
|
222
|
+
"-Command",
|
|
223
|
+
"$names = 'mihomo|clash|verge|party'; "
|
|
224
|
+
"$procs = Get-CimInstance Win32_Process | "
|
|
225
|
+
"Where-Object { $_.Name -match $names } | "
|
|
226
|
+
"ForEach-Object { \"$($_.ProcessId):$($_.Name)\" }; "
|
|
227
|
+
"$ports = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | "
|
|
228
|
+
"Where-Object { $_.LocalPort -in 7890,9090 } | "
|
|
229
|
+
"ForEach-Object { \"listen:$($_.LocalAddress):$($_.LocalPort)\" }; "
|
|
230
|
+
"$procs + $ports",
|
|
231
|
+
],
|
|
232
|
+
check=False,
|
|
233
|
+
capture_output=True,
|
|
234
|
+
text=True,
|
|
235
|
+
timeout=5,
|
|
236
|
+
encoding="utf-8",
|
|
237
|
+
errors="ignore",
|
|
238
|
+
)
|
|
239
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
return unique_preserve_order(line.strip() for line in completed.stdout.splitlines() if line.strip())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _find_running_mihomo_processes_posix() -> list[str]:
|
|
246
|
+
try:
|
|
247
|
+
completed = subprocess.run(
|
|
248
|
+
["pgrep", "-fl", "mihomo|clash|verge|party"],
|
|
249
|
+
check=False,
|
|
250
|
+
capture_output=True,
|
|
251
|
+
text=True,
|
|
252
|
+
timeout=5,
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
errors="ignore",
|
|
255
|
+
)
|
|
256
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
current_pid = str(os.getpid())
|
|
260
|
+
return unique_preserve_order(
|
|
261
|
+
line.strip()
|
|
262
|
+
for line in completed.stdout.splitlines()
|
|
263
|
+
if line.strip() and not line.strip().startswith(current_pid + " ")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def flush_windows_dns() -> bool:
|
|
268
|
+
if not sys.platform.startswith("win"):
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
completed = subprocess.run(
|
|
273
|
+
["ipconfig", "/flushdns"],
|
|
274
|
+
check=False,
|
|
275
|
+
capture_output=True,
|
|
276
|
+
text=True,
|
|
277
|
+
timeout=10,
|
|
278
|
+
encoding="utf-8",
|
|
279
|
+
errors="ignore",
|
|
280
|
+
)
|
|
281
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
return completed.returncode == 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_yaml(
|
|
288
|
+
node: TrojanLink,
|
|
289
|
+
*,
|
|
290
|
+
mixed_port: int,
|
|
291
|
+
controller: str,
|
|
292
|
+
enable_tun: bool,
|
|
293
|
+
server_ips: list[str],
|
|
294
|
+
connect_ip: str,
|
|
295
|
+
skip_cert_verify: bool,
|
|
296
|
+
interface_name: str,
|
|
297
|
+
node_name: str,
|
|
298
|
+
host_aliases: list[str],
|
|
299
|
+
) -> str:
|
|
300
|
+
pinned_ips = unique_preserve_order([*server_ips, connect_ip])
|
|
301
|
+
server = connect_ip or (server_ips[0] if server_ips else node.host)
|
|
302
|
+
route_excludes = pinned_ips if pinned_ips else ([node.host] if is_ip_address(node.host) else [])
|
|
303
|
+
display_name = node_name or node.name
|
|
304
|
+
|
|
305
|
+
host_map: dict[str, list[str]] = {}
|
|
306
|
+
if pinned_ips and not is_ip_address(node.host):
|
|
307
|
+
for host in unique_preserve_order([node.host, *host_aliases]):
|
|
308
|
+
host_map[host] = pinned_ips
|
|
309
|
+
|
|
310
|
+
lines: list[str] = [
|
|
311
|
+
f"mixed-port: {mixed_port}",
|
|
312
|
+
"allow-lan: false",
|
|
313
|
+
"bind-address: 127.0.0.1",
|
|
314
|
+
"mode: rule",
|
|
315
|
+
"log-level: error",
|
|
316
|
+
"ipv6: false",
|
|
317
|
+
f"interface-name: {yaml_quote(interface_name)}" if interface_name else 'interface-name: ""',
|
|
318
|
+
"geodata-mode: false",
|
|
319
|
+
"geo-auto-update: false",
|
|
320
|
+
"geo-update-interval: 24",
|
|
321
|
+
f"external-controller: {yaml_quote(controller)}",
|
|
322
|
+
'secret: ""',
|
|
323
|
+
"unified-delay: true",
|
|
324
|
+
"tcp-concurrent: false",
|
|
325
|
+
"",
|
|
326
|
+
"tun:",
|
|
327
|
+
f" enable: {yaml_bool(enable_tun)}",
|
|
328
|
+
" stack: mixed",
|
|
329
|
+
" auto-route: true",
|
|
330
|
+
" auto-detect-interface: true",
|
|
331
|
+
" strict-route: false",
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
if route_excludes:
|
|
335
|
+
lines.append(" route-exclude-address:")
|
|
336
|
+
for value in route_excludes:
|
|
337
|
+
suffix = "/32" if "." in value and "/" not in value else ""
|
|
338
|
+
lines.append(f" - {value}{suffix}")
|
|
339
|
+
|
|
340
|
+
lines.extend(
|
|
341
|
+
[
|
|
342
|
+
" dns-hijack:",
|
|
343
|
+
" - 198.18.0.2:53",
|
|
344
|
+
" - tcp://198.18.0.2:53",
|
|
345
|
+
" - any:53",
|
|
346
|
+
" - tcp://any:53",
|
|
347
|
+
"",
|
|
348
|
+
"profile:",
|
|
349
|
+
" store-selected: true",
|
|
350
|
+
" store-fake-ip: false",
|
|
351
|
+
"",
|
|
352
|
+
"dns:",
|
|
353
|
+
" enable: true",
|
|
354
|
+
" listen: 0.0.0.0:1053",
|
|
355
|
+
" ipv6: false",
|
|
356
|
+
" enhanced-mode: redir-host",
|
|
357
|
+
" use-hosts: true",
|
|
358
|
+
" use-system-hosts: true",
|
|
359
|
+
]
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if host_map:
|
|
363
|
+
lines.append(" nameserver-hosts:")
|
|
364
|
+
for host, ips in host_map.items():
|
|
365
|
+
lines.append(f" {yaml_quote(host)}:")
|
|
366
|
+
for ip in ips:
|
|
367
|
+
lines.append(f" - {ip}")
|
|
368
|
+
|
|
369
|
+
lines.extend(
|
|
370
|
+
[
|
|
371
|
+
" default-nameserver:",
|
|
372
|
+
" - 223.5.5.5",
|
|
373
|
+
" - 119.29.29.29",
|
|
374
|
+
" nameserver:",
|
|
375
|
+
" - https://dns.alidns.com/dns-query",
|
|
376
|
+
" - https://doh.pub/dns-query",
|
|
377
|
+
" fallback:",
|
|
378
|
+
" - https://1.1.1.1/dns-query",
|
|
379
|
+
" - https://8.8.8.8/dns-query",
|
|
380
|
+
" fallback-filter:",
|
|
381
|
+
" geoip: true",
|
|
382
|
+
" geoip-code: CN",
|
|
383
|
+
"",
|
|
384
|
+
"sniffer:",
|
|
385
|
+
" enable: true",
|
|
386
|
+
" force-dns-mapping: true",
|
|
387
|
+
" parse-pure-ip: false",
|
|
388
|
+
" sniff:",
|
|
389
|
+
" TLS:",
|
|
390
|
+
" ports:",
|
|
391
|
+
" - 443",
|
|
392
|
+
" - 8443",
|
|
393
|
+
" HTTP:",
|
|
394
|
+
" ports:",
|
|
395
|
+
" - 80",
|
|
396
|
+
" - 8080-8880",
|
|
397
|
+
" override-destination: true",
|
|
398
|
+
" QUIC:",
|
|
399
|
+
" ports:",
|
|
400
|
+
" - 443",
|
|
401
|
+
" - 8443",
|
|
402
|
+
"",
|
|
403
|
+
"proxies:",
|
|
404
|
+
f" - name: {yaml_quote(display_name)}",
|
|
405
|
+
" type: trojan",
|
|
406
|
+
f" server: {server}",
|
|
407
|
+
f" port: {node.port}",
|
|
408
|
+
f" password: {yaml_quote(node.password)}",
|
|
409
|
+
" udp: true",
|
|
410
|
+
" tls: true",
|
|
411
|
+
f" sni: {yaml_quote(node.sni)}",
|
|
412
|
+
f" network: {yaml_quote(node.network)}",
|
|
413
|
+
f" skip-cert-verify: {yaml_bool(skip_cert_verify)}",
|
|
414
|
+
"",
|
|
415
|
+
"proxy-groups:",
|
|
416
|
+
' - name: "Proxy"',
|
|
417
|
+
" type: select",
|
|
418
|
+
" proxies:",
|
|
419
|
+
f" - {yaml_quote(display_name)}",
|
|
420
|
+
" - DIRECT",
|
|
421
|
+
"",
|
|
422
|
+
"rules:",
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if not is_ip_address(node.host):
|
|
427
|
+
lines.append(f" - DOMAIN,{node.host},DIRECT")
|
|
428
|
+
for host in host_aliases:
|
|
429
|
+
if not is_ip_address(host):
|
|
430
|
+
lines.append(f" - DOMAIN,{host},DIRECT")
|
|
431
|
+
if node.sni and node.sni != node.host and not is_ip_address(node.sni):
|
|
432
|
+
lines.append(f" - DOMAIN,{node.sni},DIRECT")
|
|
433
|
+
for ip in pinned_ips:
|
|
434
|
+
lines.append(f" - IP-CIDR,{ip}/32,DIRECT,no-resolve")
|
|
435
|
+
|
|
436
|
+
lines.extend(f" - {rule}" for rule in DEFAULT_GOOGLE_RULES)
|
|
437
|
+
lines.extend(
|
|
438
|
+
[
|
|
439
|
+
" - DOMAIN-SUFFIX,local,DIRECT",
|
|
440
|
+
" - DOMAIN-SUFFIX,localhost,DIRECT",
|
|
441
|
+
" - IP-CIDR,127.0.0.0/8,DIRECT",
|
|
442
|
+
" - IP-CIDR,10.0.0.0/8,DIRECT",
|
|
443
|
+
" - IP-CIDR,172.16.0.0/12,DIRECT",
|
|
444
|
+
" - IP-CIDR,192.168.0.0/16,DIRECT",
|
|
445
|
+
" - IP-CIDR,224.0.0.0/4,DIRECT",
|
|
446
|
+
" - GEOIP,CN,DIRECT",
|
|
447
|
+
" - MATCH,Proxy",
|
|
448
|
+
]
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return "\n".join(lines) + "\n"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def generate_config(
|
|
455
|
+
link: str,
|
|
456
|
+
*,
|
|
457
|
+
mixed_port: int,
|
|
458
|
+
controller: str,
|
|
459
|
+
enable_tun: bool,
|
|
460
|
+
keep_server_domain: bool,
|
|
461
|
+
server_ips: list[str],
|
|
462
|
+
connect_ip: str,
|
|
463
|
+
resolve_timeout: float,
|
|
464
|
+
resolve_dns_server: str,
|
|
465
|
+
skip_cert_verify: bool,
|
|
466
|
+
interface_name: str,
|
|
467
|
+
node_name: str,
|
|
468
|
+
host_aliases: list[str],
|
|
469
|
+
) -> ResolvedTrojanConfig:
|
|
470
|
+
node = parse_trojan_link(link)
|
|
471
|
+
auto_host_aliases = (
|
|
472
|
+
resolve_cname_aliases(node.host, resolve_timeout, resolve_dns_server) if not keep_server_domain else []
|
|
473
|
+
)
|
|
474
|
+
all_host_aliases = unique_preserve_order([*host_aliases, *auto_host_aliases])
|
|
475
|
+
resolved_ips = unique_preserve_order(server_ips)
|
|
476
|
+
if not resolved_ips and not keep_server_domain:
|
|
477
|
+
resolved_ips = resolve_ipv4(node.host, resolve_timeout, resolve_dns_server)
|
|
478
|
+
selected_connect_ip = connect_ip or choose_connect_ip(resolved_ips, node.port, node.sni, resolve_timeout)
|
|
479
|
+
|
|
480
|
+
content = build_yaml(
|
|
481
|
+
node,
|
|
482
|
+
mixed_port=mixed_port,
|
|
483
|
+
controller=controller,
|
|
484
|
+
enable_tun=enable_tun,
|
|
485
|
+
server_ips=resolved_ips,
|
|
486
|
+
connect_ip=selected_connect_ip,
|
|
487
|
+
skip_cert_verify=skip_cert_verify,
|
|
488
|
+
interface_name=interface_name,
|
|
489
|
+
node_name=node_name,
|
|
490
|
+
host_aliases=all_host_aliases,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
return ResolvedTrojanConfig(
|
|
494
|
+
content=content,
|
|
495
|
+
node=node,
|
|
496
|
+
resolved_ips=resolved_ips,
|
|
497
|
+
connect_ip=selected_connect_ip,
|
|
498
|
+
host_aliases=all_host_aliases,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def write_config(path: Path, content: str) -> None:
|
|
503
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from mihomo_trojan_interface.config import build_yaml, parse_trojan_link
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrojanConfigTests(unittest.TestCase):
|
|
9
|
+
def test_parse_trojan_link(self) -> None:
|
|
10
|
+
node = parse_trojan_link(
|
|
11
|
+
"trojan://password@example.com:443?security=tls&type=tcp&sni=edge.example.com#Example"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
self.assertEqual(node.name, "Example")
|
|
15
|
+
self.assertEqual(node.password, "password")
|
|
16
|
+
self.assertEqual(node.host, "example.com")
|
|
17
|
+
self.assertEqual(node.port, 443)
|
|
18
|
+
self.assertEqual(node.sni, "edge.example.com")
|
|
19
|
+
self.assertEqual(node.network, "tcp")
|
|
20
|
+
|
|
21
|
+
def test_build_yaml_contains_mihomo_trojan_node(self) -> None:
|
|
22
|
+
node = parse_trojan_link("trojan://password@example.com:443?type=tcp&sni=edge.example.com#Example")
|
|
23
|
+
|
|
24
|
+
content = build_yaml(
|
|
25
|
+
node,
|
|
26
|
+
mixed_port=7890,
|
|
27
|
+
controller="127.0.0.1:9090",
|
|
28
|
+
enable_tun=True,
|
|
29
|
+
server_ips=["203.0.113.10"],
|
|
30
|
+
connect_ip="203.0.113.10",
|
|
31
|
+
skip_cert_verify=True,
|
|
32
|
+
interface_name="",
|
|
33
|
+
node_name="",
|
|
34
|
+
host_aliases=["alias.example.com"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.assertIn("mixed-port: 7890", content)
|
|
38
|
+
self.assertIn("type: trojan", content)
|
|
39
|
+
self.assertIn("server: 203.0.113.10", content)
|
|
40
|
+
self.assertIn('password: "password"', content)
|
|
41
|
+
self.assertIn('sni: "edge.example.com"', content)
|
|
42
|
+
self.assertIn(" - IP-CIDR,203.0.113.10/32,DIRECT,no-resolve", content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
unittest.main()
|