Update 2.0.1

This commit is contained in:
本间白猫 2025-03-03 11:13:52 +08:00
parent 91ffaa9fcf
commit 0a1df0dd25
31 changed files with 4546 additions and 1380 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -165,15 +165,11 @@ neko=123456
![Clip_2025-02-21_16-23-35](./Operation%20Manual.assets/Clip_2025-02-21_16-23-35.png) ![Clip_2025-02-21_16-23-35](./Operation%20Manual.assets/Clip_2025-02-21_16-23-35.png)
![Clip_2025-02-21_16-32-14](./Operation%20Manual.assets/Clip_2025-02-21_16-32-14.png) ![Clip_2025-03-03_10-54-38](./Operation%20Manual.assets/Clip_2025-03-03_10-54-38.png)
![Clip_2025-02-21_16-28-30](./Operation%20Manual.assets/Clip_2025-02-21_16-28-30-1740126513899-10.png) ![Clip_2025-03-03_10-55-05](./Operation%20Manual.assets/Clip_2025-03-03_10-55-05.png)
![Clip_2025-02-21_16-29-21](./Operation%20Manual.assets/Clip_2025-02-21_16-29-21.png) ![Clip_2025-03-03_10-55-37](./Operation%20Manual.assets/Clip_2025-03-03_10-55-37.png)
![Clip_2025-02-21_16-29-45](./Operation%20Manual.assets/Clip_2025-02-21_16-29-45.png)
![Clip_2025-02-21_16-30-51](./Operation%20Manual.assets/Clip_2025-02-21_16-30-51.png)
## 问题Q&A ## 问题Q&A

View File

@ -1,3 +1,9 @@
### 2025/03/03
- 美化 Web管理界面
- 修复大量 BUG
- 添加更多处理小脚本辅助使用
### 2025/02/21 ### 2025/02/21
- 增加 Web 管理界面 - 增加 Web 管理界面

View File

