├── Dockerfile ├── README.md ├── app.py └── requirements.txt /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | EXPOSE 3000 8 | 9 | RUN apk update && apk --no-cache add openssl bash curl &&\ 10 | chmod +x app.py &&\ 11 | pip install -r requirements.txt 12 | 13 | CMD ["python3", "app.py"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 说明 2 | 3 | 本项目是基于python环境使用xray,引用argo隧道,集成哪吒探针(可选)搭建科学上网节点。 4 | 文件说明:app.py为主运行文件,requirements.txt为需要的组件库,swith为哪吒,bot为cloudfared,web为xray。 5 | 已适配FreeBSD,自行在右边的Releases中下载 6 | 7 | # 部署 8 | 9 | 方式一:常规python环境,例如游戏平台玩具,只需上传app.py和requirements.txt两个文件即可,app.py需授权777,app.py中17至30行填写变量。 10 | 11 | 方式二:文件+命令结合,app.py需赋权,上传app.py和requirements.tx两个文件,先运行chmod +x app.py 再运行pip install -r requirements.txt 然后运行screen python app.py即可,提示screen not found说明screen未安装,Debian/Ubuntu安装命令:apt install -y screen,centos安装命令:yum install -y screen 12 | 13 | 方式三:docker部署,右边的packages中已打包好镜像,镜像地址:ghcr.io/eooce/python:latest 支持镜像部署的平台推荐优先使用镜像 14 | 15 | # 环境变量 16 | * PaaS 平台设置的环境变量 17 | | 变量名 | 是否必须 | 默认值 | 备注 | 18 | | ------------ | ------ | ------ | ------ | 19 | | PORT | 否 | 3000 |http服务监听端口,也是订阅端口 | 20 | | FILE_PATH | 否 | tmp | 运行目录 | 21 | | URL | 否 | https://www.google.com |项目分配的域名| 22 | | TIME | 否 | 120 |自动访问间隔时间(默认2分钟)单位:秒| 23 | | UUID | 否 | abe2f2de-13ae-4f1f-bea5-d6c881ca3888|UUID| 24 | | ARGO_PORT | 否 | 8001 |argo隧道端口,固定隧道token需和cloudflare后台设置的一致| 25 | | NEZHA_SERVER | 否 | | 哪吒服务端域名,例如nz.aaa.com | 26 | | NEZHA_PORT | 否 | 5555 | 当哪吒端口为443时,自动开启tls | 27 | | NEZHA_KEY | 否 | | 哪吒客户端专用KEY | 28 | | ARGO_DOMAIN | 否 | | argo固定隧道域名 | 29 | | ARGO_AUTH | 否 | | argo固定隧道json或token | 30 | | CFIP | 否 |skk.moe | 节点优选域名或ip | 31 | | CFPORT | 否 | 443 |节点端口 | 32 | | NAME | 否 | Vls | 节点名称前缀,例如:Glitch,Replit| 33 | 34 | # 节点输出 35 | * 输出sub.txt节点文件,默认存放路径为temp 36 | * 订阅:分配的域名/sub;例如https://www.google.com/sub 37 | * 非标端口订阅(游戏类):分配的域名:端口/sub,前缀不是https,而是http,例如http://www.google.com:1234/sub 38 | 39 | # 其他 40 | * 此版本为Argo版,直连版本请移步:https://github.com/eoovve/python-xray-direct 41 | * 如需链接github部署,Fork后请先删除此README.md说明文件部署;支持Docker镜像部署又需要链接github部署的平台,只需新建项目,新建一个Dockerfile文件,里面填写FROM ghcr.io/eooce/python:latest部署即可 42 | 43 | # 免责声明 44 | 本程序仅供学习了解, 非盈利目的,请于下载后 24 小时内删除, 不得用作任何商业用途, 文字、数据及图片均有所属版权, 如转载须注明来源。 45 | 使用本程序必循遵守部署免责声明。使用本程序必循遵守部署服务器所在地、所在国家和用户所在国家的法律法规, 程序作者不对使用者任何不当行为负责。 46 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import subprocess 5 | import http.server 6 | import socketserver 7 | import threading 8 | import requests 9 | from flask import Flask 10 | import json 11 | import time 12 | import base64 13 | 14 | app = Flask(__name__) 15 | 16 | # Set environment variables 17 | FILE_PATH = os.environ.get('FILE_PATH', './tmp') 18 | PROJECT_URL = os.environ.get('URL', '') # 填写项目分配的url可实现自动访问,例如:https://www.google.com,留空即不启用该功能 19 | INTERVAL_SECONDS = int(os.environ.get("TIME", 120)) # 访问间隔时间,默认120s,单位:秒 20 | UUID = os.environ.get('UUID', '0004add9-5c68-8bab-870c-08cd5320df00') # UUID 21 | NEZHA_SERVER = os.environ.get('NEZHA_SERVER', 'nz.f4i.cn') # 哪吒3个变量不全不运行 22 | NEZHA_PORT = os.environ.get('NEZHA_PORT', '5555') # 哪吒端口为{443,8443,2096,2097,2083}其中之一时自动开启tls 23 | NEZHA_KEY = os.environ.get('NEZHA_KEY', '') # 哪吒客户端密钥 24 | ARGO_DOMAIN = os.environ.get('ARGO_DOMAIN', '') # 国定隧道域名,留空即启用临时隧道 25 | ARGO_AUTH = os.environ.get('ARGO_AUTH', '') # 国定隧道json或token,留空即启用临时隧道,json获取地址:https://fscarmen.cloudflare.now.cc 26 | ARGO_PORT = int(os.environ.get('ARGO_PORT', 8001)) # Argo端口,固定隧道token请改回8080或在cf后台设置的端口与这里对应 27 | CFIP = os.environ.get('CFIP', 'www.visa.com.tw') # 优选域名或优选ip 28 | CFPORT = int(os.environ.get('CFPORT', 443)) # 优选域名或优选ip对应端口 29 | NAME = os.environ.get('NAME', 'Vls') # 节点名称 30 | PORT = int(os.environ.get('SERVER_PORT') or os.environ.get('PORT') or 3000) # 订阅端口,如无法订阅,请手动修改为分配的端口 31 | 32 | # Create directory if it doesn't exist 33 | if not os.path.exists(FILE_PATH): 34 | os.makedirs(FILE_PATH) 35 | print(f"{FILE_PATH} has been created") 36 | else: 37 | print(f"{FILE_PATH} already exists") 38 | 39 | # Clean old files 40 | paths_to_delete = ['boot.log', 'list.txt','sub.txt', 'npm', 'web', 'bot', 'tunnel.yml', 'tunnel.json'] 41 | for file in paths_to_delete: 42 | file_path = os.path.join(FILE_PATH, file) 43 | try: 44 | os.unlink(file_path) 45 | print(f"{file_path} has been deleted") 46 | except Exception as e: 47 | print(f"Skip Delete {file_path}") 48 | 49 | # http server 50 | class MyHandler(http.server.SimpleHTTPRequestHandler): 51 | 52 | def log_message(self, format, *args): 53 | pass 54 | 55 | def do_GET(self): 56 | if self.path == '/': 57 | self.send_response(200) 58 | self.end_headers() 59 | self.wfile.write(b'Hello, world') 60 | elif self.path == '/sub': 61 | try: 62 | with open(os.path.join(FILE_PATH, 'sub.txt'), 'rb') as file: 63 | content = file.read() 64 | self.send_response(200) 65 | self.send_header('Content-Type', 'text/plain; charset=utf-8') 66 | self.end_headers() 67 | self.wfile.write(content) 68 | except FileNotFoundError: 69 | self.send_response(500) 70 | self.end_headers() 71 | self.wfile.write(b'Error reading file') 72 | else: 73 | self.send_response(404) 74 | self.end_headers() 75 | self.wfile.write(b'Not found') 76 | 77 | httpd = socketserver.TCPServer(('', PORT), MyHandler) 78 | server_thread = threading.Thread(target=httpd.serve_forever) 79 | server_thread.daemon = True 80 | server_thread.start() 81 | 82 | # Generate xr-ay config file 83 | def generate_config(): 84 | config ={"log":{"access":"/dev/null","error":"/dev/null","loglevel":"none",},"inbounds":[{"port":ARGO_PORT ,"protocol":"vless","settings":{"clients":[{"id":UUID ,"flow":"xtls-rprx-vision",},],"decryption":"none","fallbacks":[{"dest":3001 },{"path":"/vless-argo","dest":3002 },{"path":"/vmess-argo","dest":3003 },{"path":"/trojan-argo","dest":3004 },],},"streamSettings":{"network":"tcp",},},{"port":3001 ,"listen":"127.0.0.1","protocol":"vless","settings":{"clients":[{"id":UUID },],"decryption":"none"},"streamSettings":{"network":"ws","security":"none"}},{"port":3002 ,"listen":"127.0.0.1","protocol":"vless","settings":{"clients":[{"id":UUID ,"level":0 }],"decryption":"none"},"streamSettings":{"network":"ws","security":"none","wsSettings":{"path":"/vless-argo"}},"sniffing":{"enabled":True ,"destOverride":["http","tls","quic"],"metadataOnly":False }},{"port":3003 ,"listen":"127.0.0.1","protocol":"vmess","settings":{"clients":[{"id":UUID ,"alterId":0 }]},"streamSettings":{"network":"ws","wsSettings":{"path":"/vmess-argo"}},"sniffing":{"enabled":True ,"destOverride":["http","tls","quic"],"metadataOnly":False }},{"port":3004 ,"listen":"127.0.0.1","protocol":"trojan","settings":{"clients":[{"password":UUID },]},"streamSettings":{"network":"ws","security":"none","wsSettings":{"path":"/trojan-argo"}},"sniffing":{"enabled":True ,"destOverride":["http","tls","quic"],"metadataOnly":False }},],"dns":{"servers":["https+local://8.8.8.8/dns-query"]},"outbounds":[{"protocol":"freedom","tag": "direct" },{"protocol":"blackhole","tag":"block"}]} 85 | with open(os.path.join(FILE_PATH, 'config.json'), 'w', encoding='utf-8') as config_file: 86 | json.dump(config, config_file, ensure_ascii=False, indent=2) 87 | 88 | generate_config() 89 | 90 | # Determine system architecture 91 | def get_system_architecture(): 92 | arch = os.uname().machine 93 | if 'arm' in arch or 'aarch64' in arch or 'arm64' in arch: 94 | return 'arm' 95 | else: 96 | return 'amd' 97 | 98 | # Download file 99 | def download_file(file_name, file_url): 100 | file_path = os.path.join(FILE_PATH, file_name) 101 | with requests.get(file_url, stream=True) as response, open(file_path, 'wb') as file: 102 | shutil.copyfileobj(response.raw, file) 103 | 104 | # Download and run files 105 | def download_files_and_run(): 106 | architecture = get_system_architecture() 107 | files_to_download = get_files_for_architecture(architecture) 108 | 109 | if not files_to_download: 110 | print("Can't find a file for the current architecture") 111 | return 112 | 113 | for file_info in files_to_download: 114 | try: 115 | download_file(file_info['file_name'], file_info['file_url']) 116 | print(f"Downloaded {file_info['file_name']} successfully") 117 | except Exception as e: 118 | print(f"Download {file_info['file_name']} failed: {e}") 119 | 120 | # Authorize and run 121 | files_to_authorize = ['npm', 'web', 'bot'] 122 | authorize_files(files_to_authorize) 123 | 124 | # Run ne-zha 125 | NEZHA_TLS = '' 126 | valid_ports = ['443', '8443', '2096', '2087', '2083', '2053'] 127 | if NEZHA_SERVER and NEZHA_PORT and NEZHA_KEY: 128 | if NEZHA_PORT in valid_ports: 129 | NEZHA_TLS = '--tls' 130 | command = f"nohup {FILE_PATH}/npm -s {NEZHA_SERVER}:{NEZHA_PORT} -p {NEZHA_KEY} {NEZHA_TLS} >/dev/null 2>&1 &" 131 | try: 132 | subprocess.run(command, shell=True, check=True) 133 | print('npm is running') 134 | subprocess.run('sleep 1', shell=True) # Wait for 1 second 135 | except subprocess.CalledProcessError as e: 136 | print(f'npm running error: {e}') 137 | else: 138 | print('NEZHA variable is empty, skip running') 139 | 140 | # Run xr-ay 141 | command1 = f"nohup {FILE_PATH}/web -c {FILE_PATH}/config.json >/dev/null 2>&1 &" 142 | try: 143 | subprocess.run(command1, shell=True, check=True) 144 | print('web is running') 145 | subprocess.run('sleep 1', shell=True) # Wait for 1 second 146 | except subprocess.CalledProcessError as e: 147 | print(f'web running error: {e}') 148 | 149 | # Run cloud-fared 150 | if os.path.exists(os.path.join(FILE_PATH, 'bot')): 151 | # Get command line arguments for cloud-fared 152 | args = get_cloud_flare_args() 153 | # print(args) 154 | try: 155 | subprocess.run(f"nohup {FILE_PATH}/bot {args} >/dev/null 2>&1 &", shell=True, check=True) 156 | print('bot is running') 157 | subprocess.run('sleep 2', shell=True) # Wait for 2 seconds 158 | except subprocess.CalledProcessError as e: 159 | print(f'Error executing command: {e}') 160 | 161 | subprocess.run('sleep 3', shell=True) # Wait for 3 seconds 162 | 163 | 164 | def get_cloud_flare_args(): 165 | 166 | processed_auth = ARGO_AUTH 167 | try: 168 | auth_data = json.loads(ARGO_AUTH) 169 | if 'TunnelSecret' in auth_data and 'AccountTag' in auth_data and 'TunnelID' in auth_data: 170 | processed_auth = 'TunnelSecret' 171 | except json.JSONDecodeError: 172 | pass 173 | 174 | # Determines the condition and generates the corresponding args 175 | if not processed_auth and not ARGO_DOMAIN: 176 | args = f'tunnel --edge-ip-version auto --no-autoupdate --protocol http2 --logfile {FILE_PATH}/boot.log --loglevel info --url http://localhost:{ARGO_PORT}' 177 | elif processed_auth == 'TunnelSecret': 178 | args = f'tunnel --edge-ip-version auto --config {FILE_PATH}/tunnel.yml run' 179 | elif processed_auth and ARGO_DOMAIN and 120 <= len(processed_auth) <= 250: 180 | args = f'tunnel --edge-ip-version auto --no-autoupdate --protocol http2 run --token {processed_auth}' 181 | else: 182 | # Default args for other cases 183 | args = f'tunnel --edge-ip-version auto --no-autoupdate --protocol http2 --logfile {FILE_PATH}/boot.log --loglevel info --url http://localhost:{ARGO_PORT}' 184 | 185 | return args 186 | 187 | # Return file information based on system architecture 188 | def get_files_for_architecture(architecture): 189 | if architecture == 'arm': 190 | return [ 191 | {'file_name': 'npm', 'file_url': 'https://arm64.ssss.nyc.mn/agent'}, 192 | {'file_name': 'web', 'file_url': 'https://arm64.ssss.nyc.mn/web'}, 193 | {'file_name': 'bot', 'file_url': 'https://arm64.ssss.nyc.mn/2go'}, 194 | ] 195 | elif architecture == 'amd': 196 | return [ 197 | {'file_name': 'npm', 'file_url': 'https://amd64.ssss.nyc.mn/agent'}, 198 | {'file_name': 'web', 'file_url': 'https://amd64.ssss.nyc.mn/web'}, 199 | {'file_name': 'bot', 'file_url': 'https://amd64.ssss.nyc.mn/2go'}, 200 | ] 201 | return [] 202 | 203 | # Authorize files 204 | def authorize_files(file_paths): 205 | new_permissions = 0o775 206 | 207 | for relative_file_path in file_paths: 208 | absolute_file_path = os.path.join(FILE_PATH, relative_file_path) 209 | try: 210 | os.chmod(absolute_file_path, new_permissions) 211 | print(f"Empowerment success for {absolute_file_path}: {oct(new_permissions)}") 212 | except Exception as e: 213 | print(f"Empowerment failed for {absolute_file_path}: {e}") 214 | 215 | 216 | # Get fixed tunnel JSON and yml 217 | def argo_config(): 218 | if not ARGO_AUTH or not ARGO_DOMAIN: 219 | print("ARGO_DOMAIN or ARGO_AUTH is empty, use quick Tunnels") 220 | return 221 | 222 | if 'TunnelSecret' in ARGO_AUTH: 223 | with open(os.path.join(FILE_PATH, 'tunnel.json'), 'w') as file: 224 | file.write(ARGO_AUTH) 225 | tunnel_yaml = f""" 226 | tunnel: {ARGO_AUTH.split('"')[11]} 227 | credentials-file: {os.path.join(FILE_PATH, 'tunnel.json')} 228 | protocol: http2 229 | 230 | ingress: 231 | - hostname: {ARGO_DOMAIN} 232 | service: http://localhost:{ARGO_PORT} 233 | originRequest: 234 | noTLSVerify: true 235 | - service: http_status:404 236 | """ 237 | with open(os.path.join(FILE_PATH, 'tunnel.yml'), 'w') as file: 238 | file.write(tunnel_yaml) 239 | else: 240 | print("Use token connect to tunnel") 241 | 242 | argo_config() 243 | 244 | # Get temporary tunnel domain 245 | def extract_domains(): 246 | argo_domain = '' 247 | 248 | if ARGO_AUTH and ARGO_DOMAIN: 249 | argo_domain = ARGO_DOMAIN 250 | print('ARGO_DOMAIN:', argo_domain) 251 | generate_links(argo_domain) 252 | else: 253 | try: 254 | with open(os.path.join(FILE_PATH, 'boot.log'), 'r', encoding='utf-8') as file: 255 | content = file.read() 256 | # Use regular expressions to match domain ending in trycloudflare.com 257 | match = re.search(r'https://([^ ]+\.trycloudflare\.com)', content) 258 | if match: 259 | argo_domain = match.group(1) 260 | print('ArgoDomain:', argo_domain) 261 | generate_links(argo_domain) 262 | else: 263 | print('ArgoDomain not found, re-running bot to obtain ArgoDomain') 264 | # 结束现有bot进程 265 | try: 266 | subprocess.run("pkill -f 'bot tunnel'", shell=True) 267 | print('Stopped existing bot process') 268 | except Exception as e: 269 | print(f'Error stopping bot process: {e}') 270 | 271 | time.sleep(2) # 等待2秒 272 | # 删除boot.log文件 273 | os.remove(os.path.join(FILE_PATH, 'boot.log')) 274 | 275 | # 最多重试10次 276 | max_retries = 10 277 | for attempt in range(max_retries): 278 | print(f'Attempt {attempt + 1} of {max_retries}') 279 | args = f"tunnel --edge-ip-version auto --no-autoupdate --protocol http2 --logfile {FILE_PATH}/boot.log --loglevel info --url http://localhost:{ARGO_PORT}" 280 | try: 281 | subprocess.run(f"nohup {FILE_PATH}/bot {args} >/dev/null 2>&1 &", shell=True, check=True) 282 | print('bot is running') 283 | time.sleep(3) 284 | # 尝试获取域名,使用相同的正则表达式 285 | with open(os.path.join(FILE_PATH, 'boot.log'), 'r', encoding='utf-8') as file: 286 | content = file.read() 287 | match = re.search(r'https://([^ ]+\.trycloudflare\.com)', content) 288 | if match: 289 | argo_domain = match.group(1) 290 | print('ArgoDomain:', argo_domain) 291 | generate_links(argo_domain) 292 | break 293 | if attempt < max_retries - 1: 294 | print('ArgoDomain not found, retrying...') 295 | subprocess.run("pkill -f 'bot tunnel'", shell=True) 296 | time.sleep(2) 297 | except subprocess.CalledProcessError as e: 298 | print(f"Error executing command: {e}") 299 | except Exception as e: 300 | print(f"Error: {e}") 301 | else: 302 | print("Failed to obtain ArgoDomain after maximum retries") 303 | except IndexError as e: 304 | print(f"IndexError while reading boot.log: {e}") 305 | except Exception as e: 306 | print(f"Error reading boot.log: {e}") 307 | 308 | 309 | # Generate list and sub info 310 | def generate_links(argo_domain): 311 | meta_info = subprocess.run(['curl', '-s', 'https://speed.cloudflare.com/meta'], capture_output=True, text=True) 312 | meta_info = meta_info.stdout.split('"') 313 | ISP = f"{meta_info[25]}-{meta_info[17]}".replace(' ', '_').strip() 314 | 315 | time.sleep(2) 316 | VMESS = {"v": "2", "ps": f"{NAME}-{ISP}", "add": CFIP, "port": CFPORT, "id": UUID, "aid": "0", "scy": "none", "net": "ws", "type": "none", "host": argo_domain, "path": "/vmess-argo?ed=2048", "tls": "tls", "sni": argo_domain, "alpn": ""} 317 | 318 | list_txt = f""" 319 | vless://{UUID}@{CFIP}:{CFPORT}?encryption=none&security=tls&sni={argo_domain}&type=ws&host={argo_domain}&path=%2Fvless-argo%3Fed%3D2048#{NAME}-{ISP} 320 | 321 | vmess://{ base64.b64encode(json.dumps(VMESS).encode('utf-8')).decode('utf-8')} 322 | 323 | trojan://{UUID}@{CFIP}:{CFPORT}?security=tls&sni={argo_domain}&type=ws&host={argo_domain}&path=%2Ftrojan-argo%3Fed%3D2048#{NAME}-{ISP} 324 | """ 325 | 326 | with open(os.path.join(FILE_PATH, 'list.txt'), 'w', encoding='utf-8') as list_file: 327 | list_file.write(list_txt) 328 | 329 | sub_txt = base64.b64encode(list_txt.encode('utf-8')).decode('utf-8') 330 | with open(os.path.join(FILE_PATH, 'sub.txt'), 'w', encoding='utf-8') as sub_file: 331 | sub_file.write(sub_txt) 332 | 333 | try: 334 | with open(os.path.join(FILE_PATH, 'sub.txt'), 'rb') as file: 335 | sub_content = file.read() 336 | print(f"\n{sub_content.decode('utf-8')}") 337 | except FileNotFoundError: 338 | print(f"sub.txt not found") 339 | 340 | print(f'\n{FILE_PATH}/sub.txt saved successfully') 341 | time.sleep(45) # wait 45s 342 | 343 | # cleanup files 344 | files_to_delete = ['npm', 'web', 'bot', 'boot.log', 'list.txt', 'config.json', 'tunnel.yml', 'tunnel.json'] 345 | for file_to_delete in files_to_delete: 346 | file_path_to_delete = os.path.join(FILE_PATH, file_to_delete) 347 | if os.path.exists(file_path_to_delete): 348 | try: 349 | os.remove(file_path_to_delete) 350 | # print(f"{file_path_to_delete} has been deleted") 351 | except Exception as e: 352 | print(f"Error deleting {file_path_to_delete}: {e}") 353 | else: 354 | print(f"{file_path_to_delete} doesn't exist, skipping deletion") 355 | 356 | print('\033c', end='') 357 | print('App is running') 358 | print('Thank you for using this script, enjoy!') 359 | 360 | # Run the callback 361 | def start_server(): 362 | download_files_and_run() 363 | extract_domains() 364 | 365 | start_server() 366 | 367 | # auto visit project page 368 | has_logged_empty_message = False 369 | 370 | def visit_project_page(): 371 | try: 372 | if not PROJECT_URL or not INTERVAL_SECONDS: 373 | global has_logged_empty_message 374 | if not has_logged_empty_message: 375 | print("URL or TIME variable is empty, Skipping visit web") 376 | has_logged_empty_message = True 377 | return 378 | 379 | response = requests.get(PROJECT_URL) 380 | response.raise_for_status() 381 | 382 | # print(f"Visiting project page: {PROJECT_URL}") 383 | print("Page visited successfully") 384 | print('\033c', end='') 385 | except requests.exceptions.RequestException as error: 386 | print(f"Error visiting project page: {error}") 387 | 388 | if __name__ == "__main__": 389 | while True: 390 | visit_project_page() 391 | time.sleep(INTERVAL_SECONDS) 392 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | requests 3 | --------------------------------------------------------------------------------