frp-tunnel 1.0.4__py3-none-any.whl
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.
- frp_tunnel/__init__.py +9 -0
- frp_tunnel/_version.py +1 -0
- frp_tunnel/cli.py +172 -0
- frp_tunnel/core/__init__.py +16 -0
- frp_tunnel/core/config.py +98 -0
- frp_tunnel/core/installer.py +118 -0
- frp_tunnel/core/platform.py +92 -0
- frp_tunnel/core/tunnel.py +208 -0
- frp_tunnel-1.0.4.dist-info/METADATA +170 -0
- frp_tunnel-1.0.4.dist-info/RECORD +14 -0
- frp_tunnel-1.0.4.dist-info/WHEEL +5 -0
- frp_tunnel-1.0.4.dist-info/entry_points.txt +3 -0
- frp_tunnel-1.0.4.dist-info/licenses/LICENSE +21 -0
- frp_tunnel-1.0.4.dist-info/top_level.txt +1 -0
frp_tunnel/__init__.py
ADDED
frp_tunnel/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "1.0.4"
|
frp_tunnel/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
FRP Tunnel CLI - Easy SSH tunneling with FRP
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from .core.installer import install_binaries
|
|
11
|
+
from .core.tunnel import TunnelManager
|
|
12
|
+
from .core.platform import detect_platform, is_colab
|
|
13
|
+
from .core.config import ConfigManager
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
tunnel_manager = TunnelManager()
|
|
17
|
+
config_manager = ConfigManager()
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
@click.version_option()
|
|
21
|
+
def cli():
|
|
22
|
+
"""🚀 FRP Tunnel - Easy SSH tunneling with FRP"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@cli.command()
|
|
26
|
+
@click.option('--mode', type=click.Choice(['server', 'client', 'colab', 'auto']), default='auto', help='Setup mode')
|
|
27
|
+
@click.option('--server', help='Server address (for client mode)')
|
|
28
|
+
@click.option('--token', help='Authentication token')
|
|
29
|
+
@click.option('--port', default=6001, help='Remote port')
|
|
30
|
+
@click.option('--user', default='colab', help='SSH username')
|
|
31
|
+
def setup(mode, server, token, port, user):
|
|
32
|
+
"""Interactive setup wizard"""
|
|
33
|
+
console.print(Panel.fit("🚀 FRP Tunnel Setup", style="bold blue"))
|
|
34
|
+
|
|
35
|
+
# Auto-detect mode if not specified
|
|
36
|
+
if mode == 'auto':
|
|
37
|
+
if is_colab():
|
|
38
|
+
mode = 'colab'
|
|
39
|
+
console.print("🔬 Detected Google Colab environment")
|
|
40
|
+
else:
|
|
41
|
+
mode = click.prompt('Setup mode', type=click.Choice(['server', 'client']))
|
|
42
|
+
|
|
43
|
+
if mode == 'server':
|
|
44
|
+
setup_server()
|
|
45
|
+
elif mode in ['client', 'colab']:
|
|
46
|
+
setup_client(mode, server, token, port, user)
|
|
47
|
+
|
|
48
|
+
def setup_server():
|
|
49
|
+
"""Setup FRP server"""
|
|
50
|
+
console.print("🖥️ Setting up FRP server...")
|
|
51
|
+
|
|
52
|
+
port = click.prompt('Server port', default=7000, type=int)
|
|
53
|
+
token = click.prompt('Authentication token (empty to generate)', default='', show_default=False)
|
|
54
|
+
|
|
55
|
+
if not token:
|
|
56
|
+
import secrets
|
|
57
|
+
token = f"frp_{secrets.token_hex(16)}"
|
|
58
|
+
console.print(f"🔑 Generated token: [bold yellow]{token}[/bold yellow]")
|
|
59
|
+
|
|
60
|
+
# Install server binary
|
|
61
|
+
with console.status("📦 Installing FRP server..."):
|
|
62
|
+
install_binaries('server')
|
|
63
|
+
|
|
64
|
+
# Create configuration
|
|
65
|
+
config = {
|
|
66
|
+
'bind_port': port,
|
|
67
|
+
'token': token
|
|
68
|
+
}
|
|
69
|
+
config_manager.create_server_config(config)
|
|
70
|
+
|
|
71
|
+
# Start server
|
|
72
|
+
if click.confirm('Start server now?', default=True):
|
|
73
|
+
tunnel_manager.start_server(config)
|
|
74
|
+
console.print("✅ Server started successfully!")
|
|
75
|
+
console.print(f"🔑 Share this token with clients: [bold]{token}[/bold]")
|
|
76
|
+
|
|
77
|
+
def setup_client(mode, server, token, port, user):
|
|
78
|
+
"""Setup FRP client"""
|
|
79
|
+
console.print(f"📱 Setting up FRP client ({mode})...")
|
|
80
|
+
|
|
81
|
+
# Get configuration
|
|
82
|
+
if not server:
|
|
83
|
+
server = click.prompt('Server address')
|
|
84
|
+
if not token:
|
|
85
|
+
token = click.prompt('Authentication token')
|
|
86
|
+
|
|
87
|
+
# Install client binary
|
|
88
|
+
with console.status("📦 Installing FRP client..."):
|
|
89
|
+
install_binaries('client')
|
|
90
|
+
|
|
91
|
+
# Create configuration
|
|
92
|
+
config = {
|
|
93
|
+
'server_addr': server,
|
|
94
|
+
'server_port': 7000,
|
|
95
|
+
'token': token,
|
|
96
|
+
'remote_port': port,
|
|
97
|
+
'username': user
|
|
98
|
+
}
|
|
99
|
+
config_manager.create_client_config(config)
|
|
100
|
+
|
|
101
|
+
# Setup SSH for Colab
|
|
102
|
+
if mode == 'colab':
|
|
103
|
+
with console.status("🔧 Setting up SSH..."):
|
|
104
|
+
tunnel_manager.setup_colab_ssh(user)
|
|
105
|
+
|
|
106
|
+
# Start client
|
|
107
|
+
tunnel_manager.start_client(config)
|
|
108
|
+
console.print("✅ Client connected successfully!")
|
|
109
|
+
console.print(f"🔗 SSH command: [bold]ssh -p {port} {user}@{server}[/bold]")
|
|
110
|
+
|
|
111
|
+
@cli.command()
|
|
112
|
+
@click.option('--server', required=True, help='Server address')
|
|
113
|
+
@click.option('--token', required=True, help='Authentication token')
|
|
114
|
+
@click.option('--port', default=6001, help='Remote port')
|
|
115
|
+
@click.option('--user', default='colab', help='SSH username')
|
|
116
|
+
def colab(server, token, port, user):
|
|
117
|
+
"""Quick setup for Google Colab"""
|
|
118
|
+
console.print("🔬 Setting up Google Colab tunnel...")
|
|
119
|
+
setup_client('colab', server, token, port, user)
|
|
120
|
+
|
|
121
|
+
@cli.command()
|
|
122
|
+
def status():
|
|
123
|
+
"""Show tunnel status"""
|
|
124
|
+
console.print("📊 Tunnel Status")
|
|
125
|
+
status_info = tunnel_manager.get_status()
|
|
126
|
+
|
|
127
|
+
if status_info['server_running']:
|
|
128
|
+
console.print("🖥️ Server: [green]Running[/green]")
|
|
129
|
+
else:
|
|
130
|
+
console.print("🖥️ Server: [red]Stopped[/red]")
|
|
131
|
+
|
|
132
|
+
if status_info['client_running']:
|
|
133
|
+
console.print("📱 Client: [green]Connected[/green]")
|
|
134
|
+
else:
|
|
135
|
+
console.print("📱 Client: [red]Disconnected[/red]")
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
def stop():
|
|
139
|
+
"""Stop all tunnels"""
|
|
140
|
+
console.print("🛑 Stopping tunnels...")
|
|
141
|
+
tunnel_manager.stop_all()
|
|
142
|
+
console.print("✅ All tunnels stopped")
|
|
143
|
+
|
|
144
|
+
@cli.command()
|
|
145
|
+
def logs():
|
|
146
|
+
"""View tunnel logs"""
|
|
147
|
+
console.print("📝 Recent logs:")
|
|
148
|
+
logs = tunnel_manager.get_logs()
|
|
149
|
+
for log_line in logs:
|
|
150
|
+
console.print(log_line)
|
|
151
|
+
|
|
152
|
+
@cli.command()
|
|
153
|
+
def install():
|
|
154
|
+
"""Install/update FRP binaries"""
|
|
155
|
+
console.print("📦 Installing FRP binaries...")
|
|
156
|
+
with console.status("Downloading..."):
|
|
157
|
+
install_binaries()
|
|
158
|
+
console.print("✅ Installation complete")
|
|
159
|
+
|
|
160
|
+
@cli.command()
|
|
161
|
+
def clean():
|
|
162
|
+
"""Clean cache and temporary files"""
|
|
163
|
+
console.print("🧹 Cleaning cache...")
|
|
164
|
+
tunnel_manager.clean_cache()
|
|
165
|
+
console.print("✅ Cache cleaned")
|
|
166
|
+
|
|
167
|
+
def main():
|
|
168
|
+
"""Entry point for the CLI"""
|
|
169
|
+
cli()
|
|
170
|
+
|
|
171
|
+
if __name__ == '__main__':
|
|
172
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Core module initialization"""
|
|
2
|
+
|
|
3
|
+
from .platform import detect_platform, is_colab
|
|
4
|
+
from .installer import install_binaries, get_binary_path, is_installed
|
|
5
|
+
from .config import ConfigManager
|
|
6
|
+
from .tunnel import TunnelManager
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'detect_platform',
|
|
10
|
+
'is_colab',
|
|
11
|
+
'install_binaries',
|
|
12
|
+
'get_binary_path',
|
|
13
|
+
'is_installed',
|
|
14
|
+
'ConfigManager',
|
|
15
|
+
'TunnelManager'
|
|
16
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Configuration management"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
import configparser
|
|
8
|
+
|
|
9
|
+
class ConfigManager:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.config_dir = Path.home() / '.frp-tunnel'
|
|
12
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
13
|
+
|
|
14
|
+
self.server_config_path = self.config_dir / 'frps.ini'
|
|
15
|
+
self.client_config_path = self.config_dir / 'frpc.ini'
|
|
16
|
+
|
|
17
|
+
def create_server_config(self, config: Dict[str, Any]) -> Path:
|
|
18
|
+
"""Create server configuration file"""
|
|
19
|
+
config_content = f"""[common]
|
|
20
|
+
bind_port = {config.get('bind_port', 7000)}
|
|
21
|
+
token = {config.get('token', self._generate_token())}
|
|
22
|
+
|
|
23
|
+
# Dashboard (optional)
|
|
24
|
+
dashboard_port = 7500
|
|
25
|
+
dashboard_user = admin
|
|
26
|
+
dashboard_pwd = admin
|
|
27
|
+
|
|
28
|
+
# Logging
|
|
29
|
+
log_file = {self.config_dir}/frps.log
|
|
30
|
+
log_level = info
|
|
31
|
+
log_max_days = 3
|
|
32
|
+
|
|
33
|
+
# Security
|
|
34
|
+
authentication_method = token
|
|
35
|
+
heartbeat_timeout = 90
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
with open(self.server_config_path, 'w') as f:
|
|
39
|
+
f.write(config_content)
|
|
40
|
+
|
|
41
|
+
return self.server_config_path
|
|
42
|
+
|
|
43
|
+
def create_client_config(self, config: Dict[str, Any]) -> Path:
|
|
44
|
+
"""Create client configuration file"""
|
|
45
|
+
config_content = f"""[common]
|
|
46
|
+
server_addr = {config['server_addr']}
|
|
47
|
+
server_port = {config.get('server_port', 7000)}
|
|
48
|
+
token = {config['token']}
|
|
49
|
+
|
|
50
|
+
# Logging
|
|
51
|
+
log_file = {self.config_dir}/frpc.log
|
|
52
|
+
log_level = info
|
|
53
|
+
|
|
54
|
+
[ssh_{config.get('username', 'colab')}]
|
|
55
|
+
type = tcp
|
|
56
|
+
local_ip = 127.0.0.1
|
|
57
|
+
local_port = 22
|
|
58
|
+
remote_port = {config.get('remote_port', 6001)}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
with open(self.client_config_path, 'w') as f:
|
|
62
|
+
f.write(config_content)
|
|
63
|
+
|
|
64
|
+
return self.client_config_path
|
|
65
|
+
|
|
66
|
+
def get_server_config(self) -> Dict[str, Any]:
|
|
67
|
+
"""Read server configuration"""
|
|
68
|
+
if not self.server_config_path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
config = configparser.ConfigParser()
|
|
72
|
+
config.read(self.server_config_path)
|
|
73
|
+
|
|
74
|
+
if 'common' not in config:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
return dict(config['common'])
|
|
78
|
+
|
|
79
|
+
def get_client_config(self) -> Dict[str, Any]:
|
|
80
|
+
"""Read client configuration"""
|
|
81
|
+
if not self.client_config_path.exists():
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
config = configparser.ConfigParser()
|
|
85
|
+
config.read(self.client_config_path)
|
|
86
|
+
|
|
87
|
+
if 'common' not in config:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
return dict(config['common'])
|
|
91
|
+
|
|
92
|
+
def _generate_token(self) -> str:
|
|
93
|
+
"""Generate a secure token"""
|
|
94
|
+
return f"frp_{secrets.token_hex(16)}"
|
|
95
|
+
|
|
96
|
+
def get_log_path(self, component: str) -> Path:
|
|
97
|
+
"""Get log file path"""
|
|
98
|
+
return self.config_dir / f"frp{component[0]}.log"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Binary installer for FRP"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .platform import get_frp_binary_url, get_binary_names, detect_platform
|
|
11
|
+
|
|
12
|
+
class BinaryInstaller:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.platform_info = detect_platform()
|
|
15
|
+
self.binary_names = get_binary_names()
|
|
16
|
+
self.install_dir = Path.home() / '.frp-tunnel' / 'bin'
|
|
17
|
+
self.install_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
def install_binaries(self, component: Optional[str] = None) -> bool:
|
|
20
|
+
"""Install FRP binaries
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
component: 'server', 'client', or None for both
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
# Download and extract
|
|
27
|
+
binary_path = self._download_and_extract()
|
|
28
|
+
|
|
29
|
+
# Copy required binaries
|
|
30
|
+
if component == 'server' or component is None:
|
|
31
|
+
self._copy_binary(binary_path, 'server')
|
|
32
|
+
|
|
33
|
+
if component == 'client' or component is None:
|
|
34
|
+
self._copy_binary(binary_path, 'client')
|
|
35
|
+
|
|
36
|
+
# Make binaries executable
|
|
37
|
+
self._make_executable()
|
|
38
|
+
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(f"Error installing binaries: {e}")
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
def _download_and_extract(self) -> Path:
|
|
46
|
+
"""Download and extract FRP archive"""
|
|
47
|
+
url = get_frp_binary_url()
|
|
48
|
+
|
|
49
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
50
|
+
temp_path = Path(temp_dir)
|
|
51
|
+
archive_path = temp_path / 'frp.tar.gz'
|
|
52
|
+
|
|
53
|
+
# Download
|
|
54
|
+
urllib.request.urlretrieve(url, archive_path)
|
|
55
|
+
|
|
56
|
+
# Extract
|
|
57
|
+
with tarfile.open(archive_path, 'r:gz') as tar:
|
|
58
|
+
tar.extractall(temp_path)
|
|
59
|
+
|
|
60
|
+
# Find extracted directory
|
|
61
|
+
extracted_dirs = [d for d in temp_path.iterdir() if d.is_dir()]
|
|
62
|
+
if not extracted_dirs:
|
|
63
|
+
raise Exception("No directory found in archive")
|
|
64
|
+
|
|
65
|
+
binary_path = extracted_dirs[0]
|
|
66
|
+
|
|
67
|
+
# Copy to permanent location
|
|
68
|
+
import shutil
|
|
69
|
+
permanent_path = self.install_dir / 'extracted'
|
|
70
|
+
if permanent_path.exists():
|
|
71
|
+
shutil.rmtree(permanent_path)
|
|
72
|
+
shutil.copytree(binary_path, permanent_path)
|
|
73
|
+
|
|
74
|
+
return permanent_path
|
|
75
|
+
|
|
76
|
+
def _copy_binary(self, source_dir: Path, component: str):
|
|
77
|
+
"""Copy binary to install directory"""
|
|
78
|
+
binary_name = self.binary_names[component]
|
|
79
|
+
source_file = source_dir / binary_name
|
|
80
|
+
dest_file = self.install_dir / binary_name
|
|
81
|
+
|
|
82
|
+
if not source_file.exists():
|
|
83
|
+
raise Exception(f"Binary {binary_name} not found in archive")
|
|
84
|
+
|
|
85
|
+
import shutil
|
|
86
|
+
shutil.copy2(source_file, dest_file)
|
|
87
|
+
|
|
88
|
+
def _make_executable(self):
|
|
89
|
+
"""Make binaries executable on Unix systems"""
|
|
90
|
+
if self.platform_info['os'] != 'windows':
|
|
91
|
+
for binary_name in self.binary_names.values():
|
|
92
|
+
binary_path = self.install_dir / binary_name
|
|
93
|
+
if binary_path.exists():
|
|
94
|
+
os.chmod(binary_path, 0o755)
|
|
95
|
+
|
|
96
|
+
def get_binary_path(self, component: str) -> Path:
|
|
97
|
+
"""Get path to installed binary"""
|
|
98
|
+
binary_name = self.binary_names[component]
|
|
99
|
+
return self.install_dir / binary_name
|
|
100
|
+
|
|
101
|
+
def is_installed(self, component: str) -> bool:
|
|
102
|
+
"""Check if binary is installed"""
|
|
103
|
+
return self.get_binary_path(component).exists()
|
|
104
|
+
|
|
105
|
+
# Global installer instance
|
|
106
|
+
_installer = BinaryInstaller()
|
|
107
|
+
|
|
108
|
+
def install_binaries(component: Optional[str] = None) -> bool:
|
|
109
|
+
"""Install FRP binaries"""
|
|
110
|
+
return _installer.install_binaries(component)
|
|
111
|
+
|
|
112
|
+
def get_binary_path(component: str) -> Path:
|
|
113
|
+
"""Get path to binary"""
|
|
114
|
+
return _installer.get_binary_path(component)
|
|
115
|
+
|
|
116
|
+
def is_installed(component: str) -> bool:
|
|
117
|
+
"""Check if binary is installed"""
|
|
118
|
+
return _installer.is_installed(component)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Platform detection and utilities"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
def detect_platform() -> Dict[str, str]:
|
|
9
|
+
"""Detect current platform and architecture"""
|
|
10
|
+
system = platform.system().lower()
|
|
11
|
+
machine = platform.machine().lower()
|
|
12
|
+
|
|
13
|
+
# Normalize architecture names
|
|
14
|
+
arch_map = {
|
|
15
|
+
'x86_64': 'amd64',
|
|
16
|
+
'amd64': 'amd64',
|
|
17
|
+
'aarch64': 'arm64',
|
|
18
|
+
'arm64': 'arm64',
|
|
19
|
+
'armv7l': 'arm',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
arch = arch_map.get(machine, machine)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
'os': system,
|
|
26
|
+
'arch': arch,
|
|
27
|
+
'platform': f"{system}-{arch}"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def is_colab() -> bool:
|
|
31
|
+
"""Check if running in Google Colab"""
|
|
32
|
+
return 'COLAB_GPU' in os.environ or os.path.exists('/content')
|
|
33
|
+
|
|
34
|
+
def is_docker() -> bool:
|
|
35
|
+
"""Check if running in Docker"""
|
|
36
|
+
return os.path.exists('/.dockerenv')
|
|
37
|
+
|
|
38
|
+
def check_requirements() -> Dict[str, bool]:
|
|
39
|
+
"""Check if required tools are available"""
|
|
40
|
+
requirements = {
|
|
41
|
+
'ssh': _command_exists('ssh'),
|
|
42
|
+
'wget': _command_exists('wget') or _command_exists('curl'),
|
|
43
|
+
'tar': _command_exists('tar'),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Additional checks for different platforms
|
|
47
|
+
if platform.system().lower() == 'linux':
|
|
48
|
+
requirements['systemctl'] = _command_exists('systemctl')
|
|
49
|
+
|
|
50
|
+
return requirements
|
|
51
|
+
|
|
52
|
+
def _command_exists(command: str) -> bool:
|
|
53
|
+
"""Check if a command exists in PATH"""
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(['which', command],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
check=True)
|
|
58
|
+
return True
|
|
59
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def get_frp_binary_url(version: str = "0.52.3") -> str:
|
|
63
|
+
"""Get FRP binary download URL for current platform"""
|
|
64
|
+
platform_info = detect_platform()
|
|
65
|
+
|
|
66
|
+
# Map platform names to FRP naming convention
|
|
67
|
+
os_map = {
|
|
68
|
+
'linux': 'linux',
|
|
69
|
+
'darwin': 'darwin',
|
|
70
|
+
'windows': 'windows'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
os_name = os_map.get(platform_info['os'], platform_info['os'])
|
|
74
|
+
arch = platform_info['arch']
|
|
75
|
+
|
|
76
|
+
filename = f"frp_{version}_{os_name}_{arch}.tar.gz"
|
|
77
|
+
url = f"https://github.com/fatedier/frp/releases/download/v{version}/{filename}"
|
|
78
|
+
|
|
79
|
+
return url
|
|
80
|
+
|
|
81
|
+
def get_binary_names() -> Dict[str, str]:
|
|
82
|
+
"""Get binary names for current platform"""
|
|
83
|
+
if platform.system().lower() == 'windows':
|
|
84
|
+
return {
|
|
85
|
+
'server': 'frps.exe',
|
|
86
|
+
'client': 'frpc.exe'
|
|
87
|
+
}
|
|
88
|
+
else:
|
|
89
|
+
return {
|
|
90
|
+
'server': 'frps',
|
|
91
|
+
'client': 'frpc'
|
|
92
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tunnel management"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import signal
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .installer import get_binary_path, is_installed, install_binaries
|
|
11
|
+
from .config import ConfigManager
|
|
12
|
+
from .platform import is_colab
|
|
13
|
+
|
|
14
|
+
class TunnelManager:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.config_manager = ConfigManager()
|
|
17
|
+
self.pid_dir = Path.home() / '.frp-tunnel' / 'pids'
|
|
18
|
+
self.pid_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
def start_server(self, config: Dict) -> bool:
|
|
21
|
+
"""Start FRP server"""
|
|
22
|
+
if not is_installed('server'):
|
|
23
|
+
install_binaries('server')
|
|
24
|
+
|
|
25
|
+
binary_path = get_binary_path('server')
|
|
26
|
+
config_path = self.config_manager.create_server_config(config)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
# Start server process
|
|
30
|
+
process = subprocess.Popen([
|
|
31
|
+
str(binary_path),
|
|
32
|
+
'-c', str(config_path)
|
|
33
|
+
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
34
|
+
|
|
35
|
+
# Save PID
|
|
36
|
+
pid_file = self.pid_dir / 'frps.pid'
|
|
37
|
+
with open(pid_file, 'w') as f:
|
|
38
|
+
f.write(str(process.pid))
|
|
39
|
+
|
|
40
|
+
# Wait a moment to check if it started successfully
|
|
41
|
+
time.sleep(2)
|
|
42
|
+
if process.poll() is None:
|
|
43
|
+
return True
|
|
44
|
+
else:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Error starting server: {e}")
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def start_client(self, config: Dict) -> bool:
|
|
52
|
+
"""Start FRP client"""
|
|
53
|
+
if not is_installed('client'):
|
|
54
|
+
install_binaries('client')
|
|
55
|
+
|
|
56
|
+
binary_path = get_binary_path('client')
|
|
57
|
+
config_path = self.config_manager.create_client_config(config)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Start client process
|
|
61
|
+
process = subprocess.Popen([
|
|
62
|
+
str(binary_path),
|
|
63
|
+
'-c', str(config_path)
|
|
64
|
+
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
65
|
+
|
|
66
|
+
# Save PID
|
|
67
|
+
pid_file = self.pid_dir / 'frpc.pid'
|
|
68
|
+
with open(pid_file, 'w') as f:
|
|
69
|
+
f.write(str(process.pid))
|
|
70
|
+
|
|
71
|
+
# Wait a moment to check if it started successfully
|
|
72
|
+
time.sleep(2)
|
|
73
|
+
if process.poll() is None:
|
|
74
|
+
return True
|
|
75
|
+
else:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
print(f"Error starting client: {e}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def setup_colab_ssh(self, username: str = 'colab'):
|
|
83
|
+
"""Setup SSH for Google Colab environment"""
|
|
84
|
+
try:
|
|
85
|
+
# Generate SSH key if not exists
|
|
86
|
+
subprocess.run([
|
|
87
|
+
'bash', '-c',
|
|
88
|
+
'test -f ~/.ssh/id_rsa || ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N ""'
|
|
89
|
+
], check=True)
|
|
90
|
+
|
|
91
|
+
# Create user if not exists
|
|
92
|
+
subprocess.run([
|
|
93
|
+
'bash', '-c',
|
|
94
|
+
f'id {username} || sudo useradd -m -s /bin/bash {username}'
|
|
95
|
+
], check=False) # Don't fail if user exists
|
|
96
|
+
|
|
97
|
+
# Setup SSH key
|
|
98
|
+
subprocess.run([
|
|
99
|
+
'bash', '-c',
|
|
100
|
+
f'''
|
|
101
|
+
sudo mkdir -p /home/{username}/.ssh
|
|
102
|
+
sudo cp ~/.ssh/id_rsa.pub /home/{username}/.ssh/authorized_keys
|
|
103
|
+
sudo chown -R {username}:{username} /home/{username}/.ssh
|
|
104
|
+
sudo chmod 700 /home/{username}/.ssh
|
|
105
|
+
sudo chmod 600 /home/{username}/.ssh/authorized_keys
|
|
106
|
+
'''
|
|
107
|
+
], check=False)
|
|
108
|
+
|
|
109
|
+
# Start SSH service
|
|
110
|
+
subprocess.run([
|
|
111
|
+
'bash', '-c',
|
|
112
|
+
'sudo systemctl start ssh || sudo service ssh start'
|
|
113
|
+
], check=False)
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(f"Warning: Some SSH setup steps failed: {e}")
|
|
117
|
+
|
|
118
|
+
def stop_process(self, component: str) -> bool:
|
|
119
|
+
"""Stop FRP process"""
|
|
120
|
+
pid_file = self.pid_dir / f'frp{component[0]}.pid'
|
|
121
|
+
|
|
122
|
+
if not pid_file.exists():
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with open(pid_file, 'r') as f:
|
|
127
|
+
pid = int(f.read().strip())
|
|
128
|
+
|
|
129
|
+
# Try to terminate gracefully
|
|
130
|
+
os.kill(pid, signal.SIGTERM)
|
|
131
|
+
time.sleep(2)
|
|
132
|
+
|
|
133
|
+
# Force kill if still running
|
|
134
|
+
try:
|
|
135
|
+
os.kill(pid, signal.SIGKILL)
|
|
136
|
+
except ProcessLookupError:
|
|
137
|
+
pass # Process already dead
|
|
138
|
+
|
|
139
|
+
# Remove PID file
|
|
140
|
+
pid_file.unlink()
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(f"Error stopping {component}: {e}")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def stop_all(self):
|
|
148
|
+
"""Stop all FRP processes"""
|
|
149
|
+
self.stop_process('server')
|
|
150
|
+
self.stop_process('client')
|
|
151
|
+
|
|
152
|
+
def get_status(self) -> Dict[str, bool]:
|
|
153
|
+
"""Get status of FRP processes"""
|
|
154
|
+
return {
|
|
155
|
+
'server_running': self._is_process_running('server'),
|
|
156
|
+
'client_running': self._is_process_running('client')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def _is_process_running(self, component: str) -> bool:
|
|
160
|
+
"""Check if process is running"""
|
|
161
|
+
pid_file = self.pid_dir / f'frp{component[0]}.pid'
|
|
162
|
+
|
|
163
|
+
if not pid_file.exists():
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
with open(pid_file, 'r') as f:
|
|
168
|
+
pid = int(f.read().strip())
|
|
169
|
+
|
|
170
|
+
# Check if process exists
|
|
171
|
+
os.kill(pid, 0)
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
175
|
+
# Remove stale PID file
|
|
176
|
+
if pid_file.exists():
|
|
177
|
+
pid_file.unlink()
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def get_logs(self, lines: int = 20) -> List[str]:
|
|
181
|
+
"""Get recent log lines"""
|
|
182
|
+
logs = []
|
|
183
|
+
|
|
184
|
+
for component in ['server', 'client']:
|
|
185
|
+
log_path = self.config_manager.get_log_path(component)
|
|
186
|
+
if log_path.exists():
|
|
187
|
+
try:
|
|
188
|
+
with open(log_path, 'r') as f:
|
|
189
|
+
log_lines = f.readlines()
|
|
190
|
+
recent_lines = log_lines[-lines:]
|
|
191
|
+
logs.extend([f"[{component}] {line.strip()}" for line in recent_lines])
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
return logs
|
|
196
|
+
|
|
197
|
+
def clean_cache(self):
|
|
198
|
+
"""Clean cache and temporary files"""
|
|
199
|
+
import shutil
|
|
200
|
+
|
|
201
|
+
# Clean extracted binaries
|
|
202
|
+
extracted_path = Path.home() / '.frp-tunnel' / 'bin' / 'extracted'
|
|
203
|
+
if extracted_path.exists():
|
|
204
|
+
shutil.rmtree(extracted_path)
|
|
205
|
+
|
|
206
|
+
# Clean old log files
|
|
207
|
+
for log_file in self.config_manager.config_dir.glob('*.log.*'):
|
|
208
|
+
log_file.unlink()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: frp-tunnel
|
|
3
|
+
Version: 1.0.4
|
|
4
|
+
Summary: Easy SSH tunneling with FRP - One command setup for Google Colab and remote servers
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cicy-dev/frp-tunnel
|
|
8
|
+
Project-URL: Repository, https://github.com/cicy-dev/frp-tunnel.git
|
|
9
|
+
Project-URL: Issues, https://github.com/cicy-dev/frp-tunnel/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/cicy-dev/frp-tunnel/docs
|
|
11
|
+
Keywords: frp,ssh,tunnel,proxy,colab,remote,development
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: click>=8.0.0
|
|
28
|
+
Requires-Dist: rich>=12.0.0
|
|
29
|
+
Requires-Dist: requests>=2.25.0
|
|
30
|
+
Requires-Dist: pyyaml>=5.4.0
|
|
31
|
+
Requires-Dist: psutil>=5.8.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=2.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=21.0; extra == "dev"
|
|
36
|
+
Requires-Dist: flake8>=3.8; extra == "dev"
|
|
37
|
+
Requires-Dist: mypy>=0.800; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# 🚀 FRP Tunnel - SSH Access Made Easy
|
|
41
|
+
|
|
42
|
+
**[中文文档](README_CN.md) | [English](README.md)**
|
|
43
|
+
|
|
44
|
+
[](https://opensource.org/licenses/MIT)
|
|
45
|
+
[](https://github.com/cicy-dev/frp-tunnel)
|
|
46
|
+
|
|
47
|
+
> **Connect to Google Colab or any remote server via SSH in 30 seconds. No complex setup needed!**
|
|
48
|
+
|
|
49
|
+
## 🎯 What This Does
|
|
50
|
+
|
|
51
|
+
- **Problem**: Can't SSH into Google Colab or access remote servers behind firewalls
|
|
52
|
+
- **Solution**: Creates a secure tunnel so you can SSH from anywhere
|
|
53
|
+
- **Result**: Use your favorite tools (VS Code, file transfer, etc.) with remote servers
|
|
54
|
+
|
|
55
|
+
## 🏗️ How It Works
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
59
|
+
│ Local Client │ │ GCP Server │ │ Google Colab │
|
|
60
|
+
│ (Any Platform) │ │ (frps:7000) │ │ (frpc+SSH) │
|
|
61
|
+
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
62
|
+
│ │ │
|
|
63
|
+
│ SSH -p 6001-6010 │ │
|
|
64
|
+
└──────────────────────┼───────────────────────┘
|
|
65
|
+
│
|
|
66
|
+
FRP Tunnel Forwarding
|
|
67
|
+
6001-6010 → Target:22
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## ⚡ Quick Start (3 Steps)
|
|
71
|
+
|
|
72
|
+
### Step 1: Install
|
|
73
|
+
```bash
|
|
74
|
+
pip install frp-tunnel
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Step 2: Set Up Server (One-time)
|
|
78
|
+
```bash
|
|
79
|
+
# On your VPS/cloud server
|
|
80
|
+
frp-tunnel setup
|
|
81
|
+
```
|
|
82
|
+
*Follow the prompts - it takes 30 seconds*
|
|
83
|
+
|
|
84
|
+
### Step 3: Connect from Anywhere
|
|
85
|
+
```bash
|
|
86
|
+
# Google Colab (paste in notebook)
|
|
87
|
+
!pip install frp-tunnel && frp-tunnel colab --server YOUR_SERVER_IP --token YOUR_TOKEN
|
|
88
|
+
|
|
89
|
+
# Your computer
|
|
90
|
+
frp-tunnel client --server YOUR_SERVER_IP --token YOUR_TOKEN
|
|
91
|
+
|
|
92
|
+
# Then SSH normally
|
|
93
|
+
ssh -p 6001 colab@YOUR_SERVER_IP
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 🔧 Real-World Examples
|
|
97
|
+
|
|
98
|
+
### Example 1: Access Google Colab Files
|
|
99
|
+
```python
|
|
100
|
+
# In Colab notebook
|
|
101
|
+
!pip install frp-tunnel && frp-tunnel colab --server 34.123.45.67 --token abc123
|
|
102
|
+
```
|
|
103
|
+
```bash
|
|
104
|
+
# On your computer
|
|
105
|
+
ssh -p 6001 colab@34.123.45.67
|
|
106
|
+
# Now you can browse files, upload/download, use git, etc.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Example 2: VS Code Remote Development
|
|
110
|
+
1. Set up tunnel (steps above)
|
|
111
|
+
2. In VS Code: Install "Remote-SSH" extension
|
|
112
|
+
3. Connect to `colab@YOUR_SERVER_IP:6001`
|
|
113
|
+
4. Code directly in Colab with full VS Code features!
|
|
114
|
+
|
|
115
|
+
### Example 3: Multiple Connections
|
|
116
|
+
```bash
|
|
117
|
+
# Colab 1
|
|
118
|
+
frp-tunnel colab --server YOUR_IP --token YOUR_TOKEN --port 6001
|
|
119
|
+
|
|
120
|
+
# Colab 2
|
|
121
|
+
frp-tunnel colab --server YOUR_IP --token YOUR_TOKEN --port 6002
|
|
122
|
+
|
|
123
|
+
# Your laptop
|
|
124
|
+
frp-tunnel client --server YOUR_IP --token YOUR_TOKEN --port 6003
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## 🛠️ Troubleshooting (Common Issues)
|
|
128
|
+
|
|
129
|
+
### "Connection refused"
|
|
130
|
+
```bash
|
|
131
|
+
# Check if server is running
|
|
132
|
+
ssh YOUR_SERVER_IP "ps aux | grep frps"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### "Permission denied"
|
|
136
|
+
```bash
|
|
137
|
+
# Make sure you're using the right port
|
|
138
|
+
ssh -p 6001 colab@YOUR_SERVER_IP # Not port 22!
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### "Token mismatch"
|
|
142
|
+
```bash
|
|
143
|
+
# Get the token from your server
|
|
144
|
+
ssh YOUR_SERVER_IP "cat ~/data/frp/frps.ini | grep token"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 📋 What You Need
|
|
148
|
+
|
|
149
|
+
- **Server**: Any Linux VPS (Google Cloud, AWS, DigitalOcean, etc.)
|
|
150
|
+
- **Ports**: Open ports 6001-6010 and 7000 on your server
|
|
151
|
+
- **Client**: Any computer with SSH (Windows/Mac/Linux)
|
|
152
|
+
|
|
153
|
+
### Quick Server Setup (GCP/AWS)
|
|
154
|
+
```bash
|
|
155
|
+
# Open firewall ports
|
|
156
|
+
gcloud compute firewall-rules create frp-tunnel --allow tcp:6001-6010,tcp:7000
|
|
157
|
+
|
|
158
|
+
# Or for AWS
|
|
159
|
+
aws ec2 authorize-security-group-ingress --group-id sg-xxxxx --protocol tcp --port 6001-6010 --cidr 0.0.0.0/0
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## 🎉 That's It!
|
|
163
|
+
|
|
164
|
+
No complex configuration files, no networking knowledge needed. Just install, run, and connect!
|
|
165
|
+
|
|
166
|
+
**Need help?** [Open an issue](https://github.com/cicy-dev/frp-tunnel/issues) - we respond quickly!
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
⭐ **Star this repo if it saved you time!**
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
frp_tunnel/__init__.py,sha256=xef6hdo6PQOVvOmCnQqJJ708jGKFZ3wtioy6pQNX3Bk,153
|
|
2
|
+
frp_tunnel/_version.py,sha256=R604FHUDDjGzi5hem3DYdlBvcBVjznL7PmfCQFtRkDI,18
|
|
3
|
+
frp_tunnel/cli.py,sha256=73AgYgzK1A3tKiSS78zs_FjDXlEkZctP8uecmszrCms,5389
|
|
4
|
+
frp_tunnel/core/__init__.py,sha256=4KMZCKElGqspMj3rgyKK04hZJ1f-DCrhk0IzLOGRLfI,384
|
|
5
|
+
frp_tunnel/core/config.py,sha256=25EdLN0lKpZlnPjd3umDtKturpkaIZxFFwlAcORZV1Y,2713
|
|
6
|
+
frp_tunnel/core/installer.py,sha256=53CKUUB0ExCF2h_zBW17QRw0xe8dhY9prQ1S2ZqaD6c,4098
|
|
7
|
+
frp_tunnel/core/platform.py,sha256=1E4knN-lKUp97kArbCr0jumU3l34lzqVTNvciFLvanw,2569
|
|
8
|
+
frp_tunnel/core/tunnel.py,sha256=rZtCFs3KsOZSO1M1Nl2TnTLgHGZOF-T_0iqBHXvecR4,6930
|
|
9
|
+
frp_tunnel-1.0.4.dist-info/licenses/LICENSE,sha256=olOm-Mwd2NnXAzpefs0sowX0uPN7gio-g1KxpEv6Lvs,1079
|
|
10
|
+
frp_tunnel-1.0.4.dist-info/METADATA,sha256=JkH_7T42Z6XWf7nhPTXnSDjFSwTxiMTOerhWkh9RSIY,5787
|
|
11
|
+
frp_tunnel-1.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
+
frp_tunnel-1.0.4.dist-info/entry_points.txt,sha256=zJ-DXLnJyhjkPC-sLMPLgxm53seoVFCqMo7kDXiYHig,77
|
|
13
|
+
frp_tunnel-1.0.4.dist-info/top_level.txt,sha256=KwiOTKTjTUQkJbD0Ae6Ym9HRvNjKwVo0BO4YNQf2Vzc,11
|
|
14
|
+
frp_tunnel-1.0.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FRP SSH Tunnel Project
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
frp_tunnel
|