@ -11,13 +11,12 @@ from configparser import ConfigParser
init(autoreset=True) init(autoreset=True)
log_format = '%(asctime)s - %(levelname)s - %(message)s' def setup_logging():
formatter = ColoredFormatter(log_format) log_format = '%(asctime)s - %(levelname)s - %(message)s'
formatter = ColoredFormatter(log_format)
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[console_handler])
logging.basicConfig(level=logging.INFO, handlers=[console_handler])
def update_status(server): def update_status(server):
def print_proxy_info(): def print_proxy_info():
@ -30,69 +29,20 @@ def update_status(server):
old_port = int(server.config.get('port', '1080')) old_port = int(server.config.get('port', '1080'))
server.config.update(new_config) server.config.update(new_config)
server._update_config_values(new_config)
server.port = int(new_config.get('port', '1080'))
server.mode = new_config.get('mode', 'cycle')
server.interval = int(new_config.get('interval', '300'))
server.language = new_config.get('language', 'cn')
server.use_getip = new_config.get('use_getip', 'False').lower() == 'true'
server.check_proxies = new_config.get('check_proxies', 'True').lower() == 'true'
server.username = new_config.get('username', '')
server.password = new_config.get('password', '')
server.proxy_username = new_config.get('proxy_username', '')
server.proxy_password = new_config.get('proxy_password', '')
server.auth_required = bool(server.username and server.password)
server.proxy_file = new_config.get('proxy_file', 'ip.txt')
server.whitelist_file = new_config.get('whitelist_file', '')
server.blacklist_file = new_config.get('blacklist_file', '')
server.ip_auth_priority = new_config.get('ip_auth_priority', 'whitelist')
server.whitelist = load_ip_list(new_config.get('whitelist_file', ''))
server.blacklist = load_ip_list(new_config.get('blacklist_file', ''))
if old_use_getip != server.use_getip or old_mode != server.mode: if old_use_getip != server.use_getip or old_mode != server.mode:
if server.use_getip: server._handle_mode_change()
server.proxies = []
server.proxy_cycle = None
server.current_proxy = None
logging.info(get_message('api_mode_notice', server.language))
else:
server.proxies = server._load_file_proxies()
if server.proxies:
server.proxy_cycle = cycle(server.proxies)
server.current_proxy = next(server.proxy_cycle)
if server.check_proxies:
asyncio.run(run_proxy_check(server))
if server.use_getip:
server.getip_url = new_config.get('getip_url', '')
server.last_switch_time = time.time()
nonlocal display_level
display_level = int(new_config.get('display_level', '1'))
if hasattr(server, 'progress_bar'):
if not is_docker:
server.progress_bar.close()
delattr(server, 'progress_bar')
if hasattr(server, 'last_update_time'):
delattr(server, 'last_update_time')
if old_port != server.port: if old_port != server.port:
logging.info(get_message('port_changed', server.language, old_port, server.port)) logging.info(get_message('port_changed', server.language, old_port, server.port))
logging.info(get_message('config_updated', server.language))
display_level = int(server.config.get('display_level', '1'))
is_docker = os.path.exists('/.dockerenv')
config_file = 'config/config.ini' config_file = 'config/config.ini'
ip_file = server.proxy_file ip_file = server.proxy_file
last_config_modified_time = os.path.getmtime(config_file) if os.path.exists(config_file) else 0 last_config_modified_time = os.path.getmtime(config_file) if os.path.exists(config_file) else 0
last_ip_modified_time = os.path.getmtime(ip_file) if os.path.exists(ip_file) else 0 last_ip_modified_time = os.path.getmtime(ip_file) if os.path.exists(ip_file) else 0
display_level = int(server.config.get('display_level', '1'))
is_docker = os.path.exists('/.dockerenv')
while True: while True:
try: try:
@ -109,12 +59,7 @@ def update_status(server):
current_ip_modified_time = os.path.getmtime(ip_file) current_ip_modified_time = os.path.getmtime(ip_file)
if current_ip_modified_time > last_ip_modified_time: if current_ip_modified_time > last_ip_modified_time:
logging.info(get_message('proxy_file_changed', server.language)) logging.info(get_message('proxy_file_changed', server.language))
server.proxies = server._load_file_proxies() server._reload_proxies()
if server.proxies:
server.proxy_cycle = cycle(server.proxies)
server.current_proxy = next(server.proxy_cycle)
if server.check_proxies:
asyncio.run(run_proxy_check(server))
last_ip_modified_time = current_ip_modified_time last_ip_modified_time = current_ip_modified_time
continue continue
@ -141,10 +86,6 @@ def update_status(server):
if not hasattr(server, 'last_proxy') or server.last_proxy != server.current_proxy: if not hasattr(server, 'last_proxy') or server.last_proxy != server.current_proxy:
print_proxy_info() print_proxy_info()
server.last_proxy = server.current_proxy server.last_proxy = server.current_proxy
if display_level >= 2:
logging.info(get_message('proxy_switch_detail', server.language,
getattr(server, 'previous_proxy', 'None'),
server.current_proxy))
server.previous_proxy = server.current_proxy server.previous_proxy = server.current_proxy
total_time = int(server.interval) total_time = int(server.interval)
@ -227,24 +168,44 @@ async def run_proxy_check(server):
class ProxyCat: class ProxyCat:
def __init__(self): def __init__(self):
cpu_count = os.cpu_count() or 1
self.executor = ThreadPoolExecutor( self.executor = ThreadPoolExecutor(
max_workers=min(32, (os.cpu_count() or 1) * 4), max_workers=min(32, cpu_count + 4),
thread_name_prefix="proxy_worker" thread_name_prefix="proxy_worker",
thread_name_format="proxy_worker_%d"
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.set_default_executor(self.executor) loop.set_default_executor(self.executor)
if hasattr(asyncio, 'WindowsSelectorEventLoopPolicy'): if hasattr(loop, 'set_task_factory'):
if os.name == 'nt': loop.set_task_factory(None)
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
socket.setdefaulttimeout(30) socket.setdefaulttimeout(30)
if hasattr(socket, 'TCP_NODELAY'): if hasattr(socket, 'TCP_NODELAY'):
socket.TCP_NODELAY = True socket.TCP_NODELAY = True
if hasattr(socket, 'SO_KEEPALIVE'):
socket.SO_KEEPALIVE = True
self.running = True if os.name != 'nt':
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
except ValueError:
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, soft))
soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
try:
resource.setrlimit(resource.RLIMIT_NPROC, (hard, hard))
except ValueError:
pass
self.running = True
self.tasks = set()
self.max_tasks = 20000
self.task_semaphore = asyncio.Semaphore(self.max_tasks)
signal.signal(signal.SIGINT, self.handle_shutdown) signal.signal(signal.SIGINT, self.handle_shutdown)
signal.signal(signal.SIGTERM, self.handle_shutdown) signal.signal(signal.SIGTERM, self.handle_shutdown)
self.config = load_config('config/config.ini') self.config = load_config('config/config.ini')
@ -256,6 +217,11 @@ class ProxyCat:
if config.has_section('Users'): if config.has_section('Users'):
self.users = dict(config.items('Users')) self.users = dict(config.items('Users'))
self.auth_required = bool(self.users) self.auth_required = bool(self.users)
self.proxy_pool = {}
self.max_connections = 1000
self.connection_timeout = 30
self.read_timeout = 60
async def start_server(self): async def start_server(self):
try: try:
@ -292,9 +258,9 @@ class ProxyCat:
return return
await asyncio.get_event_loop().run_in_executor( await asyncio.get_event_loop().run_in_executor(
self.executor, self.executor,
self.process_client_request, self._handle_proxy_request,
reader, reader,
writer writer
) )
except Exception as e: except Exception as e:
@ -305,6 +271,7 @@ class ProxyCat:
await writer.wait_closed() await writer.wait_closed()
except: except:
pass pass
self.tasks.remove(task)
def _authenticate(self, auth_header): def _authenticate(self, auth_header):
if not self.users: if not self.users:
@ -322,7 +289,115 @@ class ProxyCat:
except: except:
return False return False
async def _handle_proxy_request(self, reader, writer):
try:
request_line = await reader.readline()
if not request_line:
return
method, target, version = request_line.decode().strip().split(' ')
if method == 'CONNECT':
await self._handle_connect(target, reader, writer)
else:
await self._handle_http(method, target, version, reader, writer)
except Exception as e:
logging.error(get_message('request_handling_error', self.language, e))
try:
writer.close()
except:
pass
async def _handle_connect(self, target, reader, writer):
host, port = target.split(':')
port = int(port)
try:
remote_reader, remote_writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=self.connection_timeout
)
writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n')
await writer.drain()
await self._create_pipe(reader, writer, remote_reader, remote_writer)
except Exception as e:
logging.error(get_message('proxy_forward_error', self.language, e))
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
await writer.drain()
async def _handle_http(self, method, target, version, reader, writer):
try:
from urllib.parse import urlparse
url = urlparse(target)
host = url.hostname
port = url.port or 80
remote_reader, remote_writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=self.connection_timeout
)
path = url.path + ('?' + url.query if url.query else '')
request = f'{method} {path} {version}\r\n'
remote_writer.write(request.encode())
while True:
line = await reader.readline()
if line == b'\r\n':
break
remote_writer.write(line)
remote_writer.write(b'\r\n')
await self._create_pipe(reader, writer, remote_reader, remote_writer)
except Exception as e:
logging.error(get_message('proxy_forward_error', self.language, e))
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
await writer.drain()
async def _create_pipe(self, client_reader, client_writer, remote_reader, remote_writer):
try:
pipe1 = asyncio.create_task(self._pipe(client_reader, remote_writer))
pipe2 = asyncio.create_task(self._pipe(remote_reader, client_writer))
done, pending = await asyncio.wait(
[pipe1, pipe2],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logging.error(get_message('data_transfer_error', self.language, e))
finally:
try:
remote_writer.close()
await remote_writer.wait_closed()
except:
pass
async def _pipe(self, reader, writer):
try:
while True:
data = await reader.read(8192)
if not data:
break
writer.write(data)
await writer.drain()
except asyncio.CancelledError:
pass
except Exception as e:
logging.error(get_message('data_transfer_error', self.language, e))
if __name__ == '__main__': if __name__ == '__main__':
setup_logging()
parser = argparse.ArgumentParser(description=logos()) parser = argparse.ArgumentParser(description=logos())
parser.add_argument('-c', '--config', default='config/config.ini', help='配置文件路径') parser.add_argument('-c', '--config', default='config/config.ini', help='配置文件路径')
args = parser.parse_args() args = parser.parse_args()

View File

@ -11,18 +11,11 @@
- [Development Background](#development-background) - [Development Background](#development-background)
- [Features](#features) - [Features](#features)
- [Installation and Usage](#installation-and-usage) - [Installation and Usage](#installation-and-usage)
- [Dependencies Installation](#dependencies-installation)
- [Running the Tool](#running-the-tool)
- [Manual Proxy Entry](#iptxt-manual-proxy-entry)
- [Configuration File](#configuration-file)
- [Demo Effect](#demo-effect)
- [Using API for Automatic Proxy Retrieval](#using-api-for-automatic-proxy-retrieval)
- [Docker Deployment](#docker-deployment)
- [Performance](#performance)
- [Disclaimer](#disclaimer) - [Disclaimer](#disclaimer)
- [Change Log](#change-log) - [Changelog](#changelog)
- [Development Plan](#development-plan) - [Development Plan](#development-plan)
- [Acknowledgments](#acknowledgments) - [Special Thanks](#special-thanks)
- [Sponsor](#sponsor)
- [Proxy Recommendations](#proxy-recommendations) - [Proxy Recommendations](#proxy-recommendations)
## Development Background ## Development Background
@ -35,269 +28,82 @@ Therefore, **ProxyCat** was born! This tool aims to transform short-term IPs (la
## Features ## Features
- **Dual Protocol Support**: Supports both SOCKS5 and HTTP protocol listening, compatible with more tools. - **Dual Protocol Listening**: Supports HTTP/SOCKS5 protocol listening, compatible with more tools.
- **Multiple Proxy Protocols**: Supports HTTP/HTTPS/SOCKS5 proxy servers to meet various application needs. - **Triple Proxy Types**: Supports HTTP/HTTPS/SOCKS5 proxy servers with authentication.
- **Multiple Switching Modes**: Cycles through each proxy in the list sequentially; randomly selects available proxies to distribute traffic load and improve performance. Allows users to customize proxy selection logic for specific needs. - **Flexible Switching Modes**: Supports sequential, random, and custom proxy selection for optimized traffic distribution.
- **Function-based Proxy Retrieval**: Supports dynamic proxy retrieval through GetIP function for real-time availability. - **Dynamic Proxy Acquisition**: Get available proxies in real-time through GetIP function, supports API interface calls.
- **Automatic Validity Detection**: Automatically detects proxy availability at startup to filter invalid proxies. - **Proxy Protection**: When using GetIP method, proxies are only fetched upon receiving requests, not at initial startup.
- **Switch Only During Proxy Forwarding**: Changes to new proxy server only when timer reaches zero and new requests arrive. - **Automatic Proxy Detection**: Automatically checks proxy validity at startup, removing invalid ones.
- **Proxy Failure Switching**: Automatically switches to new proxy if current proxy fails during traffic forwarding. - **Smart Proxy Switching**: Only obtains new proxies during request execution, reducing resource consumption.
- **Proxy Pool Authentication**: Supports username/password-based authentication and blacklist/whitelist mechanisms. - **Invalid Proxy Handling**: Automatically validates and switches to new proxies when current ones fail.
- **Real-time Status Updates**: Displays current proxy status and next switch time. - **Authentication Support**: Supports username/password authentication and IP blacklist/whitelist management.
- **Configurable File**: Easily adjust port, mode, authentication info via config.ini. - **Real-time Status Display**: Shows proxy status and switching times for dynamic monitoring.
- **Version Detection**: Built-in version checking for automatic updates. - **Dynamic Configuration**: Updates configuration without service restart.
- **Web UI Interface**: Provides web management interface for convenient operation.
- **Docker Deployment**: One-click Docker deployment with unified web management.
- **Bilingual Support**: Supports Chinese and English language switching.
- **Flexible Configuration**: Customize ports, modes, and authentication through config.ini.
- **Version Check**: Automatic software update checking.
## Installation and Usage ## Tool Usage
### Dependencies Installation [ProxyCat Operation Manual](../main/ProxyCat-Manual/Operation%20Manual.md)
The tool is based on Python, recommended version **Python 3.8** or above. Install dependencies using: ## Error Troubleshooting
```bash [ProxyCat Investigation Manual](../main/ProxyCat-Manual/Investigation%20Manual.md)
pip install -r requirements.txt
# Or using Chinese mirror:
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
```
### Running the Tool
Run the following command in the project directory to view help information:
```bash
python3 ProxyCat.py -h
```
Success is indicated by this response:
```
|\ _,,,---,,_ by honmashironeko
ZZZzz /,`.-'`' -. ;-;;,_
|,4- ) )-,_. ,\ ( `'-'
'---''(_/--' `-'\_) ProxyCat
Usage: ProxyCat.py [-h] [-c]
Parameters:
-h, --help Show this help message and exit
-c C Specify config file name (default: config.ini)
```
### ip.txt Manual Proxy Entry
Add proxies to `ip.txt` in the following format (`socks5://127.0.0.1:7890` or `http://127.0.0.1:7890`), one per line:
```txt
socks5://127.0.0.1:7890
https://127.0.0.1:7890
http://127.0.0.1:7890
...
```
### Configuration File
Configure parameters in `config.ini` (or custom config file):
```ini
[SETTINGS]
# Local server listening port (default: 1080)
port = 1080
# Proxy rotation mode: cycle, custom, or load_balance (default: cycle)
mode = cycle
# Proxy change interval in seconds, 0 means change every request (default: 300)
interval = 300
# Local server authentication username (default: neko) empty means no auth
username = neko
# Local server authentication password (default: 123456) empty means no auth
password = 123456
# Whether to use getip module for proxy retrieval True/False (default: False)
use_getip = False
# Proxy list file (default: ip.txt)
proxy_file = ip.txt
# Enable proxy checking True/False (default: True)
check_proxies = True
# Language setting (cn/en)
language = en
# IP whitelist file path (empty to disable)
whitelist_file = whitelist.txt
# IP blacklist file path (empty to disable)
blacklist_file = blacklist.txt
# IP authentication priority (whitelist/blacklist)
# whitelist: Check whitelist first, allow if in whitelist
# blacklist: Check blacklist first, deny if in blacklist
ip_auth_priority = whitelist
```
After configuration, run:
```bash
python3 ProxyCat.py
```
### Demo Effect
**Fixed proxy address (default)**:
```
http://neko:123456@127.0.0.1:1080
http://127.0.0.1:1080
socks5://neko:123456@127.0.0.1:1080
socks5://127.0.0.1:1080
```
If you're deploying on a public network, simply replace `127.0.0.1` with your public IP.
![主界面图](./assets/主界面图.png)
### Using API for Automatic Proxy Retrieval
The tool supports direct API calls to obtain proxy addresses. When you configure `use_getip = True`, the tool will no longer read from local `ip.txt` but instead execute **getip.py** script to obtain new proxy addresses (ensure your IP is whitelisted, and the format should be IP:port, only one proxy address can be used each time).
In this case, you need to modify the content of **getip.py** to your own API, with format `IP:PORT`. Default protocol is `socks5`, manually change to `http` if needed.
### Docker Deployment
Please install Docker and Docker-compose in advance. You can search for installation methods online.
```
# Clone the project source code locally
git clone https://github.com/honmashironeko/ProxyCat.git
# Modify the content in the config.ini file in the config folder
# Enter the ProxyCat folder, build the image and start the container
docker-compose up -d --build
# Stop and start the service (you need to restart the service after modifying the configuration each time)
docker-compose down | docker-compose up -d
# View log information
docker logs proxycat
```
## Performance
Through actual testing, when proxy server performance is sufficient, ProxyCat can handle **1000** concurrent connections without packet loss, covering most scanning and penetration testing needs.
![性能测试图](./assets/性能测试图.png)
## Disclaimer ## Disclaimer
- By downloading, installing, using, or modifying this tool and related code, you indicate your trust in this tool. - By downloading, installing, using, or modifying this tool and related code, you indicate your trust in this tool.
- We take no responsibility for any form of loss or damage caused to yourself or others while using this tool. - We are not responsible for any form of loss or damage caused to yourself or others while using this tool.
- You are solely responsible for any illegal activities during your use of this tool, and we bear no legal or associated liability. - You are solely responsible for any illegal activities conducted while using this tool.
- Please carefully read and fully understand all terms, especially liability exemption or limitation clauses, and choose to accept or not. - Please carefully read and fully understand all terms, especially liability exemption clauses.
- Unless you have read and accepted all terms of this agreement, you have no right to download, install, or use this tool. - You have no right to download, install, or use this tool unless you have read and accepted all terms.
- Your download, installation, and usage actions indicate you have read and agreed to be bound by the above agreement. - Your download, installation, and usage actions indicate your acceptance of this agreement.
## Change Log ## Changelog
### 2025/01/07 [Changelog Records](../main/ProxyCat-Manual/logs.md)
- Introduced connection pool mechanism to improve performance.
- Optimized error handling and logging.
- Enhanced proxy switching mechanism.
### 2025/01/03
- Centralized configuration parameters management into config files for better maintenance
- Fixed known bugs and improved stability and concurrency capabilities
### 2025/01/02
- Restructured software architecture for better usability
- Added blacklist/whitelist mechanism for authentication
- GetIP method now only requests proxy after receiving first request to prevent resource waste
- Changed language configuration logic, now controlled via config.ini parameter
- Updated configuration panel, addresses can be copied without username/password
- Added docker deployment support
### 2024/10/23
- Restructured code, split into separate files
- Added automatic proxy switching when current proxy fails during forwarding
### 2024/09/29
- Removed less-used single cycle mode, replaced with custom mode for customizable proxy switching logic
- Changed proxy validity checking to asynchronous for better speed
- Removed problematic SOCKS4 protocol support
- Beautified logging system
- Improved exception handling logic
- Added proxy format validation
### 2024/09/10
- Optimized concurrency efficiency, supporting next request before receiving response
- Added load balancing mode for random proxy selection and concurrent proxy usage
- Changed proxy validity checking to asynchronous for better efficiency
### 2024/09/09
- Added option to validate proxies in ip.txt at startup
- Function downgrade to support lower Python versions
### 2024/09/03
- Added local SOCKS5 listening for wider software compatibility
- Changed some functions to support lower Python versions
- Beautified output display
### 2024/08/31
- Major project structure adjustment
- Beautified display with continuous proxy switch time indication
- Added Ctrl+C support for stopping
- Major adjustment to async requests, improved concurrency efficiency
- Changed from runtime parameters to local ini config file
- Added support for local authentication-free mode
- Added version detection
- Added proxy server authentication
- Added GetIP update only on request feature
- Added proxy protocol auto-detection
- Added HTTPS protocol support
- Changed asyncio.timeout() to asyncio.wait_for() for lower Python version support
## Development Plan ## Development Plan
- [x] Added local server authentication - [x] Add detailed logging to record all IP identities connecting to ProxyCat, supporting multiple users.
- [x] Added IP change per request feature - [x] Add Web UI for a more powerful and user-friendly interface.
- [x] Added static proxy auto-update module - [ ] Develop babycat module that can run on any server or host to turn it into a proxy server.
- [x] Added load balancing mode - [ ] Add request blacklist/whitelist to specify URLs, IPs, or domains to be forcibly dropped or bypassed.
- [x] Added version detection - [ ] Package to PyPi for easier installation and use.
- [x] Added proxy authentication support
- [x] Added request-triggered getip updates
- [x] Added initial proxy validity check
- [x] Added SOCKS protocol support
- [ ] Add detailed logging with multi-user support
- [ ] Add Web UI interface
- [x] Add docker deployment
- [ ] Develop babycat module
For feedback or suggestions, please contact via WeChat Official Account: **樱花庄的本间白猫** If you have good ideas or encounter bugs during use, please contact the author through:
## Acknowledgments WeChat Official Account: **樱花庄的本间白猫**
In no particular order, thanks to: ## Special Thanks
- [AabyssZG](https://github.com/AabyssZG) In no particular order, thanks to all contributors who helped with this project:
- [ProbiusOfficial](https://github.com/ProbiusOfficial)
- [gh0stkey](https://github.com/gh0stkey) - [AabyssZG (曾哥)](https://github.com/AabyssZG)
- [ProbiusOfficial (探姬)](https://github.com/ProbiusOfficial)
- [gh0stkey (EvilChen)](https://github.com/gh0stkey)
- [huangzheng2016(HydrogenE7)](https://github.com/huangzheng2016)
- chars6 - chars6
- qianzai - qianzai千载
- ziwindlu - ziwindlu
![Star History Chart](https://api.star-history.com/svg?repos=honmashironeko/ProxyCat&type=Date) ## Sponsor
Open source development isn't easy. If you find this tool helpful, consider sponsoring the author's development!
---
| Rank | ID | Amount (CNY) |
| :--: | :-----------------: | :----------: |
| 1 | **陆沉** | 1266.62 |
| 2 | **柯林斯.民间新秀** | 696 |
| 3 | **taffy** | 150 |
| [Sponsor List](https://github.com/honmashironeko/Thanks-for-sponsorship) | Every sponsorship is a motivation for the author! | (´∀`)♡ |
---
![Sponsor](./assets/赞助.png)
## Proxy Recommendations ## Proxy Recommendations

264
app.py
View File

@ -1,9 +1,8 @@
from flask import Flask, render_template, jsonify, request, redirect, url_for from flask import Flask, render_template, jsonify, request, redirect, url_for, send_from_directory
import sys import sys
import os import os
import logging import logging
from datetime import datetime from datetime import datetime
from enum import Enum
import json import json
from configparser import ConfigParser from configparser import ConfigParser
from itertools import cycle from itertools import cycle
@ -20,18 +19,20 @@ import threading
import time import time
app = Flask(__name__, app = Flask(__name__,
template_folder='web/templates') template_folder='web/templates',
static_folder='web/static')
werkzeug.serving.WSGIRequestHandler.log = lambda self, type, message, *args: None werkzeug.serving.WSGIRequestHandler.log = lambda self, type, message, *args: None
logging.getLogger('werkzeug').setLevel(logging.ERROR) logging.getLogger('werkzeug').setLevel(logging.ERROR)
config = load_config('config/config.ini') config = load_config('config/config.ini')
server = AsyncProxyServer(config) server = AsyncProxyServer(config)
def get_config_path(filename):
return os.path.join('config', filename)
log_file = 'logs/proxycat.log' log_file = 'logs/proxycat.log'
os.makedirs('logs', exist_ok=True) os.makedirs('logs', exist_ok=True)
log_messages = [] log_messages = []
max_log_messages = 10000 max_log_messages = 10000
@ -39,9 +40,25 @@ class CustomFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None): def formatTime(self, record, datefmt=None):
return datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S') return datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
file_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s') def setup_logging():
file_handler = logging.FileHandler(log_file, encoding='utf-8') file_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler.setFormatter(file_formatter) file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
memory_handler = MemoryHandler()
memory_handler.setFormatter(CustomFormatter('%(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.addHandler(memory_handler)
class MemoryHandler(logging.Handler): class MemoryHandler(logging.Handler):
def emit(self, record): def emit(self, record):
@ -54,21 +71,6 @@ class MemoryHandler(logging.Handler):
if len(log_messages) > max_log_messages: if len(log_messages) > max_log_messages:
log_messages = log_messages[-max_log_messages:] log_messages = log_messages[-max_log_messages:]
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
memory_handler = MemoryHandler()
memory_handler.setFormatter(CustomFormatter('%(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.addHandler(memory_handler)
def require_token(f): def require_token(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
@ -110,8 +112,15 @@ def get_status():
server_config = dict(config.items('Server')) if config.has_section('Server') else {} server_config = dict(config.items('Server')) if config.has_section('Server') else {}
current_proxy = server.current_proxy
if not current_proxy and not server.use_getip:
if hasattr(server, 'proxies') and server.proxies:
current_proxy = server.proxies[0]
else:
current_proxy = get_message('no_proxy', server.language)
return jsonify({ return jsonify({
'current_proxy': server.current_proxy, 'current_proxy': current_proxy,
'mode': server.mode, 'mode': server.mode,
'port': int(server_config.get('port', '1080')), 'port': int(server_config.get('port', '1080')),
'interval': server.interval, 'interval': server.interval,
@ -121,6 +130,7 @@ def get_status():
'getip_url': getattr(server, 'getip_url', '') if getattr(server, 'use_getip', False) else '', 'getip_url': getattr(server, 'getip_url', '') if getattr(server, 'use_getip', False) else '',
'auth_required': server.auth_required, 'auth_required': server.auth_required,
'display_level': int(config.get('DEFAULT', 'display_level', fallback='1')), 'display_level': int(config.get('DEFAULT', 'display_level', fallback='1')),
'service_status': 'running' if server.running else 'stopped',
'config': { 'config': {
'port': server_config.get('port', ''), 'port': server_config.get('port', ''),
'mode': server_config.get('mode', 'cycle'), 'mode': server_config.get('mode', 'cycle'),
@ -142,90 +152,48 @@ def get_status():
} }
}) })
@app.route('/api/config', methods=['GET', 'POST']) @app.route('/api/config', methods=['POST'])
def handle_config(): def save_config():
if request.method == 'POST': try:
new_config = request.json new_config = request.get_json()
try: current_config = load_config('config/config.ini')
with open('config/config.ini', 'r', encoding='utf-8') as f: port_changed = str(new_config.get('port', '')) != str(current_config.get('port', ''))
lines = f.readlines()
config_parser = ConfigParser()
config_parser.read('config/config.ini', encoding='utf-8')
if not config_parser.has_section('Server'):
config_parser.add_section('Server')
current_section = None for key, value in new_config.items():
updated_lines = [] if key != 'users':
i = 0 config_parser.set('Server', key, str(value))
while i < len(lines):
line = lines[i].strip() with open('config/config.ini', 'w', encoding='utf-8') as f:
config_parser.write(f)
if line.startswith('['):
current_section = line[1:-1]
updated_lines.append(lines[i])
i += 1
continue
if line.startswith('#') or not line:
updated_lines.append(lines[i])
i += 1
continue
if '=' in line:
key = line.split('=')[0].strip()
if key in new_config:
updated_lines.append(f"{key} = {new_config[key]}\n")
else:
updated_lines.append(lines[i])
i += 1
continue
updated_lines.append(lines[i])
i += 1
with open('config/config.ini', 'w', encoding='utf-8') as f: server.config = load_config('config/config.ini')
f.writelines(updated_lines) server._update_config_values(server.config)
config = load_config('config/config.ini') return jsonify({
server.config = config 'status': 'success',
'port_changed': port_changed
server.mode = config.get('mode', 'cycle') })
server.interval = int(config.get('interval', '300'))
server.language = config.get('language', 'cn') except Exception as e:
server.use_getip = config.get('use_getip', 'False').lower() == 'true' logging.error(f"Error saving config: {e}")
server.check_proxies = config.get('check_proxies', 'True').lower() == 'true' return jsonify({
'status': 'error',
server.username = config.get('username', '') 'message': str(e)
server.password = config.get('password', '') })
server.proxy_username = config.get('proxy_username', '')
server.proxy_password = config.get('proxy_password', '')
server.auth_required = bool(server.username and server.password)
server.proxy_file = config.get('proxy_file')
server.whitelist_file = config.get('whitelist_file', '')
server.blacklist_file = config.get('blacklist_file', '')
if server.use_getip:
server.getip_url = config.get('getip_url', '')
old_port = int(server.config.get('port', '1080'))
new_port = int(new_config.get('port', '1080'))
needs_restart = old_port != new_port
return jsonify({
'status': 'success',
'needs_restart': needs_restart,
'message': '配置已更新,需要重启服务器' if needs_restart else '配置已更新'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
else:
with open('config/config.ini', 'r', encoding='utf-8') as f:
config_content = f.read()
return jsonify({'config': config_content})
@app.route('/api/proxies', methods=['GET', 'POST']) @app.route('/api/proxies', methods=['GET', 'POST'])
def handle_proxies(): def handle_proxies():
if request.method == 'POST': if request.method == 'POST':
try: try:
proxies = request.json.get('proxies', []) proxies = request.json.get('proxies', [])
with open(server.proxy_file, 'w', encoding='utf-8') as f: proxy_file = get_config_path(os.path.basename(server.proxy_file))
with open(proxy_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(proxies)) f.write('\n'.join(proxies))
server.proxies = server._load_file_proxies() server.proxies = server._load_file_proxies()
if server.proxies: if server.proxies:
@ -242,10 +210,11 @@ def handle_proxies():
}) })
else: else:
try: try:
with open(server.proxy_file, 'r', encoding='utf-8') as f: proxy_file = get_config_path(os.path.basename(server.proxy_file))
with open(proxy_file, 'r', encoding='utf-8') as f:
proxies = f.read().splitlines() proxies = f.read().splitlines()
return jsonify({'proxies': proxies}) return jsonify({'proxies': proxies})
except Exception as e: except Exception:
return jsonify({'proxies': []}) return jsonify({'proxies': []})
@app.route('/api/check_proxies') @app.route('/api/check_proxies')
@ -272,7 +241,8 @@ def handle_ip_lists():
try: try:
list_type = request.json.get('type') list_type = request.json.get('type')
ip_list = request.json.get('list', []) ip_list = request.json.get('list', [])
filename = server.whitelist_file if list_type == 'whitelist' else server.blacklist_file base_filename = os.path.basename(server.whitelist_file if list_type == 'whitelist' else server.blacklist_file)
filename = get_config_path(base_filename)
with open(filename, 'w', encoding='utf-8') as f: with open(filename, 'w', encoding='utf-8') as f:
f.write('\n'.join(ip_list)) f.write('\n'.join(ip_list))
@ -292,9 +262,11 @@ def handle_ip_lists():
'message': get_message('ip_list_save_failed', server.language, str(e)) 'message': get_message('ip_list_save_failed', server.language, str(e))
}) })
else: else:
whitelist_file = get_config_path(os.path.basename(server.whitelist_file))
blacklist_file = get_config_path(os.path.basename(server.blacklist_file))
return jsonify({ return jsonify({
'whitelist': list(load_ip_list(server.whitelist_file)), 'whitelist': list(load_ip_list(whitelist_file)),
'blacklist': list(load_ip_list(server.blacklist_file)) 'blacklist': list(load_ip_list(blacklist_file))
}) })
@app.route('/api/logs') @app.route('/api/logs')
@ -359,7 +331,7 @@ def switch_proxy():
new_proxy = newip() new_proxy = newip()
server.current_proxy = new_proxy server.current_proxy = new_proxy
server.last_switch_time = time.time() server.last_switch_time = time.time()
logging.info(get_message('manual_switch', server.language, old_proxy, new_proxy)) server._log_proxy_switch(old_proxy, new_proxy)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'current_proxy': server.current_proxy, 'current_proxy': server.current_proxy,
@ -380,7 +352,7 @@ def switch_proxy():
old_proxy = server.current_proxy old_proxy = server.current_proxy
server.current_proxy = next(server.proxy_cycle) server.current_proxy = next(server.proxy_cycle)
server.last_switch_time = time.time() server.last_switch_time = time.time()
logging.info(get_message('manual_switch', server.language, old_proxy, server.current_proxy)) server._log_proxy_switch(old_proxy, server.current_proxy)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'current_proxy': server.current_proxy, 'current_proxy': server.current_proxy,
@ -418,41 +390,46 @@ def control_service():
if server.running: if server.running:
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': get_message('service_start_success', server.language) 'message': get_message('service_start_success', server.language),
'service_status': 'running'
}) })
else: else:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': get_message('service_start_failed', server.language) 'message': get_message('service_start_failed', server.language),
'service_status': 'stopped'
}) })
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': get_message('service_already_running', server.language) 'message': get_message('service_already_running', server.language),
'service_status': 'running'
}) })
elif action == 'stop': elif action == 'stop':
if server.running: if server.running:
server.stop_server = True server.stop_server = True
server.running = False
if server.server_instance: if server.server_instance:
server.server_instance.close() server.server_instance.close()
for _ in range(10): if hasattr(server, 'proxy_thread') and server.proxy_thread:
if not server.running: server.proxy_thread = None
break
time.sleep(0.5)
if server.running: for _ in range(5):
if hasattr(server, 'proxy_thread') and server.proxy_thread: if server.server_instance is None:
server.proxy_thread = None break
server.running = False time.sleep(0.2)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': get_message('service_stop_success', server.language) 'message': get_message('service_stop_success', server.language),
'service_status': 'stopped'
}) })
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': get_message('service_not_running', server.language) 'message': get_message('service_not_running', server.language),
'service_status': 'stopped'
}) })
elif action == 'restart': elif action == 'restart':
@ -546,32 +523,33 @@ def check_version():
original_level = httpx_logger.level original_level = httpx_logger.level
httpx_logger.setLevel(logging.WARNING) httpx_logger.setLevel(logging.WARNING)
CURRENT_VERSION = "ProxyCat-V2.0.0" CURRENT_VERSION = "ProxyCat-V2.0.1"
try: try:
client = httpx.Client(transport=httpx.HTTPTransport(retries=3)) client = httpx.Client(transport=httpx.HTTPTransport(retries=3))
response = client.get("https://y.shironekosan.cn/1.html", timeout=10) response = client.get("https://y.shironekosan.cn/1.html", timeout=10)
response.raise_for_status() response.raise_for_status()
content = response.text content = response.text
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match:
latest_version = match.group(1)
is_latest = version.parse(latest_version.split('-V')[1]) <= version.parse(CURRENT_VERSION.split('-V')[1])
return jsonify({
'status': 'success',
'is_latest': is_latest,
'current_version': CURRENT_VERSION,
'latest_version': latest_version
})
else:
return jsonify({
'status': 'error',
'message': get_message('version_info_not_found', server.language)
})
finally: finally:
httpx_logger.setLevel(original_level) httpx_logger.setLevel(original_level)
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match:
latest_version = match.group(1)
is_latest = version.parse(latest_version.split('-V')[1]) <= version.parse(CURRENT_VERSION.split('-V')[1])
return jsonify({
'status': 'success',
'is_latest': is_latest,
'current_version': CURRENT_VERSION,
'latest_version': latest_version
})
else:
return jsonify({
'status': 'error',
'message': get_message('version_info_not_found', server.language)
})
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@ -635,10 +613,19 @@ def handle_users():
logging.error(f"Error getting users: {e}") logging.error(f"Error getting users: {e}")
return jsonify({'users': {}}) return jsonify({'users': {}})
@app.route('/static/<path:path>')
def send_static(path):
return send_from_directory('web/static', path)
def run_proxy_server(): def run_proxy_server():
asyncio.run(run_server(server)) asyncio.run(run_server(server))
except KeyboardInterrupt:
logging.info(get_message('user_interrupt', server.language))
except Exception as e:
logging.error(f"Proxy server error: {e}")
if __name__ == '__main__': if __name__ == '__main__':
setup_logging()
web_port = int(config.get('web_port', '5000')) web_port = int(config.get('web_port', '5000'))
web_url = f"http://127.0.0.1:{web_port}" web_url = f"http://127.0.0.1:{web_port}"
if config.get('token'): if config.get('token'):
@ -649,4 +636,5 @@ if __name__ == '__main__':
proxy_thread = threading.Thread(target=run_proxy_server, daemon=True) proxy_thread = threading.Thread(target=run_proxy_server, daemon=True)
proxy_thread.start() proxy_thread.start()
app.run(host='0.0.0.0', port=web_port) app.run(host='0.0.0.0', port=web_port)

View File

@ -2,10 +2,12 @@ version: '3'
services: services:
proxycat: proxycat:
build: . build: .
environment:
- TZ=Asia/Shanghai
ports: ports:
- "1080:1080" - "1080:1080"
- "5000:5000" - "5000:5000"
volumes: volumes:
- ./config:/app/config - ./config:/app/config
restart: unless-stopped restart: unless-stopped
network_mode: "bridge" network_mode: "bridge"

View File

@ -25,12 +25,10 @@ MESSAGES = {
'valid_proxies': '有效代理地址: {}', 'valid_proxies': '有效代理地址: {}',
'no_valid_proxies': '没有有效的代理地址', 'no_valid_proxies': '没有有效的代理地址',
'proxy_check_failed': '{}代理 {} 检测失败: {}', 'proxy_check_failed': '{}代理 {} 检测失败: {}',
'proxy_switch': '切换到新的代理: {}', 'proxy_switch': '切换代理: {} -> {}',
'proxy_switch_detail': '已切换代理: {} -> {}',
'proxy_consecutive_fails': '代理 {} 连续失败 {} 次,正在切换新代理', 'proxy_consecutive_fails': '代理 {} 连续失败 {} 次,正在切换新代理',
'proxy_invalid': '代理 {} 已失效,立即切换新代理', 'proxy_invalid': '代理 {} 无效,立即切换代理',
'connection_timeout': '连接超时', 'connection_timeout': '连接超时',
'proxy_invalid_switching': '代理地址失效,切换代理地址',
'data_transfer_timeout': '数据传输超时,正在重试...', 'data_transfer_timeout': '数据传输超时,正在重试...',
'connection_reset': '连接被重置', 'connection_reset': '连接被重置',
'transfer_cancelled': '传输被取消', 'transfer_cancelled': '传输被取消',
@ -81,7 +79,6 @@ MESSAGES = {
'response_write_error': '写入响应时出错: {}', 'response_write_error': '写入响应时出错: {}',
'consecutive_failures': '检测到连续代理失败: {}', 'consecutive_failures': '检测到连续代理失败: {}',
'invalid_proxy': '当前代理无效: {}', 'invalid_proxy': '当前代理无效: {}',
'proxy_switched': '已从代理 {} 切换到 {}',
'whitelist_error': '添加白名单失败: {}', 'whitelist_error': '添加白名单失败: {}',
'api_mode_notice': '当前为API模式收到请求将自动获取代理地址', 'api_mode_notice': '当前为API模式收到请求将自动获取代理地址',
'server_running': '代理服务器运行在 {}:{}', 'server_running': '代理服务器运行在 {}:{}',
@ -100,14 +97,11 @@ MESSAGES = {
2: 显示所有详细信息''', 2: 显示所有详细信息''',
'new_client_connect': '新客户端连接 - IP: {}, 用户: {}', 'new_client_connect': '新客户端连接 - IP: {}, 用户: {}',
'no_auth': '无认证', 'no_auth': '无认证',
'proxy_changed': '代理变更: {} -> {}',
'connection_error': '连接处理错误: {}', 'connection_error': '连接处理错误: {}',
'cleanup_error': '清理IP错误: {}', 'cleanup_error': '清理IP错误: {}',
'port_changed': '端口已更改: {} -> {},需要重启服务器生效', 'port_changed': '端口已更改: {} -> {},需要重启服务器生效',
'config_updated': '服务器配置已更新', 'config_updated': '服务器配置已更新',
'load_proxy_file_error': '加载代理文件失败: {}', 'load_proxy_file_error': '加载代理文件失败: {}',
'manual_switch': '手动切换代理: {} -> {}',
'auto_switch': '自动切换代理: {} -> {}',
'proxy_check_result': '代理检查完成,有效代理:{}', 'proxy_check_result': '代理检查完成,有效代理:{}',
'no_proxy': '无代理', 'no_proxy': '无代理',
'cycle_mode': '循环模式', 'cycle_mode': '循环模式',
@ -196,12 +190,10 @@ MESSAGES = {
'valid_proxies': 'Valid proxies: {}', 'valid_proxies': 'Valid proxies: {}',
'no_valid_proxies': 'No valid proxies found', 'no_valid_proxies': 'No valid proxies found',
'proxy_check_failed': '{} proxy {} check failed: {}', 'proxy_check_failed': '{} proxy {} check failed: {}',
'proxy_switch': 'Switching to new proxy: {}', 'proxy_switch': 'Switch proxy: {} -> {}',
'proxy_switch_detail': 'Switched proxy: {} -> {}',
'proxy_consecutive_fails': 'Proxy {} failed {} times consecutively, switching to new proxy', 'proxy_consecutive_fails': 'Proxy {} failed {} times consecutively, switching to new proxy',
'proxy_invalid': 'Proxy {} is invalid, switching immediately', 'proxy_invalid': 'Proxy {} is invalid, switching proxy immediately',
'connection_timeout': 'Connection timeout', 'connection_timeout': 'Connection timeout',
'proxy_invalid_switching': 'Proxy invalid, switching to new proxy',
'data_transfer_timeout': 'Data transfer timeout, retrying...', 'data_transfer_timeout': 'Data transfer timeout, retrying...',
'connection_reset': 'Connection reset', 'connection_reset': 'Connection reset',
'transfer_cancelled': 'Transfer cancelled', 'transfer_cancelled': 'Transfer cancelled',
@ -254,7 +246,6 @@ MESSAGES = {
'response_write_error': 'Error writing response: {}', 'response_write_error': 'Error writing response: {}',
'consecutive_failures': 'Consecutive proxy failures detected for {}', 'consecutive_failures': 'Consecutive proxy failures detected for {}',
'invalid_proxy': 'Current proxy is invalid: {}', 'invalid_proxy': 'Current proxy is invalid: {}',
'proxy_switched': 'Switched from proxy {} to {}',
'whitelist_error': 'Failed to add whitelist: {}', 'whitelist_error': 'Failed to add whitelist: {}',
'api_mode_notice': 'Currently in API mode, proxy address will be automatically obtained upon request', 'api_mode_notice': 'Currently in API mode, proxy address will be automatically obtained upon request',
'server_running': 'Proxy server running at {}:{}', 'server_running': 'Proxy server running at {}:{}',
@ -273,14 +264,11 @@ MESSAGES = {
2: Show all detailed information''', 2: Show all detailed information''',
'new_client_connect': 'New client connection - IP: {}, User: {}', 'new_client_connect': 'New client connection - IP: {}, User: {}',
'no_auth': 'No authentication', 'no_auth': 'No authentication',
'proxy_changed': 'Proxy changed: {} -> {}',
'connection_error': 'Connection handling error: {}', 'connection_error': 'Connection handling error: {}',
'cleanup_error': 'IP cleanup error: {}', 'cleanup_error': 'IP cleanup error: {}',
'port_changed': 'Port changed: {} -> {}, server restart required', 'port_changed': 'Port changed: {} -> {}, server restart required',
'config_updated': 'Server configuration updated', 'config_updated': 'Server configuration updated',
'load_proxy_file_error': 'Failed to load proxy file: {}', 'load_proxy_file_error': 'Failed to load proxy file: {}',
'manual_switch': 'Manual proxy switch: {} -> {}',
'auto_switch': 'Auto switch proxy: {} -> {}',
'proxy_check_result': 'Proxy check completed, valid proxies: {}', 'proxy_check_result': 'Proxy check completed, valid proxies: {}',
'no_proxy': 'No proxy', 'no_proxy': 'No proxy',
'cycle_mode': 'Cycle Mode', 'cycle_mode': 'Cycle Mode',
@ -479,29 +467,37 @@ DEFAULT_CONFIG = {
} }
def load_config(config_file='config/config.ini'): def load_config(config_file='config/config.ini'):
config = ConfigParser() try:
config.read(config_file, encoding='utf-8') config = ConfigParser()
config.read(config_file, encoding='utf-8')
settings = {}
if config.has_section('Server'):
settings.update(dict(config.items('Server')))
config_dir = os.path.dirname(config_file) if not config.has_section('Server'):
for key in ['proxy_file', 'whitelist_file', 'blacklist_file']: config.add_section('Server')
if key in settings and settings[key]: for key, value in DEFAULT_CONFIG.items():
settings[key] = os.path.join(config_dir, settings[key]) config.set('Server', key, str(value))
with open(config_file, 'w', encoding='utf-8') as f:
if config.has_section('DEFAULT'): config.write(f)
settings.update(dict(config.items('DEFAULT')))
result = dict(config.items('Server'))
return {**DEFAULT_CONFIG, **settings}
# 添加用户配置
if config.has_section('Users'):
result['Users'] = dict(config.items('Users'))
return result
except Exception as e:
logging.error(f"Error loading config: {e}")
return DEFAULT_CONFIG.copy()
def load_ip_list(file_path): def load_ip_list(file_path):
if not file_path or not os.path.exists(file_path): try:
return set() config_path = os.path.join('config', os.path.basename(file_path))
if os.path.exists(config_path):
with open(file_path, 'r') as f: with open(config_path, 'r', encoding='utf-8') as f:
return {line.strip() for line in f if line.strip()} return set(line.strip() for line in f if line.strip())
except Exception as e:
logging.error(f"Error loading IP list: {e}")
return set()
_proxy_check_cache = {} _proxy_check_cache = {}
_proxy_check_ttl = 10 _proxy_check_ttl = 10
@ -640,7 +636,7 @@ async def check_for_updates(language='cn'):
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content) match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match: if match:
latest_version = match.group(1) latest_version = match.group(1)
CURRENT_VERSION = "ProxyCat-V2.0.0" CURRENT_VERSION = "ProxyCat-V2.0.1"
if version.parse(latest_version.split('-V')[1]) > version.parse(CURRENT_VERSION.split('-V')[1]): if version.parse(latest_version.split('-V')[1]) > version.parse(CURRENT_VERSION.split('-V')[1]):
print(f"{Fore.YELLOW}{get_message('new_version_found', language)} 当前版本: {CURRENT_VERSION}, 最新版本: {latest_version}{Style.RESET_ALL}") print(f"{Fore.YELLOW}{get_message('new_version_found', language)} 当前版本: {CURRENT_VERSION}, 最新版本: {latest_version}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{get_message('visit_quark', language)}{Style.RESET_ALL}") print(f"{Fore.YELLOW}{get_message('visit_quark', language)}{Style.RESET_ALL}")

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
colorama==0.4.6 colorama==0.4.6
httpx==0.27.2 httpx==0.27.2
httpx[http2,socks]==0.27.2
packaging==24.1 packaging==24.1
Requests==2.32.3 Requests==2.32.3
tqdm>=4.65.0 tqdm>=4.65.0
flask>=2.0.1 flask>=2.0.1
werkzeug>=2.0.0

View File

@ -0,0 +1,25 @@
@keyframes slideIn {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeInUp 0.3s ease;
}

48
web/static/css/base.css Normal file
View File

@ -0,0 +1,48 @@
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--warning-color: #f1c40f;
--danger-color: #e74c3c;
--background-color: #f5f6fa;
--text-color: #2c3e50;
--border-color: #dcdde1;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
body {
background-color: var(--background-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: var(--card-shadow);
margin-bottom: 20px;
padding: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
.section-title {
color: var(--text-color);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}

View File

@ -0,0 +1,45 @@
.btn {
border: none;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn i {
font-size: 1.1em;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-success {
background-color: var(--secondary-color);
color: white;
}
.btn-warning {
background-color: var(--warning-color);
color: white;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.btn:active {
transform: translateY(1px);
}

View File

@ -0,0 +1,61 @@
@media (prefers-color-scheme: dark) {
:root {
--background-color: #1a1a1a;
--text-color: #ffffff;
--border-color: #2d2d2d;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
.card {
background: #2d2d2d;
}
.status-card {
background: linear-gradient(145deg, #2d2d2d, #252525);
}
.status-card .card-title {
color: #e0e0e0;
}
.status-card .card-value {
color: #3498db;
}
.status-card .card-footer {
color: #888;
}
.btn {
border: 1px solid rgba(255, 255, 255, 0.1);
}
input, select, textarea {
background-color: #2d2d2d;
border-color: #3d3d3d;
color: #ffffff;
}
input:focus, select:focus, textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.progress {
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.progress-bar {
background: linear-gradient(45deg, #64b5f6, #4a90e2);
}
.card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
}

210
web/static/css/forms.css Normal file
View File

@ -0,0 +1,210 @@
.form-control, .form-select {
border-radius: 8px;
border: 2px solid #e9ecef;
padding: 10px 15px;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: #4a90e2;
box-shadow: 0 0 0 0.2rem rgba(74, 144, 226, 0.25);
}
.table {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
}
.table thead th {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
color: #2c3e50;
font-weight: 600;
padding: 15px;
}
.table tbody td {
padding: 12px 15px;
vertical-align: middle;
border-bottom: 1px solid #eee;
}
@media (prefers-color-scheme: dark) {
.form-control, .form-select {
background-color: #2d2d2d;
color: #ffffff;
border-color: #3d3d3d;
}
.table {
background-color: #2d2d2d;
color: #ffffff;
}
.table thead th {
background-color: #3d3d3d;
color: #ffffff;
}
}
/* 浮动保存按钮样式 */
.floating-save-container {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 1000;
}
.floating-save-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
background: linear-gradient(135deg, #4a90e2, #357abd);
}
.floating-save-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.floating-save-btn:active {
transform: translateY(1px);
}
.floating-save-btn i {
font-size: 1.1em;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.floating-save-btn {
background: linear-gradient(135deg, #2980b9, #2c3e50);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.floating-save-container {
bottom: 20px;
right: 20px;
}
.floating-save-btn {
padding: 10px 20px;
}
}
/* Toast 样式优化 */
.toast {
position: fixed;
top: 20px;
right: 20px;
min-width: 300px;
padding: 16px 20px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 12px;
color: white;
transform: translateX(120%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
font-weight: 500;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
/* 优化不同类型的 Toast 样式 */
.toast-success {
background: linear-gradient(135deg, #00b09b, #96c93d);
border-left: 4px solid #96c93d;
}
.toast-error {
background: linear-gradient(135deg, #ff5f6d, #ffc371);
border-left: 4px solid #ff5f6d;
}
.toast-info {
background: linear-gradient(135deg, #2193b0, #6dd5ed);
border-left: 4px solid #2193b0;
}
.toast-warning {
background: linear-gradient(135deg, #f2994a, #f2c94c);
border-left: 4px solid #f2994a;
}
/* Toast 图标样式 */
.toast i {
font-size: 1.4em;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* Toast 堆叠效果 */
.toast + .toast {
margin-top: 16px;
}
/* Toast 文本样式 */
.toast span {
flex: 1;
font-size: 14px;
line-height: 1.4;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.toast-success {
background: linear-gradient(135deg, #004d40, #1b5e20);
border-left: 4px solid #00c853;
}
.toast-error {
background: linear-gradient(135deg, #b71c1c, #c62828);
border-left: 4px solid #ff1744;
}
.toast-info {
background: linear-gradient(135deg, #0d47a1, #1976d2);
border-left: 4px solid #2196f3;
}
.toast-warning {
background: linear-gradient(135deg, #e65100, #f57c00);
border-left: 4px solid #ffd600;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.toast {
min-width: auto;
width: calc(100% - 32px);
margin: 0 16px;
padding: 12px 16px;
}
.toast + .toast {
margin-top: 12px;
}
}

107
web/static/css/logs.css Normal file
View File

@ -0,0 +1,107 @@
.log-container {
height: 500px;
position: relative;
margin: 1rem 0;
}
.log-INFO {
background-color: #e8f5e9;
color: #1b5e20;
border-left: 6px solid #2e7d32;
font-size: 17px;
font-weight: 600;
}
.log-WARNING {
background-color: #fff3e0;
color: #e65100;
border-left: 6px solid #f57c00;
font-size: 17px;
font-weight: 700;
}
.log-ERROR {
background-color: #ffebee;
color: #b71c1c;
border-left: 6px solid #c62828;
font-size: 18px;
font-weight: 800;
}
.log-CRITICAL {
background-color: #ffebee;
color: #7f0000;
border-left: 6px solid #b71c1c;
font-size: 18px;
font-weight: 900;
text-transform: uppercase;
}
.log-message {
font-weight: 600;
line-height: 1.5;
}
/* 搜索高亮样式 */
.log-highlight {
background-color: #fff176;
color: #000000;
padding: 2px 4px;
border-radius: 3px;
font-weight: bold;
margin: 0 2px;
}
/* 暗色模式下的高亮样式 */
@media (prefers-color-scheme: dark) {
.log-highlight {
background-color: #ffd600;
color: #000000;
}
.log-INFO {
background-color: rgba(232, 245, 233, 0.1);
color: #81c784;
border-left-color: #2e7d32;
}
.log-WARNING {
background-color: rgba(255, 243, 224, 0.1);
color: #ffb74d;
border-left-color: #f57c00;
}
.log-ERROR {
background-color: rgba(255, 235, 238, 0.1);
color: #e57373;
border-left-color: #c62828;
}
.log-CRITICAL {
background-color: rgba(255, 235, 238, 0.1);
color: #ef5350;
border-left-color: #b71c1c;
}
}
/* 日志条目样式 */
.log-entry {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.log-entry:hover {
filter: brightness(0.95);
}
.log-time {
font-family: monospace;
opacity: 0.8;
}
.log-level {
font-weight: bold;
margin: 0 4px;
}

View File

@ -0,0 +1,33 @@
.nav-tabs {
border-bottom: 2px solid #dee2e6;
margin-bottom: 20px;
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
padding: 10px 20px;
font-weight: 500;
transition: all 0.2s;
}
.nav-tabs .nav-link:hover {
border: none;
color: var(--primary-color);
}
.nav-tabs .nav-link.active {
border: none;
color: var(--primary-color);
position: relative;
}
.nav-tabs .nav-link.active:after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background-color: var(--primary-color);
}

View File

@ -0,0 +1,44 @@
.progress-container {
margin-top: auto;
}
.progress {
height: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background: linear-gradient(45deg, #4a90e2, #357abd);
box-shadow: 0 0 10px rgba(74, 144, 226, 0.5);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,41 @@
@media (max-width: 768px) {
.status-card {
margin-bottom: 20px;
}
.btn {
width: 100%;
margin-bottom: 10px;
}
.table-responsive {
margin-bottom: 20px;
}
.log-container {
height: 300px;
}
.log-search input {
font-size: 14px;
padding: 10px 40px;
}
}
.language-switch {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: white;
padding: 8px 16px;
border-radius: 20px;
box-shadow: var(--card-shadow);
}
@media (max-width: 768px) {
.language-switch {
top: 10px;
right: 10px;
}
}

44
web/static/css/search.css Normal file
View File

@ -0,0 +1,44 @@
.log-search {
position: relative;
margin-bottom: 20px;
}
.log-search input {
padding: 12px 45px;
border-radius: 25px;
font-size: 15px;
font-weight: 500;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.log-search input:focus {
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
}
.log-search .clear-search {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 5px;
transition: all 0.2s ease;
}
.log-search .clear-search:hover {
color: #dc3545;
transform: translateY(-50%) scale(1.1);
}
.log-search i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}

View File

@ -0,0 +1,80 @@
.service-control {
height: 100%;
display: flex;
flex-direction: column;
}
.service-control .card-body {
display: flex;
flex-direction: column;
height: 100%;
}
.service-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.service-description {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
}
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: auto;
}
.service-btn {
border: none;
border-radius: 6px;
padding: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.service-btn .btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.service-btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.service-btn:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.service-actions {
grid-template-columns: 1fr;
}
.service-btn {
padding: 10px;
}
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.service-title {
color: #e0e0e0;
}
.service-description {
color: #888;
}
}

View File

@ -0,0 +1,827 @@
.status-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 25px;
margin-bottom: 30px;
}
/* 统一卡片基础样式 */
.card.status-card, .card.service-control {
height: 150px;
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 15px;
margin-bottom: 15px;
}
.card-body {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
padding: 0;
}
.card.status-card:hover, .card.service-control:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 统一卡片标题样式 */
.card-title, .service-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.7rem;
display: flex;
align-items: center;
gap: 8px;
}
.card-title i, .service-title i {
font-size: 1.1em;
color: var(--primary-color);
background: rgba(52, 152, 219, 0.1);
padding: 4px;
border-radius: 8px;
}
/* 统一卡片内容样式 */
.card-text {
color: var(--text-color);
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.7rem;
flex-grow: 1;
display: flex;
align-items: center;
}
/* 统一按钮容器样式 */
.button-container, .service-actions, .progress-container {
margin-top: auto;
}
/* 统一按钮样式 */
.btn-sm, .service-btn {
padding: 6px 10px;
height: 32px;
font-size: 0.8rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
white-space: nowrap;
}
.btn-sm i, .service-btn i {
font-size: 1rem;
}
/* 按钮颜色统一为蓝色 */
.btn-primary,
.service-btn.start-btn,
.service-btn.stop-btn,
.service-btn.restart-btn {
background: linear-gradient(135deg, #3498db, #2980b9);
}
/* 按钮悬停效果 */
.btn-primary:hover,
.service-btn:hover {
transform: translateY(-2px);
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18);
filter: brightness(1.1);
}
/* 按钮点击效果 */
.btn-primary:active,
.service-btn:active {
transform: translateY(1px);
}
/* 服务控制按钮组样式 */
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
/* 进度条样式 */
.progress {
height: 5px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.progress-bar {
background: linear-gradient(90deg, var(--primary-color), #5dade2);
box-shadow: 0 0 10px rgba(52, 152, 219, 0.5);
}
/* 卡片图标样式 */
.card-icon {
position: absolute;
top: 15px;
right: 15px;
font-size: 1.2rem;
color: var(--primary-color);
opacity: 0.15;
transform: scale(2);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.card.status-card, .card.service-control {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-title, .service-title {
color: #e0e0e0;
}
.card-title i, .service-title i {
background: rgba(52, 152, 219, 0.15);
}
.card-text {
color: #ffffff;
}
.card-icon {
color: var(--primary-color);
opacity: 0.1;
}
.progress {
background: rgba(255, 255, 255, 0.1);
}
.btn-sm, .service-btn {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-sm:hover, .service-btn:hover {
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3);
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.status-cards {
grid-template-columns: 1fr;
gap: 20px;
}
.card.status-card, .card.service-control {
height: auto;
min-height: 140px;
}
.service-actions {
margin-top: 0.5rem;
}
.btn-sm, .service-btn {
padding: 6px;
}
}
/* 服务控制卡片特殊样式 */
.service-control .card-body {
padding: 0;
justify-content: space-between;
}
.service-control .service-title {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-top: auto;
}
/* 服务控制按钮样式优化 */
.service-btn {
padding: 6px 10px;
min-height: 32px;
font-size: 0.8rem;
}
.service-btn .btn-content {
white-space: nowrap;
}
.service-btn i {
font-size: 1rem;
}
/* 添加信息卡片样式 */
.info-card {
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 10px;
height: 100%;
min-height: 120px;
}
.info-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 地址容器样式 */
.address-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 0.4rem;
}
.address-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
min-height: 28px;
}
.address-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-color);
min-width: 80px;
font-size: 0.8rem;
}
.address-label i {
color: var(--primary-color);
font-size: 1rem;
}
.address-value {
flex-grow: 1;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-color);
padding: 2px 4px;
background: rgba(0, 0, 0, 0.03);
border-radius: 4px;
}
.copy-btn {
background: none;
border: none;
color: var(--primary-color);
padding: 3px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
font-size: 0.85rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.1);
transform: scale(1.1);
}
.copy-btn.copy-success {
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
/* 关于信息样式 */
.about-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 0.4rem;
}
.about-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
min-height: 28px;
}
.about-item i {
font-size: 1.1rem;
width: 20px;
}
.about-content {
font-size: 0.85rem;
}
.github-link {
color: var(--primary-color);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
font-size: 0.85rem;
}
.github-link:hover {
text-decoration: underline;
}
.wechat-name {
font-weight: 500;
color: var(--text-color);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.info-card {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.address-item, .about-item {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.address-value {
background: rgba(0, 0, 0, 0.2);
color: #ffffff;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.2);
}
.wechat-name {
color: #e0e0e0;
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.info-card {
margin-bottom: 12px;
min-height: auto;
padding: 10px;
}
.address-item, .about-item {
padding: 6px 8px;
}
}
/* 卡片标题样式优化 */
.info-card .card-title {
font-size: 0.95rem;
margin-bottom: 0.6rem;
gap: 6px;
}
.info-card .card-title i {
font-size: 1rem;
padding: 4px;
}
/* 背景图标优化 */
.info-card .card-icon {
font-size: 1.1rem;
transform: scale(1.8);
top: 12px;
right: 12px;
}
/* 标签页卡片统一样式 */
.tab-pane .card {
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 20px;
}
.tab-pane .card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 标签页标题样式 */
.tab-pane .card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 1.2rem;
display: flex;
align-items: center;
gap: 8px;
}
.tab-pane .card-title i {
font-size: 1.2em;
color: var(--primary-color);
background: rgba(52, 152, 219, 0.1);
padding: 6px;
border-radius: 8px;
}
/* 标签页背景图标 */
.tab-pane .card-icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 1.5rem;
color: var(--primary-color);
opacity: 0.15;
transform: scale(2.5);
}
/* 按钮统一样式 */
.tab-pane .btn {
padding: 8px 16px;
height: 36px;
font-size: 0.85rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
white-space: nowrap;
background: linear-gradient(135deg, #3498db, #2980b9);
}
.tab-pane .btn:hover {
transform: translateY(-2px);
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18);
filter: brightness(1.1);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.tab-pane .card {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab-pane .card-title {
color: #e0e0e0;
}
.tab-pane .card-title i {
background: rgba(52, 152, 219, 0.15);
}
}
/* 代理管理标签页样式 */
.proxy-management {
display: flex;
flex-direction: column;
gap: 1rem;
}
.proxy-actions {
display: flex;
gap: 12px;
margin-top: 1rem;
}
.proxy-actions .btn {
padding: 10px 20px;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 120px;
justify-content: center;
border-radius: 8px;
transition: all 0.3s ease;
}
.proxy-actions .btn i {
font-size: 1rem;
}
/* IP名单标签页样式 */
.ip-lists-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ip-lists-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 1rem;
}
.ip-list-section {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.ip-list-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.5rem;
}
.ip-auth-settings {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.ip-lists-actions {
display: flex;
gap: 12px;
margin-top: 1rem;
}
.ip-lists-actions .btn {
padding: 10px 20px;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 120px;
justify-content: center;
border-radius: 8px;
transition: all 0.3s ease;
}
/* 按钮悬停效果 */
.proxy-actions .btn:hover,
.ip-lists-actions .btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.ip-list-title {
color: #e0e0e0;
}
.ip-auth-settings {
border-top-color: rgba(255, 255, 255, 0.1);
}
}
/* 代理列表区域样式 */
.proxy-lists {
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
padding: 15px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.proxy-list-section {
margin-bottom: 1rem;
}
.proxy-list-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-color);
}
.proxy-list-header i {
color: var(--primary-color);
font-size: 1.1rem;
}
.proxy-list-help {
margin-left: auto;
font-size: 0.85rem;
color: #666;
font-weight: normal;
}
.proxy-list-section textarea {
font-family: monospace;
font-size: 0.9rem;
resize: vertical;
background: rgba(255, 255, 255, 0.8);
}
.proxy-list-section textarea:focus {
background: white;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.proxy-lists {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.proxy-list-header {
color: #e0e0e0;
}
.proxy-list-help {
color: #888;
}
.proxy-list-section textarea {
background: rgba(0, 0, 0, 0.2);
color: #e0e0e0;
}
.proxy-list-section textarea:focus {
background: rgba(0, 0, 0, 0.3);
}
}
/* 地址显示样式 */
.address-value {
font-family: monospace;
background: rgba(0, 0, 0, 0.05);
padding: 6px 10px;
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-color);
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.address-value {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.2);
}
.copy-btn.copy-success {
background: rgba(46, 204, 113, 0.2);
}
}
/* 服务状态样式 */
.service-status {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 0.7rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.status-dot.running {
background-color: #2ecc71;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
animation: pulse 2s infinite;
}
.status-dot.stopped {
background-color: #e74c3c;
box-shadow: 0 0 10px rgba(231, 76, 60, 0.5);
animation: none;
}
.status-text {
font-weight: 600;
color: var(--text-color);
transition: color 0.3s ease;
}
/* 添加脉冲动画 */
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
/* 服务控制卡片的按钮容器 */
.status-card .service-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: auto;
}
/* 服务控制按钮基础样式 */
.status-card .service-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 6px;
color: white;
transition: all 0.3s ease;
font-size: 0.85rem;
min-width: 70px;
height: 32px;
background: linear-gradient(135deg, #3498db, #2980b9);
}
/* 服务控制按钮交互效果 */
.status-card .service-btn:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.status-card .service-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
}
/* 响应式布局 */
@media (max-width: 768px) {
.status-card .service-actions {
flex-direction: column;
gap: 8px;
}
.status-card .service-btn {
width: 100%;
}
}
/* 添加过渡效果 */
.status-text {
transition: color 0.3s ease;
}
/* 优化按钮状态过渡 */
.service-btn {
transition: all 0.3s ease;
}
.service-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

BIN
web/static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

File diff suppressed because it is too large Load Diff