localstream 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localstream-1.0.0/PKG-INFO +89 -0
- localstream-1.0.0/README.md +70 -0
- localstream-1.0.0/localstream/__init__.py +1 -0
- localstream-1.0.0/localstream/__main__.py +4 -0
- localstream-1.0.0/localstream/cli.py +321 -0
- localstream-1.0.0/localstream/config.py +40 -0
- localstream-1.0.0/localstream/connection.py +403 -0
- localstream-1.0.0/localstream/downloader.py +197 -0
- localstream-1.0.0/localstream.egg-info/PKG-INFO +89 -0
- localstream-1.0.0/localstream.egg-info/SOURCES.txt +14 -0
- localstream-1.0.0/localstream.egg-info/dependency_links.txt +1 -0
- localstream-1.0.0/localstream.egg-info/entry_points.txt +2 -0
- localstream-1.0.0/localstream.egg-info/requires.txt +2 -0
- localstream-1.0.0/localstream.egg-info/top_level.txt +1 -0
- localstream-1.0.0/pyproject.toml +35 -0
- localstream-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: localstream
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python CLI client for slipstream-rust DNS tunnel
|
|
5
|
+
Author: LocalStream Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dns,tunnel,vpn,slipstream,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: requests>=2.31.0
|
|
18
|
+
Requires-Dist: colorama>=0.4.6
|
|
19
|
+
|
|
20
|
+
# LocalStream
|
|
21
|
+
|
|
22
|
+
A Python command-line client for [slipstream-rust](https://github.com/Mygod/slipstream-rust) DNS tunnel.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **VPN Mode**: System-wide traffic tunneling (requires Admin)
|
|
27
|
+
- **PROXY Mode**: SOCKS5 proxy on local port
|
|
28
|
+
- Auto-reconnect on connection drops
|
|
29
|
+
- Easy pip installation
|
|
30
|
+
- Colorful CLI interface
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Python 3.13+
|
|
35
|
+
- Windows OS
|
|
36
|
+
- Administrator privileges (for VPN Mode)
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
LocalStream # Normal user (PROXY mode only)
|
|
48
|
+
# Run as Administrator for VPN mode
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Menu Options
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
[1] Connect to server
|
|
55
|
+
├── [1] VPN Mode (system-wide, requires Admin)
|
|
56
|
+
├── [2] PROXY Mode (SOCKS5 only)
|
|
57
|
+
└── [3] Back
|
|
58
|
+
[2] Edit configuration
|
|
59
|
+
[3] Exit
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Keyboard Shortcuts
|
|
63
|
+
|
|
64
|
+
- **Ctrl+C**: Disconnect and exit
|
|
65
|
+
- **Ctrl+D**: Restart connection
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Config stored in `~/.localstream/config.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"server_ip": "203.0.113.2",
|
|
74
|
+
"server_port": 53,
|
|
75
|
+
"local_port": 5201,
|
|
76
|
+
"domain": "s.example.com"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Downloaded Binaries
|
|
81
|
+
|
|
82
|
+
Stored in `~/.localstream/bin/`:
|
|
83
|
+
- `slipstream-client.exe` - DNS tunnel client
|
|
84
|
+
- `tun2proxy.exe` - TUN adapter for VPN mode
|
|
85
|
+
- `wintun.dll` - Windows TUN driver
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# LocalStream
|
|
2
|
+
|
|
3
|
+
A Python command-line client for [slipstream-rust](https://github.com/Mygod/slipstream-rust) DNS tunnel.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **VPN Mode**: System-wide traffic tunneling (requires Admin)
|
|
8
|
+
- **PROXY Mode**: SOCKS5 proxy on local port
|
|
9
|
+
- Auto-reconnect on connection drops
|
|
10
|
+
- Easy pip installation
|
|
11
|
+
- Colorful CLI interface
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Python 3.13+
|
|
16
|
+
- Windows OS
|
|
17
|
+
- Administrator privileges (for VPN Mode)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
LocalStream # Normal user (PROXY mode only)
|
|
29
|
+
# Run as Administrator for VPN mode
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Menu Options
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
[1] Connect to server
|
|
36
|
+
├── [1] VPN Mode (system-wide, requires Admin)
|
|
37
|
+
├── [2] PROXY Mode (SOCKS5 only)
|
|
38
|
+
└── [3] Back
|
|
39
|
+
[2] Edit configuration
|
|
40
|
+
[3] Exit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Keyboard Shortcuts
|
|
44
|
+
|
|
45
|
+
- **Ctrl+C**: Disconnect and exit
|
|
46
|
+
- **Ctrl+D**: Restart connection
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Config stored in `~/.localstream/config.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"server_ip": "203.0.113.2",
|
|
55
|
+
"server_port": 53,
|
|
56
|
+
"local_port": 5201,
|
|
57
|
+
"domain": "s.example.com"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Downloaded Binaries
|
|
62
|
+
|
|
63
|
+
Stored in `~/.localstream/bin/`:
|
|
64
|
+
- `slipstream-client.exe` - DNS tunnel client
|
|
65
|
+
- `tun2proxy.exe` - TUN adapter for VPN mode
|
|
66
|
+
- `wintun.dll` - Windows TUN driver
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import colorama
|
|
6
|
+
colorama.init()
|
|
7
|
+
except ImportError:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
from localstream.config import config_exists, load_config, save_config, get_default_config
|
|
11
|
+
from localstream.connection import ConnectionManager, is_admin
|
|
12
|
+
from localstream.downloader import client_exists, download_client, tun2proxy_exists, download_tun2proxy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CYAN = "\033[96m"
|
|
16
|
+
GREEN = "\033[92m"
|
|
17
|
+
YELLOW = "\033[93m"
|
|
18
|
+
RED = "\033[91m"
|
|
19
|
+
MAGENTA = "\033[95m"
|
|
20
|
+
BLUE = "\033[94m"
|
|
21
|
+
WHITE = "\033[97m"
|
|
22
|
+
DIM = "\033[2m"
|
|
23
|
+
BOLD = "\033[1m"
|
|
24
|
+
RESET = "\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LOGO = f"""
|
|
28
|
+
{CYAN}╔═══════════════════════════════════════════════════════════════╗
|
|
29
|
+
║ ║
|
|
30
|
+
║ {MAGENTA}██╗ ██████╗ ██████╗ █████╗ ██╗ {CYAN} ║
|
|
31
|
+
║ {MAGENTA}██║ ██╔═══██╗██╔════╝██╔══██╗██║ {CYAN} ║
|
|
32
|
+
║ {MAGENTA}██║ ██║ ██║██║ ███████║██║ {CYAN} ║
|
|
33
|
+
║ {MAGENTA}██║ ██║ ██║██║ ██╔══██║██║ {CYAN} ║
|
|
34
|
+
║ {MAGENTA}███████╗╚██████╔╝╚██████╗██║ ██║███████╗{CYAN} ║
|
|
35
|
+
║ {MAGENTA}╚══════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝{CYAN} ║
|
|
36
|
+
║ ║
|
|
37
|
+
║ {BLUE}███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗{CYAN} ║
|
|
38
|
+
║ {BLUE}██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║{CYAN} ║
|
|
39
|
+
║ {BLUE}███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║{CYAN} ║
|
|
40
|
+
║ {BLUE}╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║{CYAN} ║
|
|
41
|
+
║ {BLUE}███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║{CYAN} ║
|
|
42
|
+
║ {BLUE}╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝{CYAN} ║
|
|
43
|
+
║ ║
|
|
44
|
+
║ {DIM}SlipStream DNS Tunnel • Python CLI Client{CYAN} ║
|
|
45
|
+
╚═══════════════════════════════════════════════════════════════╝{RESET}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
MINI_LOGO = f"""
|
|
49
|
+
{CYAN}╔═══════════════════════════════════════════╗
|
|
50
|
+
║ {MAGENTA}LocalStream{CYAN} • {DIM}SlipStream CLI Client{CYAN} ║
|
|
51
|
+
╚═══════════════════════════════════════════╝{RESET}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear_screen():
|
|
56
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_logo(mini: bool = False):
|
|
60
|
+
if mini:
|
|
61
|
+
print(MINI_LOGO)
|
|
62
|
+
else:
|
|
63
|
+
print(LOGO)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_box(title: str, content: list, color: str = CYAN):
|
|
67
|
+
max_len = max(len(line) for line in content) if content else 20
|
|
68
|
+
max_len = max(max_len, len(title) + 4)
|
|
69
|
+
width = max_len + 4
|
|
70
|
+
|
|
71
|
+
print(f"\n{color}┌{'─' * width}┐{RESET}")
|
|
72
|
+
print(f"{color}│{RESET} {BOLD}{title}{RESET}{' ' * (width - len(title) - 2)}{color}│{RESET}")
|
|
73
|
+
print(f"{color}├{'─' * width}┤{RESET}")
|
|
74
|
+
|
|
75
|
+
for line in content:
|
|
76
|
+
padding = width - len(line) - 2
|
|
77
|
+
print(f"{color}│{RESET} {line}{' ' * padding}{color}│{RESET}")
|
|
78
|
+
|
|
79
|
+
print(f"{color}└{'─' * width}┘{RESET}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def print_config(config: dict):
|
|
83
|
+
server = f"{config.get('server_ip', 'N/A')}:{config.get('server_port', 53)}"
|
|
84
|
+
admin_status = f"{GREEN}✓ Admin{RESET}" if is_admin() else f"{YELLOW}○ User{RESET}"
|
|
85
|
+
content = [
|
|
86
|
+
f"{GREEN}●{RESET} Server : {WHITE}{server}{RESET}",
|
|
87
|
+
f"{GREEN}●{RESET} Domain : {WHITE}{config.get('domain', 'N/A')}{RESET}",
|
|
88
|
+
f"{GREEN}●{RESET} Local Port : {WHITE}{config.get('local_port', 5201)}{RESET}",
|
|
89
|
+
f"{DIM}─────────────────────────────────{RESET}",
|
|
90
|
+
f"{BLUE}●{RESET} Status : {admin_status}",
|
|
91
|
+
]
|
|
92
|
+
print_box("Current Configuration", content, CYAN)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def print_main_menu():
|
|
96
|
+
content = [
|
|
97
|
+
f"{YELLOW}[1]{RESET} Connect to server",
|
|
98
|
+
f"{YELLOW}[2]{RESET} Edit configuration",
|
|
99
|
+
f"{YELLOW}[3]{RESET} Exit",
|
|
100
|
+
]
|
|
101
|
+
print_box("Menu", content, BLUE)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def print_connection_menu():
|
|
105
|
+
vpn_status = f"{GREEN}●{RESET}" if is_admin() else f"{RED}○{RESET}"
|
|
106
|
+
admin_note = "" if is_admin() else f" {DIM}(requires Admin){RESET}"
|
|
107
|
+
content = [
|
|
108
|
+
f"{YELLOW}[1]{RESET} {vpn_status} VPN Mode {DIM}(system-wide){RESET}{admin_note}",
|
|
109
|
+
f"{YELLOW}[2]{RESET} {GREEN}●{RESET} PROXY Mode {DIM}(SOCKS5 only){RESET}",
|
|
110
|
+
f"{YELLOW}[3]{RESET} Back to menu",
|
|
111
|
+
]
|
|
112
|
+
print_box("Connection Mode", content, MAGENTA)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_input(prompt: str, default: str = "") -> str:
|
|
116
|
+
if default:
|
|
117
|
+
result = input(f" {GREEN}▸{RESET} {prompt} {DIM}[{default}]{RESET}: ").strip()
|
|
118
|
+
return result if result else default
|
|
119
|
+
return input(f" {GREEN}▸{RESET} {prompt}: ").strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_int_input(prompt: str, default: int) -> int:
|
|
123
|
+
while True:
|
|
124
|
+
result = input(f" {GREEN}▸{RESET} {prompt} {DIM}[{default}]{RESET}: ").strip()
|
|
125
|
+
if not result:
|
|
126
|
+
return default
|
|
127
|
+
try:
|
|
128
|
+
return int(result)
|
|
129
|
+
except ValueError:
|
|
130
|
+
print(f" {RED}✗{RESET} Please enter a valid number")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def prompt_for_config() -> dict:
|
|
134
|
+
print(f"\n{YELLOW}━━━ Server Configuration ━━━{RESET}\n")
|
|
135
|
+
|
|
136
|
+
config = get_default_config()
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
server_ip = get_input("Server IP (e.g., 203.0.113.2)")
|
|
140
|
+
if server_ip:
|
|
141
|
+
config["server_ip"] = server_ip
|
|
142
|
+
break
|
|
143
|
+
print(f" {RED}✗{RESET} Server IP is required")
|
|
144
|
+
|
|
145
|
+
config["server_port"] = get_int_input("Server Port", 53)
|
|
146
|
+
config["local_port"] = get_int_input("Local Port", 5201)
|
|
147
|
+
|
|
148
|
+
while True:
|
|
149
|
+
domain = get_input("Domain (e.g., s.example.com)")
|
|
150
|
+
if domain:
|
|
151
|
+
config["domain"] = domain
|
|
152
|
+
break
|
|
153
|
+
print(f" {RED}✗{RESET} Domain is required")
|
|
154
|
+
|
|
155
|
+
return config
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def show_main_menu() -> int:
|
|
159
|
+
print_main_menu()
|
|
160
|
+
print()
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
choice = input(f" {MAGENTA}▸{RESET} Select option {DIM}[1-3]{RESET}: ").strip()
|
|
165
|
+
if choice in ["1", "2", "3"]:
|
|
166
|
+
return int(choice)
|
|
167
|
+
print(f" {RED}✗{RESET} Please enter 1, 2, or 3")
|
|
168
|
+
except (ValueError, EOFError):
|
|
169
|
+
return 3
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def show_connection_menu() -> int:
|
|
173
|
+
print_connection_menu()
|
|
174
|
+
print()
|
|
175
|
+
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
choice = input(f" {MAGENTA}▸{RESET} Select mode {DIM}[1-3]{RESET}: ").strip()
|
|
179
|
+
if choice in ["1", "2", "3"]:
|
|
180
|
+
return int(choice)
|
|
181
|
+
print(f" {RED}✗{RESET} Please enter 1, 2, or 3")
|
|
182
|
+
except (ValueError, EOFError):
|
|
183
|
+
return 3
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def print_connecting_banner(config: dict, mode: str):
|
|
187
|
+
server = f"{config.get('server_ip')}:{config.get('server_port')}"
|
|
188
|
+
domain = config.get('domain')
|
|
189
|
+
local_port = config.get('local_port')
|
|
190
|
+
mode_display = f"{GREEN}VPN{RESET}" if mode == "vpn" else f"{BLUE}PROXY{RESET}"
|
|
191
|
+
|
|
192
|
+
print(f"\n{CYAN}╔═══════════════════════════════════════════════════════╗{RESET}")
|
|
193
|
+
print(f"{CYAN}║{RESET} {GREEN}●{RESET} {BOLD}Connecting in {mode_display} Mode...{RESET}{' ' * 24}{CYAN}║{RESET}")
|
|
194
|
+
print(f"{CYAN}╟───────────────────────────────────────────────────────╢{RESET}")
|
|
195
|
+
print(f"{CYAN}║{RESET} Resolver : {WHITE}{server:<38}{RESET}{CYAN}║{RESET}")
|
|
196
|
+
print(f"{CYAN}║{RESET} Domain : {WHITE}{domain:<38}{RESET}{CYAN}║{RESET}")
|
|
197
|
+
print(f"{CYAN}║{RESET} Local Port : {WHITE}{local_port:<38}{RESET}{CYAN}║{RESET}")
|
|
198
|
+
print(f"{CYAN}╟───────────────────────────────────────────────────────╢{RESET}")
|
|
199
|
+
print(f"{CYAN}║{RESET} {DIM}Press Ctrl+C to disconnect • Ctrl+D to restart{RESET} {CYAN}║{RESET}")
|
|
200
|
+
print(f"{CYAN}╚═══════════════════════════════════════════════════════╝{RESET}\n")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_first_run():
|
|
204
|
+
clear_screen()
|
|
205
|
+
print_logo()
|
|
206
|
+
|
|
207
|
+
print(f"\n{GREEN}✓{RESET} Welcome to LocalStream!")
|
|
208
|
+
print(f"{DIM} First-time setup - Let's configure your connection{RESET}\n")
|
|
209
|
+
|
|
210
|
+
if not client_exists():
|
|
211
|
+
print(f"\n{YELLOW}⟳{RESET} Downloading slipstream-client...")
|
|
212
|
+
if not download_client():
|
|
213
|
+
print(f"\n{RED}✗{RESET} Failed to download client. Check your internet connection.")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
print(f"{GREEN}✓{RESET} Download complete!\n")
|
|
216
|
+
|
|
217
|
+
config = prompt_for_config()
|
|
218
|
+
save_config(config)
|
|
219
|
+
print(f"\n{GREEN}✓{RESET} Configuration saved!")
|
|
220
|
+
|
|
221
|
+
print_config(config)
|
|
222
|
+
|
|
223
|
+
input(f"\n {DIM}Press Enter to continue to menu...{RESET}")
|
|
224
|
+
handle_menu()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def handle_connection(config: dict, mode: str):
|
|
228
|
+
while True:
|
|
229
|
+
clear_screen()
|
|
230
|
+
print_logo(mini=True)
|
|
231
|
+
print_connecting_banner(config, mode)
|
|
232
|
+
|
|
233
|
+
manager = ConnectionManager()
|
|
234
|
+
|
|
235
|
+
if mode == "vpn":
|
|
236
|
+
result = manager.connect_vpn(config)
|
|
237
|
+
else:
|
|
238
|
+
result = manager.connect_proxy(config)
|
|
239
|
+
|
|
240
|
+
if result == "restart":
|
|
241
|
+
print(f"\n{YELLOW}⟳{RESET} Restarting connection...")
|
|
242
|
+
continue
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def handle_menu():
|
|
247
|
+
while True:
|
|
248
|
+
clear_screen()
|
|
249
|
+
print_logo()
|
|
250
|
+
|
|
251
|
+
config = load_config()
|
|
252
|
+
print_config(config)
|
|
253
|
+
|
|
254
|
+
choice = show_main_menu()
|
|
255
|
+
|
|
256
|
+
if choice == 1:
|
|
257
|
+
clear_screen()
|
|
258
|
+
print_logo(mini=True)
|
|
259
|
+
print_config(config)
|
|
260
|
+
|
|
261
|
+
mode_choice = show_connection_menu()
|
|
262
|
+
|
|
263
|
+
if mode_choice == 1:
|
|
264
|
+
if not is_admin():
|
|
265
|
+
print(f"\n{RED}✗{RESET} VPN Mode requires Administrator privileges!")
|
|
266
|
+
print(f"{YELLOW}!{RESET} Please restart LocalStream as Administrator")
|
|
267
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
if not tun2proxy_exists():
|
|
271
|
+
print(f"\n{YELLOW}⟳{RESET} Downloading VPN components...")
|
|
272
|
+
if not download_tun2proxy():
|
|
273
|
+
print(f"\n{RED}✗{RESET} Failed to download VPN components.")
|
|
274
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
handle_connection(config, "vpn")
|
|
278
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
279
|
+
|
|
280
|
+
elif mode_choice == 2:
|
|
281
|
+
if not client_exists():
|
|
282
|
+
print(f"\n{YELLOW}⟳{RESET} Downloading client...")
|
|
283
|
+
if not download_client():
|
|
284
|
+
print(f"\n{RED}✗{RESET} Failed to download client.")
|
|
285
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
handle_connection(config, "proxy")
|
|
289
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
290
|
+
|
|
291
|
+
elif mode_choice == 3:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
elif choice == 2:
|
|
295
|
+
print()
|
|
296
|
+
config = prompt_for_config()
|
|
297
|
+
save_config(config)
|
|
298
|
+
print(f"\n{GREEN}✓{RESET} Configuration updated!")
|
|
299
|
+
input(f"\n {DIM}Press Enter to continue...{RESET}")
|
|
300
|
+
|
|
301
|
+
elif choice == 3:
|
|
302
|
+
clear_screen()
|
|
303
|
+
print(f"\n{CYAN}╔═══════════════════════════════════════╗{RESET}")
|
|
304
|
+
print(f"{CYAN}║{RESET} {GREEN}✓{RESET} Thanks for using LocalStream! {CYAN}║{RESET}")
|
|
305
|
+
print(f"{CYAN}╚═══════════════════════════════════════╝{RESET}\n")
|
|
306
|
+
sys.exit(0)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def main():
|
|
310
|
+
try:
|
|
311
|
+
if config_exists():
|
|
312
|
+
handle_menu()
|
|
313
|
+
else:
|
|
314
|
+
handle_first_run()
|
|
315
|
+
except KeyboardInterrupt:
|
|
316
|
+
print(f"\n\n{GREEN}✓{RESET} Goodbye!")
|
|
317
|
+
sys.exit(0)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_config_dir() -> Path:
|
|
7
|
+
config_dir = Path.home() / ".localstream"
|
|
8
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
9
|
+
return config_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config_path() -> Path:
|
|
13
|
+
return get_config_dir() / "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_exists() -> bool:
|
|
17
|
+
return get_config_path().exists()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict:
|
|
21
|
+
config_path = get_config_path()
|
|
22
|
+
if not config_path.exists():
|
|
23
|
+
return {}
|
|
24
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
25
|
+
return json.load(f)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_config(config: dict) -> None:
|
|
29
|
+
config_path = get_config_path()
|
|
30
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
31
|
+
json.dump(config, f, indent=2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_default_config() -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"server_ip": "",
|
|
37
|
+
"server_port": 53,
|
|
38
|
+
"local_port": 5201,
|
|
39
|
+
"domain": ""
|
|
40
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import ctypes
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from localstream.downloader import get_client_path, get_tun2proxy_path, client_exists, tun2proxy_exists, download_client, download_tun2proxy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CYAN = "\033[96m"
|
|
14
|
+
GREEN = "\033[92m"
|
|
15
|
+
YELLOW = "\033[93m"
|
|
16
|
+
RED = "\033[91m"
|
|
17
|
+
BLUE = "\033[94m"
|
|
18
|
+
MAGENTA = "\033[95m"
|
|
19
|
+
WHITE = "\033[97m"
|
|
20
|
+
DIM = "\033[2m"
|
|
21
|
+
BOLD = "\033[1m"
|
|
22
|
+
RESET = "\033[0m"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOG_PATTERNS = {
|
|
26
|
+
r"INFO.*Listening on TCP port (\d+)": lambda m: f"{GREEN}●{RESET} Listening on port {WHITE}{m.group(1)}{RESET}",
|
|
27
|
+
r"INFO.*Connection ready": lambda m: f"{GREEN}●{RESET} {GREEN}Connection ready!{RESET}",
|
|
28
|
+
r"INFO.*accepted new client": lambda m: f"{BLUE}→{RESET} New client connected",
|
|
29
|
+
r"INFO.*client disconnected": lambda m: f"{BLUE}←{RESET} Client disconnected",
|
|
30
|
+
r"WARN.*certificate pinning": lambda m: f"{YELLOW}!{RESET} {DIM}Certificate pinning disabled{RESET}",
|
|
31
|
+
r"ERROR|FATAL": lambda m: f"{RED}✗{RESET} {m.group(0)}",
|
|
32
|
+
r"INFO.*bytes.*transferred": lambda m: f"{DIM}↔{RESET} {DIM}Data transferred{RESET}",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_log_line(line: str) -> str:
|
|
37
|
+
line = line.strip()
|
|
38
|
+
if not line:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
for pattern, formatter in LOG_PATTERNS.items():
|
|
42
|
+
match = re.search(pattern, line, re.IGNORECASE)
|
|
43
|
+
if match:
|
|
44
|
+
return formatter(match)
|
|
45
|
+
|
|
46
|
+
if "INFO" in line or "DEBUG" in line:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
return f"{DIM}│{RESET} {line}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_admin() -> bool:
|
|
53
|
+
try:
|
|
54
|
+
return ctypes.windll.shell32.IsUserAnAdmin()
|
|
55
|
+
except:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_as_admin():
|
|
60
|
+
if sys.platform != "win32":
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
ctypes.windll.shell32.ShellExecuteW(
|
|
65
|
+
None, "runas", sys.executable, " ".join(sys.argv), None, 1
|
|
66
|
+
)
|
|
67
|
+
return True
|
|
68
|
+
except:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConnectionManager:
|
|
73
|
+
def __init__(self):
|
|
74
|
+
self.slipstream_process = None
|
|
75
|
+
self.tun2proxy_process = None
|
|
76
|
+
self.restart_requested = False
|
|
77
|
+
self.user_disconnected = False
|
|
78
|
+
self.stop_listener = False
|
|
79
|
+
self._original_sigint = None
|
|
80
|
+
self._listener_thread = None
|
|
81
|
+
self._monitor_thread = None
|
|
82
|
+
|
|
83
|
+
def _setup_signal_handlers(self):
|
|
84
|
+
self._original_sigint = signal.signal(signal.SIGINT, self._sigint_handler)
|
|
85
|
+
|
|
86
|
+
def _restore_signal_handlers(self):
|
|
87
|
+
if self._original_sigint:
|
|
88
|
+
signal.signal(signal.SIGINT, self._original_sigint)
|
|
89
|
+
|
|
90
|
+
def _sigint_handler(self, signum, frame):
|
|
91
|
+
print(f"\n\n{YELLOW}⟳{RESET} Disconnecting...")
|
|
92
|
+
self.user_disconnected = True
|
|
93
|
+
self.disconnect()
|
|
94
|
+
raise KeyboardInterrupt()
|
|
95
|
+
|
|
96
|
+
def _keyboard_listener(self):
|
|
97
|
+
try:
|
|
98
|
+
import msvcrt
|
|
99
|
+
while not self.stop_listener:
|
|
100
|
+
if msvcrt.kbhit():
|
|
101
|
+
key = msvcrt.getch()
|
|
102
|
+
if key == b'\x04':
|
|
103
|
+
self.restart_requested = True
|
|
104
|
+
self.disconnect()
|
|
105
|
+
return
|
|
106
|
+
threading.Event().wait(0.1)
|
|
107
|
+
except:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def _start_keyboard_listener(self):
|
|
111
|
+
self.stop_listener = False
|
|
112
|
+
self._listener_thread = threading.Thread(target=self._keyboard_listener, daemon=True)
|
|
113
|
+
self._listener_thread.start()
|
|
114
|
+
|
|
115
|
+
def _stop_keyboard_listener(self):
|
|
116
|
+
self.stop_listener = True
|
|
117
|
+
if self._listener_thread and self._listener_thread.is_alive():
|
|
118
|
+
self._listener_thread.join(timeout=0.5)
|
|
119
|
+
|
|
120
|
+
def connect_proxy(self, config: dict) -> str:
|
|
121
|
+
if not client_exists():
|
|
122
|
+
if not download_client():
|
|
123
|
+
print(f"{RED}✗{RESET} Cannot connect: slipstream-client not available")
|
|
124
|
+
return "error"
|
|
125
|
+
|
|
126
|
+
client_path = get_client_path()
|
|
127
|
+
server_ip = config.get("server_ip", "")
|
|
128
|
+
server_port = config.get("server_port", 53)
|
|
129
|
+
local_port = config.get("local_port", 5201)
|
|
130
|
+
domain = config.get("domain", "")
|
|
131
|
+
|
|
132
|
+
if not server_ip or not domain:
|
|
133
|
+
print(f"{RED}✗{RESET} Invalid configuration: server_ip and domain are required")
|
|
134
|
+
return "error"
|
|
135
|
+
|
|
136
|
+
resolver = f"{server_ip}:{server_port}"
|
|
137
|
+
|
|
138
|
+
cmd = [
|
|
139
|
+
str(client_path),
|
|
140
|
+
"--resolver", resolver,
|
|
141
|
+
"--domain", domain,
|
|
142
|
+
"--tcp-listen-port", str(local_port)
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
self._setup_signal_handlers()
|
|
146
|
+
self.restart_requested = False
|
|
147
|
+
self.user_disconnected = False
|
|
148
|
+
reconnect_count = 0
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
while True:
|
|
152
|
+
self.slipstream_process = subprocess.Popen(
|
|
153
|
+
cmd,
|
|
154
|
+
stdout=subprocess.PIPE,
|
|
155
|
+
stderr=subprocess.STDOUT,
|
|
156
|
+
text=True,
|
|
157
|
+
bufsize=1,
|
|
158
|
+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._start_keyboard_listener()
|
|
162
|
+
|
|
163
|
+
if reconnect_count == 0:
|
|
164
|
+
print(f"\n{CYAN}┌{'─' * 50}┐{RESET}")
|
|
165
|
+
print(f"{CYAN}│{RESET} {GREEN}●{RESET} {BOLD}PROXY Mode - Connection Status{RESET}{' ' * 16}{CYAN}│{RESET}")
|
|
166
|
+
print(f"{CYAN}└{'─' * 50}┘{RESET}\n")
|
|
167
|
+
else:
|
|
168
|
+
print(f"\n {GREEN}●{RESET} Reconnected! (attempt #{reconnect_count + 1})\n")
|
|
169
|
+
|
|
170
|
+
for line in self.slipstream_process.stdout:
|
|
171
|
+
if self.restart_requested or self.user_disconnected:
|
|
172
|
+
break
|
|
173
|
+
formatted = format_log_line(line)
|
|
174
|
+
if formatted:
|
|
175
|
+
print(f" {formatted}")
|
|
176
|
+
|
|
177
|
+
self.slipstream_process.wait()
|
|
178
|
+
self._stop_keyboard_listener()
|
|
179
|
+
|
|
180
|
+
if self.restart_requested:
|
|
181
|
+
return "restart"
|
|
182
|
+
|
|
183
|
+
if self.user_disconnected:
|
|
184
|
+
return "done"
|
|
185
|
+
|
|
186
|
+
return_code = self.slipstream_process.returncode
|
|
187
|
+
if return_code != 0:
|
|
188
|
+
reconnect_count += 1
|
|
189
|
+
print(f"\n {YELLOW}!{RESET} Connection dropped (code: {return_code})")
|
|
190
|
+
print(f" {YELLOW}⟳{RESET} Auto-reconnecting in 3 seconds...")
|
|
191
|
+
time.sleep(3)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
return "done"
|
|
195
|
+
|
|
196
|
+
except KeyboardInterrupt:
|
|
197
|
+
self._stop_keyboard_listener()
|
|
198
|
+
return "done"
|
|
199
|
+
except FileNotFoundError:
|
|
200
|
+
print(f"{RED}✗{RESET} Client not found: {client_path}")
|
|
201
|
+
return "error"
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"{RED}✗{RESET} Connection error: {e}")
|
|
204
|
+
return "error"
|
|
205
|
+
finally:
|
|
206
|
+
self._restore_signal_handlers()
|
|
207
|
+
self._stop_keyboard_listener()
|
|
208
|
+
self.disconnect()
|
|
209
|
+
|
|
210
|
+
def _monitor_slipstream(self, cmd):
|
|
211
|
+
reconnect_count = 0
|
|
212
|
+
while not self.user_disconnected and not self.restart_requested:
|
|
213
|
+
if self.slipstream_process and self.slipstream_process.poll() is not None:
|
|
214
|
+
return_code = self.slipstream_process.returncode
|
|
215
|
+
if return_code != 0 and not self.user_disconnected:
|
|
216
|
+
reconnect_count += 1
|
|
217
|
+
print(f"\n {YELLOW}!{RESET} Slipstream dropped (code: {return_code})")
|
|
218
|
+
print(f" {YELLOW}⟳{RESET} Auto-reconnecting...")
|
|
219
|
+
time.sleep(2)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
self.slipstream_process = subprocess.Popen(
|
|
223
|
+
cmd,
|
|
224
|
+
stdout=subprocess.PIPE,
|
|
225
|
+
stderr=subprocess.STDOUT,
|
|
226
|
+
text=True,
|
|
227
|
+
bufsize=1,
|
|
228
|
+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
|
229
|
+
)
|
|
230
|
+
time.sleep(2)
|
|
231
|
+
if self.slipstream_process.poll() is None:
|
|
232
|
+
print(f" {GREEN}●{RESET} Slipstream reconnected! (attempt #{reconnect_count + 1})")
|
|
233
|
+
except:
|
|
234
|
+
pass
|
|
235
|
+
time.sleep(1)
|
|
236
|
+
|
|
237
|
+
def connect_vpn(self, config: dict) -> str:
|
|
238
|
+
if not is_admin():
|
|
239
|
+
print(f"\n{RED}✗{RESET} VPN Mode requires Administrator privileges!")
|
|
240
|
+
print(f"{YELLOW}!{RESET} Please run LocalStream as Administrator")
|
|
241
|
+
return "error"
|
|
242
|
+
|
|
243
|
+
if not client_exists():
|
|
244
|
+
if not download_client():
|
|
245
|
+
print(f"{RED}✗{RESET} Cannot connect: slipstream-client not available")
|
|
246
|
+
return "error"
|
|
247
|
+
|
|
248
|
+
if not tun2proxy_exists():
|
|
249
|
+
if not download_tun2proxy():
|
|
250
|
+
print(f"{RED}✗{RESET} Cannot connect: tun2proxy not available")
|
|
251
|
+
return "error"
|
|
252
|
+
|
|
253
|
+
client_path = get_client_path()
|
|
254
|
+
tun2proxy_path = get_tun2proxy_path()
|
|
255
|
+
server_ip = config.get("server_ip", "")
|
|
256
|
+
server_port = config.get("server_port", 53)
|
|
257
|
+
local_port = config.get("local_port", 5201)
|
|
258
|
+
domain = config.get("domain", "")
|
|
259
|
+
|
|
260
|
+
if not server_ip or not domain:
|
|
261
|
+
print(f"{RED}✗{RESET} Invalid configuration: server_ip and domain are required")
|
|
262
|
+
return "error"
|
|
263
|
+
|
|
264
|
+
resolver = f"{server_ip}:{server_port}"
|
|
265
|
+
|
|
266
|
+
slipstream_cmd = [
|
|
267
|
+
str(client_path),
|
|
268
|
+
"--resolver", resolver,
|
|
269
|
+
"--domain", domain,
|
|
270
|
+
"--tcp-listen-port", str(local_port)
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
tun2proxy_cmd = [
|
|
274
|
+
str(tun2proxy_path),
|
|
275
|
+
"--setup",
|
|
276
|
+
"--proxy", f"socks5://127.0.0.1:{local_port}",
|
|
277
|
+
"--bypass", server_ip,
|
|
278
|
+
"--dns", "virtual"
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
self._setup_signal_handlers()
|
|
282
|
+
self.restart_requested = False
|
|
283
|
+
self.user_disconnected = False
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
print(f"\n{CYAN}┌{'─' * 50}┐{RESET}")
|
|
287
|
+
print(f"{CYAN}│{RESET} {GREEN}●{RESET} {BOLD}VPN Mode - Starting Tunnel{RESET}{' ' * 21}{CYAN}│{RESET}")
|
|
288
|
+
print(f"{CYAN}└{'─' * 50}┘{RESET}\n")
|
|
289
|
+
|
|
290
|
+
print(f" {YELLOW}⟳{RESET} Starting slipstream-client...")
|
|
291
|
+
self.slipstream_process = subprocess.Popen(
|
|
292
|
+
slipstream_cmd,
|
|
293
|
+
stdout=subprocess.PIPE,
|
|
294
|
+
stderr=subprocess.STDOUT,
|
|
295
|
+
text=True,
|
|
296
|
+
bufsize=1,
|
|
297
|
+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
time.sleep(2)
|
|
301
|
+
|
|
302
|
+
if self.slipstream_process.poll() is not None:
|
|
303
|
+
print(f" {RED}✗{RESET} Slipstream failed to start")
|
|
304
|
+
return "error"
|
|
305
|
+
|
|
306
|
+
print(f" {GREEN}✓{RESET} Slipstream-client running on port {local_port}")
|
|
307
|
+
|
|
308
|
+
print(f" {YELLOW}⟳{RESET} Starting tun2proxy (VPN tunnel)...")
|
|
309
|
+
self.tun2proxy_process = subprocess.Popen(
|
|
310
|
+
tun2proxy_cmd,
|
|
311
|
+
stdout=subprocess.PIPE,
|
|
312
|
+
stderr=subprocess.STDOUT,
|
|
313
|
+
text=True,
|
|
314
|
+
bufsize=1
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
time.sleep(2)
|
|
318
|
+
|
|
319
|
+
if self.tun2proxy_process.poll() is not None:
|
|
320
|
+
print(f" {RED}✗{RESET} tun2proxy failed to start")
|
|
321
|
+
self.disconnect()
|
|
322
|
+
return "error"
|
|
323
|
+
|
|
324
|
+
print(f" {GREEN}✓{RESET} VPN tunnel established!")
|
|
325
|
+
print(f"\n{GREEN}{'═' * 52}{RESET}")
|
|
326
|
+
print(f"{GREEN} ✓ All traffic is now routed through the VPN!{RESET}")
|
|
327
|
+
print(f"{GREEN}{'═' * 52}{RESET}")
|
|
328
|
+
print(f"\n {DIM}Press Ctrl+C to disconnect{RESET}\n")
|
|
329
|
+
|
|
330
|
+
self._monitor_thread = threading.Thread(
|
|
331
|
+
target=self._monitor_slipstream,
|
|
332
|
+
args=(slipstream_cmd,),
|
|
333
|
+
daemon=True
|
|
334
|
+
)
|
|
335
|
+
self._monitor_thread.start()
|
|
336
|
+
|
|
337
|
+
self._start_keyboard_listener()
|
|
338
|
+
|
|
339
|
+
ignore_patterns = [
|
|
340
|
+
"brokenpipe",
|
|
341
|
+
"tcp connection closed",
|
|
342
|
+
"connection closed",
|
|
343
|
+
"connectionnotallowed",
|
|
344
|
+
"ending #",
|
|
345
|
+
"info tun2proxy",
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
for line in self.tun2proxy_process.stdout:
|
|
349
|
+
if self.restart_requested:
|
|
350
|
+
break
|
|
351
|
+
line = line.strip()
|
|
352
|
+
if not line:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
line_lower = line.lower()
|
|
356
|
+
if any(pattern in line_lower for pattern in ignore_patterns):
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
if "error" in line_lower or "fatal" in line_lower:
|
|
360
|
+
print(f" {RED}✗{RESET} {line}")
|
|
361
|
+
|
|
362
|
+
self.tun2proxy_process.wait()
|
|
363
|
+
self._stop_keyboard_listener()
|
|
364
|
+
|
|
365
|
+
if self.restart_requested:
|
|
366
|
+
return "restart"
|
|
367
|
+
|
|
368
|
+
return "done"
|
|
369
|
+
|
|
370
|
+
except KeyboardInterrupt:
|
|
371
|
+
self._stop_keyboard_listener()
|
|
372
|
+
return "done"
|
|
373
|
+
except FileNotFoundError as e:
|
|
374
|
+
print(f"{RED}✗{RESET} Binary not found: {e}")
|
|
375
|
+
return "error"
|
|
376
|
+
except Exception as e:
|
|
377
|
+
print(f"{RED}✗{RESET} Connection error: {e}")
|
|
378
|
+
return "error"
|
|
379
|
+
finally:
|
|
380
|
+
self._restore_signal_handlers()
|
|
381
|
+
self._stop_keyboard_listener()
|
|
382
|
+
self.disconnect()
|
|
383
|
+
|
|
384
|
+
def disconnect(self):
|
|
385
|
+
self.user_disconnected = True
|
|
386
|
+
|
|
387
|
+
if self.tun2proxy_process and self.tun2proxy_process.poll() is None:
|
|
388
|
+
self.tun2proxy_process.terminate()
|
|
389
|
+
try:
|
|
390
|
+
self.tun2proxy_process.wait(timeout=5)
|
|
391
|
+
except subprocess.TimeoutExpired:
|
|
392
|
+
self.tun2proxy_process.kill()
|
|
393
|
+
print(f"{GREEN}✓{RESET} VPN tunnel closed")
|
|
394
|
+
self.tun2proxy_process = None
|
|
395
|
+
|
|
396
|
+
if self.slipstream_process and self.slipstream_process.poll() is None:
|
|
397
|
+
self.slipstream_process.terminate()
|
|
398
|
+
try:
|
|
399
|
+
self.slipstream_process.wait(timeout=5)
|
|
400
|
+
except subprocess.TimeoutExpired:
|
|
401
|
+
self.slipstream_process.kill()
|
|
402
|
+
print(f"{GREEN}✓{RESET} Slipstream disconnected")
|
|
403
|
+
self.slipstream_process = None
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import requests
|
|
4
|
+
import zipfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from localstream.config import get_config_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CYAN = "\033[96m"
|
|
12
|
+
GREEN = "\033[92m"
|
|
13
|
+
YELLOW = "\033[93m"
|
|
14
|
+
RED = "\033[91m"
|
|
15
|
+
DIM = "\033[2m"
|
|
16
|
+
RESET = "\033[0m"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SLIPSTREAM_URL = "https://github.com/AliRezaBeigy/slipstream-rust-deploy/releases/latest/download/slipstream-client-windows-amd64.exe"
|
|
20
|
+
TUN2PROXY_URL = "https://github.com/tun2proxy/tun2proxy/releases/download/v0.7.19/tun2proxy-x86_64-pc-windows-msvc.zip"
|
|
21
|
+
WINTUN_URL = "https://www.wintun.net/builds/wintun-0.14.1.zip"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_bin_dir() -> Path:
|
|
25
|
+
bin_dir = get_config_dir() / "bin"
|
|
26
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return bin_dir
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_client_path() -> Path:
|
|
31
|
+
return get_bin_dir() / "slipstream-client.exe"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_tun2proxy_path() -> Path:
|
|
35
|
+
return get_bin_dir() / "tun2proxy.exe"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_wintun_path() -> Path:
|
|
39
|
+
return get_bin_dir() / "wintun.dll"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def client_exists() -> bool:
|
|
43
|
+
return get_client_path().exists()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tun2proxy_exists() -> bool:
|
|
47
|
+
return get_tun2proxy_path().exists() and get_wintun_path().exists()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def download_file(url: str, dest_path: Path, name: str) -> bool:
|
|
51
|
+
print(f"\n{CYAN}╔═══════════════════════════════════════════════════════╗{RESET}")
|
|
52
|
+
print(f"{CYAN}║{RESET} {YELLOW}⟳{RESET} Downloading {name}...{' ' * (37 - len(name))}{CYAN}║{RESET}")
|
|
53
|
+
print(f"{CYAN}╚═══════════════════════════════════════════════════════╝{RESET}")
|
|
54
|
+
print(f"\n{DIM} URL: {url[:50]}...{RESET}")
|
|
55
|
+
print(f"{DIM} Destination: {dest_path}{RESET}\n")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
response = requests.get(url, stream=True, timeout=120)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
|
|
61
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
62
|
+
downloaded = 0
|
|
63
|
+
|
|
64
|
+
with open(dest_path, "wb") as f:
|
|
65
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
66
|
+
if chunk:
|
|
67
|
+
f.write(chunk)
|
|
68
|
+
downloaded += len(chunk)
|
|
69
|
+
if total_size > 0:
|
|
70
|
+
percent = (downloaded / total_size) * 100
|
|
71
|
+
bar_length = 40
|
|
72
|
+
filled = int(bar_length * downloaded / total_size)
|
|
73
|
+
bar = f"{GREEN}{'█' * filled}{DIM}{'░' * (bar_length - filled)}{RESET}"
|
|
74
|
+
size_mb = downloaded / (1024 * 1024)
|
|
75
|
+
total_mb = total_size / (1024 * 1024)
|
|
76
|
+
print(f"\r {bar} {percent:5.1f}% ({size_mb:.1f}/{total_mb:.1f} MB)", end="", flush=True)
|
|
77
|
+
|
|
78
|
+
print(f"\n\n{GREEN}✓{RESET} Download complete!")
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
except requests.RequestException as e:
|
|
82
|
+
print(f"\n\n{RED}✗{RESET} Download failed: {e}")
|
|
83
|
+
if dest_path.exists():
|
|
84
|
+
dest_path.unlink()
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def download_client(force: bool = False) -> bool:
|
|
89
|
+
client_path = get_client_path()
|
|
90
|
+
|
|
91
|
+
if client_path.exists() and not force:
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
return download_file(SLIPSTREAM_URL, client_path, "slipstream-client")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def download_tun2proxy(force: bool = False) -> bool:
|
|
98
|
+
tun2proxy_path = get_tun2proxy_path()
|
|
99
|
+
wintun_path = get_wintun_path()
|
|
100
|
+
bin_dir = get_bin_dir()
|
|
101
|
+
|
|
102
|
+
if tun2proxy_path.exists() and wintun_path.exists() and not force:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
if not tun2proxy_path.exists() or force:
|
|
106
|
+
print(f"\n{CYAN}╔═══════════════════════════════════════════════════════╗{RESET}")
|
|
107
|
+
print(f"{CYAN}║{RESET} {YELLOW}⟳{RESET} Downloading tun2proxy... {CYAN}║{RESET}")
|
|
108
|
+
print(f"{CYAN}╚═══════════════════════════════════════════════════════╝{RESET}")
|
|
109
|
+
print(f"\n{DIM} URL: {TUN2PROXY_URL[:50]}...{RESET}\n")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
113
|
+
tmp_path = Path(tmp.name)
|
|
114
|
+
|
|
115
|
+
response = requests.get(TUN2PROXY_URL, stream=True, timeout=120)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
|
|
118
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
119
|
+
downloaded = 0
|
|
120
|
+
|
|
121
|
+
with open(tmp_path, "wb") as f:
|
|
122
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
123
|
+
if chunk:
|
|
124
|
+
f.write(chunk)
|
|
125
|
+
downloaded += len(chunk)
|
|
126
|
+
if total_size > 0:
|
|
127
|
+
percent = (downloaded / total_size) * 100
|
|
128
|
+
bar_length = 40
|
|
129
|
+
filled = int(bar_length * downloaded / total_size)
|
|
130
|
+
bar = f"{GREEN}{'█' * filled}{DIM}{'░' * (bar_length - filled)}{RESET}"
|
|
131
|
+
size_mb = downloaded / (1024 * 1024)
|
|
132
|
+
total_mb = total_size / (1024 * 1024)
|
|
133
|
+
print(f"\r {bar} {percent:5.1f}% ({size_mb:.1f}/{total_mb:.1f} MB)", end="", flush=True)
|
|
134
|
+
|
|
135
|
+
print(f"\n\n{YELLOW}⟳{RESET} Extracting tun2proxy...")
|
|
136
|
+
|
|
137
|
+
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
|
|
138
|
+
for file_info in zip_ref.namelist():
|
|
139
|
+
if file_info.endswith("tun2proxy-bin.exe") or file_info.endswith("tun2proxy.exe"):
|
|
140
|
+
with zip_ref.open(file_info) as src:
|
|
141
|
+
with open(tun2proxy_path, "wb") as dst:
|
|
142
|
+
dst.write(src.read())
|
|
143
|
+
break
|
|
144
|
+
elif file_info.endswith(".exe") and "tun2proxy" in file_info:
|
|
145
|
+
with zip_ref.open(file_info) as src:
|
|
146
|
+
with open(tun2proxy_path, "wb") as dst:
|
|
147
|
+
dst.write(src.read())
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
tmp_path.unlink()
|
|
151
|
+
|
|
152
|
+
if not tun2proxy_path.exists():
|
|
153
|
+
print(f"{RED}✗{RESET} Failed to extract tun2proxy.exe from archive")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
print(f"{GREEN}✓{RESET} tun2proxy extracted!")
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
print(f"\n{RED}✗{RESET} Failed to download tun2proxy: {e}")
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
if not wintun_path.exists() or force:
|
|
163
|
+
print(f"\n{YELLOW}⟳{RESET} Downloading wintun driver...")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
167
|
+
tmp_path = Path(tmp.name)
|
|
168
|
+
|
|
169
|
+
response = requests.get(WINTUN_URL, timeout=120)
|
|
170
|
+
response.raise_for_status()
|
|
171
|
+
|
|
172
|
+
with open(tmp_path, "wb") as f:
|
|
173
|
+
f.write(response.content)
|
|
174
|
+
|
|
175
|
+
print(f"{YELLOW}⟳{RESET} Extracting wintun.dll...")
|
|
176
|
+
|
|
177
|
+
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
|
|
178
|
+
for file_info in zip_ref.namelist():
|
|
179
|
+
if file_info.endswith("amd64/wintun.dll"):
|
|
180
|
+
with zip_ref.open(file_info) as src:
|
|
181
|
+
with open(wintun_path, "wb") as dst:
|
|
182
|
+
dst.write(src.read())
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
tmp_path.unlink()
|
|
186
|
+
|
|
187
|
+
if not wintun_path.exists():
|
|
188
|
+
print(f"{RED}✗{RESET} Failed to extract wintun.dll from archive")
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
print(f"{GREEN}✓{RESET} Wintun driver extracted!")
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
print(f"{RED}✗{RESET} Failed to download wintun: {e}")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
return True
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: localstream
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python CLI client for slipstream-rust DNS tunnel
|
|
5
|
+
Author: LocalStream Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dns,tunnel,vpn,slipstream,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: requests>=2.31.0
|
|
18
|
+
Requires-Dist: colorama>=0.4.6
|
|
19
|
+
|
|
20
|
+
# LocalStream
|
|
21
|
+
|
|
22
|
+
A Python command-line client for [slipstream-rust](https://github.com/Mygod/slipstream-rust) DNS tunnel.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **VPN Mode**: System-wide traffic tunneling (requires Admin)
|
|
27
|
+
- **PROXY Mode**: SOCKS5 proxy on local port
|
|
28
|
+
- Auto-reconnect on connection drops
|
|
29
|
+
- Easy pip installation
|
|
30
|
+
- Colorful CLI interface
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Python 3.13+
|
|
35
|
+
- Windows OS
|
|
36
|
+
- Administrator privileges (for VPN Mode)
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
LocalStream # Normal user (PROXY mode only)
|
|
48
|
+
# Run as Administrator for VPN mode
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Menu Options
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
[1] Connect to server
|
|
55
|
+
├── [1] VPN Mode (system-wide, requires Admin)
|
|
56
|
+
├── [2] PROXY Mode (SOCKS5 only)
|
|
57
|
+
└── [3] Back
|
|
58
|
+
[2] Edit configuration
|
|
59
|
+
[3] Exit
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Keyboard Shortcuts
|
|
63
|
+
|
|
64
|
+
- **Ctrl+C**: Disconnect and exit
|
|
65
|
+
- **Ctrl+D**: Restart connection
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Config stored in `~/.localstream/config.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"server_ip": "203.0.113.2",
|
|
74
|
+
"server_port": 53,
|
|
75
|
+
"local_port": 5201,
|
|
76
|
+
"domain": "s.example.com"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Downloaded Binaries
|
|
81
|
+
|
|
82
|
+
Stored in `~/.localstream/bin/`:
|
|
83
|
+
- `slipstream-client.exe` - DNS tunnel client
|
|
84
|
+
- `tun2proxy.exe` - TUN adapter for VPN mode
|
|
85
|
+
- `wintun.dll` - Windows TUN driver
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
localstream/__init__.py
|
|
4
|
+
localstream/__main__.py
|
|
5
|
+
localstream/cli.py
|
|
6
|
+
localstream/config.py
|
|
7
|
+
localstream/connection.py
|
|
8
|
+
localstream/downloader.py
|
|
9
|
+
localstream.egg-info/PKG-INFO
|
|
10
|
+
localstream.egg-info/SOURCES.txt
|
|
11
|
+
localstream.egg-info/dependency_links.txt
|
|
12
|
+
localstream.egg-info/entry_points.txt
|
|
13
|
+
localstream.egg-info/requires.txt
|
|
14
|
+
localstream.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
localstream
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "localstream"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python CLI client for slipstream-rust DNS tunnel"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "LocalStream Team"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["dns", "tunnel", "vpn", "slipstream", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Internet :: Proxy Servers",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"requests>=2.31.0",
|
|
27
|
+
"colorama>=0.4.6",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
LocalStream = "localstream.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["localstream*"]
|