├── .drone.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml └── opt ├── init.py ├── init.sh ├── supervisord.conf └── xray └── d2ray.json.in /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: docker 3 | name: Docker image build 4 | 5 | trigger: 6 | branch: 7 | - master 8 | 9 | steps: 10 | - name: build 11 | image: plugins/docker 12 | settings: 13 | username: 14 | from_secret: docker_username 15 | password: 16 | from_secret: docker_password 17 | repo: quackerd/d2ray -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 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 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | ENV VER_XRAY 24.12.31 4 | 5 | # install packages 6 | RUN set -xe && apk add --no-cache unzip wget openssl python3 py3-jinja2 supervisor apache2-utils bash libqrencode libqrencode-tools 7 | 8 | # download packages 9 | RUN set -xe && \ 10 | mkdir -p /downloads && \ 11 | wget -P /downloads https://github.com/XTLS/Xray-core/releases/download/v$VER_XRAY/Xray-linux-64.zip && \ 12 | unzip /downloads/Xray-linux-64.zip -d /opt/xray && \ 13 | rm -rf /downloads 14 | 15 | COPY ./opt /opt/ 16 | 17 | # remove packages 18 | RUN set -xe && apk del unzip wget 19 | 20 | VOLUME /etc/d2ray 21 | CMD ["sh", "/opt/init.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 quackerd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://ci.quacker.org/api/badges/d/d2ray/status.svg)](https://ci.quacker.org/d/d2ray) 2 | # Xray + xtls-vision + reality all in Docker! 3 | ## What Is d2ray? 4 | d2ray is a single Docker container that provides easy 5-minute setups and braindead configurations for xtls-vision + reality. 5 | 6 | ## Quickstart 7 | 1. You can start with the example `docker-compose.yml` from this repo. 8 | 2. Adjust environment variables: 9 | - `HOST`: the hostname/IP of the server. `REQUIRED`. 10 | - `PORT`: the port Xray listens on. `Optional, default = 443`. 11 | - `TARGET_HOST`: the target host to redirect non proxy connections. `Required`. 12 | - `TARGET_PORT`: the target port to redirect non proxy connections. `Optional, default = 443`. 13 | - `TARGET_SNI`: comma separated list of the target website's SNIs. `Required`. 14 | - `PRIVATE_KEY` : server's private key. `Optional`. 15 | - `USERS`: comma separated list of usernames that can access Xray. `Required`. 16 | - `LOG_LEVEL`: the verbosity of Xray logs. `Optional, default = warn`. 17 | 3. `docker compose up -d` 18 | 4. Check the container log using `docker logs` for per user shareable links and QR codes supported by most Xray apps. These can also be found under `/etc/xray/users/[USERNAME]` folders. 19 | 5. Test your connection. 20 | 21 | ## Docker Volume 22 | The logs and private key are stored in `/etc/d2ray` in the container. You can mount an external folder to that location to persist settings. Otherwise d2ray creates an anonymous Docker volume. 23 | 24 | ## Key Generation 25 | If `PRIVATE_KEY` is provided, d2ray uses that key. Otherwise, d2ray generates a new key pair and persists it in `/etc/xray/certs/keys`. The corresponding public key is always printed to the container log, which clients use to connect. 26 | 27 | To make d2ray regenerate a new key pair, manually delete the key file `/etc/xray/certs/keys` from the mounted volume. 28 | 29 | ## How To Update? 30 | - `docker compose down` 31 | - `docker compose pull` 32 | - `docker compose up -d` 33 | 34 | ## Notes 35 | - The old xtls-vision + TLS + Nginx fallback has been branched out to the `vision` branch. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | d2ray_br: 3 | external: false 4 | 5 | services: 6 | d2ray: 7 | image: quackerd/d2ray 8 | container_name: d2ray 9 | ports: 10 | - 8443:8443 11 | environment: 12 | - HOST=myvps.com 13 | - PORT=8443 14 | - TARGET_HOST=www.apple.com 15 | - TARGET_PORT=443 16 | - TARGET_SNI=www.apple.com,apple.com 17 | - USERS=alice,bob,eve 18 | - PRIVATE_KEY=KE5MCI5e395fub55O1lsNPzvWw9nNAyCaecRSp3BvHg # Do NOT use this random key 19 | - LOG_LEVEL=warn 20 | restart: unless-stopped 21 | networks: 22 | - d2ray_br 23 | volumes: 24 | - ./config:/etc/d2ray 25 | -------------------------------------------------------------------------------- /opt/init.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import jinja2 4 | import random 5 | import sys 6 | import string 7 | import pathlib 8 | 9 | CONFIG_DIR = pathlib.Path("/etc/d2ray") 10 | KEY_FILE = CONFIG_DIR.joinpath("certs/keys") 11 | LOG_DIR = CONFIG_DIR.joinpath("logs") 12 | QR_DIR = CONFIG_DIR.joinpath("users") 13 | XRAY_BIN = pathlib.Path("/opt/xray/xray") 14 | 15 | class d2args: 16 | host : str 17 | port : int 18 | target_port : int 19 | target_host : str 20 | target_sni : str 21 | log_level : str 22 | private_key : str 23 | public_key : str 24 | users : list[str] 25 | def __init__(self) -> None: 26 | self._from_env() 27 | 28 | @staticmethod 29 | def _get_env(name : str, default : str = None, required : bool = True) -> str: 30 | env = os.getenv(name) 31 | if env == None: 32 | if required: 33 | raise Exception(f"Missing environment variable \"{name}\".") 34 | else: 35 | return default 36 | return env 37 | 38 | @staticmethod 39 | def _parse_xray_x25519_output(stdout : str) -> tuple[str, str]: 40 | skey = None 41 | pkey = None 42 | lines = stdout.split("\n") 43 | if len(lines) < 2: 44 | raise Exception(f"Unknown Xray output format:\n\"{stdout}\"\n") 45 | 46 | priv_key_hdr = "Private key: " 47 | pub_key_hdr = "Public key: " 48 | for line in lines: 49 | if line.startswith(priv_key_hdr): 50 | skey = line[len(priv_key_hdr):] 51 | elif line.startswith(pub_key_hdr): 52 | pkey = line[len(pub_key_hdr):] 53 | if (skey == None) or (pkey == None): 54 | raise Exception(f"Unable to extract private or public key from Xray output:\n\"{stdout}\"\n") 55 | return (skey.strip(), pkey.strip()) 56 | 57 | def _from_env(self) -> None: 58 | self.host = self._get_env("HOST") 59 | self.target_host = self._get_env("TARGET_HOST") 60 | self.target_sni = self._get_env("TARGET_SNI").split(",") 61 | self.users = self._get_env("USERS").split(",") 62 | 63 | self.port = int(self._get_env("PORT", default="443", required=False)) 64 | self.target_port = int(self._get_env("TARGET_PORT", default="443", required=False)) 65 | self.log_level = self._get_env("LOG_LEVEL", default="warn", required=False) 66 | 67 | self.private_key = self._get_env("PRIVATE_KEY", default=None, required=False) 68 | if (self.private_key == None): 69 | print(f"Private key not provided.", flush=True) 70 | if not KEY_FILE.exists(): 71 | print(f"Key file {KEY_FILE} not found. Generating new keys...") 72 | self.private_key, _ = self._parse_xray_x25519_output(subprocess.check_output(f"{XRAY_BIN} x25519", shell = True).decode()) 73 | with open(KEY_FILE, "w") as f: 74 | f.write(self.private_key) 75 | else: 76 | print(f"Reading from key file {KEY_FILE} ...") 77 | with open(KEY_FILE, "r") as f: 78 | self.private_key = f.read().strip() 79 | 80 | _ , self.public_key = self._parse_xray_x25519_output(subprocess.check_output(f"{XRAY_BIN} x25519 -i {self.private_key}", shell = True).decode()) 81 | 82 | def __str__(self) -> str: 83 | ret = (f"Host: {self.host}\n" 84 | f"Port: {self.port}\n" 85 | f"Target Port: {self.target_port}\n" 86 | f"Target Host: {self.target_host}\n" 87 | f"Target SNI: {', '.join(self.target_sni)}\n" 88 | f"Log Level: {self.log_level}\n" 89 | f"Users: {', '.join(self.users)}\n" 90 | f"Public Key: {self.public_key}" 91 | ) 92 | return ret 93 | 94 | def get_shareable_links(self) -> dict[str, str]: 95 | ret = {} 96 | for user in self.users: 97 | ret[user] = (f"vless://{user}@{self.host}:{self.port}/?" 98 | "flow=xtls-rprx-vision&" 99 | "type=tcp&security=reality&" 100 | "fp=chrome&" 101 | f"sni={','.join(self.target_sni)}&" 102 | f"pbk={self.public_key}#" 103 | f"{self.host}" 104 | ) 105 | return ret 106 | 107 | def process_directory(path : str, vars : dict[str, str], delete_template : bool = True) -> None: 108 | for f in os.listdir(path): 109 | full_path = os.path.join(path, f) 110 | if os.path.isdir(full_path): 111 | process_directory(full_path, vars, delete_template) 112 | elif f.endswith(".in"): 113 | with open(full_path, "r") as sf: 114 | with open(full_path[:-3], "w") as df: 115 | template : jinja2.Template = jinja2.Template(sf.read()) 116 | df.write(template.render(**vars)) 117 | print(f"Processed template {full_path}.", flush=True) 118 | if delete_template: 119 | subprocess.check_call(f"rm {full_path}", shell=True) 120 | 121 | def build_target_snis(snis : list[str]) -> str: 122 | return ', '.join(['"' + item + '"' for item in snis]) 123 | 124 | def build_users_json(users: list[str]) -> str: 125 | return ', '.join(["{\"id\": \"" + item + "\", \"flow\": \"xtls-rprx-vision\"}" for item in users]) 126 | 127 | def build_jinja_dict(args : d2args) -> dict[str, str]: 128 | jinja_dict : dict[str,str] = dict() 129 | jinja_dict["PORT"] = str(args.port) 130 | 131 | jinja_dict["TARGET_HOST"] = args.target_host 132 | jinja_dict["TARGET_PORT"] = str(args.target_port) 133 | jinja_dict["TARGET_SNI"] = build_target_snis(args.target_sni) 134 | 135 | jinja_dict["LOG_DIR"] = str(LOG_DIR) 136 | jinja_dict["LOG_LEVEL"] = args.log_level 137 | 138 | jinja_dict["USERS"] = build_users_json(args.users) 139 | jinja_dict["PRIVATE_KEY"] = args.private_key 140 | return jinja_dict 141 | 142 | 143 | def main(): 144 | print(f"Initializing d2ray...", flush=True) 145 | args = d2args() 146 | 147 | print(f"\nConfiguration:\n{str(args)}\n", flush=True) 148 | 149 | template = build_jinja_dict(args) 150 | 151 | print(f"Processing config files...", flush=True) 152 | process_directory("/opt/xray", template) 153 | 154 | print(f"Generating shareable links...", flush=True) 155 | links = args.get_shareable_links() 156 | 157 | for user, link in links.items(): 158 | dir = QR_DIR.joinpath(user) 159 | os.makedirs(str(dir), exist_ok=True) 160 | linkf = dir.joinpath("link.txt") 161 | with open(str(linkf), "w") as f: 162 | f.write(link + "\n") 163 | subprocess.check_output(f"qrencode -o {str(dir.joinpath('qrcode.png'))} < {linkf}", shell=True) 164 | print("") 165 | print(f"User \"{user}\":", flush=True) 166 | print(f"{link}") 167 | print(subprocess.check_output(f"qrencode -t ansiutf8 < {linkf}", shell=True).decode()) 168 | print("") 169 | 170 | print(f"Initialization completed.\n", flush=True) 171 | 172 | main() 173 | -------------------------------------------------------------------------------- /opt/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # create directories 3 | mkdir -p /etc/d2ray/logs/xray 4 | mkdir -p /etc/d2ray/logs/supervisord 5 | mkdir -p /etc/d2ray/certs 6 | rm -rf /etc/d2ray/users 7 | 8 | python3 /opt/init.py 9 | retval=$? 10 | if [ $retval -ne 0 ]; then 11 | exit $retval 12 | fi 13 | 14 | exec /usr/bin/supervisord -c /opt/supervisord.conf -------------------------------------------------------------------------------- /opt/supervisord.conf: -------------------------------------------------------------------------------- 1 | [unix_http_server] 2 | file=/var/run/supervisord.sock 3 | username = dummy1234 4 | password = dummy1234 5 | 6 | [rpcinterface:supervisor] 7 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 8 | 9 | [supervisord] 10 | nodaemon=true 11 | loglevel=warn 12 | logfile=/etc/d2ray/logs/supervisord/supervisord.log 13 | logfile_maxbytes=0 14 | 15 | [supervisorctl] 16 | serverurl=unix:///var/run/supervisord.sock 17 | username = dummy1234 18 | password = dummy1234 19 | 20 | # [program:nginx] 21 | # command=nginx -c /opt/nginx/nginx.conf 22 | # autostart=true 23 | # autorestart=false 24 | # stdout_logfile=/dev/fd/1 25 | # stdout_logfile_maxbytes=0 26 | # redirect_stderr=true 27 | 28 | # [program:cron] 29 | # command=crond -f -L /etc/d2ray/logs/cron/crond.log -c /opt/crontabs 30 | # autostart=true 31 | # autorestart=false 32 | # stdout_logfile=/etc/d2ray/logs/cron/crond.log 33 | # stdout_logfile_maxbytes=0 34 | # redirect_stderr=true 35 | 36 | [program:xray] 37 | command=/opt/xray/xray -c /opt/xray/d2ray.json 38 | autostart=true 39 | autorestart=false 40 | stdout_logfile=/dev/fd/1 41 | stdout_logfile_maxbytes=0 42 | redirect_stderr=true 43 | 44 | [eventlistener:exit] 45 | command=bash -c "printf 'READY\n' && while read line; do kill -SIGQUIT $PPID; done < /dev/stdin" 46 | events=PROCESS_STATE_FATAL,PROCESS_STATE_STOPPED,PROCESS_STATE_EXITED 47 | -------------------------------------------------------------------------------- /opt/xray/d2ray.json.in: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "loglevel": "{{LOG_LEVEL}}", 4 | "access": "{{LOG_DIR}}/xray/access.log", 5 | "error": "{{LOG_DIR}}/xray/error.log" 6 | }, 7 | "inbounds": [ 8 | { 9 | "port": {{ PORT }}, 10 | "protocol": "vless", 11 | "settings": { 12 | "clients": [ 13 | {{ USERS }} 14 | ], 15 | "decryption": "none" 16 | }, 17 | "streamSettings": { 18 | "network": "tcp", 19 | "security": "reality", 20 | "realitySettings": { 21 | "show": false, 22 | "dest": "{{ TARGET_HOST }}:{{ TARGET_PORT }}", 23 | "xver": 0, 24 | "serverNames": [ 25 | {{ TARGET_SNI }} 26 | ], 27 | "privateKey": "{{ PRIVATE_KEY }}", 28 | "shortIds": [ 29 | "" 30 | ], 31 | "sockopt": { 32 | "tcpFastOpen": true, 33 | "tcpNoDelay": true 34 | } 35 | } 36 | }, 37 | "sniffing": { 38 | "enabled": true, 39 | "destOverride": [ 40 | "http", 41 | "tls" 42 | ] 43 | } 44 | } 45 | ], 46 | "outbounds": [ 47 | { 48 | "protocol": "freedom" 49 | } 50 | ] 51 | } --------------------------------------------------------------------------------