├── .gitignore ├── HISTORY.rst ├── LICENSE ├── README.md ├── app.py ├── cli.py ├── examples ├── client.cfg ├── server.cfg ├── simpleauth.passwd └── upstream.json ├── publish.py ├── pyproject.toml ├── scripts ├── build.sh ├── install.sh ├── org.furion.plist └── pyinstaller │ ├── furion-cli.spec │ ├── furion.ico │ ├── furion.spec │ └── pyinstaller.bat ├── src └── furion │ ├── __init__.py │ ├── config.py │ ├── dns.py │ ├── furion.py │ ├── helpers.py │ ├── ping.py │ ├── servers.py │ ├── simpleauth.py │ └── socks5.py └── tests └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | furion.egg-info/ 2 | furion.cfg 3 | furion.log 4 | upstream.json 5 | build/ 6 | dist/ 7 | *.pyc 8 | .DS_Store 9 | .idea/ 10 | *.bak 11 | *.orig 12 | .python-version 13 | .pdm-python 14 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keli/furion/b25caa0850234b6e555cd94f011a5a304f1feebd/HISTORY.rst -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Keli Hu 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | - Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Furion Socks5 SSL Proxy 2 | ======================= 3 | 4 | Furion is an encrypted proxy written in Python. In essence, it's just socks5 server with ssl support. It's often used with upstream Furion servers to avoid censorship. 5 | 6 | Features 7 | -------- 8 | 9 | - Automatic upstream fail over (when multiple upstream servers are available). 10 | - Built-in latency check for choosing the fastest upstream. 11 | - Periodical upstream updates from a designated central registry. 12 | - Builtin DNS server/proxy to avoid poisoning. 13 | - Limit what ports that clients are allowed to connect to. 14 | - Easy account management on the server side. 15 | 16 | Dependencies 17 | ------------ 18 | 19 | Furion has no external dependencies other than a standard Python 3.x (>=3.8) installation. There is optional support for gevent, which would be used if an existing gevent installation was discovered. 20 | 21 | Installation 22 | ------------ 23 | 24 | Furion can be installed via pip: 25 | 26 | pip install furion 27 | 28 | or pipx 29 | 30 | pipx install furion 31 | 32 | To start using Furion, you need at least a furion.cfg file. 33 | 34 | By default, Furion will look for furion.cfg and upstream.json in `/etc/furion` or the current working directory. You can specify path to the configuration file after a `-c` switch. 35 | 36 | For client, an upstream.json file is also needed for upstream checking to work in the same directory your furion.cfg resides in. 37 | 38 | Alternatively, you can put the upstream.json file somewhere accessible via http(s), so that you can share that address with your friends. Then configure the `upstream` section of your `furion.cfg` file like below, to use that upstream file. 39 | 40 | [upstream] 41 | 42 | central_url = https://your.upstream.json 43 | 44 | autoupdate_upstream_list = on 45 | 46 | update_frequency = start 47 | 48 | upstream_list_path = upstream.json 49 | 50 | Read configuration files in [examples](https://github.com/keli/furion/blob/master/examples) directory for more information. 51 | 52 | 53 | ### Building Standalone Windows Clients 54 | 55 | * Install miniconda3 56 | ``` 57 | winget install miniconda3 58 | ``` 59 | 60 | * Create a new conda environment 61 | ``` 62 | conda create -n furion 63 | ``` 64 | 65 | * Activate the environment 66 | ``` 67 | conda activate furion 68 | ``` 69 | 70 | * Install pyinstaller 71 | ``` 72 | conda install -c conda-forge pyinstaller 73 | ``` 74 | 75 | * Install wxPython (optional if you only want to build the cli version) 76 | ``` 77 | conda install -c conda-forge wxpython 78 | ``` 79 | 80 | * Install furion itself 81 | ``` 82 | cd furion 83 | pip install -e . 84 | ``` 85 | 86 | * Build the standalone client 87 | ``` 88 | cd scripts/pyinstaller 89 | pyinstaller.bat 90 | ``` 91 | 92 | * The standalone clients will be in `dist/furion.exe` and `dist/furion-cli.exe` 93 | * Furion.exe is only a tray icon currently. 94 | 95 | Note: You need to put a config file `furion.cfg` in the same directory of the exe for it to work. 96 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import sys 3 | import wx 4 | import wx.adv 5 | import threading 6 | import subprocess 7 | 8 | from furion.furion import setup_server 9 | 10 | ID_ICON_TIMER = wx.NewIdRef() 11 | OPEN_CONFIG=wx.NewIdRef() 12 | SHOW_LOGS=wx.NewIdRef() 13 | 14 | 15 | def resource_path(relative_path): 16 | """ Get absolute path to resource, works for dev and for PyInstaller """ 17 | base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 18 | return os.path.join(base_path, relative_path) 19 | 20 | 21 | class FurionTaskBarIcon(wx.adv.TaskBarIcon): 22 | 23 | def __init__(self, parent): 24 | wx.adv.TaskBarIcon.__init__(self) 25 | self.parentApp = parent 26 | self.icon = wx.Icon(resource_path("furion.ico"), wx.BITMAP_TYPE_ICO) 27 | self.CreateMenu() 28 | self.SetIconImage() 29 | 30 | def CreateMenu(self): 31 | self.Bind(wx.adv.EVT_TASKBAR_RIGHT_UP, self.ShowMenu) 32 | self.Bind(wx.EVT_MENU, self.parentApp.openConfig, id=OPEN_CONFIG) 33 | self.Bind(wx.EVT_MENU, self.parentApp.showLogs, id=SHOW_LOGS) 34 | self.menu=wx.Menu() 35 | self.menu.Append(OPEN_CONFIG, "Open Config File") 36 | # self.menu.Append(SHOW_LOGS, "Show Logs") 37 | self.menu.AppendSeparator() 38 | self.menu.Append(wx.ID_EXIT, "Exit Furion") 39 | 40 | def ShowMenu(self,event): 41 | self.PopupMenu(self.menu) 42 | 43 | def SetIconImage(self, mail=False): 44 | self.SetIcon(self.icon) 45 | 46 | 47 | class FurionFrame(wx.Frame): 48 | 49 | def __init__(self, parent, id, title): 50 | wx.Frame.__init__(self, parent, -1, title, size = (1, 1), 51 | style=wx.FRAME_NO_TASKBAR|wx.NO_FULL_REPAINT_ON_RESIZE) 52 | 53 | self.tbicon = FurionTaskBarIcon(self) 54 | self.tbicon.Bind(wx.EVT_MENU, self.exitApp, id=wx.ID_EXIT) 55 | self.Show(True) 56 | self.svr = None 57 | self.thr = threading.Thread(target=self.runServer) 58 | self.thr.setDaemon(1) 59 | self.thr.start() 60 | 61 | def runServer(self): 62 | self.svr = setup_server() 63 | self.svr.serve_forever() 64 | 65 | 66 | def exitServer(self): 67 | self.svr.shutdown() 68 | self.Close() 69 | 70 | def exitApp(self, event): 71 | self.tbicon.RemoveIcon() 72 | self.tbicon.Destroy() 73 | self.exitServer() 74 | 75 | 76 | def openConfig(self, event): 77 | fpath = 'furion.cfg' 78 | if sys.platform.startswith('darwin'): 79 | subprocess.call(('open', fpath)) 80 | elif os.name == 'nt': # For Windows 81 | os.startfile(fpath) 82 | elif os.name == 'posix': # For Linux, Mac, etc. 83 | subprocess.call(('xdg-open', fpath)) 84 | 85 | def showLogs(self, event): 86 | pass 87 | 88 | 89 | def main(argv=None): 90 | app = wx.App(False) 91 | frame = FurionFrame(None, -1, ' ') 92 | frame.Show(False) 93 | app.MainLoop() 94 | 95 | if __name__ == '__main__': 96 | main() 97 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | from furion.furion import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /examples/client.cfg: -------------------------------------------------------------------------------- 1 | # An example Furion client configuration 2 | # It listens on localhost 11080 with SSL and Authentication off, 3 | # so browsers won't have a problem using it as a socks5 proxy. 4 | # When an connection is made, it connects to a Furion Server(upstream) with SSL, 5 | # so the data is transferred via the Furion server, encrypted. 6 | 7 | # 8 | # Main section. 9 | # 10 | 11 | [main] 12 | # The IP address and port that the socks5 proxy will listen on. 13 | local_ip = 127.0.0.1 14 | local_port = 11080 15 | 16 | # Disable SSL 17 | local_ssl = off 18 | 19 | # Disable authentication 20 | local_auth = off 21 | 22 | # Start a simple DNS server, which uses upstream server for queries. 23 | dns_server = on 24 | dns_server_port = 15353 25 | 26 | # Log level 27 | log_level = 20 28 | 29 | # Log path 30 | log_path = 31 | 32 | # 33 | # Upstream section. Specifies how you connect to a Furion server 34 | # 35 | 36 | [upstream] 37 | # A central web service where furion could fetch a list of upstream servers from 38 | central_url = 39 | 40 | # On - the central_url mechanism is enabled 41 | # Off - use/manage your own upstream list file 42 | autoupdate_upstream_list = off 43 | 44 | # How frequently you want to fetch a new upstream list from central 45 | # Currently on two options are supported: 46 | # "start" means update on every start 47 | # "weekly" means update once every week 48 | # Default is weekly 49 | update_frequency = weekly 50 | 51 | # Path to the file where you want to keep your upstream list 52 | upstream_list_path = upstream.json 53 | -------------------------------------------------------------------------------- /examples/server.cfg: -------------------------------------------------------------------------------- 1 | # An example Furion server configuration 2 | # Server listens on 443 with SSL and Authentication enabled. 3 | 4 | # 5 | # Main section. 6 | # 7 | [main] 8 | # The IP address and port that the server will listen on. 9 | # Set local_ip = 0.0.0.0 to listen on all available interfaces. 10 | local_ip = 0.0.0.0 11 | local_port = 443 12 | with_systemd = on 13 | 14 | # Enable SSL and locate the SSL server pem file. 15 | local_ssl = on 16 | pem_path = furion.pem 17 | 18 | # Username/password authentication 19 | local_auth = on 20 | 21 | # Ports that are allowed to connect to 22 | allowed_ports = all 23 | 24 | # Start UDP ping server for network delay detection 25 | ping_server = on 26 | ping_server_port = 17777 27 | 28 | # Log level 29 | log_level = 20 30 | 31 | # Log path 32 | log_path = /var/log/furion/furion.log 33 | 34 | # 35 | # Plugin section. 36 | # 37 | [plugin] 38 | # Choose which auth plugin you want to use. 39 | auth_plugin = simpleauth 40 | 41 | [simpleauth] 42 | password_path = simpleauth.passwd -------------------------------------------------------------------------------- /examples/simpleauth.passwd: -------------------------------------------------------------------------------- 1 | username password -------------------------------------------------------------------------------- /examples/upstream.json: -------------------------------------------------------------------------------- 1 | { 2 | "upstream_list": [ 3 | { 4 | "auth": true, 5 | "ip": "upstream.ip.1", 6 | "password": "password", 7 | "port": 443, 8 | "ssl": true, 9 | "username": "username" 10 | }, 11 | { 12 | "auth": true, 13 | "ip": "upstream.ip.2", 14 | "password": "password", 15 | "port": 443, 16 | "ssl": true, 17 | "username": "username" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import subprocess 4 | import sys 5 | 6 | 7 | def run_command(command): 8 | process = subprocess.run(command, shell=True, check=True) 9 | return process.returncode 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser(description="Publish package to PyPI or TestPyPI") 14 | parser.add_argument("version", help="Version to publish (e.g., 0.2.0)") 15 | parser.add_argument( 16 | "--production", 17 | "-p", 18 | action="store_true", 19 | help="Publish to PyPI instead of TestPyPI", 20 | ) 21 | args = parser.parse_args() 22 | 23 | version = args.version 24 | if not version.startswith("v"): 25 | version = f"v{version}" 26 | 27 | # Default repository is TestPyPI 28 | twine_command = "twine upload --repository testpypi dist/*" 29 | if args.production: 30 | twine_command = "twine upload dist/*" 31 | 32 | commands = [ 33 | f"git tag {version}", 34 | f"git push origin {version}", 35 | "rm -rf dist/ build/ *.egg-info", 36 | "python -m build", 37 | twine_command, 38 | ] 39 | 40 | for command in commands: 41 | print(f"Executing: {command}") 42 | try: 43 | run_command(command) 44 | except subprocess.CalledProcessError as e: 45 | print(f"Error executing command: {command}") 46 | print(f"Error: {e}") 47 | sys.exit(1) 48 | 49 | repo = "PyPI" if args.production else "TestPyPI" 50 | print(f"Successfully published version {version} to {repo}") 51 | 52 | 53 | if __name__ == "__main__": 54 | main() 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "furion" 7 | dynamic = ["version"] 8 | description = "A socks5 proxy with ssl support" 9 | authors = [{ name = "Keli Hu", email = "dev@keli.hu" }] 10 | dependencies = [] 11 | requires-python = ">=3.8" 12 | readme = "README.md" 13 | license = { text = "MIT" } 14 | keywords = ["socks5 proxy ssl"] 15 | 16 | [project.urls] 17 | Homepage = "https://github.com/keli/furion" 18 | 19 | [project.optional-dependencies] 20 | dev = ["check-manifest"] 21 | test = ["coverage"] 22 | 23 | [project.scripts] 24 | furion = "furion.furion:main" 25 | 26 | [tool.pdm] 27 | distribution = true 28 | 29 | [tool.setuptools_scm] 30 | local_scheme = 'no-local-version' 31 | version_scheme = "post-release" 32 | 33 | [tool.setuptools.dynamic] 34 | version = { attr = "setuptools_scm.get_version" } 35 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | rm -rf build dist furion.egg-info 3 | python setup.py sdist 4 | python setup.py bdist_wheel --universal 5 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | INSTALL_PATH=/usr/local/furion 7 | 8 | function check_sanity { 9 | if [[ $(id -u) != "0" ]]; then 10 | die 'Must be run by root user' 11 | fi 12 | if [[ -z $(echo `which python`) ]]; then 13 | die "Cannot find python" 14 | fi 15 | } 16 | 17 | function check_install { 18 | if [[ -d $INSTALL_PATH ]]; then 19 | cd $INSTALL_PATH 20 | print_info "Already installed, trying to upgrade..." 21 | if [[ -d .git ]]; then 22 | git pull 23 | elif [[ -d .hg ]]; then 24 | hg pull && hg up 25 | else 26 | die "Not a git or hg repo, cannot upgrade. Please remove $INSTALL_PATH and try again." 27 | fi 28 | 29 | DIFF=$(echo `diff examples/furion_$1.cfg furion.cfg`) 30 | if [[ -n $DIFF ]]; then 31 | read -r -p "A new furion.cfg is found, update and override your local changes? (y/n):" 32 | if [[ $REPLY =~ ^[Yy]$ ]]; then 33 | cp -f examples/furion_$1.cfg furion.cfg 34 | fi 35 | fi 36 | 37 | if [[ $1 == "client" ]]; then 38 | if [[ -f upstream.json ]]; then 39 | DIFF=$(echo `diff examples/latest_upstream.json upstream.json`) 40 | if [[ -n $DIFF ]]; then 41 | read -r -p "A new upstream.json is found, update and override your local changes? (y/n):" 42 | if [[ $REPLY =~ ^[Yy]$ ]]; then 43 | cp -f examples/latest_upstream.json upstream.json 44 | fi 45 | fi 46 | else 47 | cp -f examples/latest_upstream.json upstream.json 48 | fi 49 | fi 50 | 51 | print_info "Restarting service..." 52 | case $OSTYPE in 53 | darwin*) 54 | if [[ -f /Library/LaunchDaemons/hu.keli.furion.plist ]]; then 55 | launchctl unload /Library/LaunchDaemons/hu.keli.furion.plist 56 | rm -f /Library/LaunchDaemons/hu.keli.furion.plist 57 | cp -f examples/org.furion.plist /Library/LaunchDaemons/ 58 | launchctl load /Library/LaunchDaemons/org.furion.plist 59 | else 60 | launchctl unload /Library/LaunchDaemons/org.furion.plist 61 | launchctl load /Library/LaunchDaemons/org.furion.plist 62 | fi 63 | ;; 64 | linux*) 65 | service furion restart 66 | ;; 67 | esac 68 | 69 | print_info "Upgrade finished." 70 | exit 0 71 | fi 72 | } 73 | 74 | function die { 75 | echo "ERROR:" $1 > /dev/null 1>&2 76 | exit 1 77 | } 78 | 79 | function print_info { 80 | echo -n $'\e[1;36m' 81 | echo -n $1 82 | echo $'\e[0m' 83 | } 84 | 85 | function usage { 86 | cat << EOF 87 | Usage: 88 | $0 client # install furion as a client (use upstream servers as proxies) 89 | $0 server # install furion as a server (acting as an upstream proxy server) 90 | EOF 91 | exit 92 | } 93 | 94 | function download { 95 | GIT=$(echo `which git`) 96 | HG=$(echo `which hg`) 97 | 98 | if [[ -f furion.py ]]; then 99 | print_info "Copying $PWD to $INSTALL_PATH..." 100 | cp -r "$PWD" /usr/local/furion 101 | return 102 | fi 103 | if [[ -n $GIT ]]; then 104 | git clone https://github.com/keli/furion.git $INSTALL_PATH 105 | elif [[ -n $HG ]]; then 106 | hg clone https://bitbucket.org/keli/furion $INSTALL_PATH 107 | else 108 | die "Can't find git or hg in your system, install one of them first." 109 | fi 110 | } 111 | 112 | function prepare_server { 113 | cd $INSTALL_PATH 114 | cp examples/furion_server.cfg furion.cfg 115 | cp examples/simpleauth.passwd . 116 | 117 | openssl req \ 118 | -x509 -nodes -days 3650 \ 119 | -subj "/C=US/ST=CA/L=LA/CN=$1.com" \ 120 | -newkey rsa:2048 -keyout furion.pem -out furion.pem 121 | } 122 | 123 | function prepare_client { 124 | cd $INSTALL_PATH 125 | cp examples/furion_client.cfg furion.cfg 126 | cp examples/latest_upstream.json upstream.json 127 | } 128 | 129 | function install { 130 | check_sanity 131 | check_install $1 132 | print_info "Installing Furion as $1..." 133 | case $OSTYPE in 134 | darwin*) 135 | download 136 | prepare_$1 `date | md5 | head -c 10` 137 | cp -f examples/org.furion.plist /Library/LaunchDaemons/ 138 | launchctl load /Library/LaunchDaemons/org.furion.plist 139 | ;; 140 | linux*) 141 | if [ ! -f /etc/debian_version ]; then 142 | die "The script supports only Debian for now." 143 | fi 144 | download 145 | prepare_$1 `date | md5sum | head -c 10` 146 | cp -f examples/furion.init /etc/init.d/furion 147 | update-rc.d furion defaults 148 | service furion start 149 | ;; 150 | esac 151 | print_info "Installation Complete." 152 | } 153 | 154 | [[ $# < 1 ]] && usage 155 | 156 | case $1 in 157 | client|server) 158 | install $1 159 | ;; 160 | *) 161 | usage 162 | ;; 163 | esac 164 | -------------------------------------------------------------------------------- /scripts/org.furion.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | org.furion 7 | RunAtLoad 8 | 9 | KeepAlive 10 | 11 | ProgramArguments 12 | 13 | /usr/local/bin/furion 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /scripts/pyinstaller/furion-cli.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | a = Analysis(['..\\..\\cli.py'], 3 | pathex=['.'], 4 | hiddenimports=[], 5 | hookspath=None) 6 | a.datas += [('furion.ico', '.\\furion.ico', 'DATA')] 7 | pyz = PYZ(a.pure) 8 | exe = EXE(pyz, 9 | a.scripts, 10 | a.binaries, 11 | a.zipfiles, 12 | a.datas, 13 | name=os.path.join('dist', 'furion-cli.exe'), 14 | debug=False, 15 | strip=None, 16 | upx=True, 17 | console=True , icon='furion.ico') 18 | app = BUNDLE(exe, 19 | name=os.path.join('dist', 'furion-cli.exe.app')) 20 | -------------------------------------------------------------------------------- /scripts/pyinstaller/furion.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keli/furion/b25caa0850234b6e555cd94f011a5a304f1feebd/scripts/pyinstaller/furion.ico -------------------------------------------------------------------------------- /scripts/pyinstaller/furion.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | a = Analysis(['..\\..\\app.py'], 3 | pathex=['.'], 4 | hiddenimports=[], 5 | hookspath=None) 6 | a.datas += [('furion.ico', '.\\furion.ico', 'DATA')] 7 | pyz = PYZ(a.pure) 8 | exe = EXE(pyz, 9 | a.scripts, 10 | a.binaries, 11 | a.zipfiles, 12 | a.datas, 13 | name=os.path.join('dist', 'furion.exe'), 14 | debug=False, 15 | strip=None, 16 | upx=True, 17 | console=False , icon='furion.ico') 18 | app = BUNDLE(exe, 19 | name=os.path.join('dist', 'furion.exe.app')) 20 | -------------------------------------------------------------------------------- /scripts/pyinstaller/pyinstaller.bat: -------------------------------------------------------------------------------- 1 | pyinstaller.exe furion.spec 2 | pyinstaller.exe furion-cli.spec -------------------------------------------------------------------------------- /src/furion/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keli/furion/b25caa0850234b6e555cd94f011a5a304f1feebd/src/furion/__init__.py -------------------------------------------------------------------------------- /src/furion/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import time 4 | from configparser import ConfigParser 5 | from io import StringIO 6 | from os.path import abspath, basename, dirname, exists, join 7 | 8 | from .helpers import get_upstream_from_central 9 | from .simpleauth import SimpleAuth 10 | 11 | default_config = """ 12 | [main] 13 | local_ip = 127.0.0.1 14 | local_port = 11080 15 | with_systemd = off 16 | local_ssl = off 17 | pem_path = 18 | local_auth = off 19 | allowed_ports = 22,53,80,443 20 | ping_server = off 21 | ping_server_port = 17777 22 | dns_server = on 23 | dns_server_port = 15353 24 | remote_tcp_dns = 8.8.4.4 25 | log_level = 20 26 | log_path = 27 | 28 | [upstream] 29 | central_url = 30 | autoupdate_upstream_list = off 31 | update_frequency = weekly 32 | upstream_list_path = upstream.json 33 | # upstream_ip = 34 | # upstream_port = 443 35 | # upstream_ssl = on 36 | # upstream_auth = on 37 | # upstream_username = 38 | # upstream_password = 39 | 40 | [plugin] 41 | auth_plugin = 42 | """ 43 | 44 | 45 | class FurionConfig(object): 46 | last_update = 0 47 | 48 | @classmethod 49 | def get_path(cls, path): 50 | if not path: 51 | return None 52 | if not exists(path) and basename(path) == path: 53 | return join(cls.config_dir, path) 54 | else: 55 | return path 56 | 57 | @classmethod 58 | def init(cls, path): 59 | default_cfg = StringIO(default_config) 60 | 61 | cls.config = ConfigParser() 62 | cls.config.read_file(default_cfg) 63 | cls.config.read_file(open(path)) 64 | cls.config_dir = dirname(abspath(path)) 65 | 66 | auth_plugin = cls.config.get("plugin", "auth_plugin") 67 | 68 | cls.authority = None 69 | if auth_plugin == "simpleauth": 70 | cls.password_path = cls.get_path( 71 | cls.config.get("simpleauth", "password_path") 72 | ) 73 | cls.authority = SimpleAuth(cls.password_path) 74 | 75 | cls.local_ip = cls.config.get("main", "local_ip") 76 | cls.local_port = cls.config.getint("main", "local_port") 77 | cls.with_systemd = cls.config.getboolean("main", "with_systemd") 78 | 79 | cls.local_ssl = cls.config.getboolean("main", "local_ssl") 80 | cls.local_auth = cls.config.getboolean("main", "local_auth") 81 | cls.pem_path = cls.get_path(cls.config.get("main", "pem_path")) 82 | if cls.pem_path and not exists(cls.pem_path): 83 | print('Fatal error: pem "%s" cannot be found.' % cls.pem_path) 84 | time.sleep(3) 85 | sys.exit(-1) 86 | ports = cls.config.get("main", "allowed_ports").strip() 87 | if ports == "all" or ports == "": 88 | cls.allowed_ports = [] 89 | else: 90 | cls.allowed_ports = [int(port) for port in ports.split(",")] 91 | 92 | cls.ping_server = cls.config.getboolean("main", "ping_server") 93 | cls.ping_server_port = cls.config.getint("main", "ping_server_port") 94 | cls.dns_server = cls.config.getboolean("main", "dns_server") 95 | cls.dns_server_port = cls.config.getint("main", "dns_server_port") 96 | cls.remote_tcp_dns = cls.config.get("main", "remote_tcp_dns") 97 | cls.log_level = cls.config.getint("main", "log_level") 98 | cls.log_path = cls.get_path(cls.config.get("main", "log_path")) 99 | 100 | cls.central_url = cls.config.get("upstream", "central_url") 101 | cls.autoupdate_upstream_list = cls.config.getboolean( 102 | "upstream", "autoupdate_upstream_list" 103 | ) 104 | cls.update_frequency = cls.config.get("upstream", "update_frequency") 105 | cls.upstream_list_path = cls.get_path( 106 | cls.config.get("upstream", "upstream_list_path") 107 | ) 108 | 109 | cls.upstream_list = None 110 | if exists(cls.upstream_list_path): 111 | cls.upstream_list = json.loads(open(cls.upstream_list_path).read())[ 112 | "upstream_list" 113 | ] 114 | elif cls.autoupdate_upstream_list: 115 | get_upstream_from_central(cls) 116 | # cls.upstream_ip = cls.config.get('upstream', 'upstream_ip') 117 | # cls.upstream_port = cls.config.getint('upstream', 'upstream_port') 118 | # cls.upstream_ssl = cls.config.getboolean('upstream', 'upstream_ssl') 119 | # cls.upstream_auth = cls.config.getboolean('upstream', 'upstream_auth') 120 | # cls.upstream_username = cls.config.get('upstream', 'upstream_username') 121 | # cls.upstream_password = cls.config.get('upstream', 'upstream_password') 122 | 123 | cls.local_addr = (cls.local_ip, cls.local_port) 124 | cls.upstream_addr = None 125 | cls.upstream_ping = None 126 | # cls.upstream_addr = (cls.upstream_ip, cls.upstream_port) if cls.upstream_ip else None 127 | -------------------------------------------------------------------------------- /src/furion/dns.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | import struct 3 | 4 | from .socks5 import Socks5Client 5 | 6 | # from helpers import hexstring 7 | 8 | 9 | class DNSQueryHandler(socketserver.BaseRequestHandler): 10 | """UDP DNS Proxy handler""" 11 | 12 | def handle(self): 13 | data = self.request[0].strip() 14 | sock = self.request[1] 15 | 16 | sc = Socks5Client( 17 | self.local_addr, data=(self.remote_tcp_dns, 53), enable_ssl=False 18 | ) 19 | server = sc.connect() 20 | server.sendall(struct.pack("!H", len(data)) + data) 21 | result = server.recv(65535) 22 | server.close() 23 | sock.sendto(result[2:], self.client_address) 24 | -------------------------------------------------------------------------------- /src/furion/furion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import getopt 4 | import logging.handlers 5 | import sys 6 | from os.path import exists 7 | 8 | from .config import FurionConfig as cfg 9 | from .dns import * 10 | from .helpers import * 11 | from .ping import PingHandler 12 | from .servers import * 13 | from .socks5 import * 14 | 15 | 16 | def usage(): 17 | print("Usage: furion -c ") 18 | 19 | 20 | def setup_server(): 21 | config_path = "furion.cfg" if exists("furion.cfg") else "/etc/furion/furion.cfg" 22 | 23 | try: 24 | opts, args = getopt.getopt(sys.argv[1:], "hc:") 25 | except getopt.GetoptError: 26 | usage() 27 | sys.exit(1) 28 | for opt, arg in opts: 29 | if opt == "-c": 30 | config_path = arg 31 | else: 32 | usage() 33 | sys.exit() 34 | 35 | # Initialize logger 36 | log_format = "%(asctime)s [%(filename)s:%(lineno)d][%(levelname)s] %(message)s" 37 | logging.basicConfig(format=log_format, filemode="w") 38 | 39 | logger = logging.getLogger() 40 | 41 | # Initialize config 42 | if not exists(config_path): 43 | print("Fatal error: %s is not found, exiting..." % config_path) 44 | time.sleep(3) 45 | sys.exit(-1) 46 | 47 | cfg.init(config_path) 48 | 49 | if cfg.log_path: 50 | formatter = logging.Formatter(log_format) 51 | log_handler = logging.handlers.RotatingFileHandler( 52 | cfg.log_path, maxBytes=1024 * 1024 * 10, backupCount=5 53 | ) 54 | log_handler.setFormatter(formatter) 55 | logger.addHandler(log_handler) 56 | 57 | logger.setLevel(cfg.log_level) 58 | 59 | try: 60 | import gevent 61 | import gevent.monkey 62 | 63 | gevent.monkey.patch_all() 64 | except ImportError: 65 | gevent = None 66 | 67 | if gevent: 68 | logging.info("Using gevent model...") 69 | else: 70 | logging.info("Using threading model...") 71 | 72 | if cfg.upstream_list: 73 | # Setup threads for upstream checking 74 | thr = threading.Thread(target=run_check, args=(cfg,)) 75 | thr.setDaemon(1) 76 | thr.start() 77 | 78 | thr = threading.Thread(target=update_upstream, args=(cfg,)) 79 | thr.setDaemon(1) 80 | thr.start() 81 | 82 | # Start UDP ping server 83 | if cfg.ping_server: 84 | ping_svr = PingServer((cfg.local_ip, cfg.ping_server_port), PingHandler) 85 | thr = threading.Thread(target=ping_svr.serve_forever, args=(5,)) 86 | thr.setDaemon(1) 87 | thr.start() 88 | 89 | # Start UDP DNS server 90 | if cfg.dns_server: 91 | 92 | class DNSHandler(DNSQueryHandler, cfg): 93 | pass 94 | 95 | dns_proxy = DNSServer((cfg.local_ip, cfg.dns_server_port), DNSHandler) 96 | thr = threading.Thread(target=dns_proxy.serve_forever, args=()) 97 | thr.setDaemon(1) 98 | thr.start() 99 | 100 | # Re-check upstream every 30 minutes 101 | thr = threading.Thread(target=check_upstream_repeatedly, args=(1800,)) 102 | thr.setDaemon(1) 103 | thr.start() 104 | 105 | class FurionHandler(Socks5RequestHandler, cfg): 106 | pass 107 | 108 | if cfg.local_ssl: 109 | svr = SecureSocks5Server( 110 | cfg.pem_path, cfg.local_addr, FurionHandler, cfg.with_systemd 111 | ) 112 | else: 113 | svr = Socks5Server(cfg.local_addr, FurionHandler) 114 | 115 | logging.info( 116 | "Furion server listening on %s, SSL %s, AUTH %s." 117 | % ( 118 | cfg.local_addr, 119 | "ON" if cfg.local_ssl else "OFF", 120 | "ON" if cfg.local_auth else "OFF", 121 | ) 122 | ) 123 | 124 | return svr 125 | 126 | 127 | def main(): 128 | svr = setup_server() 129 | try: 130 | svr.serve_forever() 131 | except KeyboardInterrupt: 132 | print("Exiting...") 133 | svr.server_close() 134 | sys.exit() 135 | 136 | 137 | if __name__ == "__main__": 138 | main() 139 | -------------------------------------------------------------------------------- /src/furion/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import socket 5 | import ssl 6 | import threading 7 | import time 8 | from queue import Queue 9 | from urllib.request import Request, urlopen 10 | 11 | from .ping import ping 12 | 13 | MIN_INTERVAL = 30 14 | UPSTREAM_TIMEOUT = 10 15 | CONN_TIMEOUT = 5 16 | 17 | # NoticeQueue is used for triggering an upstream check 18 | NoticeQueue = Queue(1) 19 | # Alive upstream servers are put into this queue 20 | UpstreamQueue = Queue(100) 21 | 22 | ssl_context = ssl.create_default_context() 23 | ssl_context.check_hostname = False 24 | ssl_context.verify_mode = ssl.CERT_NONE 25 | 26 | 27 | def make_connection(addr, bind_to=None, to_upstream=False): 28 | """ 29 | Make TCP connection and return socket. 30 | 31 | :param addr: (domain, port) tuple to connect to 32 | :param bind_to: ip address to bind to 33 | :param to_upstream: if the destination is an upstream server 34 | :return: socket 35 | """ 36 | 37 | domain, port = addr 38 | if to_upstream: 39 | timeout = UPSTREAM_TIMEOUT 40 | else: 41 | timeout = CONN_TIMEOUT 42 | 43 | for res in socket.getaddrinfo(domain, port, 0, socket.SOCK_STREAM): 44 | af, socktype, proto, canonname, sa = res 45 | client = None 46 | try: 47 | client = socket.socket(af, socktype, proto) 48 | client.settimeout(timeout) 49 | # Specify outgoing IP on a multihome server 50 | # if bind_to and bind_to != '127.0.0.1': 51 | # client.bind((bind_to, 0)) 52 | client.connect(sa) 53 | return client 54 | except Exception as e: 55 | if client is not None: 56 | client.close() 57 | logging.debug( 58 | "Error occurred when making connection to dest %s: %s", addr, e 59 | ) 60 | return None 61 | 62 | 63 | def set_upstream(cfg, upstream): 64 | cfg.upstream_addr = upstream["ip"], upstream["port"] 65 | cfg.upstream_auth = upstream["auth"] 66 | cfg.upstream_ssl = upstream["ssl"] 67 | cfg.upstream_username = upstream["username"] 68 | cfg.upstream_password = upstream["password"] 69 | 70 | 71 | def get_upstream_from_central(cfg, timing="now"): 72 | if timing == "weekly": 73 | st = os.stat(cfg.upstream_list_path) 74 | if time.time() - st.st_mtime < 86400 * 7: 75 | logging.info("Ignore request to get upstream from central...") 76 | return None 77 | if cfg.central_url and cfg.autoupdate_upstream_list: 78 | try: 79 | logging.info("Fetching upstream from central...") 80 | jsonstr = urlopen(cfg.central_url).read() 81 | jsonstr = jsonstr.decode("utf-8") 82 | cfg.upstream_list = json.loads(jsonstr)["upstream_list"] 83 | except Exception as e: 84 | logging.exception("Failed to fetch upstream from central") 85 | else: 86 | logging.info("Saving upstream list...") 87 | open(cfg.upstream_list_path, "w").write(jsonstr) 88 | return True 89 | else: 90 | logging.fatal("No central_url is configured or autoupdate is off.") 91 | return False 92 | 93 | 94 | def run_check(cfg): 95 | """Check alive upstream servers""" 96 | if not cfg.upstream_list: 97 | get_upstream_from_central(cfg) 98 | else: 99 | # set a default upstream if none is set already 100 | if not cfg.upstream_addr: 101 | set_upstream(cfg, cfg.upstream_list[0]) 102 | 103 | if cfg.update_frequency == "start": 104 | get_upstream_from_central(cfg) 105 | elif cfg.update_frequency == "weekly": 106 | get_upstream_from_central(cfg, "weekly") 107 | 108 | while True: 109 | ts = NoticeQueue.get() 110 | diff = ts - cfg.last_update 111 | if cfg.last_update == 0 or diff > MIN_INTERVAL: 112 | logging.info( 113 | "Last check %d seconds ago, checking for live upstream...", diff 114 | ) 115 | for upstream in cfg.upstream_list: 116 | t = threading.Thread(target=check_alive, args=(upstream,)) 117 | t.start() 118 | 119 | 120 | def check_alive(upstream): 121 | dest = None 122 | try: 123 | addr = (upstream["ip"], upstream["port"]) 124 | dest = make_connection(addr, None, True) 125 | # SSL enabled 126 | if dest and upstream["ssl"]: 127 | dest = ssl_context.wrap_socket(dest) 128 | logging.debug("Upstream %s is ALIVE", addr) 129 | 130 | if not dest: 131 | logging.debug("Upstream %s is DEAD: Connection failed", addr) 132 | except Exception as e: 133 | if dest is not None: 134 | dest.close() 135 | logging.debug("Upstream %s is DEAD: %s", addr, e) 136 | return 137 | try: 138 | ping_port = upstream["ping_port"] if "ping_port" in upstream else 17777 139 | score = ping((upstream["ip"], ping_port)) 140 | upstream["ping"] = score 141 | UpstreamQueue.put((time.time(), upstream)) 142 | except Exception as e: 143 | logging.debug("Ping to %s failed: %s", (upstream["ip"], ping_port), e) 144 | 145 | 146 | def update_upstream(cfg): 147 | while True: 148 | ts, upstream = UpstreamQueue.get() 149 | if cfg.last_update == 0 or ts - cfg.last_update > MIN_INTERVAL: 150 | logging.info("Setting upstream to: %s", (upstream["ip"], upstream["port"])) 151 | cfg.last_update = ts 152 | set_upstream(cfg, upstream) 153 | else: 154 | logging.debug("Upstream %s is not used", (upstream["ip"], upstream["port"])) 155 | 156 | 157 | def trigger_upstream_check(): 158 | logging.debug("Triggering upstream check...") 159 | NoticeQueue.put_nowait(time.time()) 160 | 161 | 162 | def check_upstream_repeatedly(seconds): 163 | while True: 164 | trigger_upstream_check() 165 | time.sleep(seconds) 166 | 167 | 168 | # Some versions of windows don't have socket.inet_pton 169 | if hasattr(socket, "inet_pton"): 170 | 171 | def my_inet_aton(ip_string): 172 | af, _, _, _, _ = socket.getaddrinfo(ip_string, 80, 0, socket.SOCK_STREAM)[0] 173 | return socket.inet_pton(af, ip_string) 174 | else: 175 | my_inet_aton = socket.inet_aton 176 | 177 | 178 | def hexstring(s): 179 | return " ".join(["%02X" % ord(c) for c in s]) 180 | 181 | 182 | # http://stackoverflow.com/a/14620633/1349791 183 | class AttrDict(dict): 184 | def __init__(self, *args, **kwargs): 185 | super(AttrDict, self).__init__(*args, **kwargs) 186 | self.__dict__ = self 187 | -------------------------------------------------------------------------------- /src/furion/ping.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | import select 4 | import socket 5 | import socketserver 6 | import time 7 | 8 | 9 | def ping(addr, count=20, timeout=1): 10 | """UDP ping client""" 11 | # print "--- PING %s:%d ---" % addr 12 | results = [] 13 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 14 | for i in range(count): 15 | ts = time.time() 16 | data = "PING %d %f %s" % (i, ts, "#" * 480) 17 | data = data.encode("utf-8") 18 | sock.sendto(data, addr) 19 | readables, writeables, exceptions = select.select([sock], [], [], timeout) 20 | # exception 21 | if exceptions: 22 | time.sleep(1) 23 | continue 24 | # timeout 25 | if (readables, writeables, exceptions) == ([], [], []): 26 | continue 27 | if readables: 28 | ret = readables[0].recv(512) 29 | if ret == data: 30 | time_spent = (time.time() - ts) * 1000 31 | results.append(time_spent) 32 | # print '%d bytes from %s:%d, seq=%d time=%.3f ms' % (len(data), addr[0], addr[1], i, time_spent) 33 | 34 | received = len(results) 35 | missing = count - received 36 | loss = count - received 37 | # print "--- %s:%d ping statistics---" % addr 38 | # print "%d packets transmitted, %d packets received, %.1f%% packet loss" % (count, received, float(loss)*100/count) 39 | logging.debug( 40 | "ping %s result: %d transmitted, %d received, %.1f%% loss", 41 | addr, 42 | count, 43 | received, 44 | float(loss) * 100 // count, 45 | ) 46 | if received != 0: 47 | min_val = min(results) 48 | max_val = max(results) 49 | avg = sum(results) // count 50 | stddev = math.sqrt(sum([(x - avg) ** 2 for x in results]) // received) 51 | # print "round-trip min/avg/max/stddev = %.3f/%.3f/%.3f/%.3f" % (min_val, avg, max_val, stddev) 52 | logging.debug( 53 | "ping %s min/avg/max/stddev = %.3f/%.3f/%.3f/%.3f", 54 | addr, 55 | min_val, 56 | avg, 57 | max_val, 58 | stddev, 59 | ) 60 | return missing * 500 + avg 61 | else: 62 | return float("inf") 63 | 64 | 65 | class PingHandler(socketserver.BaseRequestHandler): 66 | """UDP Ping server handler""" 67 | 68 | def handle(self): 69 | data = self.request[0].strip() 70 | sock = self.request[1] 71 | sock.sendto(data, self.client_address) 72 | # print data 73 | 74 | 75 | # Test client 76 | # import threading 77 | # for x in range(10): 78 | # threading.Thread(target = ping, args = (('192.3.21.149', 8888),)).start() 79 | -------------------------------------------------------------------------------- /src/furion/servers.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import socketserver 3 | import ssl 4 | import threading 5 | from queue import Queue 6 | 7 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 8 | 9 | socketserver.TCPServer.allow_reuse_address = True 10 | 11 | TIME_OUT = 30 12 | POOL_SIZE = 50 13 | 14 | 15 | class ThreadPoolMixIn(socketserver.ThreadingMixIn): 16 | """Thread pool mixin""" 17 | 18 | def serve_forever(self, pool_size=POOL_SIZE): 19 | self.requests = Queue(pool_size) 20 | 21 | for x in range(pool_size): 22 | t = threading.Thread(target=self.process_request_thread) 23 | t.setDaemon(1) 24 | t.start() 25 | 26 | while True: 27 | self.handle_request() 28 | 29 | self.server_close() 30 | 31 | def process_request_thread(self): 32 | while True: 33 | socketserver.ThreadingMixIn.process_request_thread( 34 | self, *self.requests.get() 35 | ) 36 | 37 | def handle_request(self): 38 | try: 39 | request, client_address = self.get_request() 40 | except socket.error: 41 | return 42 | if self.verify_request(request, client_address): 43 | self.requests.put((request, client_address)) 44 | 45 | 46 | class SecureTCPServer(socketserver.TCPServer): 47 | """TCP server with SSL""" 48 | 49 | SYSTEMD_FIRST_SOCKET_FD = 3 50 | 51 | def __init__(self, pem_path, server_address, handler_class, with_systemd=False): 52 | socketserver.BaseServer.__init__(self, server_address, handler_class) 53 | 54 | af, socktype, proto, canonname, sa = socket.getaddrinfo( 55 | self.server_address[0], self.server_address[1], 0, socket.SOCK_STREAM 56 | )[0] 57 | 58 | if not with_systemd: 59 | sock = socket.socket(af, socktype, proto) 60 | else: 61 | sock = socket.fromfd(self.SYSTEMD_FIRST_SOCKET_FD, af, socktype) 62 | # sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 63 | sock.settimeout(TIME_OUT) 64 | 65 | # Load the certificate 66 | ssl_context.load_cert_chain(pem_path, pem_path) 67 | 68 | # Don't do handshake on connect for ssl (which will block http://bugs.python.org/issue1251) 69 | self.socket = ssl_context.wrap_socket( 70 | sock, 71 | server_side=True, 72 | do_handshake_on_connect=False, 73 | ) 74 | if not with_systemd: 75 | self.server_bind() 76 | self.server_activate() 77 | 78 | 79 | class Socks5Server(socketserver.ThreadingMixIn, socketserver.TCPServer): 80 | """Threading Socks5 server""" 81 | 82 | pass 83 | 84 | 85 | class TPSocks5Server(ThreadPoolMixIn, socketserver.TCPServer): 86 | """Thread Pool Socks5 server""" 87 | 88 | pass 89 | 90 | 91 | class SecureSocks5Server(socketserver.ThreadingMixIn, SecureTCPServer): 92 | """Secure Socks5 server""" 93 | 94 | pass 95 | 96 | 97 | class TPSecureSocks5Server(ThreadPoolMixIn, SecureTCPServer): 98 | """Thread Pool Secure Socks5 server""" 99 | 100 | pass 101 | 102 | 103 | class PingServer(ThreadPoolMixIn, socketserver.UDPServer): 104 | """UDP Ping server""" 105 | 106 | pass 107 | 108 | 109 | class DNSServer(socketserver.ThreadingMixIn, socketserver.UDPServer): 110 | """UDP DNS Proxy""" 111 | 112 | pass 113 | 114 | 115 | # Test server 116 | # svr = PingServer(('0.0.0.0', 8888), PingHandler) 117 | # svr.serve_forever(5) 118 | -------------------------------------------------------------------------------- /src/furion/simpleauth.py: -------------------------------------------------------------------------------- 1 | from .socks5 import AUTH_ERR_USERNOTFOUND, AUTH_SUCCESSFUL 2 | 3 | 4 | class SimpleAuth: 5 | def __init__(self, path): 6 | self.users = {} 7 | for line in open(path): 8 | line = line.strip() 9 | if line: 10 | user, password = line.split(" ") 11 | self.users[user] = password 12 | 13 | def auth(self, username, password): 14 | if self.users.get(username, "") == password: 15 | return 0, AUTH_SUCCESSFUL 16 | else: 17 | return 0, AUTH_ERR_USERNOTFOUND 18 | 19 | def usage(self, member_id, bytes): 20 | pass 21 | -------------------------------------------------------------------------------- /src/furion/socks5.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import select 3 | import socket 4 | import socketserver 5 | import ssl 6 | import struct 7 | 8 | from .helpers import hexstring, make_connection, my_inet_aton, trigger_upstream_check 9 | 10 | ssl_context = ssl.create_default_context() 11 | ssl_context.check_hostname = False 12 | ssl_context.verify_mode = ssl.CERT_NONE 13 | 14 | #################################### 15 | # Constants 16 | #################################### 17 | 18 | BUF_SIZE = 1024 19 | TIME_OUT = 30 20 | 21 | # Socks5 stages 22 | INIT_STAGE = 0 23 | AUTH_STAGE = 1 24 | FINAL_STAGE = 2 25 | CONN_ACCEPTED = 3 26 | 27 | # Socks5 auth codes 28 | AUTH_SUCCESSFUL = b"\x00" 29 | AUTH_ERR_SERVER = b"\x01" 30 | AUTH_ERR_BANDWIDTH = b"\x02" 31 | AUTH_ERR_NOPLANFOUND = b"\x03" 32 | AUTH_ERR_USERNOTFOUND = b"\x04" 33 | 34 | 35 | #################################### 36 | # Exceptions 37 | #################################### 38 | 39 | 40 | class Socks5Exception(Exception): 41 | """Base socks5 exception class""" 42 | 43 | pass 44 | 45 | 46 | class Socks5NoAuthMethodAccepted(Socks5Exception): 47 | def __init__(self): 48 | Exception.__init__(self, "No auth method accepted.") 49 | 50 | 51 | class Socks5AuthFailed(Socks5Exception): 52 | def __init__(self, reason=None): 53 | if reason: 54 | Exception.__init__(self, "Authentication failed: %s." % reason) 55 | else: 56 | Exception.__init__(self, "Authentication failed.") 57 | 58 | 59 | class Socks5DnsFailed(Socks5Exception): 60 | def __init__(self): 61 | Exception.__init__(self, "DNS resolve failed.") 62 | 63 | 64 | class Socks5ConnectionFailed(Socks5Exception): 65 | def __init__(self): 66 | Exception.__init__(self, "Connection to upstream/destination failed.") 67 | 68 | 69 | class Socks5RemoteConnectionClosed(Socks5Exception): 70 | def __init__(self): 71 | Exception.__init__(self, "Remote connection closed.") 72 | 73 | 74 | class Socks5SocketError(Socks5Exception): 75 | def __init__(self): 76 | Exception.__init__(self, "A socket error occurred when forwarding.") 77 | 78 | 79 | class Socks5ConnectionClosed(Socks5Exception): 80 | def __init__(self): 81 | Exception.__init__(self, "Socks5 connection closed.") 82 | 83 | 84 | class Socks5NotImplemented(Socks5Exception): 85 | def __init__(self): 86 | Exception.__init__(self, "Protocol not implemented yet.") 87 | 88 | 89 | class Socks5PortForbidden(Socks5Exception): 90 | def __init__(self, port): 91 | Exception.__init__(self, "Port %d is not allowed" % port) 92 | 93 | 94 | #################################### 95 | # Socks5 handlers 96 | #################################### 97 | 98 | 99 | class Socks5RequestHandler(socketserver.StreamRequestHandler): 100 | """Socks5 request handler""" 101 | 102 | def setup(self): 103 | socketserver.StreamRequestHandler.setup(self) 104 | 105 | self.bytes_in = 0 106 | self.bytes_out = 0 107 | 108 | self.member_id = 0 109 | 110 | self.client_name = None 111 | self.server_name = None 112 | 113 | def handle(self): 114 | """Main handler""" 115 | 116 | stage = INIT_STAGE 117 | leftover = b"" 118 | dest = None 119 | 120 | try: 121 | while stage < CONN_ACCEPTED: 122 | data = self.request.recv(BUF_SIZE) 123 | 124 | # Client closed connection 125 | if not data: 126 | raise Socks5ConnectionClosed 127 | 128 | data = leftover + data 129 | 130 | if len(data) < 3: 131 | leftover = data 132 | continue 133 | 134 | # Init stage 135 | if stage == INIT_STAGE: 136 | # If no auth required 137 | if not self.local_auth and data == b"\x05\x01\x00": 138 | self.request.sendall(b"\x05\x00") 139 | stage = FINAL_STAGE 140 | continue 141 | # if username/password auth required 142 | elif self.local_auth and data == b"\x05\x01\x02": 143 | self.request.sendall(b"\x05\x02") 144 | stage = AUTH_STAGE 145 | continue 146 | # no auth method accepted 147 | else: 148 | self.request.sendall(b"\x05\xff") 149 | # print(hexstring(data)) 150 | raise Socks5NoAuthMethodAccepted 151 | 152 | # Auth stage 153 | elif stage == AUTH_STAGE: 154 | (name_length,) = struct.unpack("B", data[1:2]) 155 | if len(data[2:]) < name_length + 1: 156 | leftover = data 157 | continue 158 | (pass_length,) = struct.unpack( 159 | "B", data[2 + name_length : 2 + name_length + 1] 160 | ) 161 | if len(data[2 + name_length + 1 :]) < pass_length: 162 | leftover = data 163 | continue 164 | 165 | username = data[2 : 2 + name_length].decode("utf-8") 166 | password = data[2 + name_length + 1 :].decode("utf-8") 167 | 168 | self.member_id, error_code = self.authority.auth(username, password) 169 | 170 | if error_code != AUTH_SUCCESSFUL: 171 | self.request.sendall(b"\x01" + error_code) 172 | logging.info("Auth failed for user: %s", username) 173 | raise Socks5AuthFailed 174 | else: 175 | self.request.sendall(b"\x01\x00") 176 | logging.info("Auth succeeded for user: %s", username) 177 | stage = FINAL_STAGE 178 | 179 | # Final stage 180 | elif stage == FINAL_STAGE: 181 | if len(data) < 10: 182 | leftover = data 183 | continue 184 | # Only TCP connections and IPV4 are allowed 185 | if data[:2] != b"\x05\x01" or data[3:4] == b"\x04": 186 | # Protocol error 187 | self.request.sendall(b"\x05\x07") 188 | raise Socks5NotImplemented 189 | else: 190 | domain = port = None 191 | # Connect by domain name 192 | if data[3:4] == b"\x03" or data[3:4] == b"\x02": 193 | (length,) = struct.unpack("B", data[4:5]) 194 | domain = data[5 : 5 + length] 195 | (port,) = struct.unpack("!H", data[5 + length :]) 196 | # Connect by ip address 197 | elif data[3:4] == b"\x01": 198 | domain = socket.inet_ntoa(data[4:8]) 199 | (port,) = struct.unpack("!H", data[8:]) 200 | try: 201 | # Resolve domain to ip 202 | if data[3:4] == b"\x02": 203 | _, _, _, _, sa = filter( 204 | lambda x: x[0] == 2, 205 | socket.getaddrinfo( 206 | domain, port, 0, socket.SOCK_STREAM 207 | ), 208 | )[0] 209 | ip, _ = sa 210 | ip_bytes = my_inet_aton(ip) 211 | port_bytes = struct.pack("!H", port) 212 | self.request.sendall( 213 | b"\x05\x00\x00\x02" + ip_bytes + port_bytes 214 | ) 215 | # Return without actually connecting to domain 216 | break 217 | # Connect to destination 218 | else: 219 | dest = self.connect(domain, port, data) 220 | 221 | # If connected to upstream/destination, let client know 222 | dsockname = dest.getsockname() 223 | client_ip = dsockname[0] 224 | client_port = dsockname[1] 225 | ip_bytes = my_inet_aton(client_ip) 226 | port_bytes = struct.pack("!H", client_port) 227 | self.request.sendall( 228 | b"\x05\x00\x00\x01" + ip_bytes + port_bytes 229 | ) 230 | 231 | stage = CONN_ACCEPTED 232 | except: 233 | logging.exception( 234 | "Error when trying to resolve/connect to: %s", 235 | (domain, port), 236 | ) 237 | self.request.sendall(b"\x05\x01") 238 | raise 239 | 240 | # Starting to forward data 241 | try: 242 | if dest: 243 | result = self.forward(self.request, dest) 244 | if result: 245 | logging.debug("Forwarding finished") 246 | else: 247 | logging.debug("Exception/timeout when forwarding") 248 | except: 249 | logging.exception("Error when forwarding") 250 | finally: 251 | if dest: 252 | dest.close() 253 | logging.info( 254 | "%d bytes out, %d bytes in. Socks5 session finished %s <-> %s.", 255 | self.bytes_out, 256 | self.bytes_in, 257 | self.client_name, 258 | self.server_name, 259 | ) 260 | if self.local_auth and (self.bytes_in or self.bytes_out): 261 | self.authority.usage(self.member_id, self.bytes_in + self.bytes_out) 262 | except Socks5Exception: 263 | logging.exception("Connection closed") 264 | except: 265 | logging.exception("Error when proxying") 266 | # traceback.print_exc() 267 | finally: 268 | try: 269 | self.request.shutdown(socket.SHUT_RDWR) 270 | except: 271 | self.request.close() 272 | return 273 | 274 | def connect(self, domain, port, data): 275 | # Connect to upstream instead of destination 276 | if self.upstream_addr: 277 | sc = Socks5Client( 278 | self.upstream_addr, 279 | self.upstream_username, 280 | self.upstream_password, 281 | data, 282 | enable_ssl=self.upstream_ssl, 283 | ) 284 | logging.info( 285 | "Connecting to %s via upstream %s.", domain, self.upstream_addr 286 | ) 287 | return sc.connect() 288 | else: 289 | # Connect to destination directly 290 | if len(self.allowed_ports) > 0 and port not in self.allowed_ports: 291 | raise Socks5PortForbidden(port) 292 | my_ip = self.request.getsockname()[0] 293 | logging.info("Connecting to %s.", domain) 294 | return make_connection((domain, port), my_ip) 295 | 296 | def forward(self, client, server): 297 | """forward data between sockets""" 298 | self.client_name = client.getpeername() 299 | self.server_name = server.getpeername() 300 | 301 | while True: 302 | readables, writeables, exceptions = select.select( 303 | [client, server], [], [], TIME_OUT 304 | ) 305 | 306 | # exception or timeout 307 | if exceptions or (readables, writeables, exceptions) == ([], [], []): 308 | return False 309 | 310 | data = "" 311 | 312 | for readable in readables: 313 | data = readable.recv(BUF_SIZE) 314 | 315 | if data: 316 | if readable == client: 317 | self.bytes_out += len(data) 318 | server.send(data) 319 | else: 320 | self.bytes_in += len(data) 321 | client.send(data) 322 | else: 323 | return True 324 | 325 | 326 | class Socks5Client: 327 | """A socks5 client with optional SSL support""" 328 | 329 | def __init__( 330 | self, 331 | addr, 332 | username="", 333 | password="", 334 | data="", 335 | enable_ssl=True, 336 | bind_to=None, 337 | to_upstream=True, 338 | dns_only=False, 339 | ): 340 | """ 341 | :param addr: socket server address tuple 342 | :param username: username 343 | :param password: password 344 | :param data: a tuple of remote address you plan to connect to, or packed data of it. 345 | :param enable_ssl: if ssl should be enabled 346 | :param bind_to: ip to bind to for the local socket 347 | :param to_upstream: if an upstream is used 348 | :return: established socket or resolved address when dns_only is True 349 | """ 350 | self.addr = addr 351 | self.enable_ssl = enable_ssl 352 | self.username = username.encode("utf-8") 353 | self.password = password.encode("utf-8") 354 | self.data = data 355 | self.bind_to = bind_to 356 | self.to_upstream = to_upstream 357 | self.dns_only = dns_only 358 | 359 | def connect(self): 360 | dest = make_connection(self.addr, self.bind_to, self.to_upstream) 361 | # SSL enabled 362 | if dest and self.enable_ssl: 363 | dest = ssl_context.wrap_socket(dest) 364 | 365 | if not dest: 366 | trigger_upstream_check() 367 | raise Socks5ConnectionFailed() 368 | 369 | # Server needs authentication 370 | if self.username and self.password: 371 | # Send auth method (username/password auth) 372 | dest.sendall(b"\x05\x01\x02") 373 | ans = dest.recv(BUF_SIZE) 374 | # Method accepted 375 | if ans == b"\x05\x02": 376 | name_length = struct.pack("B", len(self.username)) 377 | pass_length = struct.pack("B", len(self.password)) 378 | # Start auth 379 | dest.sendall( 380 | b"\x01" + name_length + self.username + pass_length + self.password 381 | ) 382 | ans = dest.recv(BUF_SIZE) 383 | # Auth failed 384 | if ans != b"\x01\x00": 385 | if not ans or ans[1] == AUTH_ERR_SERVER: 386 | raise Socks5AuthFailed("An error occurred on server") 387 | elif ans[1] == AUTH_ERR_BANDWIDTH: 388 | raise Socks5AuthFailed("Bandwidth usage exceeded quota") 389 | elif ans[1] == AUTH_ERR_NOPLANFOUND: 390 | raise Socks5AuthFailed("Can't find a subscribed plan for user") 391 | elif ans[1] == AUTH_ERR_USERNOTFOUND: 392 | raise Socks5AuthFailed("User not found or wrong password") 393 | else: 394 | raise Socks5AuthFailed 395 | else: 396 | raise Socks5AuthFailed("No accepted authentication method") 397 | # No auth needed 398 | else: 399 | dest.sendall(b"\x05\x01\x00") 400 | ans = dest.recv(BUF_SIZE) 401 | if ans != b"\x05\x00": 402 | raise Socks5AuthFailed 403 | 404 | if type(self.data) is tuple: 405 | domain, port = self.data 406 | domain = domain.encode("utf-8") 407 | port_str = struct.pack("!H", port) 408 | len_str = struct.pack("B", len(domain)) 409 | if self.dns_only: 410 | addr_type = b"\x02" 411 | else: 412 | addr_type = b"\x03" 413 | data = b"\x05\x01\x00" + addr_type + len_str + domain + port_str 414 | else: 415 | data = self.data 416 | 417 | dest.sendall(data) 418 | ans = dest.recv(BUF_SIZE) 419 | if ans.startswith(b"\x05\x00"): 420 | if ans[3] == b"\x02": 421 | return socket.inet_ntoa(ans[4:8]) 422 | else: 423 | return dest 424 | else: 425 | raise Socks5ConnectionFailed 426 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keli/furion/b25caa0850234b6e555cd94f011a5a304f1feebd/tests/__init__.py --------------------------------------------------------------------------------