Skip to content

Commit cb6e1ef

Browse files
committed
feat: one-click public server (Pinggy) & UI polish
Added Pinggy SSH integration. Added region selector. Refined Dashboard/Setup/Library transitions. Fixed logo path issues.
1 parent 5adfe39 commit cb6e1ef

File tree

10 files changed

+288
-36
lines changed

10 files changed

+288
-36
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@
6565

6666
### 📊 Dashboard
6767
- **Real-time stats** — CPU, RAM, and uptime monitoring
68+
- **One-Click Public Server** — Use **Pinggy** (Experimental) to share your server globally via SSH tunnel
69+
- **Region Selection** — Choose between EU, US, and Asia for best latency
6870
- **Local IP display** — Easy LAN connection for friends
69-
- **Public server guide** — Tips for playit.gg, ngrok, etc.
7071
- **Quick command input** — Send commands from dashboard
7172

7273
</td>

backend/api_server.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ def __init__(self):
9898
self.loop = asyncio.get_running_loop()
9999
self.selected_server_id = None
100100

101+
# Tunnel management
102+
self.tunnel_process = None
103+
self.tunnel_address = None
104+
101105
# Do not initialize handler automatically on startup anymore
102106
# self.initialize_handler()
103107

@@ -845,6 +849,166 @@ def create_world(request: Request):
845849
# Basic stub. Minecraft creates world automatically if level-name changes to non-existent folder.
846850
pass
847851

852+
# --- Tunnel Management Endpoints (Pinggy) ---
853+
854+
@app.get("/tunnel/status")
855+
def get_tunnel_status():
856+
if not state:
857+
return {"active": False, "address": None}
858+
859+
return {
860+
"active": state.tunnel_process is not None and state.tunnel_process.poll() is None,
861+
"address": state.tunnel_address
862+
}
863+
864+
@app.post("/tunnel/start")
865+
def start_tunnel(request: Request, region: str = "eu"):
866+
if not state:
867+
raise HTTPException(status_code=500, detail="App state not initialized")
868+
869+
# If tunnel already running, return existing address
870+
if state.tunnel_process and state.tunnel_process.poll() is None:
871+
return {"message": "Tunnel already running", "address": state.tunnel_address}
872+
873+
# Get server port (default 25565)
874+
port = "25565"
875+
if state.server_handler:
876+
try:
877+
props_path = os.path.join(state.server_handler.server_path, "server.properties")
878+
if os.path.exists(props_path):
879+
with open(props_path, 'r') as f:
880+
for line in f:
881+
if line.startswith("server-port="):
882+
port = line.split("=")[1].strip()
883+
break
884+
except:
885+
pass
886+
887+
def _ensure_ssh_key():
888+
"""Ensures a dedicated SSH key exists for the app to authenticate with Pinggy."""
889+
try:
890+
ssh_dir = os.path.join(state.app_data_dir, "ssh")
891+
if not os.path.exists(ssh_dir):
892+
os.makedirs(ssh_dir)
893+
894+
key_path = os.path.join(ssh_dir, "id_rsa")
895+
pub_path = f"{key_path}.pub"
896+
897+
# If key doesn't exist, generate it
898+
if not os.path.exists(key_path) or not os.path.exists(pub_path):
899+
logging.info("Generating new SSH key for Pinggy...")
900+
subprocess.run(
901+
["ssh-keygen", "-t", "rsa", "-b", "2048", "-f", key_path, "-N", ""],
902+
check=True,
903+
stdout=subprocess.DEVNULL,
904+
stderr=subprocess.DEVNULL,
905+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
906+
)
907+
return key_path
908+
except Exception as e:
909+
logging.error(f"Failed to generate SSH key: {e}")
910+
return None
911+
912+
def run_tunnel():
913+
import subprocess
914+
import re
915+
try:
916+
# Construct host based on region
917+
# regions: eu, us, ap, sa
918+
host = f"{region}.free.pinggy.io"
919+
920+
logging.info(f"Starting Pinggy tunnel ({region.upper()}) for port {port}...")
921+
state.broadcast_log_sync(f"🌐 Iniciando túnel público ({region.upper()}) para puerto {port}...", "info")
922+
923+
# Ensure we have a key
924+
key_path = _ensure_ssh_key()
925+
926+
# Pinggy SSH command - optimized with identity
927+
cmd = [
928+
"ssh",
929+
"-p", "443",
930+
"-o", "StrictHostKeyChecking=no",
931+
"-o", "ServerAliveInterval=30",
932+
"-o", "BatchMode=yes",
933+
"-T", # Disable pseudo-terminal
934+
]
935+
936+
if key_path and os.path.exists(key_path):
937+
cmd.extend(["-i", key_path, "-o", "IdentitiesOnly=yes"])
938+
939+
cmd.extend([
940+
"-R", f"0:127.0.0.1:{port}",
941+
f"tcp@{host}"
942+
])
943+
944+
# Using bufsize=1 for line buffering
945+
state.tunnel_process = subprocess.Popen(
946+
cmd,
947+
stdout=subprocess.PIPE,
948+
stderr=subprocess.STDOUT,
949+
stdin=subprocess.PIPE,
950+
text=True,
951+
bufsize=1,
952+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
953+
)
954+
955+
# Read output to find the tunnel URL
956+
for line in state.tunnel_process.stdout:
957+
line = line.strip()
958+
if not line: continue
959+
960+
logging.debug(f"Pinggy output: {line}")
961+
962+
# Pinggy outputs something like: "tcp://xyz.a.pinggy.io:12345"
963+
# Match tcp:// format
964+
tcp_match = re.search(r'tcp://([a-zA-Z0-9\.\-]+:\d+)', line)
965+
if tcp_match:
966+
state.tunnel_address = tcp_match.group(1)
967+
968+
# Match raw address format (free.pinggy.io:12345)
969+
# Broader match: something.pinggy.io:digits
970+
if not state.tunnel_address:
971+
addr_match = re.search(r'([a-zA-Z0-9\.\-]+\.pinggy\.io:\d+)', line)
972+
if addr_match:
973+
state.tunnel_address = addr_match.group(1)
974+
975+
if state.tunnel_address:
976+
logging.info(f"Tunnel established: {state.tunnel_address}")
977+
state.broadcast_log_sync(f"✅ ¡Servidor público activo! Dirección: {state.tunnel_address}", "success")
978+
state.broadcast_log_sync({"type": "tunnel_connected", "address": state.tunnel_address})
979+
980+
# If we exit the loop, tunnel has closed
981+
state.broadcast_log_sync("🔴 Túnel cerrado", "warning")
982+
state.broadcast_log_sync({"type": "tunnel_disconnected"})
983+
state.tunnel_address = None
984+
985+
except Exception as e:
986+
logging.exception(f"Tunnel error: {e}")
987+
state.broadcast_log_sync(f"❌ Error en túnel: {e}", "error")
988+
state.tunnel_address = None
989+
990+
threading.Thread(target=run_tunnel, daemon=True).start()
991+
return {"message": "Tunnel starting...", "status": "connecting"}
992+
993+
@app.post("/tunnel/stop")
994+
def stop_tunnel():
995+
if not state:
996+
raise HTTPException(status_code=500, detail="App state not initialized")
997+
998+
if state.tunnel_process:
999+
try:
1000+
state.tunnel_process.terminate()
1001+
state.tunnel_process.wait(timeout=5)
1002+
except:
1003+
state.tunnel_process.kill()
1004+
1005+
state.tunnel_process = None
1006+
state.tunnel_address = None
1007+
state.broadcast_log_sync("🔴 Túnel detenido", "info")
1008+
state.broadcast_log_sync({"type": "tunnel_disconnected"})
1009+
1010+
return {"message": "Tunnel stopped"}
1011+
8481012
if __name__ == "__main__":
8491013
import uvicorn
8501014
uvicorn.run(app, host="127.0.0.1", port=8000)

