├── .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
--------------------------------------------------------------------------------