diff --git a/.env.example b/.env.example index 5ede661..d2b2f97 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,20 @@ -# ═══════════════════════════════════════════════════════════ -# CandyConnect - Environment Configuration -# Copy this file to .env and customize as needed -# ═══════════════════════════════════════════════════════════ - -# Panel port (the main web UI + API port) +CC_DATA_DIR=/opt/candyconnect +CC_REDIS_PASSWORD=change-me-generate-with-openssl-rand +CC_REDIS_URL=redis://:change-me-generate-with-openssl-rand@127.0.0.1:6379/0 +CC_JWT_SECRET= CC_PANEL_PORT=8443 - -# Panel URL path CC_PANEL_PATH=/candyconnect - -# Default admin credentials (CHANGE THESE!) +CC_DOMAIN=vpn.example.com CC_ADMIN_USER=admin -CC_ADMIN_PASS=admin123 - -# JWT secret for token signing (auto-generated if empty) -# Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(48))" -CC_JWT_SECRET= - -# Redis connection URL -# For Docker: redis://redis:6379/0 (uses Docker service name) -# For bare-metal: redis://127.0.0.1:6379/0 -CC_REDIS_URL=redis://redis:6379/0 +# Required on first startup — no default password is accepted by the server. +CC_ADMIN_PASS=change-me-before-first-start +# Optional TLS for uvicorn (prefer reverse proxy in production) +CC_SSL_CERT=/etc/ssl/certs/candyconnect.pem +CC_SSL_KEY=/etc/ssl/private/candyconnect.key +# Comma-separated browser origins allowed to call the API with credentials +CC_CORS_ORIGINS=https://vpn.example.com +CC_JWT_CLIENT_EXPIRE_HOURS=168 +# Set to 1 only when you intentionally want install.sh to wipe Redis +CC_WIPE_REDIS=0 +# Set to 1 to force-reset admin password from CC_ADMIN_PASS on startup +CC_FORCE_ADMIN_SYNC=0 diff --git a/Dockerfile b/Dockerfile index 1e83d36..6dde75a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,7 +89,7 @@ ENV CC_JWT_SECRET="" ENV CC_PANEL_PORT=8443 ENV CC_PANEL_PATH=/candyconnect ENV CC_ADMIN_USER=admin -ENV CC_ADMIN_PASS=admin123 +ENV CC_ADMIN_PASS= EXPOSE 8443 diff --git a/docker-compose.yml b/docker-compose.yml index c6f427f..0ceb816 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: image: redis:7-alpine container_name: candyconnect-redis restart: unless-stopped + command: redis-server --requirepass ${CC_REDIS_PASSWORD:?Set CC_REDIS_PASSWORD in .env} + environment: + - REDIS_PASSWORD=${CC_REDIS_PASSWORD} volumes: - redis_data:/data healthcheck: @@ -46,12 +49,16 @@ services: # - "9443:9443" # TrustTunnel environment: - CC_DATA_DIR=/opt/candyconnect - - CC_REDIS_URL=redis://redis:6379/0 + - CC_REDIS_URL=redis://:${CC_REDIS_PASSWORD}@redis:6379/0 + - CC_REDIS_PASSWORD=${CC_REDIS_PASSWORD} - CC_JWT_SECRET=${CC_JWT_SECRET:-} - CC_PANEL_PORT=${CC_PANEL_PORT:-8443} - CC_PANEL_PATH=${CC_PANEL_PATH:-/candyconnect} - CC_ADMIN_USER=${CC_ADMIN_USER:-admin} - - CC_ADMIN_PASS=${CC_ADMIN_PASS:-admin123} + - CC_ADMIN_PASS=${CC_ADMIN_PASS:?Set CC_ADMIN_PASS in .env before first run} + - CC_CORS_ORIGINS=${CC_CORS_ORIGINS:-} + - CC_SSL_CERT=${CC_SSL_CERT:-} + - CC_SSL_KEY=${CC_SSL_KEY:-} volumes: - candyconnect_data:/opt/candyconnect/cores - candyconnect_backups:/opt/candyconnect/backups diff --git a/install.sh b/install.sh index 10337d1..c4912c4 100644 --- a/install.sh +++ b/install.sh @@ -126,9 +126,9 @@ install_dependencies() { systemctl enable redis-server || true systemctl start redis-server || true - # Wipe database to ensure fresh config (per user request) - if command -v redis-cli &>/dev/null; then - log "Wiping existing Redis database..." + # Wipe database only when explicitly requested (destructive). + if [ "${CC_WIPE_REDIS:-0}" = "1" ] && command -v redis-cli &>/dev/null; then + warn "CC_WIPE_REDIS=1 — wiping Redis database..." redis-cli FLUSHALL >/dev/null 2>&1 || true fi @@ -209,8 +209,9 @@ ask_domain() { generate_secrets() { info "Generating secrets..." JWT_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))") + REDIS_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))") + ADMIN_PASS=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))") - # Write env file (only if it doesn't exist, to preserve user customizations) if [ -f "$CC_DIR/.env" ]; then warn ".env file already exists. Backing up to .env.bak" cp "$CC_DIR/.env" "$CC_DIR/.env.bak" @@ -218,17 +219,39 @@ generate_secrets() { cat > "$CC_DIR/.env" << EOF CC_DATA_DIR=$CC_DIR -CC_REDIS_URL=redis://127.0.0.1:6379/0 +CC_REDIS_URL=redis://:${REDIS_PASSWORD}@127.0.0.1:6379/0 +CC_REDIS_PASSWORD=${REDIS_PASSWORD} CC_JWT_SECRET=$JWT_SECRET CC_PANEL_PORT=$CC_PORT CC_PANEL_PATH=/candyconnect CC_DOMAIN=$CC_DOMAIN CC_ADMIN_USER=admin -CC_ADMIN_PASS=admin123 +CC_ADMIN_PASS=${ADMIN_PASS} EOF chmod 600 "$CC_DIR/.env" log "Secrets generated" + GENERATED_ADMIN_PASS="$ADMIN_PASS" +} + +configure_redis_auth() { + if [ ! -f "$CC_DIR/.env" ]; then + return + fi + # shellcheck disable=SC1090 + source "$CC_DIR/.env" + if [ -z "${CC_REDIS_PASSWORD:-}" ]; then + return + fi + info "Configuring Redis authentication..." + redis-cli CONFIG SET requirepass "$CC_REDIS_PASSWORD" >/dev/null 2>&1 || true + if [ -f /etc/redis/redis.conf ]; then + if ! grep -q "^requirepass " /etc/redis/redis.conf; then + echo "requirepass $CC_REDIS_PASSWORD" >> /etc/redis/redis.conf + fi + fi + systemctl restart redis-server 2>/dev/null || true + log "Redis authentication enabled" } create_systemd_service() { @@ -324,9 +347,9 @@ print_summary() { echo -e " ${BOLD}API URL:${NC} http://${SERVER_IP}:${CC_PORT}/api" echo -e " ${BOLD}Client API:${NC} http://${SERVER_IP}:${CC_PORT}/client-api" echo -e " ${BOLD}Admin User:${NC} admin" - echo -e " ${BOLD}Admin Pass:${NC} admin123" + echo -e " ${BOLD}Admin Pass:${NC} ${GENERATED_ADMIN_PASS:-see $CC_DIR/.env}" echo "" - echo -e " ${YELLOW}⚠ Change the default password immediately!${NC}" + echo -e " ${YELLOW}⚠ Save the admin password now — it is stored only in $CC_DIR/.env${NC}" echo "" echo -e " ${CYAN}Service commands:${NC}" echo -e " systemctl status ${CC_SERVICE}" @@ -350,6 +373,7 @@ main() { install_panel ask_domain generate_secrets + configure_redis_auth setup_firewall create_systemd_service print_summary diff --git a/server/config.py b/server/config.py index 2ddced2..fde8282 100644 --- a/server/config.py +++ b/server/config.py @@ -11,7 +11,25 @@ CORE_DIR = os.path.join(DATA_DIR, "cores") # ── Redis ── -REDIS_URL = os.environ.get("CC_REDIS_URL", "redis://127.0.0.1:6379/0") +def _build_redis_url() -> str: + explicit = os.environ.get("CC_REDIS_URL") + if explicit: + return explicit + password = os.environ.get("CC_REDIS_PASSWORD", "") + if password: + return f"redis://:{password}@127.0.0.1:6379/0" + return "redis://127.0.0.1:6379/0" + + +REDIS_URL = _build_redis_url() + +# ── HTTP / CORS ── +CORS_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CC_CORS_ORIGINS", "").split(",") + if origin.strip() +] +FORCE_ADMIN_SYNC = os.environ.get("CC_FORCE_ADMIN_SYNC", "").lower() in ("1", "true", "yes") # ── JWT (persist secret across restarts unless provided via env) ── @@ -45,7 +63,7 @@ def _load_or_create_jwt_secret() -> str: JWT_SECRET = _load_or_create_jwt_secret() JWT_ALGORITHM = "HS256" JWT_ADMIN_EXPIRE_HOURS = 24 -JWT_CLIENT_EXPIRE_HOURS = 720 # 30 days +JWT_CLIENT_EXPIRE_HOURS = int(os.environ.get("CC_JWT_CLIENT_EXPIRE_HOURS", "168")) # 7 days # ── Panel ── PANEL_PORT = int(os.environ.get("CC_PANEL_PORT", "8443")) @@ -56,7 +74,11 @@ def _load_or_create_jwt_secret() -> str: # ── Default Admin ── DEFAULT_ADMIN_USER = os.environ.get("CC_ADMIN_USER", "admin") -DEFAULT_ADMIN_PASS = os.environ.get("CC_ADMIN_PASS", "admin123") +DEFAULT_ADMIN_PASS = os.environ.get("CC_ADMIN_PASS", "") + +# ── TLS (optional uvicorn termination) ── +SSL_CERTFILE = os.environ.get("CC_SSL_CERT", "") +SSL_KEYFILE = os.environ.get("CC_SSL_KEY", "") # ── Protocols ── SUPPORTED_PROTOCOLS = [ diff --git a/server/database.py b/server/database.py index 4c1893e..0fe3ecf 100644 --- a/server/database.py +++ b/server/database.py @@ -8,7 +8,7 @@ import redis.asyncio as redis from config import ( - REDIS_URL, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASS, + REDIS_URL, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASS, FORCE_ADMIN_SYNC, PANEL_PORT, PANEL_PATH, PANEL_DOMAIN, PANEL_VERSION, PANEL_BUILD_DATE, ) @@ -57,6 +57,34 @@ async def close_redis(): K_HEARTBEATS = "cc:heartbeats" # hash: client_id -> last_heartbeat_unix_ts +def _hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def _verify_password(password: str, stored: str) -> bool: + if not stored: + return False + if stored.startswith("$2"): + try: + return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) + except Exception: + return False + # Legacy plaintext entry — compare once, caller may re-hash. + return stored == password + + +def public_client(client: dict) -> dict: + """Remove sensitive fields from client records returned by APIs.""" + out = dict(client) + out.pop("password", None) + out.pop("password_hash", None) + return out + + +def _gen_l2tp_psk() -> str: + return secrets.token_urlsafe(24) + + def _gen_id() -> str: return "c" + uuid.uuid4().hex[:8] @@ -65,23 +93,32 @@ def _gen_id() -> str: async def init_db(): """Seed default data if DB is empty. Raises on connection failure.""" - # Test Redis connectivity first r = await get_redis() - await r.ping() # Will raise if Redis is unreachable + await r.ping() - # Always sync admin credentials from env vars if they exist - # This allows resetting the password via .env and restart admin_user = DEFAULT_ADMIN_USER - admin_pass = DEFAULT_ADMIN_PASS - - # We use direct bcrypt to avoid passlib issues in docker - password_hash = bcrypt.hashpw(admin_pass.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - await r.hset(K_ADMIN, mapping={ - "username": admin_user, - "password_hash": password_hash, - }) - logging.getLogger("candyconnect").info(f"Admin credentials synced: {admin_user}") + admin_exists = await r.exists(K_ADMIN) + + if not admin_exists: + if not DEFAULT_ADMIN_PASS: + raise RuntimeError( + "CC_ADMIN_PASS must be set before first startup (no default password allowed)" + ) + password_hash = _hash_password(DEFAULT_ADMIN_PASS) + await r.hset(K_ADMIN, mapping={ + "username": admin_user, + "password_hash": password_hash, + }) + logging.getLogger("candyconnect").info(f"Admin account initialized: {admin_user}") + elif FORCE_ADMIN_SYNC and DEFAULT_ADMIN_PASS: + password_hash = _hash_password(DEFAULT_ADMIN_PASS) + await r.hset(K_ADMIN, mapping={ + "username": admin_user, + "password_hash": password_hash, + }) + logging.getLogger("candyconnect").warning( + "Admin credentials reset from environment (CC_FORCE_ADMIN_SYNC=1)" + ) # Panel config if not await r.exists(K_PANEL): @@ -118,17 +155,22 @@ async def init_db(): # Create default 'admin' client if it doesn't exist if not await r.hexists(K_CLIENT_IDX, admin_user): - admin_client_data = { - "username": admin_user, - "password": admin_pass, - "comment": "Default administrator client", - "enabled": True, - "protocols": {p: (p not in ["slipstream", "trusttunnel"]) for p in [ - "v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel" - ]} - } - await create_client(admin_client_data) - logging.getLogger("candyconnect").info(f"Default client '{admin_user}' created") + if not DEFAULT_ADMIN_PASS: + logging.getLogger("candyconnect").warning( + "Skipping default admin VPN client — CC_ADMIN_PASS not set" + ) + else: + admin_client_data = { + "username": admin_user, + "password": DEFAULT_ADMIN_PASS, + "comment": "Default administrator client", + "enabled": True, + "protocols": {p: (p not in ["slipstream", "trusttunnel"]) for p in [ + "v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel" + ]}, + } + await create_client(admin_client_data) + logging.getLogger("candyconnect").info(f"Default client '{admin_user}' created") def _default_core_configs() -> dict: @@ -206,7 +248,7 @@ def _default_core_configs() -> dict: }, "l2tp": { "port": 1701, "ipsec_port": 500, - "psk": "CandyConnect_L2TP_PSK_2026", + "psk": _gen_l2tp_psk(), "local_ip": "10.20.0.1", "remote_range": "10.20.0.10-10.20.0.250", "dns": "1.1.1.1", "mtu": 1400, "mru": 1400, @@ -346,15 +388,15 @@ async def get_client_by_username(username: str) -> Optional[dict]: async def create_client(data: dict) -> dict: r = await get_redis() - # Check duplicate username if await r.hexists(K_CLIENT_IDX, data["username"]): raise ValueError(f"Username '{data['username']}' already exists") + plain_password = data["password"] cid = _gen_id() now = time.strftime("%Y-%m-%d %H:%M:%S") client = { "username": data["username"], - "password": data["password"], + "password_hash": _hash_password(plain_password), "comment": data.get("comment", ""), "enabled": data.get("enabled", True), "group": data.get("group", ""), @@ -378,6 +420,8 @@ async def create_client(data: dict) -> dict: client["id"] = cid client["protocol_traffic"] = {} + # Plain password only for immediate protocol provisioning (never persisted). + client["password"] = plain_password await _add_log("INFO", "System", f"Client '{data['username']}' created") return client @@ -389,10 +433,14 @@ async def update_client(client_id: str, updates: dict) -> Optional[dict]: return None client = json.loads(raw) - for key in ["password", "comment", "enabled", "group", "traffic_limit", "time_limit", "protocols", "protocol_data"]: + for key in ["comment", "enabled", "group", "traffic_limit", "time_limit", "protocols", "protocol_data"]: if key in updates: client[key] = updates[key] + if "password" in updates and updates["password"]: + client["password_hash"] = _hash_password(updates["password"]) + client.pop("password", None) + # Recalculate expiry if time_limit changed if "time_limit" in updates: client["expires_at"] = _calc_expiry(client["created_at"], updates["time_limit"]) @@ -424,10 +472,21 @@ async def verify_client(username: str, password: str) -> Optional[dict]: client = await get_client_by_username(username) if not client: return None - if client.get("password") != password: + stored = client.get("password_hash") or client.get("password", "") + if not _verify_password(password, stored): return None if not client.get("enabled", False): return None + + # Migrate legacy plaintext password to hash on successful login. + if client.get("password") and not client.get("password_hash"): + r = await get_redis() + client["password_hash"] = _hash_password(password) + client.pop("password", None) + await r.hset(K_CLIENTS, client["id"], json.dumps({k: v for k, v in client.items() if k not in ("protocol_traffic", "id")})) + elif client.get("password"): + client.pop("password", None) + return client @@ -522,6 +581,8 @@ async def record_client_connection(client_id: str, ip: str, protocol: str): async def update_client_traffic(client_id: str, protocol: str, bytes_used: float): """Update traffic usage for a client on a specific protocol.""" + if bytes_used < 0: + return r = await get_redis() # Normalize protocol name to avoid mismatches (e.g. V2Ray-1 vs v2ray-1) proto = str(protocol).lower() @@ -595,6 +656,10 @@ async def get_core_config(section: str) -> Optional[dict]: async def update_core_config(section: str, data: dict): + from security import sanitize_wireguard_config + + if section == "wireguard": + data = sanitize_wireguard_config(data) r = await get_redis() await r.hset(K_CONFIGS, section, json.dumps(data)) await _add_log("INFO", "System", f"Configuration updated: {section}") @@ -669,7 +734,6 @@ async def add_tunnel(ip: str, port: int, name: str, username: str = "root", pass "ip": ip, "port": port, "username": username, - "ssh_password": password, "type": tunnel_type, "created_at": int(time.time()), "status": "pending", # pending, installed diff --git a/server/main.py b/server/main.py index 6ca8c5c..405879d 100644 --- a/server/main.py +++ b/server/main.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse -from config import PANEL_PORT, DATA_DIR, CORE_DIR, BACKUP_DIR, LOG_DIR +from config import PANEL_PORT, DATA_DIR, CORE_DIR, BACKUP_DIR, LOG_DIR, CORS_ORIGINS, SSL_CERTFILE, SSL_KEYFILE from database import init_db, close_redis, add_log from protocols.manager import protocol_manager @@ -111,12 +111,13 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +_cors_origins = CORS_ORIGINS or ["http://127.0.0.1:5174", "http://localhost:5174"] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) @@ -181,15 +182,18 @@ async def serve_panel(rest_of_path: str = ""): @app.get("/health") async def health(): - return {"status": "ok", "version": "1.4.2", "timestamp": int(time.time())} + return {"status": "ok", "version": "1.4.2"} if __name__ == "__main__": import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=PANEL_PORT, - log_level="info", - reload=False, - ) + uvicorn_kwargs = { + "host": "0.0.0.0", + "port": PANEL_PORT, + "log_level": "info", + "reload": False, + } + if SSL_CERTFILE and SSL_KEYFILE: + uvicorn_kwargs["ssl_certfile"] = SSL_CERTFILE + uvicorn_kwargs["ssl_keyfile"] = SSL_KEYFILE + uvicorn.run("main:app", **uvicorn_kwargs) diff --git a/server/protocols/base.py b/server/protocols/base.py index 138f6b2..78d5367 100644 --- a/server/protocols/base.py +++ b/server/protocols/base.py @@ -123,26 +123,9 @@ async def get_client_config(self, username: str, server_ip: str, protocol_data: async def _run_cmd(self, cmd: str, check: bool = True, timeout: int = 30) -> tuple[int, str, str]: """Run a shell command and return (returncode, stdout, stderr).""" - try: - proc = await asyncio.create_subprocess_shell( - cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - out = stdout.decode("utf-8", errors="replace").strip() - err = stderr.decode("utf-8", errors="replace").strip() - if check and proc.returncode != 0: - logger.error(f"Command failed: {cmd}\nstderr: {err}") - return proc.returncode, out, err - except asyncio.TimeoutError: - logger.error(f"Command timed out after {timeout}s: {cmd}") - try: - proc.kill() - await proc.wait() - except Exception: - pass - return -1, "", f"Command timed out after {timeout}s" + from security import run_cmd + + return await run_cmd(cmd, shell=True, check=check, timeout=timeout) async def _start_process(self, cmd: str, cwd: str = None) -> Optional[int]: """Start a background process and return its PID.""" diff --git a/server/protocols/dnstt.py b/server/protocols/dnstt.py index bb8e31e..c7be005 100644 --- a/server/protocols/dnstt.py +++ b/server/protocols/dnstt.py @@ -203,27 +203,30 @@ async def get_active_connections(self) -> int: return 0 async def add_client(self, username: str, client_data: dict) -> dict: + import shlex + from security import validate_vpn_username, chpasswd_entry + + username = validate_vpn_username(username) ssh_user = f"dnstt_{username}" password = client_data.get("password", self._gen_password()) - # Create system user (non-root, no shell login except through tunnel) await self._run_cmd( - f"sudo useradd -m -s /bin/false {ssh_user} 2>/dev/null || true", + f"sudo useradd -m -s /bin/false {shlex.quote(ssh_user)} 2>/dev/null || true", check=False, ) - # Set password - proc = await asyncio.create_subprocess_shell( - f"echo '{ssh_user}:{password}' | sudo chpasswd", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await proc.communicate() + rc, _, err = await asyncio.to_thread(chpasswd_entry, ssh_user, password) + if rc != 0: + await add_log("ERROR", self.PROTOCOL_NAME, f"Failed to set DNSTT password: {err}") return {"ssh_username": ssh_user, "ssh_password": password} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) ssh_user = protocol_data.get("ssh_username") or f"dnstt_{username}" - await self._run_cmd(f"sudo userdel -r {ssh_user} 2>/dev/null || true", check=False) + await self._run_cmd(f"sudo userdel -r {shlex.quote(ssh_user)} 2>/dev/null || true", check=False) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("dnstt") diff --git a/server/protocols/ikev2.py b/server/protocols/ikev2.py index e530352..c5385eb 100644 --- a/server/protocols/ikev2.py +++ b/server/protocols/ikev2.py @@ -194,52 +194,58 @@ async def get_traffic(self) -> dict: async def add_client(self, username: str, client_data: dict) -> dict: """Generate client certificate for IKEv2.""" + import shlex + from security import validate_vpn_username, append_line_to_root_file + + username = validate_vpn_username(username) password = client_data.get("password", username) - # Read cert_validity from config (default 3650 days) config = await get_core_config("ikev2") or {} cert_validity = config.get("cert_validity", 3650) - # Generate client key await self._run_cmd( f"ipsec pki --gen --type rsa --size 2048 --outform pem > {self.IPSEC_DIR}/private/{username}-key.pem", check=False, ) - # Generate client cert await self._run_cmd( f"ipsec pki --pub --in {self.IPSEC_DIR}/private/{username}-key.pem --type rsa | " f"ipsec pki --issue --lifetime {cert_validity} " f"--cacert {self.IPSEC_DIR}/cacerts/ca-cert.pem " f"--cakey {self.IPSEC_DIR}/private/ca-key.pem " - f"--dn 'CN={username}' --san '{username}' " + f"--dn CN={username} --san {username} " f"--outform pem > {self.IPSEC_DIR}/certs/{username}-cert.pem", check=False, ) - # Generate .p12 for client await self._run_cmd( f"openssl pkcs12 -export -in {self.IPSEC_DIR}/certs/{username}-cert.pem " f"-inkey {self.IPSEC_DIR}/private/{username}-key.pem " f"-certfile {self.IPSEC_DIR}/cacerts/ca-cert.pem " - f"-name '{username}' -out {self.IPSEC_DIR}/{username}.p12 " - f"-passout pass:{password}", + f"-name {username} -out {self.IPSEC_DIR}/{username}.p12 " + f"-passout pass:{shlex.quote(password)}", check=False, ) - # Add/Update EAP credentials in ipsec.secrets - await self._run_cmd(f"sudo sed -i '/{username} : EAP/d' /etc/ipsec.secrets 2>/dev/null || true", check=False) await self._run_cmd( - f"echo '{username} : EAP \"{password}\"' | sudo tee -a /etc/ipsec.secrets", + f"sudo sed -i {shlex.quote('/' + username + ' : EAP/d')} /etc/ipsec.secrets 2>/dev/null || true", check=False, ) + await append_line_to_root_file("/etc/ipsec.secrets", f'{username} : EAP "{password}"') return {"cert_generated": True, "username": username} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) for ext in ["key.pem", "cert.pem"]: path = os.path.join(self.IPSEC_DIR, "private" if "key" in ext else "certs", f"{username}-{ext}") - await self._run_cmd(f"sudo rm -f {path}", check=False) - await self._run_cmd(f"sudo rm -f {self.IPSEC_DIR}/{username}.p12", check=False) - await self._run_cmd(f"sudo sed -i '/{username}/d' /etc/ipsec.secrets", check=False) + await self._run_cmd(f"sudo rm -f {shlex.quote(path)}", check=False) + await self._run_cmd(f"sudo rm -f {shlex.quote(self.IPSEC_DIR + '/' + username + '.p12')}", check=False) + await self._run_cmd( + f"sudo sed -i {shlex.quote('/' + username + '/d')} /etc/ipsec.secrets", + check=False, + ) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("ikev2") diff --git a/server/protocols/l2tp.py b/server/protocols/l2tp.py index f98dd5b..c142e19 100644 --- a/server/protocols/l2tp.py +++ b/server/protocols/l2tp.py @@ -136,16 +136,28 @@ async def get_traffic(self) -> dict: return {"in": total_rx, "out": total_tx} async def add_client(self, username: str, client_data: dict) -> dict: + import shlex + from security import validate_vpn_username, append_line_to_root_file + + username = validate_vpn_username(username) password = client_data.get("password", username) - await self._run_cmd( - f"grep -q '{username}' /etc/ppp/chap-secrets 2>/dev/null || " - f"echo '{username} * {password} *' | sudo tee -a /etc/ppp/chap-secrets", + rc, out, _ = await self._run_cmd( + f"grep -qF {shlex.quote(username)} /etc/ppp/chap-secrets 2>/dev/null", check=False, ) + if rc != 0: + await append_line_to_root_file("/etc/ppp/chap-secrets", f"{username}\t*\t{password}\t*") return {"username": username} async def remove_client(self, username: str, protocol_data: dict): - await self._run_cmd(f"sudo sed -i '/^{username} /d' /etc/ppp/chap-secrets", check=False) + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + await self._run_cmd( + f"sudo sed -i {shlex.quote('/^' + username + ' /d')} /etc/ppp/chap-secrets", + check=False, + ) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("l2tp") diff --git a/server/protocols/openvpn.py b/server/protocols/openvpn.py index ba66de9..843930b 100644 --- a/server/protocols/openvpn.py +++ b/server/protocols/openvpn.py @@ -197,21 +197,30 @@ async def get_traffic(self) -> dict: async def add_client(self, username: str, client_data: dict) -> dict: """Generate client certificate and return .ovpn config data.""" - # Generate client cert + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + safe_user = shlex.quote(username) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa --batch build-client-full {username} nopass", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa --batch build-client-full {safe_user} nopass", check=False, timeout=120, ) return {"cert_generated": True, "username": username} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + safe_user = shlex.quote(username) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa --batch revoke {username}", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa --batch revoke {safe_user}", check=False, ) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa gen-crl", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa gen-crl", check=False, ) diff --git a/server/protocols/wireguard.py b/server/protocols/wireguard.py index 4e38a23..926632f 100644 --- a/server/protocols/wireguard.py +++ b/server/protocols/wireguard.py @@ -282,11 +282,17 @@ async def _write_config(self, config: dict): interface = config.get("bind_interface", "eth0") post_up = config.get("post_up", "") - if not post_up and interface: + if post_up: + from security import assert_safe_wireguard_hook + post_up = assert_safe_wireguard_hook(post_up, "post_up") + elif interface: post_up = f"iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o {interface} -j MASQUERADE" - + post_down = config.get("post_down", "") - if not post_down and interface: + if post_down: + from security import assert_safe_wireguard_hook + post_down = assert_safe_wireguard_hook(post_down, "post_down") + elif interface: post_down = f"iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o {interface} -j MASQUERADE" conf = f"""[Interface] diff --git a/server/routes/client_api.py b/server/routes/client_api.py index ef028ee..9fd936b 100644 --- a/server/routes/client_api.py +++ b/server/routes/client_api.py @@ -4,7 +4,7 @@ """ import time import asyncio -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from typing import Optional @@ -13,6 +13,8 @@ import uuid from system_info import get_public_ip from protocols.manager import protocol_manager +from security import login_rate_limiter, validate_traffic_bytes + from config import SUPPORTED_PROTOCOLS router = APIRouter(tags=["client"]) @@ -24,11 +26,14 @@ class ClientLoginRequest(BaseModel): @router.post("/auth/login") -async def client_login(req: ClientLoginRequest): +async def client_login(req: ClientLoginRequest, request: Request): + login_rate_limiter.check(request, "client") client = await db.verify_client(req.username, req.password) if not client: + login_rate_limiter.record_failure(request, "client") raise HTTPException(status_code=401, detail="Invalid username, password or account disabled") - + + login_rate_limiter.reset(request, "client") token = auth.create_client_token(req.username, client["id"]) # Get panel config for server name and IP if possible @@ -445,10 +450,10 @@ class TrafficReport(BaseModel): @router.post("/traffic") async def report_traffic(req: TrafficReport, payload=Depends(auth.require_client)): client_id = payload.get("client_id") - total_bytes = req.bytes_used or (req.bytes_sent + req.bytes_received) - - # Update client usage in DB (id, protocol, bytes) - await db.update_client_traffic(client_id, req.protocol, total_bytes) + sent = validate_traffic_bytes(req.bytes_sent, "bytes_sent") + received = validate_traffic_bytes(req.bytes_received, "bytes_received") + used = validate_traffic_bytes(req.bytes_used or (sent + received), "bytes_used") + await db.update_client_traffic(client_id, req.protocol, used) return {"success": True, "message": "Traffic reported"} diff --git a/server/routes/panel_api.py b/server/routes/panel_api.py index 81bd332..4c87a44 100644 --- a/server/routes/panel_api.py +++ b/server/routes/panel_api.py @@ -2,9 +2,11 @@ CandyConnect Server - Panel API Router Defines all endpoints for the web management panel. """ +import logging from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Request +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool import paramiko import time @@ -13,8 +15,16 @@ from config import SUPPORTED_PROTOCOLS from system_info import get_server_info from protocols.manager import protocol_manager +from security import ( + login_rate_limiter, + validate_vpn_username, + sanitize_wireguard_config, +) router = APIRouter(tags=["panel"]) +logger = logging.getLogger("candyconnect") + +TUNNEL_INSTALL_URL = "https://get.candyconnect.io/tunnel" # ── Auth ── @@ -23,10 +33,13 @@ class LoginRequest(BaseModel): password: str @router.post("/auth/login") -async def login(req: LoginRequest): +async def login(req: LoginRequest, request: Request): + login_rate_limiter.check(request, "admin") if await db.verify_admin(req.username, req.password): + login_rate_limiter.reset(request, "admin") token = auth.create_admin_token(req.username) return {"success": True, "message": "Login successful", "token": token} + login_rate_limiter.record_failure(request, "admin") raise HTTPException(status_code=401, detail="Invalid username or password") # ── Dashboard ── @@ -73,8 +86,8 @@ async def get_dashboard(limit: int = 10, user=Depends(auth.require_admin)): # ── Clients ── class CreateClientRequest(BaseModel): - username: str - password: str + username: str = Field(min_length=1, max_length=32) + password: str = Field(min_length=8, max_length=128) comment: str = "" enabled: bool = True group: Optional[str] = None @@ -83,7 +96,7 @@ class CreateClientRequest(BaseModel): protocols: dict class UpdateClientRequest(BaseModel): - password: Optional[str] = None + password: Optional[str] = Field(default=None, min_length=8, max_length=128) comment: Optional[str] = None enabled: Optional[bool] = None group: Optional[str] = None @@ -94,31 +107,29 @@ class UpdateClientRequest(BaseModel): @router.get("/clients") async def list_clients(user=Depends(auth.require_admin)): clients = await db.get_all_clients() - return {"success": True, "message": "Clients fetched", "data": clients} + return {"success": True, "message": "Clients fetched", "data": [db.public_client(c) for c in clients]} @router.get("/clients/{id}") async def get_client(id: str, user=Depends(auth.require_admin)): client = await db.get_client(id) if not client: raise HTTPException(status_code=404, detail="Client not found") - return {"success": True, "message": "Client fetched", "data": client} + return {"success": True, "message": "Client fetched", "data": db.public_client(client)} @router.post("/clients") async def create_client(req: CreateClientRequest, user=Depends(auth.require_admin)): try: - # Create in DB - client = await db.create_client(req.model_dump()) - - # Add to protocol backends + payload = req.model_dump() + payload["username"] = validate_vpn_username(payload["username"]) + client = await db.create_client(payload) + plain_password = client.pop("password", None) + client_for_proto = {**client, "password": plain_password or req.password} pdata = await protocol_manager.add_client_to_protocols( - client["username"], client, client["protocols"] + client["username"], client_for_proto, client["protocols"] ) - - # Update DB with protocol-specific data (keys, etc) await db.update_client(client["id"], {"protocol_data": pdata}) client["protocol_data"] = pdata - - return {"success": True, "message": "Client created", "data": client} + return {"success": True, "message": "Client created", "data": db.public_client(client)} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -127,19 +138,20 @@ async def update_client(id: str, req: UpdateClientRequest, user=Depends(auth.req existing = await db.get_client(id) if not existing: raise HTTPException(status_code=404, detail="Client not found") - + try: - updated = await db.update_client(id, req.model_dump(exclude_unset=True)) - - # Update protocol backends + updates = req.model_dump(exclude_unset=True) + updated = await db.update_client(id, updates) + client_for_proto = dict(updated) + if updates.get("password"): + client_for_proto["password"] = updates["password"] pdata = await protocol_manager.add_client_to_protocols( - updated["username"], updated, updated["protocols"], + updated["username"], client_for_proto, updated["protocols"], existing_data=updated.get("protocol_data") ) await db.update_client(id, {"protocol_data": pdata}) updated["protocol_data"] = pdata - - return {"success": True, "message": "Client updated", "data": updated} + return {"success": True, "message": "Client updated", "data": db.public_client(updated)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -173,7 +185,12 @@ class TunnelRequest(BaseModel): @router.get("/tunnels") async def get_tunnels(user=Depends(auth.require_admin)): tunnels = await db.get_tunnels() - return {"success": True, "data": tunnels} + safe = [] + for t in tunnels: + item = dict(t) + item.pop("ssh_password", None) + safe.append(item) + return {"success": True, "data": safe} async def background_install_tunnel(tunnel_id: str, ip: str, port: int, username: str, password: str, cmd: str): await db.update_tunnel_status(tunnel_id, "installing") @@ -182,9 +199,9 @@ async def background_install_tunnel(tunnel_id: str, ip: str, port: int, username def _sync_ssh(): try: client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.load_system_host_keys() + client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect(ip, port=port, username=username, password=password, timeout=15) - # Execute command stdin, stdout, stderr = client.exec_command(cmd) exit_status = stdout.channel.recv_exit_status() out_msg = stdout.read().decode().strip() @@ -194,7 +211,7 @@ def _sync_ssh(): except Exception as e: return False, "", str(e) - success, out, err = await auth.run_in_threadpool(_sync_ssh) + success, out, err = await run_in_threadpool(_sync_ssh) if success: await db.update_tunnel_status(tunnel_id, "installed") @@ -213,16 +230,20 @@ async def add_tunnel(req: TunnelRequest, tasks: BackgroundTasks, user=Depends(au master_ip = server_info.get("ip", "YOUR_SERVER_IP") # Generates a command to run on the remote tunnel server - install_cmd = f"curl -fsSL https://get.candyconnect.io/tunnel | sudo bash -s -- --master {master_ip}:8443 --secret {tunnel['id']} --type {req.tunnel_type}" + install_cmd = ( + f"curl -fsSL {TUNNEL_INSTALL_URL} | sudo bash -s -- " + f"--master {master_ip}:8443 --secret {tunnel['id']} --type {req.tunnel_type}" + ) if req.ip and req.password: tasks.add_task(background_install_tunnel, tunnel['id'], req.ip, req.port, req.username, req.password, install_cmd) return { - "success": True, - "message": "Tunnel added. installation started via SSH" if req.password else "Tunnel added. password missing for SSH install", - "data": tunnel, - "install_command": install_cmd + "success": True, + "message": "Tunnel added. installation started via SSH" if req.password else "Tunnel added. password missing for SSH install", + "data": {k: v for k, v in tunnel.items() if k != "ssh_password"}, + "install_command": install_cmd, + "security_notice": "Verify tunnel installer checksum/source before remote execution.", } @router.delete("/tunnels/{id}") @@ -239,6 +260,8 @@ async def get_cores(user=Depends(auth.require_admin)): @router.post("/cores/{id}/start") async def start_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.start_protocol(id) if success: @@ -250,6 +273,8 @@ async def start_core(id: str, user=Depends(auth.require_admin)): @router.post("/cores/{id}/stop") async def stop_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.stop_protocol(id) if success: @@ -261,6 +286,8 @@ async def stop_core(id: str, user=Depends(auth.require_admin)): @router.post("/cores/{id}/restart") async def restart_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.restart_protocol(id) if success: @@ -279,6 +306,11 @@ async def get_configs(user=Depends(auth.require_admin)): @router.put("/configs/{section}") async def update_config(section: str, data: dict = Body(...), user=Depends(auth.require_admin)): + allowed = set(SUPPORTED_PROTOCOLS) | {"candyconnect"} + if section not in allowed: + raise HTTPException(status_code=400, detail="Invalid configuration section") + if section == "wireguard": + data = sanitize_wireguard_config(data) await db.update_core_config(section, data) return {"success": True, "message": f"Config for {section} updated"} diff --git a/server/security.py b/server/security.py new file mode 100644 index 0000000..2f5ef15 --- /dev/null +++ b/server/security.py @@ -0,0 +1,210 @@ +""" +CandyConnect - Security helpers (validation, safe execution, rate limiting). +""" +from __future__ import annotations + +import asyncio +import re +import shlex +import subprocess +import time +from collections import defaultdict +from typing import Optional + +from fastapi import HTTPException, Request + +# VPN usernames are used in shell-adjacent tooling — strict charset only. +VPN_USERNAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$") + +# Linux interface names (for bind_interface validation). +IFACE_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,15}$") + +# Allowed iptables fragments for WireGuard PostUp/PostDown (no shell metacharacters). +IPTABLES_SAFE_RE = re.compile( + r"^(iptables|ip6tables)(\s+-[a-zA-Z0-9=/_%.]+|\s+%i|\s+--[a-z-]+|\s+\d+|\s+[a-zA-Z0-9_.:/-]+)*" + r"(;\s*(iptables|ip6tables)(\s+-[a-zA-Z0-9=/_%.]+|\s+%i|\s+--[a-z-]+|\s+\d+|\s+[a-zA-Z0-9_.:/-]+)*)*$" +) + +SHELL_METACHAR_RE = re.compile(r"[;|&`$<>\\'\n\r]") + + +def validate_vpn_username(username: str) -> str: + username = (username or "").strip() + if not VPN_USERNAME_RE.match(username): + raise HTTPException( + status_code=400, + detail=( + "Invalid username: use 1-32 chars, start with letter/digit, " + "only letters, digits, underscore and hyphen" + ), + ) + return username + + +def validate_bind_interface(name: str) -> str: + name = (name or "").strip() + if name and not IFACE_RE.match(name): + raise HTTPException(status_code=400, detail="Invalid network interface name") + return name + + +def assert_safe_wireguard_hook(command: str, field_name: str) -> str: + command = (command or "").strip() + if not command: + return "" + if SHELL_METACHAR_RE.search(command): + raise ValueError(f"{field_name} contains disallowed shell characters") + if not IPTABLES_SAFE_RE.match(command): + raise ValueError(f"{field_name} must be iptables/ip6tables rules only") + return command + + +def validate_wireguard_hook(command: str, field_name: str) -> str: + try: + return assert_safe_wireguard_hook(command, field_name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def sanitize_wireguard_config(data: dict) -> dict: + out = dict(data) + if "bind_interface" in out: + out["bind_interface"] = validate_bind_interface(str(out.get("bind_interface", ""))) + if "post_up" in out: + out["post_up"] = validate_wireguard_hook(str(out.get("post_up", "")), "post_up") + if "post_down" in out: + out["post_down"] = validate_wireguard_hook(str(out.get("post_down", "")), "post_down") + return out + + +def validate_traffic_bytes(value: float, field_name: str = "bytes") -> float: + if value is None: + return 0.0 + try: + num = float(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") + if num < 0: + raise HTTPException(status_code=400, detail=f"{field_name} cannot be negative") + # Cap single report to 1 TiB to prevent abuse / overflow tricks. + if num > 1024 ** 4: + raise HTTPException(status_code=400, detail=f"{field_name} exceeds maximum allowed value") + return num + + +async def run_cmd( + cmd: list[str] | str, + *, + shell: bool = False, + check: bool = True, + timeout: int = 30, + cwd: Optional[str] = None, + input_text: Optional[str] = None, +) -> tuple[int, str, str]: + """Run a subprocess without shell unless explicitly requested.""" + try: + if shell: + proc = await asyncio.create_subprocess_shell( + cmd if isinstance(cmd, str) else " ".join(shlex.quote(c) for c in cmd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + else: + if isinstance(cmd, str): + raise ValueError("Pass argv list when shell=False") + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + stdin=asyncio.subprocess.PIPE if input_text is not None else None, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(input_text.encode("utf-8") if input_text is not None else None), + timeout=timeout, + ) + out = stdout.decode("utf-8", errors="replace").strip() + err = stderr.decode("utf-8", errors="replace").strip() + if check and proc.returncode != 0: + return proc.returncode or 1, out, err + return proc.returncode or 0, out, err + except asyncio.TimeoutError: + return -1, "", f"Command timed out after {timeout}s" + + +def chpasswd_entry(username: str, password: str) -> tuple[int, str, str]: + """Set password via chpasswd stdin (no shell interpolation).""" + base = username[6:] if username.startswith("dnstt_") else username + validate_vpn_username(base) + proc = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{password}\n", + capture_output=True, + text=True, + timeout=15, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + + +async def append_line_to_root_file(path: str, line: str) -> bool: + """Append a single line to a root-owned file without shell-interpolating content.""" + import os + import tempfile + + tmp_path = "" + try: + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp: + tmp.write(line if line.endswith("\n") else f"{line}\n") + tmp_path = tmp.name + rc, _, _ = await run_cmd( + f"sudo tee -a {shlex.quote(path)} < {shlex.quote(tmp_path)} > /dev/null", + shell=True, + check=False, + ) + return rc == 0 + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + +class LoginRateLimiter: + """Simple in-memory login rate limiter (per client IP).""" + + def __init__(self, max_attempts: int = 10, window_seconds: int = 300): + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self._attempts: dict[str, list[float]] = defaultdict(list) + + def _client_ip(self, request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" + + def check(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + now = time.time() + window_start = now - self.window_seconds + self._attempts[key] = [t for t in self._attempts[key] if t >= window_start] + if len(self._attempts[key]) >= self.max_attempts: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again later.", + ) + + def record_failure(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + self._attempts[key].append(time.time()) + + def reset(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + self._attempts.pop(key, None) + + +login_rate_limiter = LoginRateLimiter() diff --git a/server/tests/test_security.py b/server/tests/test_security.py new file mode 100644 index 0000000..98c8d3c --- /dev/null +++ b/server/tests/test_security.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Basic security regression checks for CandyConnect server modules.""" +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SERVER = ROOT / "server" +sys.path.insert(0, str(SERVER)) + + +def load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, SERVER / rel) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main() -> int: + security = load("security", "security.py") + + try: + security.validate_vpn_username("valid_user-1") + except Exception as exc: + print(f"FAIL valid username rejected: {exc}") + return 1 + + rejected = False + try: + security.validate_vpn_username("bad;id") + rejected = True + except Exception: + pass + if rejected: + print("FAIL shell username accepted") + return 1 + + try: + security.assert_safe_wireguard_hook( + "iptables -A FORWARD -i %i -j ACCEPT; rm -rf /", + "post_up", + ) + print("FAIL malicious post_up accepted") + return 1 + except ValueError: + pass + + security.validate_traffic_bytes(100) + try: + security.validate_traffic_bytes(-1) + print("FAIL negative traffic accepted") + return 1 + except Exception: + pass + + print("OK security regression checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-panel/pages/ClientsPage.tsx b/web-panel/pages/ClientsPage.tsx index b1dbc65..b71b547 100644 --- a/web-panel/pages/ClientsPage.tsx +++ b/web-panel/pages/ClientsPage.tsx @@ -82,7 +82,7 @@ const ClientsPage: React.FC = () => { const openEditModal = (client: Client) => { setIsAddMode(false); setEditClient(client); - setFUsername(client.username); setFPassword(client.password); setFComment(client.comment); + setFUsername(client.username); setFPassword(''); setFComment(client.comment); setFTrafficVal(client.traffic_limit.value); setFTrafficUnit(client.traffic_limit.unit as any); setFTimeVal(client.time_limit.value); setFTimeMode(client.time_limit.mode as any); setFOnHold(client.time_limit.on_hold); setFEnabled(client.enabled); setFGroup(client.group || ''); @@ -90,7 +90,8 @@ const ClientsPage: React.FC = () => { }; const handleSave = async () => { - if (!fUsername.trim() || !fPassword.trim()) { notify('Username and password required', 'error'); return; } + if (!fUsername.trim()) { notify('Username required', 'error'); return; } + if (!editClient && !fPassword.trim()) { notify('Password required for new clients', 'error'); return; } if (fTrafficVal <= 0 || fTimeVal <= 0) { notify('Traffic and time must be positive', 'error'); return; } setSaving(true); try { @@ -105,7 +106,8 @@ const ClientsPage: React.FC = () => { notify(`Client "${fUsername}" created`, 'success'); } else if (editClient) { await updateClient(editClient.id, { - password: fPassword.trim(), comment: fComment.trim(), enabled: fEnabled, + ...(fPassword.trim() ? { password: fPassword.trim() } : {}), + comment: fComment.trim(), enabled: fEnabled, group: fGroup.trim() || undefined, traffic_limit: { value: fTrafficVal, unit: fTrafficUnit }, time_limit: { mode: fTimeMode, value: fTimeVal, on_hold: fOnHold }, @@ -210,7 +212,7 @@ const ClientsPage: React.FC = () => { {detailClient && (