electron-app/src/App.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion';
33
import { LayoutDashboard, Terminal, Settings as SettingsIcon, Users, Activity, Globe, Github } from 'lucide-react';
44
import { api } from './api';
55

6+
import logo from './assets/logo2.png';
7+
68
// Components
79
import Dashboard from './components/Dashboard';
810
import Console from './components/Console';
@@ -30,7 +32,7 @@ function Sidebar({ activeTab, setActiveTab, onBack }) {
3032

3133
<div className="p-6 flex justify-center mb-2">
3234
<img
33-
src="/images/logo2.png"
35+
src={logo}
3436
alt="Server Manager"
3537
className="w-full max-h-24 object-contain drop-shadow-[0_0_15px_rgba(99,102,241,0.3)]"
3638
/>

electron-app/src/api.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,5 +194,19 @@ export const api = {
194194
return await window.electron.openDirectory();
195195
}
196196
return null;
197+
},
198+
199+
// --- Tunnel (Public Server) ---
200+
getTunnelStatus: async () => {
201+
const res = await fetch('http://127.0.0.1:8000/tunnel/status');
202+
return await res.json();
203+
},
204+
startTunnel: async (region = "eu") => {
205+
const res = await fetch(`http://127.0.0.1:8000/tunnel/start?region=${region}`, { method: 'POST' });
206+
return await res.json();
207+
},
208+
stopTunnel: async () => {
209+
const res = await fetch('http://127.0.0.1:8000/tunnel/stop', { method: 'POST' });
210+
return await res.json();
197211
}
198212
};

electron-app/src/assets/logo2.png

247 KB
Loading

0 commit comments

Comments
 (0)