diff --git a/Backend/core.py b/Backend/core.py index 2f73e30..f39b147 100644 --- a/Backend/core.py +++ b/Backend/core.py @@ -1,245 +1,254 @@ -# core.py -import subprocess, json, random, uuid, time, ipaddress, os, psutil, shutil, re , netifaces , string -from db import SQLite -from nanoid import generate -from datetime import datetime , timedelta - -# --- Configuration Paths (Consider making these configurable in a real app) --- -SERVER_PUBLIC_KEY_PATH = "/etc/wireguard/server_public_wgX.key" -SERVER_PRIVATE_KEY_PATH = "/etc/wireguard/server_private_wgX.key" -WG_CONF_PATH = "/etc/wireguard/wgX.conf" -WG_DIR = "/etc/wireguard" -DB_FILE = "total_traffic.json" # File to store cumulative traffic data - -class CandyPanel: - def __init__(self): - """ - Initializes the CandyPanel with a SQLite database connection. - """ - self.db = SQLite() - - @staticmethod - def _is_valid_ip(ip: str) -> bool: - """ - Checks if a given string is a valid IPv4 or IPv6 address. - """ - try: - ipaddress.ip_address(ip) - return True - except ValueError: - return False - - def run_command(self, cmd: str, check: bool = True) -> str | None: - """ - Executes a shell command and returns its stdout. - Raises an exception if the command fails and 'check' is True. - """ - try: - result = subprocess.run(cmd, shell=True, check=check, capture_output=True, text=True) - if result.returncode != 0: - # Log the error instead of just printing and exiting - print(f"Error running command '{cmd}': {result.stderr.strip()}") - raise Exception(f"Command failed: {result.stderr.strip()}") - return result.stdout.strip() - except subprocess.CalledProcessError as e: - print(f"Error running '{cmd}': {e.stderr.strip()}") - if check: - # In a production app, consider raising a custom exception here - # instead of exiting, to allow the caller to handle it gracefully. - raise CommandExecutionError(f"Command '{cmd}' failed: {e.stderr.strip()}") - return None - except Exception as e: - print(f"An unexpected error occurred while running '{cmd}': {e}") - if check: - raise CommandExecutionError(f"Unexpected error: {e}") - return None - def _get_default_interface(self): - """Gets the default network interface.""" - try: - gateways = netifaces.gateways() - return gateways['default'][netifaces.AF_INET][1] - except Exception: - result = self.run_command("ip route | grep default | awk '{print $5}'", check=False) - if result: - return result - return "eth0" - @staticmethod - def load_traffic_db() -> dict: - """ - Loads the total traffic data from the JSON file. - """ - if os.path.exists(DB_FILE): - try: - with open(DB_FILE, 'r') as f: - return json.load(f) - except json.JSONDecodeError: - print(f"Error: Could not decode JSON from {DB_FILE}. Returning empty dict.") - return {} - return {} - - @staticmethod - def save_traffic_db(data: dict): - """ - Saves the total traffic data to the JSON file. - """ - with open(DB_FILE, 'w') as f: - json.dump(data, f, indent=4) - - def _get_interface_path(self, name: str) -> str: - """ - Constructs the full path for a WireGuard interface configuration file. - """ - return os.path.join(WG_DIR, f"{name}.conf") - - def _interface_exists(self, name: str) -> bool: - """ - Checks if a WireGuard interface configuration file exists. - """ - return os.path.exists(self._get_interface_path(name)) - - def _get_all_ips_in_subnet(self, subnet_cidr: str) -> list[str]: - """ - Returns all host IPs within a given subnet CIDR, supporting IPv4 and IPv6. - """ - try: - network = ipaddress.ip_network(subnet_cidr, strict=False) - return [str(ip) for ip in network.hosts()] - except ValueError: - return [] - - def _get_server_public_key(self, wg_id: int) -> str: - """ - Retrieves the server's public key for a specific WireGuard interface. - """ - try: - with open(SERVER_PUBLIC_KEY_PATH.replace('X', str(wg_id))) as f: - return f.read().strip() - except FileNotFoundError: - print(f"Error: Server public key file not found for wg{wg_id}.") - raise - - def _generate_keypair(self) -> tuple[str, str]: - """ - Generates a new WireGuard private and public key pair. - """ - priv = self.run_command("wg genkey") - pub = self.run_command(f"echo {priv} | wg pubkey") - return priv, pub - - def _get_used_ips(self, wg_id: int) -> set[str]: - """ - Parses the WireGuard configuration file to find used client IPs (IPv4 and IPv6). - """ - used_ips = set() - try: - with open(WG_CONF_PATH.replace('X', str(wg_id)), "r") as f: - content = f.read() - # Regex to find IPs in "AllowedIPs = 10.0.0.X/32" or "AllowedIPs = XXXX::/128" format - ips = re.findall(r"AllowedIPs\s*=\s*([0-9a-fA-F.:]+/\d+)", content) - for ip in ips: - # Normalize IP address to get just the host part - network = ipaddress.ip_network(ip, strict=False) - used_ips.add(str(network.network_address)) - except FileNotFoundError: - print(f"Error: WireGuard config file not found for wg{wg_id}.") - except Exception as e: - print(f"Error parsing used IPs for wg{wg_id}: {e}") - return used_ips - - def _backup_config(self, wg_id: int): - """ - Creates a backup of the WireGuard configuration file. - """ - config_path = WG_CONF_PATH.replace('X', str(wg_id)) - backup_path = f"{config_path}.bak" - try: - shutil.copy(config_path, backup_path) - print(f"[+] Backup created: {backup_path}") - except FileNotFoundError: - print(f"[!] Warning: Config file {config_path} not found for backup.") - except Exception as e: - print(f"[!] Error creating backup for wg{wg_id}: {e}") - - def _reload_wireguard(self, wg_id: int): - """ - Reloads a specific WireGuard interface. - """ - print(f"[*] Reloading WireGuard interface wg{wg_id}...") - # Ensure the interface is down before bringing it up to apply changes - # Use '|| true' to prevent error if already down, allowing 'up' to proceed - self.run_command(f"sudo wg-quick down wg{wg_id} || true", check=False) - self.run_command(f"sudo wg-quick up wg{wg_id}") - print(f"[*] WireGuard interface wg{wg_id} reloaded.") - - def _add_peer_to_config(self, wg_id: int, client_name: str, client_public_key: str, client_ip: str, client_ipv6: str = None): - """ - Adds a client peer entry to the WireGuard configuration file, including IPv6 if provided. - """ - config_path = WG_CONF_PATH.replace('X', str(wg_id)) - allowed_ips = f"{client_ip}/32" - if client_ipv6: - allowed_ips += f", {client_ipv6}/128" - - peer_entry = f""" -[Peer] -# {client_name} -PublicKey = {client_public_key} -AllowedIPs = {allowed_ips} -""" - try: - with open(config_path, "a") as f: - f.write(peer_entry) - # Apply changes to the running WireGuard interface without full restart - self.run_command(f"sudo bash -c 'wg syncconf wg{wg_id} <(wg-quick strip wg{wg_id})'") - print(f"[+] Client '{client_name}' added to wg{wg_id} config.") - except Exception as e: - raise CommandExecutionError(f"Failed to add client '{client_name}' to WireGuard configuration: {e}") - - def _remove_peer_from_config(self, wg_id: int, client_name: str, client_public_key: str): - """ - Removes a client peer entry from the WireGuard configuration file. - """ - config_path = WG_CONF_PATH.replace('X', str(wg_id)) - - if not os.path.exists(config_path): - print(f"[!] WireGuard config file {config_path} not found. Cannot remove peer from config.") - return # Cannot remove if file doesn't exist - - self._backup_config(wg_id) # Backup before modifying - - try: - with open(config_path, "r") as f: - lines = f.readlines() - - new_lines = [] - in_peer_block = False - peer_block_to_delete = False - temp_block = [] - - for line in lines: - if line.strip().startswith("[Peer]"): - if in_peer_block: # End of previous block, if any - if not peer_block_to_delete: - new_lines.extend(temp_block) - temp_block = [line] - in_peer_block = True - peer_block_to_delete = False # Reset for new block - elif in_peer_block: - temp_block.append(line) - # Check for public key to identify the peer block, more reliable than comment - if f"PublicKey = {client_public_key}" in line.strip(): - peer_block_to_delete = True - # An empty line or a new [Peer] indicates the end of the current peer block - if not line.strip() and in_peer_block: - if not peer_block_to_delete: - new_lines.extend(temp_block) - in_peer_block = False - temp_block = [] - else: - new_lines.append(line) - - # Handle the last block if file ends without an empty line - if in_peer_block and not peer_block_to_delete: +# core.py +import subprocess, json, random, uuid, time, ipaddress, os, psutil, shutil, re , netifaces , string +from db import SQLite +from nanoid import generate +from datetime import datetime , timedelta +from werkzeug.security import generate_password_hash, check_password_hash + +# --- Configuration Paths (Consider making these configurable in a real app) --- +SERVER_PUBLIC_KEY_PATH = "/etc/wireguard/server_public_wgX.key" +SERVER_PRIVATE_KEY_PATH = "/etc/wireguard/server_private_wgX.key" +WG_CONF_PATH = "/etc/wireguard/wgX.conf" +WG_DIR = "/etc/wireguard" +DB_FILE = "total_traffic.json" # File to store cumulative traffic data + +class CandyPanel: + def __init__(self): + """ + Initializes the CandyPanel with a SQLite database connection. + """ + self.db = SQLite() + + @staticmethod + def _is_valid_ip(ip: str) -> bool: + """ + Checks if a given string is a valid IPv4 or IPv6 address. + """ + try: + ipaddress.ip_address(ip) + return True + except ValueError: + return False + + def run_command(self, cmd: str | list, check: bool = True, shell: bool = False) -> str | None: + """ + Executes a shell command and returns its stdout. + Raises an exception if the command fails and 'check' is True. + Accepts cmd as string (requires shell=True) or list of args (preferred, shell=False). + """ + try: + # If cmd is a list, shell should default to False unless overridden + if isinstance(cmd, list) and not shell: + result = subprocess.run(cmd, check=check, capture_output=True, text=True) + else: + # Fallback for string commands, explicitly needing shell=True + result = subprocess.run(cmd, shell=True, check=check, capture_output=True, text=True) + + if result.returncode != 0: + # Log the error + error_msg = result.stderr.strip() if result.stderr else "Unknown error" + print(f"Error running command '{cmd}': {error_msg}") + if check: + raise Exception(f"Command failed: {error_msg}") + return result.stdout.strip() + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else "Unknown error" + print(f"Error running '{cmd}': {error_msg}") + if check: + raise CommandExecutionError(f"Command '{cmd}' failed: {error_msg}") + return None + except Exception as e: + print(f"An unexpected error occurred while running '{cmd}': {e}") + if check: + raise CommandExecutionError(f"Unexpected error: {e}") + return None + def _get_default_interface(self): + """Gets the default network interface.""" + try: + gateways = netifaces.gateways() + return gateways['default'][netifaces.AF_INET][1] + except Exception: + result = self.run_command("ip route | grep default | awk '{print $5}'", check=False) + if result: + return result + return "eth0" + @staticmethod + def load_traffic_db() -> dict: + """ + Loads the total traffic data from the JSON file. + """ + if os.path.exists(DB_FILE): + try: + with open(DB_FILE, 'r') as f: + return json.load(f) + except json.JSONDecodeError: + print(f"Error: Could not decode JSON from {DB_FILE}. Returning empty dict.") + return {} + return {} + + @staticmethod + def save_traffic_db(data: dict): + """ + Saves the total traffic data to the JSON file. + """ + with open(DB_FILE, 'w') as f: + json.dump(data, f, indent=4) + + def _get_interface_path(self, name: str) -> str: + """ + Constructs the full path for a WireGuard interface configuration file. + """ + return os.path.join(WG_DIR, f"{name}.conf") + + def _interface_exists(self, name: str) -> bool: + """ + Checks if a WireGuard interface configuration file exists. + """ + return os.path.exists(self._get_interface_path(name)) + + def _get_all_ips_in_subnet(self, subnet_cidr: str) -> list[str]: + """ + Returns all host IPs within a given subnet CIDR, supporting IPv4 and IPv6. + """ + try: + network = ipaddress.ip_network(subnet_cidr, strict=False) + return [str(ip) for ip in network.hosts()] + except ValueError: + return [] + + def _get_server_public_key(self, wg_id: int) -> str: + """ + Retrieves the server's public key for a specific WireGuard interface. + """ + try: + with open(SERVER_PUBLIC_KEY_PATH.replace('X', str(wg_id))) as f: + return f.read().strip() + except FileNotFoundError: + print(f"Error: Server public key file not found for wg{wg_id}.") + raise + + def _generate_keypair(self) -> tuple[str, str]: + """ + Generates a new WireGuard private and public key pair. + """ + priv = self.run_command("wg genkey") + pub = self.run_command(f"echo {priv} | wg pubkey") + return priv, pub + + def _get_used_ips(self, wg_id: int) -> set[str]: + """ + Parses the WireGuard configuration file to find used client IPs (IPv4 and IPv6). + """ + used_ips = set() + try: + with open(WG_CONF_PATH.replace('X', str(wg_id)), "r") as f: + content = f.read() + # Regex to find IPs in "AllowedIPs = 10.0.0.X/32" or "AllowedIPs = XXXX::/128" format + ips = re.findall(r"AllowedIPs\s*=\s*([0-9a-fA-F.:]+/\d+)", content) + for ip in ips: + # Normalize IP address to get just the host part + network = ipaddress.ip_network(ip, strict=False) + used_ips.add(str(network.network_address)) + except FileNotFoundError: + print(f"Error: WireGuard config file not found for wg{wg_id}.") + except Exception as e: + print(f"Error parsing used IPs for wg{wg_id}: {e}") + return used_ips + + def _backup_config(self, wg_id: int): + """ + Creates a backup of the WireGuard configuration file. + """ + config_path = WG_CONF_PATH.replace('X', str(wg_id)) + backup_path = f"{config_path}.bak" + try: + shutil.copy(config_path, backup_path) + print(f"[+] Backup created: {backup_path}") + except FileNotFoundError: + print(f"[!] Warning: Config file {config_path} not found for backup.") + except Exception as e: + print(f"[!] Error creating backup for wg{wg_id}: {e}") + + def _reload_wireguard(self, wg_id: int): + """ + Reloads a specific WireGuard interface. + """ + print(f"[*] Reloading WireGuard interface wg{wg_id}...") + # Ensure the interface is down before bringing it up to apply changes + # Use '|| true' to prevent error if already down, allowing 'up' to proceed + self.run_command(f"sudo wg-quick down wg{wg_id} || true", check=False) + self.run_command(f"sudo wg-quick up wg{wg_id}") + print(f"[*] WireGuard interface wg{wg_id} reloaded.") + + def _add_peer_to_config(self, wg_id: int, client_name: str, client_public_key: str, client_ip: str, client_ipv6: str = None): + """ + Adds a client peer entry to the WireGuard configuration file, including IPv6 if provided. + """ + config_path = WG_CONF_PATH.replace('X', str(wg_id)) + allowed_ips = f"{client_ip}/32" + if client_ipv6: + allowed_ips += f", {client_ipv6}/128" + + peer_entry = f""" +[Peer] +# {client_name} +PublicKey = {client_public_key} +AllowedIPs = {allowed_ips} +""" + try: + with open(config_path, "a") as f: + f.write(peer_entry) + # Apply changes to the running WireGuard interface without full restart + self.run_command(f"sudo bash -c 'wg syncconf wg{wg_id} <(wg-quick strip wg{wg_id})'") + print(f"[+] Client '{client_name}' added to wg{wg_id} config.") + except Exception as e: + raise CommandExecutionError(f"Failed to add client '{client_name}' to WireGuard configuration: {e}") + + def _remove_peer_from_config(self, wg_id: int, client_name: str, client_public_key: str): + """ + Removes a client peer entry from the WireGuard configuration file. + """ + config_path = WG_CONF_PATH.replace('X', str(wg_id)) + + if not os.path.exists(config_path): + print(f"[!] WireGuard config file {config_path} not found. Cannot remove peer from config.") + return # Cannot remove if file doesn't exist + + self._backup_config(wg_id) # Backup before modifying + + try: + with open(config_path, "r") as f: + lines = f.readlines() + + new_lines = [] + in_peer_block = False + peer_block_to_delete = False + temp_block = [] + + for line in lines: + if line.strip().startswith("[Peer]"): + if in_peer_block: # End of previous block, if any + if not peer_block_to_delete: + new_lines.extend(temp_block) + temp_block = [line] + in_peer_block = True + peer_block_to_delete = False # Reset for new block + elif in_peer_block: + temp_block.append(line) + # Check for public key to identify the peer block, more reliable than comment + if f"PublicKey = {client_public_key}" in line.strip(): + peer_block_to_delete = True + # An empty line or a new [Peer] indicates the end of the current peer block + if not line.strip() and in_peer_block: + if not peer_block_to_delete: + new_lines.extend(temp_block) + in_peer_block = False + temp_block = [] + else: + new_lines.append(line) + + # Handle the last block if file ends without an empty line + if in_peer_block and not peer_block_to_delete: new_lines.extend(temp_block) if peer_block_to_delete: @@ -263,6 +272,7 @@ def _get_current_wg_peer_traffic(self, wg_id: int) -> dict: traffic_data = {} try: # Use 'sudo wg show dump' to get machine-readable output + # We use subprocess.run directly with a list for safety, skipping the helper since we need structured output processing result = subprocess.run(['sudo', 'wg', 'show', f"wg{wg_id}", 'dump'], capture_output=True, text=True, check=True) output_lines = result.stdout.strip().splitlines() @@ -273,7 +283,7 @@ def _get_current_wg_peer_traffic(self, wg_id: int) -> dict: parts = line.strip().split('\t') # Explicitly split by tab # A valid peer line should have exactly 8 parts - if len(parts) == 8: + if len(parts) >= 8: # Sometimes fields can be more, but first 8 are standard try: pubkey = parts[0] # Peer public key is the first field rx = int(parts[5]) # transfer_rx is the 6th field (index 5) @@ -281,7 +291,7 @@ def _get_current_wg_peer_traffic(self, wg_id: int) -> dict: traffic_data[pubkey] = {'rx': rx, 'tx': tx} except (ValueError, IndexError) as e: print(f"Warning: Could not parse wg dump peer line: '{line.strip()}'. Error: {e}") - elif len(parts) == 4: + elif len(parts) >= 4: # This is likely the interface line (Private Key, Public Key, Listen Port, FwMark) # We can skip this line as it doesn't contain peer traffic info. pass @@ -290,7 +300,8 @@ def _get_current_wg_peer_traffic(self, wg_id: int) -> dict: print(f"Warning: Unexpected line format or number of parts in wg dump output: '{line.strip()}'") except subprocess.CalledProcessError as e: - print(f"Warning: Failed to run `sudo wg show wg{wg_id} dump`. Error: {e.stderr.strip()}. Please ensure WireGuard is installed and you have appropriate permissions (e.g., sudo access).") + error_msg = e.stderr.strip() if e.stderr else "Unknown error" + print(f"Warning: Failed to run `sudo wg show wg{wg_id} dump`. Error: {error_msg}. Please ensure WireGuard is installed and you have appropriate permissions (e.g., sudo access).") except Exception as e: print(f"An unexpected error occurred while getting traffic for wg{wg_id}: {e}") return traffic_data @@ -440,8 +451,9 @@ def _install_candy_panel(self, server_ip: str, self.db.update('settings', {'value': wg_dns}, {'key': 'dns'}) if wg_ipv6_dns: self.db.update('settings', {'value': wg_ipv6_dns},{'key': 'ipv6_dns'}) - # IMPORTANT: In a real app, hash the admin password before storing! - admin_data = json.dumps({'user': admin_user, 'password': admin_password}) + # Hash the admin password before storing + hashed_password = generate_password_hash(admin_password) + admin_data = json.dumps({'user': admin_user, 'password': hashed_password}) self.db.update('settings', {'value': admin_data}, {'key': 'admin'}) self.db.update('settings', {'value': '1'}, {'key': 'install'}) print("[+] Installation completed. Sync will run automatically in the background via main.py thread.") @@ -450,11 +462,22 @@ def _install_candy_panel(self, server_ip: str, def _admin_login(self, user: str, password: str) -> tuple[bool, str]: """ Authenticates an admin user. -{{ ... }} - WARNING: Password stored in plaintext in DB. This should be hashed! """ admin_settings = json.loads(self.db.get('settings', where={'key': 'admin'})['value']) - if admin_settings.get('user') == user and admin_settings.get('password') == password: + stored_password = admin_settings.get('password') + + # Check if password matches directly (legacy plaintext) or via hash + is_valid = False + if stored_password == password: + # If it matched as plaintext, upgrade to hash immediately + is_valid = True + new_hash = generate_password_hash(password) + admin_settings['password'] = new_hash + self.db.update('settings', {'value': json.dumps(admin_settings)}, {'key': 'admin'}) + elif check_password_hash(stored_password, password): + is_valid = True + + if admin_settings.get('user') == user and is_valid: session_token = str(uuid.uuid4()) self.db.update('settings', {'value': session_token}, {'key': 'session_token'}) return True, session_token @@ -646,720 +669,720 @@ def _edit_client(self, name: str, expire: str = None, traffic: str = None, statu update_data = {} client_public_key = current_client['public_key'] - if expire is not None: - update_data['expires'] = expire - if traffic is not None: - update_data['traffic'] = traffic - if note is not None: - update_data['note'] = note - - # Handle status change - if status is not None and status != current_client['status']: - update_data['status'] = status - wg_id = current_client['wg'] - - if status: # Changing to Active - try: - self._add_peer_to_config(wg_id, name, client_public_key, current_client['address'], current_client.get('ipv6_address')) - except CommandExecutionError as e: - return False, str(e) - else: # Changing to Inactive - try: - self._remove_peer_from_config(wg_id, name, client_public_key) - except CommandExecutionError as e: - return False, str(e) - - # Only update if there's actual data to change - if update_data: - self.db.update('clients', update_data, {'name': name}) - return True, f"Client '{name}' edited successfully." - else: - return False, "No valid update data provided." # Or True, "Nothing to update." if that's desired - - - - def _new_interface_wg(self, address_range: str, port: int, ipv6_address_range: str = None) -> tuple[bool, str]: - """ - Creates a new WireGuard interface configuration and adds it to the database, with IPv6 support. - """ - interfaces = self.db.select('interfaces') - # Check for existing port or address range conflicts - for interface in interfaces: - if int(interface['port']) == port: # Ensure type consistency for comparison - return False, f"An interface with port {port} already exists." - if interface['address_range'] == address_range: - return False, f"An interface with address range {address_range} already exists." - if ipv6_address_range and interface.get('ipv6_address_range') == ipv6_address_range: - return False, f"An interface with IPv6 address range {ipv6_address_range} already exists." - - # Find the next available wg ID - existing_wg_ids = sorted([int(i['wg']) for i in interfaces]) - new_wg_id = 0 - while new_wg_id in existing_wg_ids: - new_wg_id += 1 - - interface_name = f"wg{new_wg_id}" - path = self._get_interface_path(interface_name) - print("[+] Installing and configuring UFW...") - try: - self.run_command("sudo ufw default deny incoming") - self.run_command("sudo ufw default allow outgoing") - self.run_command(f"sudo ufw allow {port}/udp") - self.run_command("sudo ufw --force enable") - print("[+] UFW configured successfully.") - except Exception as e: - return False, f"Failed to configure UFW: {e}" - if self._interface_exists(interface_name): - return False, f"Interface {interface_name} configuration file already exists." - default_interface = self._get_default_interface() - private_key, public_key = self._generate_keypair() - server_private_key_path = SERVER_PRIVATE_KEY_PATH.replace('X', str(new_wg_id)) - server_public_key_path = SERVER_PUBLIC_KEY_PATH.replace('X', str(new_wg_id)) - with open(server_private_key_path, "w") as f: - f.write(private_key) - os.chmod(server_private_key_path, 0o600) - with open(server_public_key_path, "w") as f: - f.write(public_key) - - # Build Address and DNS lines for the config - addresses = [address_range] - if ipv6_address_range: - addresses.append(ipv6_address_range) - address_line = "Address = " + ", ".join(addresses) - - dns_settings = self.db.get('settings', where={'key': 'dns'}) - dns = dns_settings['value'] if dns_settings else '8.8.8.8' - dns_servers = [dns] - ipv6_dns_settings = self.db.get('settings', where={'key': 'ipv6_dns'}) - if ipv6_dns_settings and ipv6_dns_settings['value']: - dns_servers.append(ipv6_dns_settings['value']) - dns_line = "DNS = " + ", ".join(dns_servers) - - config = f"""[Interface] -PrivateKey = {private_key} -{address_line} -ListenPort = {port} -MTU = 1420 -{dns_line} - -PostUp = iptables -A FORWARD -i {interface_name} -j ACCEPT; iptables -t nat -A POSTROUTING -o {default_interface} -j MASQUERADE; ip6tables -A FORWARD -i {interface_name} -j ACCEPT; ip6tables -t nat -A POSTROUTING -o {default_interface} -j MASQUERADE -PostDown = iptables -D FORWARD -i {interface_name} -j ACCEPT; iptables -t nat -D POSTROUTING -o {default_interface} -j MASQUERADE; ip6tables -D FORWARD -i {interface_name} -j ACCEPT; ip6tables -t nat -D POSTROUTING -o {default_interface} -j MASQUERADE -""" - try: - with open(path, "w") as f: - f.write(config) - os.chmod(path, 0o600) - print(f"[+] Interface {interface_name} created.") - self.run_command(f"sudo systemctl enable wg-quick@{interface_name}") # Enable service - self._reload_wireguard(new_wg_id) # Reload the new interface - except Exception as e: - return False, f"Failed to create or reload interface {interface_name}: {e}" - - self.db.insert('interfaces', { - 'wg': new_wg_id, - 'private_key': private_key, - 'public_key': public_key, - 'port': port, - 'address_range': address_range, - 'ipv6_address_range': ipv6_address_range, - 'status': True - }) - return True, 'New Interface Created!' - - def _edit_interface(self, name: str, address: str = None, port: int = None, status: bool = None) -> tuple[bool, str]: - """ - Edits an existing WireGuard interface configuration and updates the database, with IPv6 support. - 'name' should be in 'wgX' format (e.g., 'wg0'). - Handles starting/stopping the interface based on status change. - """ - wg_id = int(name.replace('wg', '')) - current_interface = self.db.get('interfaces', where={'wg': wg_id}) - if not current_interface: - return False, f"Interface {name} does not exist in database." - - config_path = self._get_interface_path(name) - if not self._interface_exists(name): - return False, f"Interface {name} configuration file does not exist." - - update_data = {} - reload_needed = False - service_action_needed = False - - try: - # Read current config to modify - with open(config_path, "r") as f: - lines = f.readlines() - - new_lines = [] - for line in lines: - if line.strip().startswith("Address ="): - # This logic needs to be more robust for multiple addresses - if address is not None and current_interface['address_range'] != address: - new_line = line.replace(current_interface['address_range'], address) - new_lines.append(new_line) - update_data['address_range'] = address - reload_needed = True - else: - new_lines.append(line) - elif port is not None and line.strip().startswith("ListenPort ="): - if int(current_interface['port']) != port: - new_lines.append(f"ListenPort = {port}\n") - update_data['port'] = port - reload_needed = True - else: - new_lines.append(line) - else: - new_lines.append(line) - - # Write updated config back - if reload_needed: - with open(config_path, "w") as f: - f.writelines(new_lines) - - # Handle status change (start/stop service) - if status is not None and status != current_interface['status']: - update_data['status'] = status - service_action_needed = True - if status: # Changing to Active - self.run_command(f"sudo systemctl start wg-quick@{name}") - print(f"[+] Interface {name} started.") - else: # Changing to Inactive - self.run_command(f"sudo systemctl stop wg-quick@{name}") - print(f"[+] Interface {name} stopped.") - - # Perform DB update only if there's data to update - if update_data: - self.db.update('interfaces', update_data, {'wg': wg_id}) - - # Reload only if config file was changed and service wasn't explicitly started/stopped - if reload_needed and not service_action_needed: - self._reload_wireguard(wg_id) - - return True, f"Interface {name} edited successfully." - except Exception as e: - return False, f"Error editing interface {name}: {e}" - - def _delete_interface(self, wg_id: int) -> tuple[bool, str]: - """ - Deletes a WireGuard interface, stops its service, removes config files, - and deletes associated clients and the interface from the database. - """ - interface_name = f"wg{wg_id}" - interface = self.db.get('interfaces', where={'wg': wg_id}) - if not interface: - return False, f"Interface {interface_name} not found." - - try: - # 1. Stop and disable the WireGuard service - self.run_command(f"sudo systemctl stop wg-quick@{interface_name}", check=False) - self.run_command(f"sudo systemctl disable wg-quick@{interface_name}", check=False) - print(f"[+] WireGuard service wg-quick@{interface_name} stopped and disabled.") - - # 2. Remove WireGuard configuration files and keys - config_path = WG_CONF_PATH.replace('X', str(wg_id)) - private_key_path = SERVER_PRIVATE_KEY_PATH.replace('X', str(wg_id)) - public_key_path = SERVER_PUBLIC_KEY_PATH.replace('X', str(wg_id)) - - if os.path.exists(config_path): - os.remove(config_path) - print(f"[+] Removed config file: {config_path}") - if os.path.exists(private_key_path): - os.remove(private_key_path) - print(f"[+] Removed private key: {private_key_path}") - if os.path.exists(public_key_path): - os.remove(public_key_path) - print(f"[+] Removed public key: {public_key_path}") - - # 3. Delete all clients associated with this interface from the database - clients_to_delete = self.db.select('clients', where={'wg': wg_id}) - for client in clients_to_delete: - self.db.delete('clients', {'name': client['name']}) - print(f"[+] Deleted associated client: {client['name']}") - - # 4. Delete the interface record from the database - self.db.delete('interfaces', {'wg': wg_id}) - print(f"[+] Interface {interface_name} deleted from database.") - - return True, f"Interface {interface_name} and all associated clients deleted successfully." - except Exception as e: - return False, f"Error deleting interface {interface_name}: {e}" - - - def _get_client_config(self, name: str) -> tuple[bool, str]: - """ - Generates and returns the WireGuard client configuration for a given client name, with IPv6 support. - """ - client = self.db.get('clients', where={'name': name}) - if not client: - return False, 'Client not found.' - - interface = self.db.get('interfaces', where={'wg': client['wg']}) - if not interface: - return False, f"Associated WireGuard interface wg{client['wg']} not found." - - dns = self.db.get('settings', where={'key': 'dns'})['value'] - ipv6_dns_setting = self.db.get('settings', where={'key': 'ipv6_dns'}) - mtu = self.db.get('settings', where={'key': 'mtu'}) - mtu_value = mtu['value'] if mtu else '1420' # Default MTU if not found - server_ip = self.db.get('settings', where={'key': 'custom_endpont'})['value'] - - address_line = f"Address = {client['address']}/32" - if client.get('ipv6_address'): - address_line += f", {client['ipv6_address']}/128" - - dns_line = f"DNS = {dns}" - if ipv6_dns_setting and ipv6_dns_setting['value']: - dns_line += f", {ipv6_dns_setting['value']}" - - client_config = f""" -[Interface] -PrivateKey = {client['private_key']} -{address_line} -{dns_line} -MTU = {mtu_value} - -[Peer] -PublicKey = {interface['public_key']} -Endpoint = {server_ip}:{interface['port']} -AllowedIPs = 0.0.0.0/0, ::/0 -PersistentKeepalive = 25 -""" - return True, client_config - - def _change_settings(self, key: str, value: str) -> tuple[bool, str]: - """ - Changes a specific setting in the database. - """ - if not self.db.has('settings', {'key': key}): - return False, 'Invalid Key' - # Corrected: Update the 'value' column for the given 'key' - self.db.update('settings', {'value': value}, {'key': key}) - return True, 'Changed!' - - def _add_api_token(self, name: str, token: str) -> tuple[bool, str]: - """ - Adds or updates an API token in the settings. - Tokens are stored as a JSON string dictionary. - """ - try: - settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) - # Initialize with empty dict if 'api_tokens' key doesn't exist or value is not valid JSON - current_tokens = {} - if settings_entry and settings_entry['value']: - try: - current_tokens = json.loads(settings_entry['value']) - except json.JSONDecodeError: - print(f"Warning: 'api_tokens' setting contains invalid JSON. Resetting.") - current_tokens[name] = token - self.db.update('settings', {'value': json.dumps(current_tokens)}, {'key': 'api_tokens'}) - return True, f"API token '{name}' added/updated successfully." - except Exception as e: - return False, f"Failed to add/update API token: {e}" - - def _delete_api_token(self, name: str) -> tuple[bool, str]: - """ - Deletes an API token from the settings. - """ - try: - settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) - if not settings_entry or not settings_entry['value']: - return False, "API tokens setting not found or is empty." - - current_tokens = json.loads(settings_entry['value']) - if name in current_tokens: - del current_tokens[name] - self.db.update('settings', {'value': json.dumps(current_tokens)}, {'key': 'api_tokens'}) - return True, f"API token '{name}' deleted successfully." - else: - return False, f"API token '{name}' not found." - except json.JSONDecodeError: - return False, "API tokens setting contains invalid JSON. Cannot delete token." - except Exception as e: - return False, f"Failed to delete API token: {e}" - - def _get_api_token(self, name: str) -> tuple[bool, str | None]: - """ - Retrieves a specific API token from the settings. - """ - try: - settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) - if not settings_entry or not settings_entry['value']: - return False, "API tokens setting not found or is empty." - - current_tokens = json.loads(settings_entry['value']) - if name in current_tokens: - return True, current_tokens[name] - else: - return False, f"API token '{name}' not found." - except json.JSONDecodeError: - return False, "API tokens setting contains invalid JSON. Cannot retrieve token." - except Exception as e: - return False, f"Failed to retrieve API token: {e}" - def _generate_unique_short_code(self, length=7): # - """ - Generates a unique short alphanumeric code for a URL. - """ - characters = string.ascii_letters + string.digits - while True: - short_code = generate(characters, length) # - if not self.db.has('shortlinks', {'short_code': short_code}): # - return short_code - def _get_client_by_name_and_public_key(self, name: str, public_key: str) -> dict | None: - """ - Retrieves a client record by its name AND public key. - This is used for public-facing client detail pages. - """ - client = self.db.get('clients', where={'name': name, 'public_key': public_key}) - if not client: - return None - - # Parse used_trafic JSON string into a dict, handling potential errors - try: - used_traffic_raw = client.get('used_trafic', '{"download":0,"upload":0}') - client['used_trafic'] = json.loads(used_traffic_raw) - except (json.JSONDecodeError, TypeError): - print(f"[!] Warning: Invalid JSON in used_trafic for client '{name}'. Resetting to defaults.") - client['used_trafic'] = {"download": 0, "upload": 0} - - client.pop('wg', None) - - # Fetch relevant interface details - interface = self.db.get('interfaces', where={'wg': client.get('wg', 0)}) # Use .get with default in case 'wg' was popped - if interface: - client['interface_public_key'] = interface['public_key'] - client['interface_port'] = interface['port'] - else: - client['interface_public_key'] = None - client['interface_port'] = None - - # Add server endpoint details from settings - client['server_endpoint_ip'] = self.db.get('settings', where={'key': 'custom_endpont'})['value'] - client['server_dns'] = self.db.get('settings', where={'key': 'dns'})['value'] - client['server_mtu'] = self.db.get('settings', where={'key': 'mtu'})['value'] - return client - def _is_telegram_bot_running(self, pid: int) -> bool: - """ - Checks if the Telegram bot process with the given PID is running. - """ - if pid <= 0: - return False - try: - process = psutil.Process(pid) - return process.is_running() and "bot.py" in " ".join(process.cmdline()) - except psutil.NoSuchProcess: - return False - except Exception as e: - print(f"Error checking Telegram bot status for PID {pid}: {e}") - return False - def _manage_telegram_bot_process(self, action: str) -> bool: - """ - Starts or stops the bot.py script as a detached subprocess. - Stores/clears its PID in the settings. - This method is called directly by API for immediate effect. - """ - pid_setting = self.db.get('settings', where={'key': 'telegram_bot_pid'}) - current_pid = int(pid_setting['value']) if pid_setting and pid_setting['value'].isdigit() else 0 - - is_running = self._is_telegram_bot_running(current_pid) - - # Get the path to the virtual environment's python interpreter - current_script_dir = os.path.dirname(os.path.abspath(__file__)) - venv_python_path = os.path.join(current_script_dir, 'venv', 'bin', 'python3') - - if action == 'start': - if is_running: - print(f"[*] Telegram bot (PID: {current_pid}) is already running.") - return True - print("[*] Attempting to start Telegram bot...") - try: - bot_token_setting = self.db.get('settings', where={'key': 'telegram_bot_token'}) - api_id_setting = self.db.get('settings', where={'key': 'telegram_api_id'}) - api_hash_setting = self.db.get('settings', where={'key': 'telegram_api_hash'}) - ap_port_setting = self.db.get('settings', where={'key': 'ap_port'}) # Get AP_PORT - - if not bot_token_setting or bot_token_setting['value'] == 'YOUR_TELEGRAM_BOT_TOKEN': - print("[!] Telegram bot token not configured. Cannot start bot.") - return False - if not api_id_setting or not api_id_setting['value'].isdigit(): - print("[!] Telegram API ID not configured or invalid. Cannot start bot.") - return False - if not api_hash_setting or not api_hash_setting['value']: - print("[!] Telegram API Hash not configured. Cannot start bot.") - return False - - bot_script_path = os.path.join(current_script_dir, 'bot.py') - - # Verify venv python path exists - if not os.path.exists(venv_python_path): - print(f"[!] Error: Virtual environment Python interpreter not found at {venv_python_path}. Please ensure the virtual environment is correctly set up.") - return False - - env = os.environ.copy() - env["TELEGRAM_API_ID"] = api_id_setting['value'] - env["TELEGRAM_API_HASH"] = api_hash_setting['value'] - if ap_port_setting and ap_port_setting['value'].isdigit(): - env["AP_PORT"] = ap_port_setting['value'] - else: - env["AP_PORT"] = '3446' # Default if not set in DB - - log_file_path = "/var/log/candy-telegram-bot.log" - with open(log_file_path, "a") as log_file: - process = subprocess.Popen( - [venv_python_path, bot_script_path], # Use venv's python - stdout=log_file, - stderr=log_file, - preexec_fn=os.setsid, - env=env - ) - self.db.update('settings', {'value': str(process.pid)}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '1'}, {'key': 'telegram_bot_status'}) - print(f"[+] Telegram bot started with PID: {process.pid}") - return True - except FileNotFoundError: - print(f"[!] Error: bot.py not found at {bot_script_path} or venv python not found. Cannot start bot.") - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return False - except Exception as e: - print(f"[!] Failed to start Telegram bot: {e}") - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return False - - elif action == 'stop': - if not is_running: - print("[*] Telegram bot is already stopped (or PID is stale).") - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return True - - print("[*] Attempting to stop Telegram bot...") - try: - process = psutil.Process(current_pid) - cmdline = " ".join(process.cmdline()).lower() - if "bot.py" in cmdline and "python" in cmdline: - process.terminate() - process.wait(timeout=5) - print(f"[+] Telegram bot (PID: {current_pid}) stopped.") - else: - print(f"[!] PID {current_pid} is not identified as the Telegram bot. Not terminating.") - - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return True - except psutil.NoSuchProcess: - print(f"[!] Telegram bot process with PID {current_pid} not found. Assuming it's already stopped.") - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return True - except psutil.TimeoutExpired: - print(f"[!] Telegram bot process with PID {current_pid} did not terminate gracefully. Killing...") - process.kill() - process.wait() - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) - self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) - return True - except Exception as e: - print(f"[!] Error stopping Telegram bot (PID: {current_pid}): {e}") - return False - return False # Invalid action - - def _calculate_and_update_traffic(self): - """ - Calculates and updates cumulative traffic for all clients. - This replaces the old traffic.json logic. - """ - print("[*] Calculating and updating client traffic statistics...") - - # Get current traffic from all interfaces - current_wg_traffic = {} - for interface_row in self.db.select('interfaces'): - wg_id = interface_row['wg'] - current_wg_traffic.update(self._get_current_wg_peer_traffic(wg_id)) - - # Total bandwidth consumed by all clients in this cycle - total_bandwidth_consumed_this_cycle = 0 - - # Iterate through all clients in the database - all_clients_in_db = self.db.select('clients') - for client in all_clients_in_db: - client_public_key = client['public_key'] - client_name = client['name'] - - # Get the current readings from 'wg show dump' - current_rx = current_wg_traffic.get(client_public_key, {}).get('rx', 0) - current_tx = current_wg_traffic.get(client_public_key, {}).get('tx', 0) - - try: - # Parse existing used_trafic data (which now includes last_wg_rx/tx) - used_traffic_data = json.loads(client.get('used_trafic', '{"download":0,"upload":0,"last_wg_rx":0,"last_wg_tx":0}')) - - cumulative_download = used_traffic_data.get('download', 0) - cumulative_upload = used_traffic_data.get('upload', 0) - last_wg_rx = used_traffic_data.get('last_wg_rx', 0) - last_wg_tx = used_traffic_data.get('last_wg_tx', 0) - - # Calculate delta for this sync cycle - # Handle WireGuard counter resets: If current < last, assume reset and add current as delta. - delta_rx = current_rx - last_wg_rx - if delta_rx < 0: - print(f"[*] Detected RX counter reset for client '{client_name}'. Adding current RX ({current_rx} bytes) as delta.") - delta_rx = current_rx - - delta_tx = current_tx - last_wg_tx - if delta_tx < 0: - print(f"[*] Detected TX counter reset for client '{client_name}'. Adding current TX ({current_tx} bytes) as delta.") - delta_tx = current_tx - - delta_rx = max(0, delta_rx) # Ensure non-negative - delta_tx = max(0, delta_tx) # Ensure non-negative - - # Update cumulative totals - cumulative_download += delta_rx - cumulative_upload += delta_tx - - # Prepare updated JSON for DB - updated_used_traffic = { - 'download': cumulative_download, - 'upload': cumulative_upload, - 'last_wg_rx': current_rx, # Store current readings for next cycle's delta calculation - 'last_wg_tx': current_tx - } - - self.db.update('clients', {'used_trafic': json.dumps(updated_used_traffic)}, {'name': client_name}) - - total_bandwidth_consumed_this_cycle += (delta_rx + delta_tx) - - except (json.JSONDecodeError, ValueError, TypeError) as e: - print(f"[!] Error processing traffic for client '{client_name}': {e}. Skipping this client's traffic update.") - - # Update overall server bandwidth in settings - old_bandwidth_setting = self.db.get('settings', where={'key': 'bandwidth'}) - current_total_bandwidth = int(old_bandwidth_setting['value']) if old_bandwidth_setting and old_bandwidth_setting['value'].isdigit() else 0 - new_total_bandwidth = current_total_bandwidth + total_bandwidth_consumed_this_cycle - self.db.update('settings', {'value': str(new_total_bandwidth)}, {'key': 'bandwidth'}) - print("[*] Client traffic statistics updated.") - - - def _sync(self): - """ - Synchronizes client data, traffic, and performs scheduled tasks. - This method should be run periodically (e.g., via cron). - """ - print("[*] Starting synchronization process...") - - # --- Handle Reset Timer for Interface Reloads --- - reset_time_setting = self.db.get('settings', where={'key': 'reset_time'}) - reset_time = int(reset_time_setting['value']) if reset_time_setting and reset_time_setting['value'].isdigit() else 0 - - reset_timer_file = 'reset.timer' - if reset_time != 0: - if not os.path.exists(reset_timer_file): - # If timer file doesn't exist, create it with future reset time - future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) - with open(reset_timer_file, 'w') as o: - o.write(str(future_reset_timestamp)) - print(f"[*] Reset timer file created. Next reset scheduled for {datetime.fromtimestamp(future_reset_timestamp)}.") - else: - # Check if reset time has passed - try: - with open(reset_timer_file, 'r') as o: - scheduled_reset_timestamp = int(float(o.read().strip())) # Use float for robustness - except (ValueError, FileNotFoundError): - print(f"Warning: Could not read or parse {reset_timer_file}. Recreating.") - future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) - with open(reset_timer_file, 'w') as o: - o.write(str(future_reset_timestamp)) - scheduled_reset_timestamp = future_reset_timestamp # Set for current cycle - - if int(time.time()) >= scheduled_reset_timestamp: - print("[*] Reset time reached. Reloading WireGuard interfaces...") - # Update timer for next reset - new_future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) - with open(reset_timer_file, 'w') as o: - o.write(str(new_future_reset_timestamp)) - print(f"[*] Reset timer updated. Next reset scheduled for {datetime.fromtimestamp(new_future_reset_timestamp)}.") - - # Reload all active interfaces - for interface in self.db.select('interfaces', where={'status': True}): - self._reload_wireguard(interface['wg']) - else: - print(f"[*] Next reset in {scheduled_reset_timestamp - int(time.time())} seconds.") - else: - if os.path.exists(reset_timer_file): - os.remove(reset_timer_file) # Clean up if reset_time is 0 - - - # --- Auto Backup --- - auto_backup_setting = self.db.get('settings', where={'key': 'auto_backup'}) - auto_backup_enabled = bool(int(auto_backup_setting['value'])) if auto_backup_setting and auto_backup_setting['value'].isdigit() else False - - if auto_backup_enabled: - print("[*] Performing auto backup of WireGuard configurations...") - for interface in self.db.select('interfaces'): - self._backup_config(interface['wg']) - - # --- Client Expiration and Traffic Limit Enforcement (Disable, not Delete) --- - current_time = datetime.now() - # FIX: Fetch all clients to disable into a list first, BEFORE iterating and updating - clients_to_disable = [] - active_clients = self.db.select('clients', where={'status': True}) - for client in active_clients: # Iterating over fetched results - should_disable = False - disable_reason = "" - - # Check expiration - try: - expires_dt = datetime.fromisoformat(client['expires']) - if current_time >= expires_dt: - should_disable = True - disable_reason = "expired" - except (ValueError, TypeError): - print(f"[!] Warning: Invalid expires date format for client '{client['name']}'. Skipping expiration check.") - - # Check traffic limit (only if not already marked for disabling by expiration) - if not should_disable: - try: - traffic_limit = int(client['traffic']) # Expected total traffic quota in bytes - used_traffic_data = json.loads(client['used_trafic']) - total_used_traffic = used_traffic_data.get('download', 0) + used_traffic_data.get('upload', 0) - - if traffic_limit > 0 and total_used_traffic >= traffic_limit: - should_disable = True - disable_reason = "exceeded traffic limit" - except (ValueError, TypeError, json.JSONDecodeError) as e: - print(f"[!] Warning: Invalid traffic data for client '{client['name']}'. Skipping traffic limit check. Error: {e}") - - if should_disable: - clients_to_disable.append(client['name']) # Collect names to disable - - # Now, iterate over the collected names and perform the database updates - for client_name_to_disable in clients_to_disable: - print(f"[!] Client '{client_name_to_disable}' needs disabling. Disabling...") - self._disable_client(client_name_to_disable) - # --- Update Traffic Statistics --- - self._calculate_and_update_traffic() - - # --- Update Uptime --- - # Get system boot time and calculate uptime - boot_time_timestamp = psutil.boot_time() # Returns UTC timestamp - current_timestamp = time.time() - calculated_uptime_seconds = int(current_timestamp - boot_time_timestamp) - self.db.update('settings', {'value': str(calculated_uptime_seconds)}, {'key': 'uptime'}) - print("[*] Uptime updated.") - - # --- Ensure AP_PORT setting is in sync with environment (for display purposes) --- - # This just updates the DB with what the system is actually running on, not - # to trigger a change in the running port which needs a Flask app restart. - actual_ap_port = os.environ.get('AP_PORT', '3446') - stored_ap_port = self.db.get('settings', where={'key': 'ap_port'}) - if not stored_ap_port or stored_ap_port['value'] != actual_ap_port: - self.db.update('settings', {'value': actual_ap_port}, {'key': 'ap_port'}) - print(f"[*] Updated ap_port in settings to reflect environment variable: {actual_ap_port}") - - print("[*] Synchronization process completed.") - - -# Custom exception for command execution errors -class CommandExecutionError(Exception): + if expire is not None: + update_data['expires'] = expire + if traffic is not None: + update_data['traffic'] = traffic + if note is not None: + update_data['note'] = note + + # Handle status change + if status is not None and status != current_client['status']: + update_data['status'] = status + wg_id = current_client['wg'] + + if status: # Changing to Active + try: + self._add_peer_to_config(wg_id, name, client_public_key, current_client['address'], current_client.get('ipv6_address')) + except CommandExecutionError as e: + return False, str(e) + else: # Changing to Inactive + try: + self._remove_peer_from_config(wg_id, name, client_public_key) + except CommandExecutionError as e: + return False, str(e) + + # Only update if there's actual data to change + if update_data: + self.db.update('clients', update_data, {'name': name}) + return True, f"Client '{name}' edited successfully." + else: + return False, "No valid update data provided." # Or True, "Nothing to update." if that's desired + + + + def _new_interface_wg(self, address_range: str, port: int, ipv6_address_range: str = None) -> tuple[bool, str]: + """ + Creates a new WireGuard interface configuration and adds it to the database, with IPv6 support. + """ + interfaces = self.db.select('interfaces') + # Check for existing port or address range conflicts + for interface in interfaces: + if int(interface['port']) == port: # Ensure type consistency for comparison + return False, f"An interface with port {port} already exists." + if interface['address_range'] == address_range: + return False, f"An interface with address range {address_range} already exists." + if ipv6_address_range and interface.get('ipv6_address_range') == ipv6_address_range: + return False, f"An interface with IPv6 address range {ipv6_address_range} already exists." + + # Find the next available wg ID + existing_wg_ids = sorted([int(i['wg']) for i in interfaces]) + new_wg_id = 0 + while new_wg_id in existing_wg_ids: + new_wg_id += 1 + + interface_name = f"wg{new_wg_id}" + path = self._get_interface_path(interface_name) + print("[+] Installing and configuring UFW...") + try: + self.run_command("sudo ufw default deny incoming") + self.run_command("sudo ufw default allow outgoing") + self.run_command(f"sudo ufw allow {port}/udp") + self.run_command("sudo ufw --force enable") + print("[+] UFW configured successfully.") + except Exception as e: + return False, f"Failed to configure UFW: {e}" + if self._interface_exists(interface_name): + return False, f"Interface {interface_name} configuration file already exists." + default_interface = self._get_default_interface() + private_key, public_key = self._generate_keypair() + server_private_key_path = SERVER_PRIVATE_KEY_PATH.replace('X', str(new_wg_id)) + server_public_key_path = SERVER_PUBLIC_KEY_PATH.replace('X', str(new_wg_id)) + with open(server_private_key_path, "w") as f: + f.write(private_key) + os.chmod(server_private_key_path, 0o600) + with open(server_public_key_path, "w") as f: + f.write(public_key) + + # Build Address and DNS lines for the config + addresses = [address_range] + if ipv6_address_range: + addresses.append(ipv6_address_range) + address_line = "Address = " + ", ".join(addresses) + + dns_settings = self.db.get('settings', where={'key': 'dns'}) + dns = dns_settings['value'] if dns_settings else '8.8.8.8' + dns_servers = [dns] + ipv6_dns_settings = self.db.get('settings', where={'key': 'ipv6_dns'}) + if ipv6_dns_settings and ipv6_dns_settings['value']: + dns_servers.append(ipv6_dns_settings['value']) + dns_line = "DNS = " + ", ".join(dns_servers) + + config = f"""[Interface] +PrivateKey = {private_key} +{address_line} +ListenPort = {port} +MTU = 1420 +{dns_line} + +PostUp = iptables -A FORWARD -i {interface_name} -j ACCEPT; iptables -t nat -A POSTROUTING -o {default_interface} -j MASQUERADE; ip6tables -A FORWARD -i {interface_name} -j ACCEPT; ip6tables -t nat -A POSTROUTING -o {default_interface} -j MASQUERADE +PostDown = iptables -D FORWARD -i {interface_name} -j ACCEPT; iptables -t nat -D POSTROUTING -o {default_interface} -j MASQUERADE; ip6tables -D FORWARD -i {interface_name} -j ACCEPT; ip6tables -t nat -D POSTROUTING -o {default_interface} -j MASQUERADE +""" + try: + with open(path, "w") as f: + f.write(config) + os.chmod(path, 0o600) + print(f"[+] Interface {interface_name} created.") + self.run_command(f"sudo systemctl enable wg-quick@{interface_name}") # Enable service + self._reload_wireguard(new_wg_id) # Reload the new interface + except Exception as e: + return False, f"Failed to create or reload interface {interface_name}: {e}" + + self.db.insert('interfaces', { + 'wg': new_wg_id, + 'private_key': private_key, + 'public_key': public_key, + 'port': port, + 'address_range': address_range, + 'ipv6_address_range': ipv6_address_range, + 'status': True + }) + return True, 'New Interface Created!' + + def _edit_interface(self, name: str, address: str = None, port: int = None, status: bool = None) -> tuple[bool, str]: + """ + Edits an existing WireGuard interface configuration and updates the database, with IPv6 support. + 'name' should be in 'wgX' format (e.g., 'wg0'). + Handles starting/stopping the interface based on status change. + """ + wg_id = int(name.replace('wg', '')) + current_interface = self.db.get('interfaces', where={'wg': wg_id}) + if not current_interface: + return False, f"Interface {name} does not exist in database." + + config_path = self._get_interface_path(name) + if not self._interface_exists(name): + return False, f"Interface {name} configuration file does not exist." + + update_data = {} + reload_needed = False + service_action_needed = False + + try: + # Read current config to modify + with open(config_path, "r") as f: + lines = f.readlines() + + new_lines = [] + for line in lines: + if line.strip().startswith("Address ="): + # This logic needs to be more robust for multiple addresses + if address is not None and current_interface['address_range'] != address: + new_line = line.replace(current_interface['address_range'], address) + new_lines.append(new_line) + update_data['address_range'] = address + reload_needed = True + else: + new_lines.append(line) + elif port is not None and line.strip().startswith("ListenPort ="): + if int(current_interface['port']) != port: + new_lines.append(f"ListenPort = {port}\n") + update_data['port'] = port + reload_needed = True + else: + new_lines.append(line) + else: + new_lines.append(line) + + # Write updated config back + if reload_needed: + with open(config_path, "w") as f: + f.writelines(new_lines) + + # Handle status change (start/stop service) + if status is not None and status != current_interface['status']: + update_data['status'] = status + service_action_needed = True + if status: # Changing to Active + self.run_command(f"sudo systemctl start wg-quick@{name}") + print(f"[+] Interface {name} started.") + else: # Changing to Inactive + self.run_command(f"sudo systemctl stop wg-quick@{name}") + print(f"[+] Interface {name} stopped.") + + # Perform DB update only if there's data to update + if update_data: + self.db.update('interfaces', update_data, {'wg': wg_id}) + + # Reload only if config file was changed and service wasn't explicitly started/stopped + if reload_needed and not service_action_needed: + self._reload_wireguard(wg_id) + + return True, f"Interface {name} edited successfully." + except Exception as e: + return False, f"Error editing interface {name}: {e}" + + def _delete_interface(self, wg_id: int) -> tuple[bool, str]: + """ + Deletes a WireGuard interface, stops its service, removes config files, + and deletes associated clients and the interface from the database. + """ + interface_name = f"wg{wg_id}" + interface = self.db.get('interfaces', where={'wg': wg_id}) + if not interface: + return False, f"Interface {interface_name} not found." + + try: + # 1. Stop and disable the WireGuard service + self.run_command(f"sudo systemctl stop wg-quick@{interface_name}", check=False) + self.run_command(f"sudo systemctl disable wg-quick@{interface_name}", check=False) + print(f"[+] WireGuard service wg-quick@{interface_name} stopped and disabled.") + + # 2. Remove WireGuard configuration files and keys + config_path = WG_CONF_PATH.replace('X', str(wg_id)) + private_key_path = SERVER_PRIVATE_KEY_PATH.replace('X', str(wg_id)) + public_key_path = SERVER_PUBLIC_KEY_PATH.replace('X', str(wg_id)) + + if os.path.exists(config_path): + os.remove(config_path) + print(f"[+] Removed config file: {config_path}") + if os.path.exists(private_key_path): + os.remove(private_key_path) + print(f"[+] Removed private key: {private_key_path}") + if os.path.exists(public_key_path): + os.remove(public_key_path) + print(f"[+] Removed public key: {public_key_path}") + + # 3. Delete all clients associated with this interface from the database + clients_to_delete = self.db.select('clients', where={'wg': wg_id}) + for client in clients_to_delete: + self.db.delete('clients', {'name': client['name']}) + print(f"[+] Deleted associated client: {client['name']}") + + # 4. Delete the interface record from the database + self.db.delete('interfaces', {'wg': wg_id}) + print(f"[+] Interface {interface_name} deleted from database.") + + return True, f"Interface {interface_name} and all associated clients deleted successfully." + except Exception as e: + return False, f"Error deleting interface {interface_name}: {e}" + + + def _get_client_config(self, name: str) -> tuple[bool, str]: + """ + Generates and returns the WireGuard client configuration for a given client name, with IPv6 support. + """ + client = self.db.get('clients', where={'name': name}) + if not client: + return False, 'Client not found.' + + interface = self.db.get('interfaces', where={'wg': client['wg']}) + if not interface: + return False, f"Associated WireGuard interface wg{client['wg']} not found." + + dns = self.db.get('settings', where={'key': 'dns'})['value'] + ipv6_dns_setting = self.db.get('settings', where={'key': 'ipv6_dns'}) + mtu = self.db.get('settings', where={'key': 'mtu'}) + mtu_value = mtu['value'] if mtu else '1420' # Default MTU if not found + server_ip = self.db.get('settings', where={'key': 'custom_endpont'})['value'] + + address_line = f"Address = {client['address']}/32" + if client.get('ipv6_address'): + address_line += f", {client['ipv6_address']}/128" + + dns_line = f"DNS = {dns}" + if ipv6_dns_setting and ipv6_dns_setting['value']: + dns_line += f", {ipv6_dns_setting['value']}" + + client_config = f""" +[Interface] +PrivateKey = {client['private_key']} +{address_line} +{dns_line} +MTU = {mtu_value} + +[Peer] +PublicKey = {interface['public_key']} +Endpoint = {server_ip}:{interface['port']} +AllowedIPs = 0.0.0.0/0, ::/0 +PersistentKeepalive = 25 +""" + return True, client_config + + def _change_settings(self, key: str, value: str) -> tuple[bool, str]: + """ + Changes a specific setting in the database. + """ + if not self.db.has('settings', {'key': key}): + return False, 'Invalid Key' + # Corrected: Update the 'value' column for the given 'key' + self.db.update('settings', {'value': value}, {'key': key}) + return True, 'Changed!' + + def _add_api_token(self, name: str, token: str) -> tuple[bool, str]: + """ + Adds or updates an API token in the settings. + Tokens are stored as a JSON string dictionary. + """ + try: + settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) + # Initialize with empty dict if 'api_tokens' key doesn't exist or value is not valid JSON + current_tokens = {} + if settings_entry and settings_entry['value']: + try: + current_tokens = json.loads(settings_entry['value']) + except json.JSONDecodeError: + print(f"Warning: 'api_tokens' setting contains invalid JSON. Resetting.") + current_tokens[name] = token + self.db.update('settings', {'value': json.dumps(current_tokens)}, {'key': 'api_tokens'}) + return True, f"API token '{name}' added/updated successfully." + except Exception as e: + return False, f"Failed to add/update API token: {e}" + + def _delete_api_token(self, name: str) -> tuple[bool, str]: + """ + Deletes an API token from the settings. + """ + try: + settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) + if not settings_entry or not settings_entry['value']: + return False, "API tokens setting not found or is empty." + + current_tokens = json.loads(settings_entry['value']) + if name in current_tokens: + del current_tokens[name] + self.db.update('settings', {'value': json.dumps(current_tokens)}, {'key': 'api_tokens'}) + return True, f"API token '{name}' deleted successfully." + else: + return False, f"API token '{name}' not found." + except json.JSONDecodeError: + return False, "API tokens setting contains invalid JSON. Cannot delete token." + except Exception as e: + return False, f"Failed to delete API token: {e}" + + def _get_api_token(self, name: str) -> tuple[bool, str | None]: + """ + Retrieves a specific API token from the settings. + """ + try: + settings_entry = self.db.get('settings', where={'key': 'api_tokens'}) + if not settings_entry or not settings_entry['value']: + return False, "API tokens setting not found or is empty." + + current_tokens = json.loads(settings_entry['value']) + if name in current_tokens: + return True, current_tokens[name] + else: + return False, f"API token '{name}' not found." + except json.JSONDecodeError: + return False, "API tokens setting contains invalid JSON. Cannot retrieve token." + except Exception as e: + return False, f"Failed to retrieve API token: {e}" + def _generate_unique_short_code(self, length=7): # + """ + Generates a unique short alphanumeric code for a URL. + """ + characters = string.ascii_letters + string.digits + while True: + short_code = generate(characters, length) # + if not self.db.has('shortlinks', {'short_code': short_code}): # + return short_code + def _get_client_by_name_and_public_key(self, name: str, public_key: str) -> dict | None: + """ + Retrieves a client record by its name AND public key. + This is used for public-facing client detail pages. + """ + client = self.db.get('clients', where={'name': name, 'public_key': public_key}) + if not client: + return None + + # Parse used_trafic JSON string into a dict, handling potential errors + try: + used_traffic_raw = client.get('used_trafic', '{"download":0,"upload":0}') + client['used_trafic'] = json.loads(used_traffic_raw) + except (json.JSONDecodeError, TypeError): + print(f"[!] Warning: Invalid JSON in used_trafic for client '{name}'. Resetting to defaults.") + client['used_trafic'] = {"download": 0, "upload": 0} + + client.pop('wg', None) + + # Fetch relevant interface details + interface = self.db.get('interfaces', where={'wg': client.get('wg', 0)}) # Use .get with default in case 'wg' was popped + if interface: + client['interface_public_key'] = interface['public_key'] + client['interface_port'] = interface['port'] + else: + client['interface_public_key'] = None + client['interface_port'] = None + + # Add server endpoint details from settings + client['server_endpoint_ip'] = self.db.get('settings', where={'key': 'custom_endpont'})['value'] + client['server_dns'] = self.db.get('settings', where={'key': 'dns'})['value'] + client['server_mtu'] = self.db.get('settings', where={'key': 'mtu'})['value'] + return client + def _is_telegram_bot_running(self, pid: int) -> bool: + """ + Checks if the Telegram bot process with the given PID is running. + """ + if pid <= 0: + return False + try: + process = psutil.Process(pid) + return process.is_running() and "bot.py" in " ".join(process.cmdline()) + except psutil.NoSuchProcess: + return False + except Exception as e: + print(f"Error checking Telegram bot status for PID {pid}: {e}") + return False + def _manage_telegram_bot_process(self, action: str) -> bool: + """ + Starts or stops the bot.py script as a detached subprocess. + Stores/clears its PID in the settings. + This method is called directly by API for immediate effect. + """ + pid_setting = self.db.get('settings', where={'key': 'telegram_bot_pid'}) + current_pid = int(pid_setting['value']) if pid_setting and pid_setting['value'].isdigit() else 0 + + is_running = self._is_telegram_bot_running(current_pid) + + # Get the path to the virtual environment's python interpreter + current_script_dir = os.path.dirname(os.path.abspath(__file__)) + venv_python_path = os.path.join(current_script_dir, 'venv', 'bin', 'python3') + + if action == 'start': + if is_running: + print(f"[*] Telegram bot (PID: {current_pid}) is already running.") + return True + print("[*] Attempting to start Telegram bot...") + try: + bot_token_setting = self.db.get('settings', where={'key': 'telegram_bot_token'}) + api_id_setting = self.db.get('settings', where={'key': 'telegram_api_id'}) + api_hash_setting = self.db.get('settings', where={'key': 'telegram_api_hash'}) + ap_port_setting = self.db.get('settings', where={'key': 'ap_port'}) # Get AP_PORT + + if not bot_token_setting or bot_token_setting['value'] == 'YOUR_TELEGRAM_BOT_TOKEN': + print("[!] Telegram bot token not configured. Cannot start bot.") + return False + if not api_id_setting or not api_id_setting['value'].isdigit(): + print("[!] Telegram API ID not configured or invalid. Cannot start bot.") + return False + if not api_hash_setting or not api_hash_setting['value']: + print("[!] Telegram API Hash not configured. Cannot start bot.") + return False + + bot_script_path = os.path.join(current_script_dir, 'bot.py') + + # Verify venv python path exists + if not os.path.exists(venv_python_path): + print(f"[!] Error: Virtual environment Python interpreter not found at {venv_python_path}. Please ensure the virtual environment is correctly set up.") + return False + + env = os.environ.copy() + env["TELEGRAM_API_ID"] = api_id_setting['value'] + env["TELEGRAM_API_HASH"] = api_hash_setting['value'] + if ap_port_setting and ap_port_setting['value'].isdigit(): + env["AP_PORT"] = ap_port_setting['value'] + else: + env["AP_PORT"] = '3446' # Default if not set in DB + + log_file_path = "/var/log/candy-telegram-bot.log" + with open(log_file_path, "a") as log_file: + process = subprocess.Popen( + [venv_python_path, bot_script_path], # Use venv's python + stdout=log_file, + stderr=log_file, + preexec_fn=os.setsid, + env=env + ) + self.db.update('settings', {'value': str(process.pid)}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '1'}, {'key': 'telegram_bot_status'}) + print(f"[+] Telegram bot started with PID: {process.pid}") + return True + except FileNotFoundError: + print(f"[!] Error: bot.py not found at {bot_script_path} or venv python not found. Cannot start bot.") + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return False + except Exception as e: + print(f"[!] Failed to start Telegram bot: {e}") + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return False + + elif action == 'stop': + if not is_running: + print("[*] Telegram bot is already stopped (or PID is stale).") + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return True + + print("[*] Attempting to stop Telegram bot...") + try: + process = psutil.Process(current_pid) + cmdline = " ".join(process.cmdline()).lower() + if "bot.py" in cmdline and "python" in cmdline: + process.terminate() + process.wait(timeout=5) + print(f"[+] Telegram bot (PID: {current_pid}) stopped.") + else: + print(f"[!] PID {current_pid} is not identified as the Telegram bot. Not terminating.") + + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return True + except psutil.NoSuchProcess: + print(f"[!] Telegram bot process with PID {current_pid} not found. Assuming it's already stopped.") + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return True + except psutil.TimeoutExpired: + print(f"[!] Telegram bot process with PID {current_pid} did not terminate gracefully. Killing...") + process.kill() + process.wait() + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_pid'}) + self.db.update('settings', {'value': '0'}, {'key': 'telegram_bot_status'}) + return True + except Exception as e: + print(f"[!] Error stopping Telegram bot (PID: {current_pid}): {e}") + return False + return False # Invalid action + + def _calculate_and_update_traffic(self): + """ + Calculates and updates cumulative traffic for all clients. + This replaces the old traffic.json logic. + """ + print("[*] Calculating and updating client traffic statistics...") + + # Get current traffic from all interfaces + current_wg_traffic = {} + for interface_row in self.db.select('interfaces'): + wg_id = interface_row['wg'] + current_wg_traffic.update(self._get_current_wg_peer_traffic(wg_id)) + + # Total bandwidth consumed by all clients in this cycle + total_bandwidth_consumed_this_cycle = 0 + + # Iterate through all clients in the database + all_clients_in_db = self.db.select('clients') + for client in all_clients_in_db: + client_public_key = client['public_key'] + client_name = client['name'] + + # Get the current readings from 'wg show dump' + current_rx = current_wg_traffic.get(client_public_key, {}).get('rx', 0) + current_tx = current_wg_traffic.get(client_public_key, {}).get('tx', 0) + + try: + # Parse existing used_trafic data (which now includes last_wg_rx/tx) + used_traffic_data = json.loads(client.get('used_trafic', '{"download":0,"upload":0,"last_wg_rx":0,"last_wg_tx":0}')) + + cumulative_download = used_traffic_data.get('download', 0) + cumulative_upload = used_traffic_data.get('upload', 0) + last_wg_rx = used_traffic_data.get('last_wg_rx', 0) + last_wg_tx = used_traffic_data.get('last_wg_tx', 0) + + # Calculate delta for this sync cycle + # Handle WireGuard counter resets: If current < last, assume reset and add current as delta. + delta_rx = current_rx - last_wg_rx + if delta_rx < 0: + print(f"[*] Detected RX counter reset for client '{client_name}'. Adding current RX ({current_rx} bytes) as delta.") + delta_rx = current_rx + + delta_tx = current_tx - last_wg_tx + if delta_tx < 0: + print(f"[*] Detected TX counter reset for client '{client_name}'. Adding current TX ({current_tx} bytes) as delta.") + delta_tx = current_tx + + delta_rx = max(0, delta_rx) # Ensure non-negative + delta_tx = max(0, delta_tx) # Ensure non-negative + + # Update cumulative totals + cumulative_download += delta_rx + cumulative_upload += delta_tx + + # Prepare updated JSON for DB + updated_used_traffic = { + 'download': cumulative_download, + 'upload': cumulative_upload, + 'last_wg_rx': current_rx, # Store current readings for next cycle's delta calculation + 'last_wg_tx': current_tx + } + + self.db.update('clients', {'used_trafic': json.dumps(updated_used_traffic)}, {'name': client_name}) + + total_bandwidth_consumed_this_cycle += (delta_rx + delta_tx) + + except (json.JSONDecodeError, ValueError, TypeError) as e: + print(f"[!] Error processing traffic for client '{client_name}': {e}. Skipping this client's traffic update.") + + # Update overall server bandwidth in settings + old_bandwidth_setting = self.db.get('settings', where={'key': 'bandwidth'}) + current_total_bandwidth = int(old_bandwidth_setting['value']) if old_bandwidth_setting and old_bandwidth_setting['value'].isdigit() else 0 + new_total_bandwidth = current_total_bandwidth + total_bandwidth_consumed_this_cycle + self.db.update('settings', {'value': str(new_total_bandwidth)}, {'key': 'bandwidth'}) + print("[*] Client traffic statistics updated.") + + + def _sync(self): + """ + Synchronizes client data, traffic, and performs scheduled tasks. + This method should be run periodically (e.g., via cron). + """ + print("[*] Starting synchronization process...") + + # --- Handle Reset Timer for Interface Reloads --- + reset_time_setting = self.db.get('settings', where={'key': 'reset_time'}) + reset_time = int(reset_time_setting['value']) if reset_time_setting and reset_time_setting['value'].isdigit() else 0 + + reset_timer_file = 'reset.timer' + if reset_time != 0: + if not os.path.exists(reset_timer_file): + # If timer file doesn't exist, create it with future reset time + future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) + with open(reset_timer_file, 'w') as o: + o.write(str(future_reset_timestamp)) + print(f"[*] Reset timer file created. Next reset scheduled for {datetime.fromtimestamp(future_reset_timestamp)}.") + else: + # Check if reset time has passed + try: + with open(reset_timer_file, 'r') as o: + scheduled_reset_timestamp = int(float(o.read().strip())) # Use float for robustness + except (ValueError, FileNotFoundError): + print(f"Warning: Could not read or parse {reset_timer_file}. Recreating.") + future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) + with open(reset_timer_file, 'w') as o: + o.write(str(future_reset_timestamp)) + scheduled_reset_timestamp = future_reset_timestamp # Set for current cycle + + if int(time.time()) >= scheduled_reset_timestamp: + print("[*] Reset time reached. Reloading WireGuard interfaces...") + # Update timer for next reset + new_future_reset_timestamp = int(time.time()) + (reset_time * 60 * 60) + with open(reset_timer_file, 'w') as o: + o.write(str(new_future_reset_timestamp)) + print(f"[*] Reset timer updated. Next reset scheduled for {datetime.fromtimestamp(new_future_reset_timestamp)}.") + + # Reload all active interfaces + for interface in self.db.select('interfaces', where={'status': True}): + self._reload_wireguard(interface['wg']) + else: + print(f"[*] Next reset in {scheduled_reset_timestamp - int(time.time())} seconds.") + else: + if os.path.exists(reset_timer_file): + os.remove(reset_timer_file) # Clean up if reset_time is 0 + + + # --- Auto Backup --- + auto_backup_setting = self.db.get('settings', where={'key': 'auto_backup'}) + auto_backup_enabled = bool(int(auto_backup_setting['value'])) if auto_backup_setting and auto_backup_setting['value'].isdigit() else False + + if auto_backup_enabled: + print("[*] Performing auto backup of WireGuard configurations...") + for interface in self.db.select('interfaces'): + self._backup_config(interface['wg']) + + # --- Client Expiration and Traffic Limit Enforcement (Disable, not Delete) --- + current_time = datetime.now() + # FIX: Fetch all clients to disable into a list first, BEFORE iterating and updating + clients_to_disable = [] + active_clients = self.db.select('clients', where={'status': True}) + for client in active_clients: # Iterating over fetched results + should_disable = False + disable_reason = "" + + # Check expiration + try: + expires_dt = datetime.fromisoformat(client['expires']) + if current_time >= expires_dt: + should_disable = True + disable_reason = "expired" + except (ValueError, TypeError): + print(f"[!] Warning: Invalid expires date format for client '{client['name']}'. Skipping expiration check.") + + # Check traffic limit (only if not already marked for disabling by expiration) + if not should_disable: + try: + traffic_limit = int(client['traffic']) # Expected total traffic quota in bytes + used_traffic_data = json.loads(client['used_trafic']) + total_used_traffic = used_traffic_data.get('download', 0) + used_traffic_data.get('upload', 0) + + if traffic_limit > 0 and total_used_traffic >= traffic_limit: + should_disable = True + disable_reason = "exceeded traffic limit" + except (ValueError, TypeError, json.JSONDecodeError) as e: + print(f"[!] Warning: Invalid traffic data for client '{client['name']}'. Skipping traffic limit check. Error: {e}") + + if should_disable: + clients_to_disable.append(client['name']) # Collect names to disable + + # Now, iterate over the collected names and perform the database updates + for client_name_to_disable in clients_to_disable: + print(f"[!] Client '{client_name_to_disable}' needs disabling. Disabling...") + self._disable_client(client_name_to_disable) + # --- Update Traffic Statistics --- + self._calculate_and_update_traffic() + + # --- Update Uptime --- + # Get system boot time and calculate uptime + boot_time_timestamp = psutil.boot_time() # Returns UTC timestamp + current_timestamp = time.time() + calculated_uptime_seconds = int(current_timestamp - boot_time_timestamp) + self.db.update('settings', {'value': str(calculated_uptime_seconds)}, {'key': 'uptime'}) + print("[*] Uptime updated.") + + # --- Ensure AP_PORT setting is in sync with environment (for display purposes) --- + # This just updates the DB with what the system is actually running on, not + # to trigger a change in the running port which needs a Flask app restart. + actual_ap_port = os.environ.get('AP_PORT', '3446') + stored_ap_port = self.db.get('settings', where={'key': 'ap_port'}) + if not stored_ap_port or stored_ap_port['value'] != actual_ap_port: + self.db.update('settings', {'value': actual_ap_port}, {'key': 'ap_port'}) + print(f"[*] Updated ap_port in settings to reflect environment variable: {actual_ap_port}") + + print("[*] Synchronization process completed.") + + +# Custom exception for command execution errors +class CommandExecutionError(Exception): pass \ No newline at end of file diff --git a/Backend/db.py b/Backend/db.py index dd959a2..8b578e4 100644 --- a/Backend/db.py +++ b/Backend/db.py @@ -1,257 +1,258 @@ -# db.py -import sqlite3 -import time -import json , os -from datetime import datetime - -class SQLite: - def __init__(self, db_path='CandyPanel.db'): - """ - Initializes the SQLite database connection. - """ - script_dir = os.path.dirname(os.path.abspath(__file__)) - self.db_path = os.path.join(script_dir, db_path) - self.conn = None - self.cursor = None - self._connect() - self._initialize_tables() - - def _connect(self): - """ - Establishes a connection to the SQLite database. - """ - try: - self.conn = sqlite3.connect(self.db_path, timeout=30, check_same_thread=False) - self.conn.row_factory = sqlite3.Row - self.cursor = self.conn.cursor() - self.cursor.execute("PRAGMA journal_mode=WAL;") - self.conn.commit() - except sqlite3.Error as e: - print(f"Database connection error: {e}") - raise ConnectionError(f"Database connection error: {e}") - - def _initialize_tables(self): - """ - Creates necessary tables if they don't exist and inserts default settings. - Includes tables for CandyPanel and Telegram Bot. - """ - try: - self.cursor.execute(""" - CREATE TABLE IF NOT EXISTS `interfaces` ( - `wg` INTEGER PRIMARY KEY, - `private_key` TEXT NOT NULL, - `public_key` TEXT NOT NULL, - `port` INTEGER NOT NULL UNIQUE, - `address_range` TEXT NOT NULL UNIQUE, - `ipv6_address_range` TEXT UNIQUE, - `status` BOOLEAN DEFAULT 1 - ); - """) - self.cursor.execute(""" - CREATE TABLE IF NOT EXISTS `clients` ( - `name` TEXT NOT NULL UNIQUE, - `wg` INTEGER NOT NULL, - `public_key` TEXT NOT NULL UNIQUE, - `private_key` TEXT NOT NULL, - `address` TEXT NOT NULL UNIQUE, - `ipv6_address` TEXT UNIQUE, - `created_at` TEXT NOT NULL, - `expires` TEXT NOT NULL, - `note` TEXT DEFAULT '', - `traffic` TEXT NOT NULL, - `used_trafic` TEXT NOT NULL DEFAULT '{"download":0,"upload":0, "last_wg_rx":0, "last_wg_tx":0}', - `connected_now` BOOLEAN NOT NULL DEFAULT 0, - `status` BOOLEAN NOT NULL DEFAULT 1 - ); - """) - - # Add IPv6 columns if they don't exist (migration for existing DBs) - self.cursor.execute("PRAGMA table_info(interfaces);") - interfaces_cols = [col[1] for col in self.cursor.fetchall()] - if 'ipv6_address_range' not in interfaces_cols: - self.cursor.execute("ALTER TABLE `interfaces` ADD COLUMN `ipv6_address_range` TEXT UNIQUE;") - - self.cursor.execute("PRAGMA table_info(clients);") - clients_cols = [col[1] for col in self.cursor.fetchall()] - if 'ipv6_address' not in clients_cols: - self.cursor.execute("ALTER TABLE `clients` ADD COLUMN `ipv6_address` TEXT UNIQUE;") - - self.cursor.execute(""" - CREATE TABLE IF NOT EXISTS `settings` ( - `key` TEXT PRIMARY KEY, - `value` TEXT NOT NULL - ); - """) - self.cursor.execute(""" - CREATE TABLE IF NOT EXISTS `users` ( - `telegram_id` INTEGER PRIMARY KEY, - `candy_client_name` TEXT UNIQUE, - `traffic_bought_gb` REAL DEFAULT 0, - `time_bought_days` INTEGER DEFAULT 0, - `status` TEXT DEFAULT 'active', - `is_admin` BOOLEAN DEFAULT 0, - `created_at` TEXT NOT NULL, - `language` TEXT DEFAULT 'en' - ); - """) - self.cursor.execute(""" - CREATE TABLE IF NOT EXISTS `transactions` ( - `order_id` TEXT PRIMARY KEY, - `telegram_id` INTEGER NOT NULL, - `amount` REAL NOT NULL, - `card_number_sent` TEXT, - `status` TEXT DEFAULT 'pending', - `requested_at` TEXT NOT NULL, - `approved_at` TEXT, - `admin_note` TEXT, - `purchase_type` TEXT, - `quantity` REAL, - `time_quantity` REAL DEFAULT 0, - `traffic_quantity` REAL DEFAULT 0, - FOREIGN KEY (`telegram_id`) REFERENCES `users`(`telegram_id`) - ); - """) - self.conn.commit() - - # Insert default settings if the settings table is empty - if self.count('settings') == 0: - self._insert_default_settings() - - except sqlite3.Error as e: - print(f"Database table initialization error: {e}") - raise RuntimeError(f"Database table initialization error: {e}") - - def _insert_default_settings(self): - """ - Inserts initial default settings into the 'settings' table. - Includes settings for CandyPanel and Telegram Bot. - """ - default_settings = [ - {'key': 'server_ip', 'value': '192.168.1.100'}, - {'key': 'custom_endpont', 'value': '192.168.1.100'}, - {'key': 'session_token', 'value': 'NONE'}, - {'key': 'dns', 'value': '8.8.8.8'}, - {'key': 'ipv6_dns', 'value': '2001:4860:4860::8888'}, - {'key': 'admin', 'value': '{"user":"admin","password":"admin"}'}, - {'key': 'status', 'value': '1'}, - {'key': 'alert', 'value': '["Welcome To Candy Panel - by AmiRCandy"]'}, - {'key': 'reset_time', 'value': '0'}, - {'key': 'mtu', 'value': '1420'}, - {'key': 'bandwidth', 'value': '0'}, - {'key': 'uptime', 'value': '0'}, - {'key': 'telegram_bot_status', 'value': '0'}, - {'key': 'telegram_bot_admin_id', 'value': '0'}, - {'key': 'telegram_bot_token', 'value': 'YOUR_TELEGRAM_BOT_TOKEN'}, - {'key': 'telegram_api_hash', 'value': 'YOUR_TELEGRAM_API_HASH'}, - {'key': 'telegram_api_id', 'value': 'YOUR_TELEGRAM_API_ID'}, - {'key': 'admin_card_number', 'value': 'YOUR_ADMIN_CARD_NUMBER'}, - {'key': 'prices', 'value': '{"1GB": 4000, "1Month": 75000}'}, - {'key': 'api_tokens', 'value': '{}'}, - {'key': 'auto_backup', 'value': '1'}, - {'key': 'install', 'value': '0'}, - {'key': 'telegram_bot_pid', 'value': '0'}, - {'key': 'ap_port', 'value': '3446'}, - ] - for setting in default_settings: - self.insert('settings', setting) - print("Default settings inserted.") - - def _execute_query(self, query: str, params: tuple = (), fetch_type: str = None): - """ - Executes a SQL query with given parameters. - 'fetch_type' can be 'all' (for fetchall), 'one' (for fetchone), or None (for DML operations). - """ - try: - self.cursor.execute(query, params) - if fetch_type == 'all': - return [dict(row) for row in self.cursor.fetchall()] - elif fetch_type == 'one': - row = self.cursor.fetchone() - return dict(row) if row else None - else: - self.conn.commit() - if 'INSERT' in query.upper(): - return self.cursor.lastrowid - return self.cursor.rowcount - except sqlite3.Error as e: - print(f"Database query failed: {e}\nQuery: {query}\nParams: {params}") - raise - - def select(self, table: str, columns: str | list[str] = '*', where: dict = None) -> list[dict]: - """ - Selects data from a table. - 'columns' can be a string (e.g., '*') or a list of column names. - 'where' is a dictionary for filtering (e.g., {'id': 1}). - """ - cols = ', '.join(f"`{c}`" for c in columns) if isinstance(columns, list) else columns - query = f"SELECT {cols} FROM `{table}`" - params = [] - if where: - query += " WHERE " + " AND ".join(f"`{k}`=?" for k in where) - params = list(where.values()) - return self._execute_query(query, tuple(params), 'all') - - def get(self, table: str, columns: str | list[str] = '*', where: dict = None) -> dict | None: - """ - Retrieves a single row from a table. - Returns None if no row is found. - """ - rows = self.select(table, columns, where) - return rows[0] if rows else None - - def has(self, table: str, where: dict) -> bool: - """ - Checks if a record exists in a table based on the where clause. - """ - return self.count(table, where) > 0 - - def count(self, table: str, where: dict = None) -> int: - """ - Counts the number of rows in a table, optionally with a where clause. - """ - query = f"SELECT COUNT(*) as count FROM `{table}`" - params = [] - if where: - query += " WHERE " + " AND ".join(f"`{k}`=?" for k in where) - params = list(where.values()) - result = self._execute_query(query, tuple(params), 'one') - return result["count"] if result else 0 - - def insert(self, table: str, data: dict): - """ - Inserts a new row into a table. - 'data' is a dictionary of column-value pairs. - """ - keys = ', '.join(f"`{k}`" for k in data.keys()) - placeholders = ', '.join(['?'] * len(data)) - query = f"INSERT INTO `{table}` ({keys}) VALUES ({placeholders})" - return self._execute_query(query, tuple(data.values())) - - def update(self, table: str, data: dict, where: dict): - """ - Updates existing rows in a table. - 'data' is a dictionary of column-value pairs to update. - 'where' is a dictionary for filtering which rows to update. - """ - set_clause = ', '.join(f"`{k}`=?" for k in data) - where_clause = ' AND '.join(f"`{k}`=?" for k in where) - query = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}" - return self._execute_query(query, tuple(data.values()) + tuple(where.values())) - - def delete(self, table: str, where: dict): - """ - Deletes rows from a table. - 'where' is a dictionary for filtering which rows to delete. - """ - where_clause = ' AND '.join(f"`{k}`=?" for k in where) - query = f"DELETE FROM `{table}` WHERE {where_clause}" - return self._execute_query(query, tuple(where.values())) - - def close(self): - """ - Closes the database connection. - """ - if self.conn: - self.conn.close() - self.conn = None +# db.py +import sqlite3 +import time +import json , os +from datetime import datetime +from werkzeug.security import generate_password_hash + +class SQLite: + def __init__(self, db_path='CandyPanel.db'): + """ + Initializes the SQLite database connection. + """ + script_dir = os.path.dirname(os.path.abspath(__file__)) + self.db_path = os.path.join(script_dir, db_path) + self.conn = None + self.cursor = None + self._connect() + self._initialize_tables() + + def _connect(self): + """ + Establishes a connection to the SQLite database. + """ + try: + self.conn = sqlite3.connect(self.db_path, timeout=30, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.cursor = self.conn.cursor() + self.cursor.execute("PRAGMA journal_mode=WAL;") + self.conn.commit() + except sqlite3.Error as e: + print(f"Database connection error: {e}") + raise ConnectionError(f"Database connection error: {e}") + + def _initialize_tables(self): + """ + Creates necessary tables if they don't exist and inserts default settings. + Includes tables for CandyPanel and Telegram Bot. + """ + try: + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS `interfaces` ( + `wg` INTEGER PRIMARY KEY, + `private_key` TEXT NOT NULL, + `public_key` TEXT NOT NULL, + `port` INTEGER NOT NULL UNIQUE, + `address_range` TEXT NOT NULL UNIQUE, + `ipv6_address_range` TEXT UNIQUE, + `status` BOOLEAN DEFAULT 1 + ); + """) + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS `clients` ( + `name` TEXT NOT NULL UNIQUE, + `wg` INTEGER NOT NULL, + `public_key` TEXT NOT NULL UNIQUE, + `private_key` TEXT NOT NULL, + `address` TEXT NOT NULL UNIQUE, + `ipv6_address` TEXT UNIQUE, + `created_at` TEXT NOT NULL, + `expires` TEXT NOT NULL, + `note` TEXT DEFAULT '', + `traffic` TEXT NOT NULL, + `used_trafic` TEXT NOT NULL DEFAULT '{"download":0,"upload":0, "last_wg_rx":0, "last_wg_tx":0}', + `connected_now` BOOLEAN NOT NULL DEFAULT 0, + `status` BOOLEAN NOT NULL DEFAULT 1 + ); + """) + + # Add IPv6 columns if they don't exist (migration for existing DBs) + self.cursor.execute("PRAGMA table_info(interfaces);") + interfaces_cols = [col[1] for col in self.cursor.fetchall()] + if 'ipv6_address_range' not in interfaces_cols: + self.cursor.execute("ALTER TABLE `interfaces` ADD COLUMN `ipv6_address_range` TEXT UNIQUE;") + + self.cursor.execute("PRAGMA table_info(clients);") + clients_cols = [col[1] for col in self.cursor.fetchall()] + if 'ipv6_address' not in clients_cols: + self.cursor.execute("ALTER TABLE `clients` ADD COLUMN `ipv6_address` TEXT UNIQUE;") + + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS `settings` ( + `key` TEXT PRIMARY KEY, + `value` TEXT NOT NULL + ); + """) + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS `users` ( + `telegram_id` INTEGER PRIMARY KEY, + `candy_client_name` TEXT UNIQUE, + `traffic_bought_gb` REAL DEFAULT 0, + `time_bought_days` INTEGER DEFAULT 0, + `status` TEXT DEFAULT 'active', + `is_admin` BOOLEAN DEFAULT 0, + `created_at` TEXT NOT NULL, + `language` TEXT DEFAULT 'en' + ); + """) + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS `transactions` ( + `order_id` TEXT PRIMARY KEY, + `telegram_id` INTEGER NOT NULL, + `amount` REAL NOT NULL, + `card_number_sent` TEXT, + `status` TEXT DEFAULT 'pending', + `requested_at` TEXT NOT NULL, + `approved_at` TEXT, + `admin_note` TEXT, + `purchase_type` TEXT, + `quantity` REAL, + `time_quantity` REAL DEFAULT 0, + `traffic_quantity` REAL DEFAULT 0, + FOREIGN KEY (`telegram_id`) REFERENCES `users`(`telegram_id`) + ); + """) + self.conn.commit() + + # Insert default settings if the settings table is empty + if self.count('settings') == 0: + self._insert_default_settings() + + except sqlite3.Error as e: + print(f"Database table initialization error: {e}") + raise RuntimeError(f"Database table initialization error: {e}") + + def _insert_default_settings(self): + """ + Inserts initial default settings into the 'settings' table. + Includes settings for CandyPanel and Telegram Bot. + """ + default_settings = [ + {'key': 'server_ip', 'value': '192.168.1.100'}, + {'key': 'custom_endpont', 'value': '192.168.1.100'}, + {'key': 'session_token', 'value': 'NONE'}, + {'key': 'dns', 'value': '8.8.8.8'}, + {'key': 'ipv6_dns', 'value': '2001:4860:4860::8888'}, + {'key': 'admin', 'value': json.dumps({"user": "admin", "password": generate_password_hash("admin")})}, + {'key': 'status', 'value': '1'}, + {'key': 'alert', 'value': '["Welcome To Candy Panel - by AmiRCandy"]'}, + {'key': 'reset_time', 'value': '0'}, + {'key': 'mtu', 'value': '1420'}, + {'key': 'bandwidth', 'value': '0'}, + {'key': 'uptime', 'value': '0'}, + {'key': 'telegram_bot_status', 'value': '0'}, + {'key': 'telegram_bot_admin_id', 'value': '0'}, + {'key': 'telegram_bot_token', 'value': 'YOUR_TELEGRAM_BOT_TOKEN'}, + {'key': 'telegram_api_hash', 'value': 'YOUR_TELEGRAM_API_HASH'}, + {'key': 'telegram_api_id', 'value': 'YOUR_TELEGRAM_API_ID'}, + {'key': 'admin_card_number', 'value': 'YOUR_ADMIN_CARD_NUMBER'}, + {'key': 'prices', 'value': '{"1GB": 4000, "1Month": 75000}'}, + {'key': 'api_tokens', 'value': '{}'}, + {'key': 'auto_backup', 'value': '1'}, + {'key': 'install', 'value': '0'}, + {'key': 'telegram_bot_pid', 'value': '0'}, + {'key': 'ap_port', 'value': '3446'}, + ] + for setting in default_settings: + self.insert('settings', setting) + print("Default settings inserted.") + + def _execute_query(self, query: str, params: tuple = (), fetch_type: str = None): + """ + Executes a SQL query with given parameters. + 'fetch_type' can be 'all' (for fetchall), 'one' (for fetchone), or None (for DML operations). + """ + try: + self.cursor.execute(query, params) + if fetch_type == 'all': + return [dict(row) for row in self.cursor.fetchall()] + elif fetch_type == 'one': + row = self.cursor.fetchone() + return dict(row) if row else None + else: + self.conn.commit() + if 'INSERT' in query.upper(): + return self.cursor.lastrowid + return self.cursor.rowcount + except sqlite3.Error as e: + print(f"Database query failed: {e}\nQuery: {query}\nParams: {params}") + raise + + def select(self, table: str, columns: str | list[str] = '*', where: dict = None) -> list[dict]: + """ + Selects data from a table. + 'columns' can be a string (e.g., '*') or a list of column names. + 'where' is a dictionary for filtering (e.g., {'id': 1}). + """ + cols = ', '.join(f"`{c}`" for c in columns) if isinstance(columns, list) else columns + query = f"SELECT {cols} FROM `{table}`" + params = [] + if where: + query += " WHERE " + " AND ".join(f"`{k}`=?" for k in where) + params = list(where.values()) + return self._execute_query(query, tuple(params), 'all') + + def get(self, table: str, columns: str | list[str] = '*', where: dict = None) -> dict | None: + """ + Retrieves a single row from a table. + Returns None if no row is found. + """ + rows = self.select(table, columns, where) + return rows[0] if rows else None + + def has(self, table: str, where: dict) -> bool: + """ + Checks if a record exists in a table based on the where clause. + """ + return self.count(table, where) > 0 + + def count(self, table: str, where: dict = None) -> int: + """ + Counts the number of rows in a table, optionally with a where clause. + """ + query = f"SELECT COUNT(*) as count FROM `{table}`" + params = [] + if where: + query += " WHERE " + " AND ".join(f"`{k}`=?" for k in where) + params = list(where.values()) + result = self._execute_query(query, tuple(params), 'one') + return result["count"] if result else 0 + + def insert(self, table: str, data: dict): + """ + Inserts a new row into a table. + 'data' is a dictionary of column-value pairs. + """ + keys = ', '.join(f"`{k}`" for k in data.keys()) + placeholders = ', '.join(['?'] * len(data)) + query = f"INSERT INTO `{table}` ({keys}) VALUES ({placeholders})" + return self._execute_query(query, tuple(data.values())) + + def update(self, table: str, data: dict, where: dict): + """ + Updates existing rows in a table. + 'data' is a dictionary of column-value pairs to update. + 'where' is a dictionary for filtering which rows to update. + """ + set_clause = ', '.join(f"`{k}`=?" for k in data) + where_clause = ' AND '.join(f"`{k}`=?" for k in where) + query = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}" + return self._execute_query(query, tuple(data.values()) + tuple(where.values())) + + def delete(self, table: str, where: dict): + """ + Deletes rows from a table. + 'where' is a dictionary for filtering which rows to delete. + """ + where_clause = ' AND '.join(f"`{k}`=?" for k in where) + query = f"DELETE FROM `{table}` WHERE {where_clause}" + return self._execute_query(query, tuple(where.values())) + + def close(self): + """ + Closes the database connection. + """ + if self.conn: + self.conn.close() + self.conn = None self.cursor = None \ No newline at end of file diff --git a/Backend/main.py b/Backend/main.py index c3641f1..a94ae84 100644 --- a/Backend/main.py +++ b/Backend/main.py @@ -1,1181 +1,1183 @@ -# main.py -from flask import Flask, request, jsonify, abort, g, send_from_directory , send_file, redirect -from functools import wraps -from flask_cors import CORS -import asyncio -import json -from datetime import datetime, timedelta -import os -import subprocess -import threading -import time - -# Import your CandyPanel logic -from core import CandyPanel, CommandExecutionError - -# --- Initialize CandyPanel --- -candy_panel = CandyPanel() - -# --- Flask Application Setup --- -app = Flask(__name__, static_folder=os.path.join(os.getcwd(), '..', 'Frontend', 'dist'), static_url_path='/static') -app.config['SECRET_KEY'] = 'your_super_secret_key' -CORS(app) - -# --- Background Sync Thread --- -def background_sync(): - """Background thread function that runs sync every 5 minutes""" - while True: - try: - print("[*] Starting background sync...") - candy_panel._sync() - print("[*] Background sync completed successfully.") - except Exception as e: - print(f"[!] Error in background sync: {e}") - # Sleep for 5 minutes (300 seconds) - time.sleep(300) - -# Start background sync thread -sync_thread = threading.Thread(target=background_sync, daemon=True) -sync_thread.start() -print("[+] Background sync thread started.") - -# --- Authentication Decorator for CandyPanel Admin API --- -def authenticate_admin(f): - @wraps(f) - async def decorated_function(*args, **kwargs): - auth_header = request.headers.get('Authorization') - if not auth_header: - abort(401, description="Authorization header missing") - - try: - token_type, token = auth_header.split(None, 1) - except ValueError: - abort(401, description="Invalid Authorization header format") - - if token_type.lower() != 'bearer': - abort(401, description="Unsupported authorization type") - - # Run synchronous DB operation in a thread pool - settings = await asyncio.to_thread(candy_panel.db.get, 'settings','*' ,{'key': 'session_token'}) - if not settings or settings['value'] != token: - abort(401, description="Invalid authentication credentials") - - g.is_authenticated = True - return await f(*args, **kwargs) - return decorated_function - -# --- Helper for common responses --- -def success_response(message: str, data=None, status_code: int = 200): - return jsonify({"message": message, "success": True, "data": data}), status_code - -def error_response(message: str, status_code: int = 400): - return jsonify({"message": message, "success": False}), status_code - -# --- CandyPanel API Endpoints --- -@app.get("/client-details//") -async def get_client_public_details(name: str, public_key: str): - """ - Retrieves public-facing details for a specific client given its name and public key. - This endpoint does NOT require authentication. - """ - try: - client_data = await asyncio.to_thread(candy_panel._get_client_by_name_and_public_key, name, public_key) - if client_data: - return success_response("Client details retrieved successfully.", data=client_data) - else: - return error_response("Client not found or public key mismatch.", 404) - except Exception as e: - return error_response(f"An error occurred: {e}", 500) - -@app.get("/shortlink//") -async def shortlink_redirect(name: str, public_key: str): - """ - Handles shortlink redirects to the client details page. - This replaces the frontend shortlink handling. - """ - try: - # Verify client exists before redirecting - client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': name, 'public_key': public_key}) - if not client: - # Serve a simple error page - return f""" - - - Client Not Found - -

Client Not Found

-

The requested client does not exist or the link is invalid.

- - - """, 404 - - # Serve the client details page directly - return send_from_directory(app.static_folder, 'client.html') - except Exception as e: - return f""" - - - Error - -

Error

-

An error occurred: {e}

- - - """, 500 - -@app.get("/qr//") -async def get_qr_code(name: str, public_key: str): - """ - Generates and returns a QR code image for a client's configuration (without the private key), with IPv6 support. - This endpoint is publicly accessible. - """ - client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': name, 'public_key': public_key}) - if not client: - return error_response("Client not found or public key mismatch.", 404) - - interface = await asyncio.to_thread(candy_panel.db.get, 'interfaces', where={'wg': client['wg']}) - if not interface: - return error_response("Associated WireGuard interface not found.", 500) - - # Reconstruct the config using live data - dns = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'dns'}) - dns_value = dns['value'] if dns else '8.8.8.8' - ipv6_dns = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'ipv6_dns'}) - ipv6_dns_value = ipv6_dns['value'] if ipv6_dns else None - - dns_list = [dns_value] - if ipv6_dns_value: - dns_list.append(ipv6_dns_value) - - dns_line = f"DNS = {', '.join(dns_list)}" - - mtu = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'mtu'}) - mtu_value = mtu['value'] if mtu else '1420' - server_ip = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'custom_endpont'}) - server_ip = server_ip['value'] - - address_line = f"Address = {client['address']}/32" - if client.get('ipv6_address'): - address_line += f", {client['ipv6_address']}/128" - - config_content = f"""[Interface] -PrivateKey = {client['private_key']} -{address_line} -{dns_line} -MTU = {mtu_value} - -[Peer] -PublicKey = {interface['public_key']} -Endpoint = {server_ip}:{interface['port']} -AllowedIPs = 0.0.0.0/0, ::/0 -PersistentKeepalive = 25""" - - # Use qrencode to generate the QR code as a temporary file - temp_file_path = f"/tmp/{name}-{public_key}.png" - try: - await asyncio.to_thread(subprocess.run,['qrencode', '-o', temp_file_path, config_content], check=True) - return send_file(temp_file_path, mimetype='image/png') - except subprocess.CalledProcessError: - return error_response("Failed to generate QR code. Is 'qrencode' installed?", 500) - except Exception as e: - return error_response(f"An error occurred: {e}", 500) - finally: - if os.path.exists(temp_file_path): - os.remove(temp_file_path) - -@app.get("/check") -async def check_installation(): - """ - Checks if the CandyPanel is installed. - """ - install_status = await asyncio.to_thread(candy_panel.db.get, 'settings', '*',{'key': 'install'}) - is_installed = bool(install_status and install_status['value'] == '1') - return jsonify({"installed": is_installed}) - -@app.post("/api/auth") -async def handle_auth(): - """ - Handles both login and installation based on the 'action' field, with IPv6 support. - """ - data = request.json - if not data or 'action' not in data: - return error_response("Missing 'action' in request body", 400) - - action = data['action'] - install_status = await asyncio.to_thread(candy_panel.db.get, 'settings', '*',{'key': 'install'}) - is_installed = bool(install_status and install_status['value'] == '1') - - if action == 'login': - if not is_installed: - return error_response("CandyPanel is not installed. Please use the 'install' action.", 400) - - if 'username' not in data or 'password' not in data: - return error_response("Missing username or password for login", 400) - - success, message = await asyncio.to_thread(candy_panel._admin_login, data['username'], data['password']) - if not success: - return error_response(message, 401) - return success_response("Login successful!", data={"access_token": message, "token_type": "bearer"}) - - elif action == 'install': - if is_installed: - return error_response("CandyPanel is already installed.", 400) - - try: - server_ip = data['server_ip'] - wg_port = data['wg_port'] - wg_address_range = data.get('wg_address_range', "10.0.0.1/24") - wg_ipv6_address = data.get('wg_ipv6_address', None) - wg_dns = data.get('wg_dns', "8.8.8.8") - wg_ipv6_dns = data.get('wg_ipv6_dns', None) - admin_user = data.get('admin_user', "admin") - admin_password = data.get('admin_password', "admin") - except KeyError as e: - return error_response(f"Missing required field for installation: {e}", 400) - - success, message = await asyncio.to_thread( - candy_panel._install_candy_panel, - server_ip, - wg_port, - wg_address_range, - wg_dns, - admin_user, - admin_password, - wg_ipv6_address, - wg_ipv6_dns - ) - if not success: - return error_response(message, 400) - return success_response(message) - else: - return error_response("Invalid action specified. Must be 'login' or 'install'.", 400) - -@app.get("/api/data") -@authenticate_admin -async def get_all_data(): - """ - Retrieves all relevant data for the dashboard, clients, interfaces, and settings in one go. - Requires authentication. - """ - try: - # Fetch all data concurrently - dashboard_stats_task = asyncio.to_thread(candy_panel._dashboard_stats) - clients_data_task = asyncio.to_thread(candy_panel._get_all_clients) - interfaces_data_task = asyncio.to_thread(candy_panel.db.select, 'interfaces') - settings_data_task = asyncio.to_thread(candy_panel.db.select, 'settings') - - dashboard_stats, clients_data, interfaces_data, settings_raw = await asyncio.gather( - dashboard_stats_task, clients_data_task, interfaces_data_task, settings_data_task - ) - - # Process client data (parse used_trafic) - for client in clients_data: - try: - client['used_trafic'] = json.loads(client['used_trafic']) - except (json.JSONDecodeError, TypeError): - client['used_trafic'] = {"download": 0, "upload": 0} - - # Process settings data (convert to dict) - settings_data = {setting['key']: setting['value'] for setting in settings_raw} - - return success_response("All data retrieved successfully.", data={ - "dashboard": dashboard_stats, - "clients": clients_data, - "interfaces": interfaces_data, - "settings": settings_data - }) - except Exception as e: - return error_response(f"Failed to retrieve all data: {e}", 500) - -@app.post("/api/manage") -@authenticate_admin -async def manage_resources(): - """ - Unified endpoint for creating/updating/deleting clients, interfaces, and settings, with IPv6 support. - Requires authentication. - """ - data = request.json - if not data or 'resource' not in data or 'action' not in data: - return error_response("Missing 'resource' or 'action' in request body", 400) - - resource = data['resource'] - action = data['action'] - - try: - if resource == 'client': - if action == 'create': - name = data.get('name') - expires = data.get('expires') - traffic = data.get('traffic') - wg_id = data.get('wg_id', 0) - note = data.get('note', '') - if not all([name, expires, traffic]): - return error_response("Missing name, expires, or traffic for client creation", 400) - success, message = await asyncio.to_thread(candy_panel._new_client, name, expires, traffic, wg_id, note) - if not success: - return error_response(message, 400) - return success_response("Client created successfully!", data={"client_config": message}) - - elif action == 'update': - name = data.get('name') - if not name: - return error_response("Missing client name for update", 400) - expires = data.get('expires') - traffic = data.get('traffic') - status = data.get('status') - note = data.get('note') - success, message = await asyncio.to_thread(candy_panel._edit_client, name, expires, traffic, status, note) - if not success: - return error_response(message, 400) - return success_response(message) - - elif action == 'delete': - name = data.get('name') - if not name: - return error_response("Missing client name for deletion", 400) - success, message = await asyncio.to_thread(candy_panel._delete_client, name) - if not success: - return error_response(message, 400) - return success_response(message) - - elif action == 'get_config': - name = data.get('name') - if not name: - return error_response("Missing client name to get config", 400) - success, config_content = await asyncio.to_thread(candy_panel._get_client_config, name) - if not success: - return error_response(config_content, 404) - return success_response("Client config retrieved successfully.", data={"config": config_content}) - else: - return error_response(f"Invalid action '{action}' for client resource", 400) - - elif resource == 'interface': - if action == 'create': - address_range = data.get('address_range') - ipv6_address_range = data.get('ipv6_address_range') - port = data.get('port') - if not all([address_range, port]): - return error_response("Missing address_range or port for interface creation", 400) - success, message = await asyncio.to_thread(candy_panel._new_interface_wg, address_range, port, ipv6_address_range) - if not success: - return error_response(message, 400) - return success_response(message) - - elif action == 'update': - name = data.get('name') # e.g., 'wg0' - if not name: - return error_response("Missing interface name for update", 400) - address = data.get('address') - port = data.get('port') - status = data.get('status') - success, message = await asyncio.to_thread(candy_panel._edit_interface, name, address, port, status) - if not success: - return error_response(message, 400) - return success_response(message) - - # New: Delete interface - elif action == 'delete': - wg_id = data.get('wg_id') - if wg_id is None: - return error_response("Missing wg_id for interface deletion", 400) - success, message = await asyncio.to_thread(candy_panel._delete_interface, wg_id) - if not success: - return error_response(message, 400) - return success_response(message) - - else: - return error_response(f"Invalid action '{action}' for interface resource", 400) - - elif resource == 'setting': - if action == 'update': - key = data.get('key') - value = data.get('value') - if not all([key, value is not None]): # Value can be an empty string or 0, so check explicitly - return error_response("Missing key or value for setting update", 400) - if key == 'telegram_bot_status': - if value == '1': # '1' means ON - bot_control_success = await asyncio.to_thread(candy_panel._manage_telegram_bot_process, 'start') - if not bot_control_success: - # Log the failure, but return success for setting update if DB was successful - print(f"Warning: Failed to start bot immediately after setting update.") - return success_response(f"(Bot start attempted, but failed.)") - else: # '0' means OFF - bot_control_success = await asyncio.to_thread(candy_panel._manage_telegram_bot_process, 'stop') - if not bot_control_success: - print(f"Warning: Failed to stop bot immediately after setting update.") - return success_response(f"(Bot stop attempted, but failed.)") - success, message = await asyncio.to_thread(candy_panel._change_settings, key, value) - if not success: - return error_response(message, 400) - return success_response(message) - else: - return error_response(f"Invalid action '{action}' for setting resource", 400) - - elif resource == 'api_token': - if action == 'create_or_update': - name = data.get('name') - token = data.get('token') - if not all([name, token]): - return error_response("Missing name or token for API token operation", 400) - success, message = await asyncio.to_thread(candy_panel._add_api_token, name, token) - if not success: - return error_response(message, 400) - return success_response(message) - - elif action == 'delete': - name = data.get('name') - if not name: - return error_response("Missing name for API token deletion", 400) - success, message = await asyncio.to_thread(candy_panel._delete_api_token, name) - if not success: - return error_response(message, 400) - return success_response(message) - else: - return error_response(f"Invalid action '{action}' for API token resource", 400) - - elif resource == 'sync': - if action == 'trigger': - await asyncio.to_thread(candy_panel._sync) - return success_response("Synchronization process initiated successfully.") - else: - return error_response(f"Invalid action '{action}' for sync resource", 400) - - else: - return error_response(f"Unknown resource type: {resource}", 400) - - except CommandExecutionError as e: - return error_response(f"Command execution error: {e}", 500) - except Exception as e: - return error_response(f"An unexpected error occurred: {e}", 500) - -# --- Telegram Bot API Endpoints (Integrated) --- - -@app.post("/bot_api/user/register") -async def bot_register_user(): - data = request.json - telegram_id = data.get('telegram_id') - if not telegram_id: - return error_response("Missing telegram_id", 400) - - user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) - if user: - return success_response("User already registered.", data={"registered": True, "language": user.get('language', 'en')}) # Return current language - - # Default language is English - await asyncio.to_thread(candy_panel.db.insert, 'users', { - 'telegram_id': telegram_id, - 'created_at': datetime.now().isoformat(), - 'language': 'en' - }) - return success_response("User registered successfully.", data={"registered": True, "language": "en"}) - -@app.post("/bot_api/user/set_language") -async def bot_set_language(): - data = request.json - telegram_id = data.get('telegram_id') - language = data.get('language') - - if not all([telegram_id, language]): - return error_response("Missing telegram_id or language", 400) - - if language not in ['en', 'fa']: # Only allow 'en' or 'fa' for now - return error_response("Unsupported language. Available: 'en', 'fa'", 400) - - if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): - return error_response("User not registered with the bot.", 404) - - await asyncio.to_thread(candy_panel.db.update, 'users', {'language': language}, {'telegram_id': telegram_id}) - return success_response("Language updated successfully.") - - -@app.post("/bot_api/user/initiate_purchase") # NEW ENDPOINT -async def bot_initiate_purchase(): - data = request.json - telegram_id = data.get('telegram_id') - - if not telegram_id: - return error_response("Missing telegram_id", 400) - - if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): - return error_response("User not registered with the bot.", 404) - - prices_json = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'prices'}) - prices = json.loads(prices_json['value']) if prices_json and prices_json['value'] else {} - - admin_card_number_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'admin_card_number'}) - admin_card_number = admin_card_number_setting['value'] if admin_card_number_setting else 'YOUR_ADMIN_CARD_NUMBER' - - return success_response("Purchase initiation details.", data={ - "admin_card_number": admin_card_number, - "prices": prices - }) - -@app.post("/bot_api/user/calculate_price") # NEW ENDPOINT -async def bot_calculate_price(): - data = request.json - telegram_id = data.get('telegram_id') - purchase_type = data.get('purchase_type') - quantity = data.get('quantity') - time_quantity = data.get('time_quantity', 0) - traffic_quantity = data.get('traffic_quantity', 0) - - if not all([telegram_id, purchase_type]): - return error_response("Missing telegram_id or purchase_type", 400) - - if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): - return error_response("User not registered with the bot.", 404) - - prices_json = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'prices'}) - prices = json.loads(prices_json['value']) if prices_json and prices_json['value'] else {} - - calculated_amount = 0 - if purchase_type == 'gb': - if quantity is None: return error_response("Missing quantity for GB purchase", 400) - price_per_gb = prices.get('1GB') - if not price_per_gb: - return error_response("Price per GB not configured. Please contact support.", 500) - calculated_amount = price_per_gb * float(quantity) - elif purchase_type == 'month': - if quantity is None: return error_response("Missing quantity for Month purchase", 400) - price_per_month = prices.get('1Month') - if not price_per_month: - return error_response("Price per Month not configured. Please contact support.", 500) - calculated_amount = price_per_month * float(quantity) - elif purchase_type == 'custom': - if time_quantity is None or traffic_quantity is None: - return error_response("Missing time_quantity or traffic_quantity for custom purchase", 400) - price_per_gb = prices.get('1GB') - price_per_month = prices.get('1Month') - if not price_per_gb or not price_per_month: - return error_response("Prices for custom plan (1GB or 1Month) not configured. Please contact support.", 500) - calculated_amount = (price_per_gb * float(traffic_quantity)) + (price_per_month * float(time_quantity)) - else: - return error_response("Invalid purchase_type. Must be 'gb', 'month', or 'custom'.", 400) - - return success_response("Price calculated successfully.", data={"calculated_amount": calculated_amount}) - - -@app.post("/bot_api/user/submit_transaction") # NEW ENDPOINT -async def bot_submit_transaction(): - data = request.json - telegram_id = data.get('telegram_id') - order_id = data.get('order_id') - card_number_sent = data.get('card_number_sent') # This will be "User confirmed payment" for now - purchase_type = data.get('purchase_type') - amount = data.get('amount') # Calculated amount from previous step - quantity = data.get('quantity', 0) # For 'gb' or 'month' - time_quantity = data.get('time_quantity', 0) # For 'custom' - traffic_quantity = data.get('traffic_quantity', 0) # For 'custom' - - if not all([telegram_id, order_id, card_number_sent, purchase_type, amount is not None]): - return error_response("Missing required transaction details.", 400) - - # Check if order_id already exists (to prevent duplicate requests) - if await asyncio.to_thread(candy_panel.db.has, 'transactions', {'order_id': order_id}): - return error_response("This Order ID has already been submitted. Please use a unique one or contact support if you believe this is an error.", 400) - - await asyncio.to_thread(candy_panel.db.insert, 'transactions', { - 'order_id': order_id, - 'telegram_id': telegram_id, - 'amount': amount, - 'card_number_sent': card_number_sent, - 'status': 'pending', - 'requested_at': datetime.now().isoformat(), - 'purchase_type': purchase_type, - 'quantity': quantity, - 'time_quantity': time_quantity, - 'traffic_quantity': traffic_quantity - }) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - return success_response("Transaction submitted for review.", data={ - "admin_telegram_id": admin_telegram_id - }) - - -@app.post("/bot_api/user/get_license") -async def bot_get_user_license(): - data = request.json - telegram_id = data.get('telegram_id') - if not telegram_id: - return error_response("Missing telegram_id", 400) - - user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) - if not user: - return error_response("User not registered with the bot. Please use /start to register.", 404) - if not user.get('candy_client_name'): - return error_response("You don't have an active license yet. Please purchase one using the 'Buy Traffic' option.", 404) - - success, config_content = await asyncio.to_thread(candy_panel._get_client_config, user['candy_client_name']) - if not success: - return error_response(f"Failed to retrieve license. Reason: {config_content}. Please contact support.", 500) - - return success_response("Your WireGuard configuration:", data={"config": config_content}) - -@app.post("/bot_api/user/account_status") -async def bot_get_account_status(): - data = request.json - telegram_id = data.get('telegram_id') - if not telegram_id: - return error_response("Missing telegram_id", 400) - - user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) - if not user: - return error_response("User not registered with the bot. Please use /start to register.", 404) - - status_info = { - "status": user['status'], - "traffic_bought_gb": user['traffic_bought_gb'], - "time_bought_days": user['time_bought_days'], - "candy_client_name": user['candy_client_name'], - "used_traffic_bytes": 0, # Default to 0 - "traffic_limit_bytes": 0, # Default to 0 - "expires": 'N/A', - "note": '' - } - - if user.get('candy_client_name'): - # Directly call CandyPanel's internal method to get all clients - all_clients_data = await asyncio.to_thread(candy_panel._get_all_clients) - - if all_clients_data: - client_info = next((c for c in all_clients_data if c['name'] == user['candy_client_name']), None) - if client_info: - try: - used_traffic = json.loads(client_info.get('used_trafic', '{"download":0,"upload":0}')) - status_info['used_traffic_bytes'] = used_traffic.get('download', 0) + used_traffic.get('upload', 0) - except (json.JSONDecodeError, TypeError): - status_info['used_traffic_bytes'] = 0 # Fallback - status_info['expires'] = client_info.get('expires') # Get expiry from CandyPanel - status_info['traffic_limit_bytes'] = int(client_info.get('traffic', 0)) - status_info['note'] = client_info.get('note', '') # Get note from CandyPanel client - else: - status_info['note'] = "Your VPN client configuration might be out of sync or deleted from the server. Please contact support." - else: - status_info['note'] = "Could not fetch live traffic data from the server. Please try again later or contact support." - - return success_response("Your account status:", data=status_info) - -@app.post("/bot_api/user/call_support") -async def bot_call_support(): - data = request.json - telegram_id = data.get('telegram_id') - message_text = data.get('message') - - if not all([telegram_id, message_text]): - return error_response("Missing telegram_id or message", 400) - - user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) - username = f"User {telegram_id}" - if user and user.get('candy_client_name'): - username = user['candy_client_name'] - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if admin_telegram_id == '0': - return error_response("Admin Telegram ID not set in bot settings. Support is unavailable.", 500) - - return success_response("Your message has been sent to support.", data={ - "admin_telegram_id": admin_telegram_id, - "support_message": f"Support request from {username} (ID: {telegram_id}):\n\n{message_text}" - }) - -# --- Admin Endpoints --- - -@app.post("/bot_api/admin/check_admin") -async def bot_check_admin(): - data = request.json - telegram_id = data.get('telegram_id') - if not telegram_id: - return error_response("Missing telegram_id", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - is_admin = (str(telegram_id) == admin_telegram_id) - return success_response("Admin status checked.", data={"is_admin": is_admin, "admin_telegram_id": admin_telegram_id}) - - -@app.post("/bot_api/admin/get_all_users") -async def bot_admin_get_all_users(): - data = request.json - telegram_id = data.get('telegram_id') - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if not telegram_id or str(telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - users = await asyncio.to_thread(candy_panel.db.select, 'users') - return success_response("All bot users retrieved.", data={"users": users}) - -@app.post("/bot_api/admin/get_transactions") -async def bot_admin_get_transactions(): - data = request.json - telegram_id = data.get('telegram_id') - status_filter = data.get('status_filter', 'pending') # 'pending', 'approved', 'rejected', 'all' - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if not telegram_id or str(telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - where_clause = {} - if status_filter != 'all': - where_clause['status'] = status_filter - - transactions = await asyncio.to_thread(candy_panel.db.select, 'transactions', where=where_clause) - return success_response("Transactions retrieved.", data={"transactions": transactions}) - -@app.post("/bot_api/admin/approve_transaction") -async def bot_admin_approve_transaction(): - data = request.json - telegram_id = data.get('telegram_id') - order_id = data.get('order_id') - admin_note = data.get('admin_note', '') - - if not all([telegram_id, order_id]): - return error_response("Missing required fields for approval.", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if str(telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - transaction = await asyncio.to_thread(candy_panel.db.get, 'transactions', where={'order_id': order_id}) - if not transaction: - return error_response("Transaction not found.", 404) - if transaction['status'] != 'pending': - return error_response("Transaction is not pending. It has been already processed.", 400) - - purchase_type = transaction['purchase_type'] - - # Determine quantities based on purchase_type - quantity_for_candy = 0 # This will be the traffic quota in bytes - expire_days_for_candy = 0 # This will be days for expiry - - user_time_bought_days = 0 - user_traffic_bought_gb = 0 - - if purchase_type == 'gb': - traffic_quantity_gb = float(transaction['quantity']) - expire_days_for_candy = 365 # Default expiry for GB plans, e.g., 1 year - quantity_for_candy = int(traffic_quantity_gb * (1024**3)) # Convert GB to bytes - user_traffic_bought_gb = traffic_quantity_gb - user_time_bought_days = 0 # No explicit time added for GB plans - elif purchase_type == 'month': - time_quantity_months = float(transaction['quantity']) - expire_days_for_candy = int(time_quantity_months * 30) - quantity_for_candy = int(1024 * (1024**3)) # Default high traffic for time-based plans (1TB) - user_traffic_bought_gb = 0 # No explicit traffic added for month plans - user_time_bought_days = expire_days_for_candy - elif purchase_type == 'custom': - time_quantity_months = float(transaction['time_quantity']) - traffic_quantity_gb = float(transaction['traffic_quantity']) - expire_days_for_candy = int(time_quantity_months * 30) - quantity_for_candy = int(traffic_quantity_gb * (1024**3)) # Convert GB to bytes - user_traffic_bought_gb = traffic_quantity_gb - user_time_bought_days = expire_days_for_candy - else: - return error_response("Invalid purchase_type in transaction record.", 500) - - # Get user from bot's DB - user_in_bot_db = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': transaction['telegram_id']}) - if not user_in_bot_db: - print(f"Warning: User {transaction['telegram_id']} not found in bot_db during transaction approval.") - return error_response(f"User {transaction['telegram_id']} not found in bot's database. Cannot approve.", 404) - - client_name = user_in_bot_db.get('candy_client_name') - if not client_name: - # Generate a unique client name if none exists (e.g., "user_") - # Use a more stable client name, maybe just based on telegram_id if unique enough - client_name = f"tguser_{transaction['telegram_id']}" - # Ensure uniqueness by appending timestamp if a client with this name already exists in CandyPanel - existing_client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': client_name}) - if existing_client: - client_name = f"tguser_{transaction['telegram_id']}_{int(datetime.now().timestamp())}" - - current_expires_str = None - current_traffic_str = None - candy_client_exists = False - - # Check if client exists in CandyPanel DB - existing_candy_client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': client_name}) - if existing_candy_client: - candy_client_exists = True - current_expires_str = existing_candy_client.get('expires') - current_traffic_str = existing_candy_client.get('traffic') - current_used_traffic = json.loads(existing_candy_client.get('used_trafic', '{"download":0,"upload":0,"last_wg_rx":0,"last_wg_tx":0}')) - current_total_used_bytes = current_used_traffic.get('download',0) + current_used_traffic.get('upload',0) - - # Calculate new expiry date based on existing one if present, otherwise from now - new_expires_dt = datetime.now() - if current_expires_str: - try: - current_expires_dt = datetime.fromisoformat(current_expires_str) - if current_expires_dt > new_expires_dt: # If current expiry is in future, extend from that point - new_expires_dt = current_expires_dt - except ValueError: - print(f"Warning: Invalid existing expiry date format for client '{client_name}'. Recalculating from now.") - - new_expires_dt += timedelta(days=expire_days_for_candy) - new_expires_iso = new_expires_dt.isoformat() - - # Calculate new total traffic limit for CandyPanel: add the new traffic to existing total - new_total_traffic_bytes_for_candy = quantity_for_candy # Start with newly bought traffic - if candy_client_exists and current_traffic_str: - try: - # If the new plan is traffic-based, add to previous traffic limit. - # If the previous plan was time-based with a large dummy traffic, overwrite it. - # This logic can be refined if there are complex plan combinations. - # For simplicity, if the new purchase is traffic-based, we add to existing. - # If it's time-based, we set to a large default unless previous was larger and explicitly traffic-limited. - previous_traffic_limit_bytes = int(current_traffic_str) - if purchase_type == 'gb' or (purchase_type == 'custom' and traffic_quantity_gb > 0): - new_total_traffic_bytes_for_candy += previous_traffic_limit_bytes - elif purchase_type == 'month' and previous_traffic_limit_bytes < 1024 * (1024**3): # If previous was not already a large default - new_total_traffic_bytes_for_candy = int(1024 * (1024**3)) # Set to 1TB if buying time - except ValueError: - print(f"Warning: Invalid existing traffic limit format for client '{client_name}'. Overwriting.") - - # Ensure new traffic limit is at least the current used traffic - if candy_client_exists and new_total_traffic_bytes_for_candy < current_total_used_bytes: - new_total_traffic_bytes_for_candy = current_total_used_bytes + quantity_for_candy # Ensure it's not less than already used + new purchase. - - client_config = None # Will store the config if a new client is created - - if not candy_client_exists: - # Create client in CandyPanel - success_cp, message_cp = await asyncio.to_thread( - candy_panel._new_client, - client_name, - new_expires_iso, - str(new_total_traffic_bytes_for_candy), # CandyPanel expects string - 0, # Assuming default wg0 for now, can be made configurable via admin settings - f"Bot User: {transaction['telegram_id']} - Order: {order_id}" - ) - if not success_cp: - return error_response(f"Failed to create client in CandyPanel: {message_cp}", 500) - client_config = message_cp # _new_client returns config on success - else: - # Update existing client in CandyPanel - # Ensure status is True when updating (unbanning if it was banned) - success_cp, message_cp = await asyncio.to_thread( - candy_panel._edit_client, - client_name, - expires=new_expires_iso, - traffic=str(new_total_traffic_bytes_for_candy), # Update traffic quota - status=True # Ensure client is active - ) - if not success_cp: - return error_response(f"Failed to update client in CandyPanel: {message_cp}", 500) - # If client was updated, user needs to get config again. - # Fetch the config explicitly here, as _edit_client doesn't return it - success_config, fetched_config = await asyncio.to_thread(candy_panel._get_client_config, client_name) - if success_config: - client_config = fetched_config - else: - print(f"Warning: Could not fetch updated config for existing client {client_name}: {fetched_config}") - - # Update bot's user table - # Accumulate bought traffic and time - await asyncio.to_thread(candy_panel.db.update, 'users', { - 'candy_client_name': client_name, - 'traffic_bought_gb': user_in_bot_db.get('traffic_bought_gb', 0) + user_traffic_bought_gb, - 'time_bought_days': user_in_bot_db.get('time_bought_days', 0) + user_time_bought_days, - 'status': 'active' # Ensure bot user status is active - }, {'telegram_id': transaction['telegram_id']}) - - # Update transaction status - await asyncio.to_thread(candy_panel.db.update, 'transactions', { - 'status': 'approved', - 'approved_at': datetime.now().isoformat(), - 'admin_note': admin_note - }, {'order_id': order_id}) - - return success_response(f"Transaction {order_id} approved. Client '{client_name}' {'created' if not candy_client_exists else 'updated'} in CandyPanel.", data={ - "client_config": client_config, # Send config back to bot for user - "telegram_id": transaction['telegram_id'], # For bot to send message to user - "client_name": client_name, # Pass client name for user message - "new_traffic_gb": user_traffic_bought_gb, # For bot message to user - "new_time_days": user_time_bought_days # For bot message to user - }) - -@app.post("/bot_api/admin/reject_transaction") -async def bot_admin_reject_transaction(): - data = request.json - telegram_id = data.get('telegram_id') - order_id = data.get('order_id') - admin_note = data.get('admin_note', '') - - if not all([telegram_id, order_id]): - return error_response("Missing telegram_id or order_id.", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if str(telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - transaction = await asyncio.to_thread(candy_panel.db.get, 'transactions', where={'order_id': order_id}) - if not transaction: - return error_response("Transaction not found.", 404) - if transaction['status'] != 'pending': - return error_response("Transaction is not pending. It has been already processed.", 400) - - await asyncio.to_thread(candy_panel.db.update, 'transactions', { - 'status': 'rejected', - 'approved_at': datetime.now().isoformat(), - 'admin_note': admin_note - }, {'order_id': order_id}) - - return success_response(f"Transaction {order_id} rejected.", data={ - "telegram_id": transaction['telegram_id'] # For bot to send message to user - }) - -@app.post("/bot_api/admin/manage_user") -async def bot_admin_manage_user(): - data = request.json - admin_telegram_id = data.get('admin_telegram_id') - target_telegram_id = data.get('target_telegram_id') - action = data.get('action') # 'ban', 'unban', 'update_traffic', 'update_time' - value = data.get('value') # For update_traffic/time - - if not all([admin_telegram_id, target_telegram_id, action]): - return error_response("Missing required fields.", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if str(admin_telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': target_telegram_id}) - if not user: - return error_response("Target user not found.", 404) - - update_data = {} - message = "" - success_status = True - - if action == 'ban': - update_data['status'] = 'banned' - message = f"User {target_telegram_id} has been banned." - # Also disable in CandyPanel if linked - if user.get('candy_client_name'): - success, msg = await asyncio.to_thread( - candy_panel._edit_client, user['candy_client_name'], status=False - ) - if not success: - message += f" (Failed to disable client in CandyPanel: {msg})" - success_status = False - elif action == 'unban': - update_data['status'] = 'active' - message = f"User {target_telegram_id} has been unbanned." - # Also enable in CandyPanel if linked - if user.get('candy_client_name'): - success, msg = await asyncio.to_thread( - candy_panel._edit_client, user['candy_client_name'], status=True - ) - if not success: - message += f" (Failed to enable client in CandyPanel: {msg})" - success_status = False - elif action == 'update_traffic' and value is not None: - try: - new_traffic_gb = float(value) - update_data['traffic_bought_gb'] = new_traffic_gb - message = f"User {target_telegram_id} traffic updated to {new_traffic_gb} GB." - # Update in CandyPanel - if user.get('candy_client_name'): - traffic_bytes = int(new_traffic_gb * (1024**3)) - success, msg = await asyncio.to_thread( - candy_panel._edit_client, user['candy_client_name'], traffic=str(traffic_bytes) - ) - if not success: - message += f" (Failed to update traffic in CandyPanel: {msg})" - success_status = False - except ValueError: - return error_response("Invalid value for traffic. Must be a number.", 400) - elif action == 'update_time' and value is not None: - try: - new_time_days = int(value) - update_data['time_bought_days'] = new_time_days - message = f"User {target_telegram_id} time updated to {new_time_days} days." - # Update in CandyPanel (this is more complex, as CandyPanel uses expiry date) - # For simplicity, we'll just update the bot's record for now. - # A full implementation would recalculate expiry based on new_time_days from current date - # or extend the existing expiry. For now, this is a placeholder. - message += " (Note: Time update in CandyPanel requires manual expiry date calculation or a dedicated API endpoint in CandyPanel.)" - except ValueError: - return error_response("Invalid value for time. Must be an integer.", 400) - else: - return error_response("Invalid action or missing value.", 400) - - if update_data: - await asyncio.to_thread(candy_panel.db.update, 'users', update_data, {'telegram_id': target_telegram_id}) - - if success_status: - return success_response(message) - else: - return error_response(message, 500) - - -@app.post("/bot_api/admin/send_message_to_all") -async def bot_admin_send_message_to_all(): - data = request.json - telegram_id = data.get('telegram_id') - message_text = data.get('message') - - if not all([telegram_id, message_text]): - return error_response("Missing telegram_id or message.", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if str(telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - all_users = await asyncio.to_thread(candy_panel.db.select, 'users') - user_ids = [user['telegram_id'] for user in all_users] - - # This API endpoint just prepares the list of users. - # The Telegram bot itself will handle the actual sending to avoid blocking the API. - return success_response("Broadcast message prepared.", data={"target_user_ids": user_ids, "message": message_text}) -@app.get("/bot_api/admin/data") -async def bot_admin_data(): - try: - # Fetch all data concurrently - dashboard_stats_task = asyncio.to_thread(candy_panel._dashboard_stats) - clients_data_task = asyncio.to_thread(candy_panel._get_all_clients) - interfaces_data_task = asyncio.to_thread(candy_panel.db.select, 'interfaces') - settings_data_task = asyncio.to_thread(candy_panel.db.select, 'settings') - - dashboard_stats, clients_data, interfaces_data, settings_raw = await asyncio.gather( - dashboard_stats_task, clients_data_task, interfaces_data_task, settings_data_task - ) - - # Process client data (parse used_trafic) - for client in clients_data: - try: - client['used_trafic'] = json.loads(client['used_trafic']) - except (json.JSONDecodeError, TypeError): - client['used_trafic'] = {"download": 0, "upload": 0} - - # Process settings data (convert to dict) - settings_data = {setting['key']: setting['value'] for setting in settings_raw} - - return success_response("All data retrieved successfully.", data={ - "dashboard": dashboard_stats, - "clients": clients_data, - "interfaces": interfaces_data, - "settings": settings_data - }) - except Exception as e: - return error_response(f"Failed to retrieve all data: {e}", 500) -@app.post("/bot_api/admin/server_control") -async def bot_admin_server_control(): - data = request.json - admin_telegram_id = data.get('admin_telegram_id') - resource = data.get('resource') - action = data.get('action') - payload_data = data.get('data', {}) # Additional data for the CandyPanel API call - - if not all([admin_telegram_id, resource, action]): - return error_response("Missing admin_telegram_id, resource, or action.", 400) - - admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) - admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' - - if str(admin_telegram_id) != admin_telegram_id: - return error_response("Unauthorized", 403) - - # Direct internal calls to CandyPanel methods - success = False - message = "Invalid operation." - candy_data = {} - - if resource == 'client': - if action == 'create': - name = payload_data.get('name') - expires = payload_data.get('expires') - traffic = payload_data.get('traffic') - wg_id = payload_data.get('wg_id', 0) - note = payload_data.get('note', '') - if all([name, expires, traffic]): - success, message = await asyncio.to_thread(candy_panel._new_client, name, expires, traffic, wg_id, note) - if success: - candy_data = {"client_config": message} # _new_client returns config on success - elif action == 'update': - name = payload_data.get('name') - expires = payload_data.get('expires') - traffic = payload_data.get('traffic') - status = payload_data.get('status') - note = payload_data.get('note') - if name: - success, message = await asyncio.to_thread(candy_panel._edit_client, name, expires, traffic, status, note) - elif action == 'delete': - name = payload_data.get('name') - if name: - success, message = await asyncio.to_thread(candy_panel._delete_client, name) - elif action == 'get_config': - name = payload_data.get('name') - if name: - success, message = await asyncio.to_thread(candy_panel._get_client_config, name) - if success: - candy_data = {"config": message} - elif resource == 'interface': - if action == 'create': - address_range = payload_data.get('address_range') - ipv6_address_range = payload_data.get('ipv6_address_range') - port = payload_data.get('port') - if all([address_range, port]): - success, message = await asyncio.to_thread(candy_panel._new_interface_wg, address_range, port, ipv6_address_range) - elif action == 'update': - name = payload_data.get('name') - address = payload_data.get('address') - port = payload_data.get('port') - status = payload_data.get('status') - if name: - success, message = await asyncio.to_thread(candy_panel._edit_interface, name, address, port, status) - elif action == 'delete': - wg_id = payload_data.get('wg_id') - if wg_id is not None: - success, message = await asyncio.to_thread(candy_panel._delete_interface, wg_id) - elif resource == 'setting': - if action == 'update': - key = payload_data.get('key') - value = payload_data.get('value') - if all([key, value is not None]): - success, message = await asyncio.to_thread(candy_panel._change_settings, key, value) - elif resource == 'sync': - if action == 'trigger': - await asyncio.to_thread(candy_panel._sync) - success = True - message = "Synchronization process initiated successfully." - else: - return error_response(f"Unknown resource type: {resource}", 400) - - if success: - return success_response(f"CandyPanel: {message}", data=candy_data) - else: - return error_response(f"CandyPanel Error: {message}", 500) - - -@app.route('/') -def serve_root_index(): - return send_file(os.path.join(app.static_folder, 'index.html')) - -@app.route('/') -def catch_all_frontend_routes(path): - static_file_path = os.path.join(app.static_folder, path) - if os.path.exists(static_file_path) and os.path.isfile(static_file_path): - return send_file(static_file_path) - else: - return send_file(os.path.join(app.static_folder, 'index.html')) -# This is for development purposes only. For production, use a WSGI server like Gunicorn. -if __name__ == '__main__': +# main.py +from flask import Flask, request, jsonify, abort, g, send_from_directory , send_file, redirect +from functools import wraps +from flask_cors import CORS +import asyncio +import json +from datetime import datetime, timedelta +import os +import subprocess +import threading +import time + +# Import your CandyPanel logic +from core import CandyPanel, CommandExecutionError + +# --- Initialize CandyPanel --- +candy_panel = CandyPanel() + +# --- Flask Application Setup --- +app = Flask(__name__, static_folder=os.path.join(os.getcwd(), '..', 'Frontend', 'dist'), static_url_path='/static') +# Use environment variable for secret key, fallback to random 24 bytes if not set (for development) +# In production, ensure SECRET_KEY is set in environment! +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24).hex()) +CORS(app) + +# --- Background Sync Thread --- +def background_sync(): + """Background thread function that runs sync every 5 minutes""" + while True: + try: + print("[*] Starting background sync...") + candy_panel._sync() + print("[*] Background sync completed successfully.") + except Exception as e: + print(f"[!] Error in background sync: {e}") + # Sleep for 5 minutes (300 seconds) + time.sleep(300) + +# Start background sync thread +sync_thread = threading.Thread(target=background_sync, daemon=True) +sync_thread.start() +print("[+] Background sync thread started.") + +# --- Authentication Decorator for CandyPanel Admin API --- +def authenticate_admin(f): + @wraps(f) + async def decorated_function(*args, **kwargs): + auth_header = request.headers.get('Authorization') + if not auth_header: + abort(401, description="Authorization header missing") + + try: + token_type, token = auth_header.split(None, 1) + except ValueError: + abort(401, description="Invalid Authorization header format") + + if token_type.lower() != 'bearer': + abort(401, description="Unsupported authorization type") + + # Run synchronous DB operation in a thread pool + settings = await asyncio.to_thread(candy_panel.db.get, 'settings','*' ,{'key': 'session_token'}) + if not settings or settings['value'] != token: + abort(401, description="Invalid authentication credentials") + + g.is_authenticated = True + return await f(*args, **kwargs) + return decorated_function + +# --- Helper for common responses --- +def success_response(message: str, data=None, status_code: int = 200): + return jsonify({"message": message, "success": True, "data": data}), status_code + +def error_response(message: str, status_code: int = 400): + return jsonify({"message": message, "success": False}), status_code + +# --- CandyPanel API Endpoints --- +@app.get("/client-details//") +async def get_client_public_details(name: str, public_key: str): + """ + Retrieves public-facing details for a specific client given its name and public key. + This endpoint does NOT require authentication. + """ + try: + client_data = await asyncio.to_thread(candy_panel._get_client_by_name_and_public_key, name, public_key) + if client_data: + return success_response("Client details retrieved successfully.", data=client_data) + else: + return error_response("Client not found or public key mismatch.", 404) + except Exception as e: + return error_response(f"An error occurred: {e}", 500) + +@app.get("/shortlink//") +async def shortlink_redirect(name: str, public_key: str): + """ + Handles shortlink redirects to the client details page. + This replaces the frontend shortlink handling. + """ + try: + # Verify client exists before redirecting + client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': name, 'public_key': public_key}) + if not client: + # Serve a simple error page + return f""" + + + Client Not Found + +

Client Not Found

+

The requested client does not exist or the link is invalid.

+ + + """, 404 + + # Serve the client details page directly + return send_from_directory(app.static_folder, 'client.html') + except Exception as e: + return f""" + + + Error + +

Error

+

An error occurred: {e}

+ + + """, 500 + +@app.get("/qr//") +async def get_qr_code(name: str, public_key: str): + """ + Generates and returns a QR code image for a client's configuration (without the private key), with IPv6 support. + This endpoint is publicly accessible. + """ + client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': name, 'public_key': public_key}) + if not client: + return error_response("Client not found or public key mismatch.", 404) + + interface = await asyncio.to_thread(candy_panel.db.get, 'interfaces', where={'wg': client['wg']}) + if not interface: + return error_response("Associated WireGuard interface not found.", 500) + + # Reconstruct the config using live data + dns = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'dns'}) + dns_value = dns['value'] if dns else '8.8.8.8' + ipv6_dns = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'ipv6_dns'}) + ipv6_dns_value = ipv6_dns['value'] if ipv6_dns else None + + dns_list = [dns_value] + if ipv6_dns_value: + dns_list.append(ipv6_dns_value) + + dns_line = f"DNS = {', '.join(dns_list)}" + + mtu = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'mtu'}) + mtu_value = mtu['value'] if mtu else '1420' + server_ip = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'custom_endpont'}) + server_ip = server_ip['value'] + + address_line = f"Address = {client['address']}/32" + if client.get('ipv6_address'): + address_line += f", {client['ipv6_address']}/128" + + config_content = f"""[Interface] +PrivateKey = {client['private_key']} +{address_line} +{dns_line} +MTU = {mtu_value} + +[Peer] +PublicKey = {interface['public_key']} +Endpoint = {server_ip}:{interface['port']} +AllowedIPs = 0.0.0.0/0, ::/0 +PersistentKeepalive = 25""" + + # Use qrencode to generate the QR code as a temporary file + temp_file_path = f"/tmp/{name}-{public_key}.png" + try: + await asyncio.to_thread(subprocess.run,['qrencode', '-o', temp_file_path, config_content], check=True) + return send_file(temp_file_path, mimetype='image/png') + except subprocess.CalledProcessError: + return error_response("Failed to generate QR code. Is 'qrencode' installed?", 500) + except Exception as e: + return error_response(f"An error occurred: {e}", 500) + finally: + if os.path.exists(temp_file_path): + os.remove(temp_file_path) + +@app.get("/check") +async def check_installation(): + """ + Checks if the CandyPanel is installed. + """ + install_status = await asyncio.to_thread(candy_panel.db.get, 'settings', '*',{'key': 'install'}) + is_installed = bool(install_status and install_status['value'] == '1') + return jsonify({"installed": is_installed}) + +@app.post("/api/auth") +async def handle_auth(): + """ + Handles both login and installation based on the 'action' field, with IPv6 support. + """ + data = request.json + if not data or 'action' not in data: + return error_response("Missing 'action' in request body", 400) + + action = data['action'] + install_status = await asyncio.to_thread(candy_panel.db.get, 'settings', '*',{'key': 'install'}) + is_installed = bool(install_status and install_status['value'] == '1') + + if action == 'login': + if not is_installed: + return error_response("CandyPanel is not installed. Please use the 'install' action.", 400) + + if 'username' not in data or 'password' not in data: + return error_response("Missing username or password for login", 400) + + success, message = await asyncio.to_thread(candy_panel._admin_login, data['username'], data['password']) + if not success: + return error_response(message, 401) + return success_response("Login successful!", data={"access_token": message, "token_type": "bearer"}) + + elif action == 'install': + if is_installed: + return error_response("CandyPanel is already installed.", 400) + + try: + server_ip = data['server_ip'] + wg_port = data['wg_port'] + wg_address_range = data.get('wg_address_range', "10.0.0.1/24") + wg_ipv6_address = data.get('wg_ipv6_address', None) + wg_dns = data.get('wg_dns', "8.8.8.8") + wg_ipv6_dns = data.get('wg_ipv6_dns', None) + admin_user = data.get('admin_user', "admin") + admin_password = data.get('admin_password', "admin") + except KeyError as e: + return error_response(f"Missing required field for installation: {e}", 400) + + success, message = await asyncio.to_thread( + candy_panel._install_candy_panel, + server_ip, + wg_port, + wg_address_range, + wg_dns, + admin_user, + admin_password, + wg_ipv6_address, + wg_ipv6_dns + ) + if not success: + return error_response(message, 400) + return success_response(message) + else: + return error_response("Invalid action specified. Must be 'login' or 'install'.", 400) + +@app.get("/api/data") +@authenticate_admin +async def get_all_data(): + """ + Retrieves all relevant data for the dashboard, clients, interfaces, and settings in one go. + Requires authentication. + """ + try: + # Fetch all data concurrently + dashboard_stats_task = asyncio.to_thread(candy_panel._dashboard_stats) + clients_data_task = asyncio.to_thread(candy_panel._get_all_clients) + interfaces_data_task = asyncio.to_thread(candy_panel.db.select, 'interfaces') + settings_data_task = asyncio.to_thread(candy_panel.db.select, 'settings') + + dashboard_stats, clients_data, interfaces_data, settings_raw = await asyncio.gather( + dashboard_stats_task, clients_data_task, interfaces_data_task, settings_data_task + ) + + # Process client data (parse used_trafic) + for client in clients_data: + try: + client['used_trafic'] = json.loads(client['used_trafic']) + except (json.JSONDecodeError, TypeError): + client['used_trafic'] = {"download": 0, "upload": 0} + + # Process settings data (convert to dict) + settings_data = {setting['key']: setting['value'] for setting in settings_raw} + + return success_response("All data retrieved successfully.", data={ + "dashboard": dashboard_stats, + "clients": clients_data, + "interfaces": interfaces_data, + "settings": settings_data + }) + except Exception as e: + return error_response(f"Failed to retrieve all data: {e}", 500) + +@app.post("/api/manage") +@authenticate_admin +async def manage_resources(): + """ + Unified endpoint for creating/updating/deleting clients, interfaces, and settings, with IPv6 support. + Requires authentication. + """ + data = request.json + if not data or 'resource' not in data or 'action' not in data: + return error_response("Missing 'resource' or 'action' in request body", 400) + + resource = data['resource'] + action = data['action'] + + try: + if resource == 'client': + if action == 'create': + name = data.get('name') + expires = data.get('expires') + traffic = data.get('traffic') + wg_id = data.get('wg_id', 0) + note = data.get('note', '') + if not all([name, expires, traffic]): + return error_response("Missing name, expires, or traffic for client creation", 400) + success, message = await asyncio.to_thread(candy_panel._new_client, name, expires, traffic, wg_id, note) + if not success: + return error_response(message, 400) + return success_response("Client created successfully!", data={"client_config": message}) + + elif action == 'update': + name = data.get('name') + if not name: + return error_response("Missing client name for update", 400) + expires = data.get('expires') + traffic = data.get('traffic') + status = data.get('status') + note = data.get('note') + success, message = await asyncio.to_thread(candy_panel._edit_client, name, expires, traffic, status, note) + if not success: + return error_response(message, 400) + return success_response(message) + + elif action == 'delete': + name = data.get('name') + if not name: + return error_response("Missing client name for deletion", 400) + success, message = await asyncio.to_thread(candy_panel._delete_client, name) + if not success: + return error_response(message, 400) + return success_response(message) + + elif action == 'get_config': + name = data.get('name') + if not name: + return error_response("Missing client name to get config", 400) + success, config_content = await asyncio.to_thread(candy_panel._get_client_config, name) + if not success: + return error_response(config_content, 404) + return success_response("Client config retrieved successfully.", data={"config": config_content}) + else: + return error_response(f"Invalid action '{action}' for client resource", 400) + + elif resource == 'interface': + if action == 'create': + address_range = data.get('address_range') + ipv6_address_range = data.get('ipv6_address_range') + port = data.get('port') + if not all([address_range, port]): + return error_response("Missing address_range or port for interface creation", 400) + success, message = await asyncio.to_thread(candy_panel._new_interface_wg, address_range, port, ipv6_address_range) + if not success: + return error_response(message, 400) + return success_response(message) + + elif action == 'update': + name = data.get('name') # e.g., 'wg0' + if not name: + return error_response("Missing interface name for update", 400) + address = data.get('address') + port = data.get('port') + status = data.get('status') + success, message = await asyncio.to_thread(candy_panel._edit_interface, name, address, port, status) + if not success: + return error_response(message, 400) + return success_response(message) + + # New: Delete interface + elif action == 'delete': + wg_id = data.get('wg_id') + if wg_id is None: + return error_response("Missing wg_id for interface deletion", 400) + success, message = await asyncio.to_thread(candy_panel._delete_interface, wg_id) + if not success: + return error_response(message, 400) + return success_response(message) + + else: + return error_response(f"Invalid action '{action}' for interface resource", 400) + + elif resource == 'setting': + if action == 'update': + key = data.get('key') + value = data.get('value') + if not all([key, value is not None]): # Value can be an empty string or 0, so check explicitly + return error_response("Missing key or value for setting update", 400) + if key == 'telegram_bot_status': + if value == '1': # '1' means ON + bot_control_success = await asyncio.to_thread(candy_panel._manage_telegram_bot_process, 'start') + if not bot_control_success: + # Log the failure, but return success for setting update if DB was successful + print(f"Warning: Failed to start bot immediately after setting update.") + return success_response(f"(Bot start attempted, but failed.)") + else: # '0' means OFF + bot_control_success = await asyncio.to_thread(candy_panel._manage_telegram_bot_process, 'stop') + if not bot_control_success: + print(f"Warning: Failed to stop bot immediately after setting update.") + return success_response(f"(Bot stop attempted, but failed.)") + success, message = await asyncio.to_thread(candy_panel._change_settings, key, value) + if not success: + return error_response(message, 400) + return success_response(message) + else: + return error_response(f"Invalid action '{action}' for setting resource", 400) + + elif resource == 'api_token': + if action == 'create_or_update': + name = data.get('name') + token = data.get('token') + if not all([name, token]): + return error_response("Missing name or token for API token operation", 400) + success, message = await asyncio.to_thread(candy_panel._add_api_token, name, token) + if not success: + return error_response(message, 400) + return success_response(message) + + elif action == 'delete': + name = data.get('name') + if not name: + return error_response("Missing name for API token deletion", 400) + success, message = await asyncio.to_thread(candy_panel._delete_api_token, name) + if not success: + return error_response(message, 400) + return success_response(message) + else: + return error_response(f"Invalid action '{action}' for API token resource", 400) + + elif resource == 'sync': + if action == 'trigger': + await asyncio.to_thread(candy_panel._sync) + return success_response("Synchronization process initiated successfully.") + else: + return error_response(f"Invalid action '{action}' for sync resource", 400) + + else: + return error_response(f"Unknown resource type: {resource}", 400) + + except CommandExecutionError as e: + return error_response(f"Command execution error: {e}", 500) + except Exception as e: + return error_response(f"An unexpected error occurred: {e}", 500) + +# --- Telegram Bot API Endpoints (Integrated) --- + +@app.post("/bot_api/user/register") +async def bot_register_user(): + data = request.json + telegram_id = data.get('telegram_id') + if not telegram_id: + return error_response("Missing telegram_id", 400) + + user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) + if user: + return success_response("User already registered.", data={"registered": True, "language": user.get('language', 'en')}) # Return current language + + # Default language is English + await asyncio.to_thread(candy_panel.db.insert, 'users', { + 'telegram_id': telegram_id, + 'created_at': datetime.now().isoformat(), + 'language': 'en' + }) + return success_response("User registered successfully.", data={"registered": True, "language": "en"}) + +@app.post("/bot_api/user/set_language") +async def bot_set_language(): + data = request.json + telegram_id = data.get('telegram_id') + language = data.get('language') + + if not all([telegram_id, language]): + return error_response("Missing telegram_id or language", 400) + + if language not in ['en', 'fa']: # Only allow 'en' or 'fa' for now + return error_response("Unsupported language. Available: 'en', 'fa'", 400) + + if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): + return error_response("User not registered with the bot.", 404) + + await asyncio.to_thread(candy_panel.db.update, 'users', {'language': language}, {'telegram_id': telegram_id}) + return success_response("Language updated successfully.") + + +@app.post("/bot_api/user/initiate_purchase") # NEW ENDPOINT +async def bot_initiate_purchase(): + data = request.json + telegram_id = data.get('telegram_id') + + if not telegram_id: + return error_response("Missing telegram_id", 400) + + if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): + return error_response("User not registered with the bot.", 404) + + prices_json = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'prices'}) + prices = json.loads(prices_json['value']) if prices_json and prices_json['value'] else {} + + admin_card_number_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'admin_card_number'}) + admin_card_number = admin_card_number_setting['value'] if admin_card_number_setting else 'YOUR_ADMIN_CARD_NUMBER' + + return success_response("Purchase initiation details.", data={ + "admin_card_number": admin_card_number, + "prices": prices + }) + +@app.post("/bot_api/user/calculate_price") # NEW ENDPOINT +async def bot_calculate_price(): + data = request.json + telegram_id = data.get('telegram_id') + purchase_type = data.get('purchase_type') + quantity = data.get('quantity') + time_quantity = data.get('time_quantity', 0) + traffic_quantity = data.get('traffic_quantity', 0) + + if not all([telegram_id, purchase_type]): + return error_response("Missing telegram_id or purchase_type", 400) + + if not await asyncio.to_thread(candy_panel.db.has, 'users', {'telegram_id': telegram_id}): + return error_response("User not registered with the bot.", 404) + + prices_json = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'prices'}) + prices = json.loads(prices_json['value']) if prices_json and prices_json['value'] else {} + + calculated_amount = 0 + if purchase_type == 'gb': + if quantity is None: return error_response("Missing quantity for GB purchase", 400) + price_per_gb = prices.get('1GB') + if not price_per_gb: + return error_response("Price per GB not configured. Please contact support.", 500) + calculated_amount = price_per_gb * float(quantity) + elif purchase_type == 'month': + if quantity is None: return error_response("Missing quantity for Month purchase", 400) + price_per_month = prices.get('1Month') + if not price_per_month: + return error_response("Price per Month not configured. Please contact support.", 500) + calculated_amount = price_per_month * float(quantity) + elif purchase_type == 'custom': + if time_quantity is None or traffic_quantity is None: + return error_response("Missing time_quantity or traffic_quantity for custom purchase", 400) + price_per_gb = prices.get('1GB') + price_per_month = prices.get('1Month') + if not price_per_gb or not price_per_month: + return error_response("Prices for custom plan (1GB or 1Month) not configured. Please contact support.", 500) + calculated_amount = (price_per_gb * float(traffic_quantity)) + (price_per_month * float(time_quantity)) + else: + return error_response("Invalid purchase_type. Must be 'gb', 'month', or 'custom'.", 400) + + return success_response("Price calculated successfully.", data={"calculated_amount": calculated_amount}) + + +@app.post("/bot_api/user/submit_transaction") # NEW ENDPOINT +async def bot_submit_transaction(): + data = request.json + telegram_id = data.get('telegram_id') + order_id = data.get('order_id') + card_number_sent = data.get('card_number_sent') # This will be "User confirmed payment" for now + purchase_type = data.get('purchase_type') + amount = data.get('amount') # Calculated amount from previous step + quantity = data.get('quantity', 0) # For 'gb' or 'month' + time_quantity = data.get('time_quantity', 0) # For 'custom' + traffic_quantity = data.get('traffic_quantity', 0) # For 'custom' + + if not all([telegram_id, order_id, card_number_sent, purchase_type, amount is not None]): + return error_response("Missing required transaction details.", 400) + + # Check if order_id already exists (to prevent duplicate requests) + if await asyncio.to_thread(candy_panel.db.has, 'transactions', {'order_id': order_id}): + return error_response("This Order ID has already been submitted. Please use a unique one or contact support if you believe this is an error.", 400) + + await asyncio.to_thread(candy_panel.db.insert, 'transactions', { + 'order_id': order_id, + 'telegram_id': telegram_id, + 'amount': amount, + 'card_number_sent': card_number_sent, + 'status': 'pending', + 'requested_at': datetime.now().isoformat(), + 'purchase_type': purchase_type, + 'quantity': quantity, + 'time_quantity': time_quantity, + 'traffic_quantity': traffic_quantity + }) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + return success_response("Transaction submitted for review.", data={ + "admin_telegram_id": admin_telegram_id + }) + + +@app.post("/bot_api/user/get_license") +async def bot_get_user_license(): + data = request.json + telegram_id = data.get('telegram_id') + if not telegram_id: + return error_response("Missing telegram_id", 400) + + user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) + if not user: + return error_response("User not registered with the bot. Please use /start to register.", 404) + if not user.get('candy_client_name'): + return error_response("You don't have an active license yet. Please purchase one using the 'Buy Traffic' option.", 404) + + success, config_content = await asyncio.to_thread(candy_panel._get_client_config, user['candy_client_name']) + if not success: + return error_response(f"Failed to retrieve license. Reason: {config_content}. Please contact support.", 500) + + return success_response("Your WireGuard configuration:", data={"config": config_content}) + +@app.post("/bot_api/user/account_status") +async def bot_get_account_status(): + data = request.json + telegram_id = data.get('telegram_id') + if not telegram_id: + return error_response("Missing telegram_id", 400) + + user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) + if not user: + return error_response("User not registered with the bot. Please use /start to register.", 404) + + status_info = { + "status": user['status'], + "traffic_bought_gb": user['traffic_bought_gb'], + "time_bought_days": user['time_bought_days'], + "candy_client_name": user['candy_client_name'], + "used_traffic_bytes": 0, # Default to 0 + "traffic_limit_bytes": 0, # Default to 0 + "expires": 'N/A', + "note": '' + } + + if user.get('candy_client_name'): + # Directly call CandyPanel's internal method to get all clients + all_clients_data = await asyncio.to_thread(candy_panel._get_all_clients) + + if all_clients_data: + client_info = next((c for c in all_clients_data if c['name'] == user['candy_client_name']), None) + if client_info: + try: + used_traffic = json.loads(client_info.get('used_trafic', '{"download":0,"upload":0}')) + status_info['used_traffic_bytes'] = used_traffic.get('download', 0) + used_traffic.get('upload', 0) + except (json.JSONDecodeError, TypeError): + status_info['used_traffic_bytes'] = 0 # Fallback + status_info['expires'] = client_info.get('expires') # Get expiry from CandyPanel + status_info['traffic_limit_bytes'] = int(client_info.get('traffic', 0)) + status_info['note'] = client_info.get('note', '') # Get note from CandyPanel client + else: + status_info['note'] = "Your VPN client configuration might be out of sync or deleted from the server. Please contact support." + else: + status_info['note'] = "Could not fetch live traffic data from the server. Please try again later or contact support." + + return success_response("Your account status:", data=status_info) + +@app.post("/bot_api/user/call_support") +async def bot_call_support(): + data = request.json + telegram_id = data.get('telegram_id') + message_text = data.get('message') + + if not all([telegram_id, message_text]): + return error_response("Missing telegram_id or message", 400) + + user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': telegram_id}) + username = f"User {telegram_id}" + if user and user.get('candy_client_name'): + username = user['candy_client_name'] + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if admin_telegram_id == '0': + return error_response("Admin Telegram ID not set in bot settings. Support is unavailable.", 500) + + return success_response("Your message has been sent to support.", data={ + "admin_telegram_id": admin_telegram_id, + "support_message": f"Support request from {username} (ID: {telegram_id}):\n\n{message_text}" + }) + +# --- Admin Endpoints --- + +@app.post("/bot_api/admin/check_admin") +async def bot_check_admin(): + data = request.json + telegram_id = data.get('telegram_id') + if not telegram_id: + return error_response("Missing telegram_id", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + is_admin = (str(telegram_id) == admin_telegram_id) + return success_response("Admin status checked.", data={"is_admin": is_admin, "admin_telegram_id": admin_telegram_id}) + + +@app.post("/bot_api/admin/get_all_users") +async def bot_admin_get_all_users(): + data = request.json + telegram_id = data.get('telegram_id') + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if not telegram_id or str(telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + users = await asyncio.to_thread(candy_panel.db.select, 'users') + return success_response("All bot users retrieved.", data={"users": users}) + +@app.post("/bot_api/admin/get_transactions") +async def bot_admin_get_transactions(): + data = request.json + telegram_id = data.get('telegram_id') + status_filter = data.get('status_filter', 'pending') # 'pending', 'approved', 'rejected', 'all' + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if not telegram_id or str(telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + where_clause = {} + if status_filter != 'all': + where_clause['status'] = status_filter + + transactions = await asyncio.to_thread(candy_panel.db.select, 'transactions', where=where_clause) + return success_response("Transactions retrieved.", data={"transactions": transactions}) + +@app.post("/bot_api/admin/approve_transaction") +async def bot_admin_approve_transaction(): + data = request.json + telegram_id = data.get('telegram_id') + order_id = data.get('order_id') + admin_note = data.get('admin_note', '') + + if not all([telegram_id, order_id]): + return error_response("Missing required fields for approval.", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if str(telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + transaction = await asyncio.to_thread(candy_panel.db.get, 'transactions', where={'order_id': order_id}) + if not transaction: + return error_response("Transaction not found.", 404) + if transaction['status'] != 'pending': + return error_response("Transaction is not pending. It has been already processed.", 400) + + purchase_type = transaction['purchase_type'] + + # Determine quantities based on purchase_type + quantity_for_candy = 0 # This will be the traffic quota in bytes + expire_days_for_candy = 0 # This will be days for expiry + + user_time_bought_days = 0 + user_traffic_bought_gb = 0 + + if purchase_type == 'gb': + traffic_quantity_gb = float(transaction['quantity']) + expire_days_for_candy = 365 # Default expiry for GB plans, e.g., 1 year + quantity_for_candy = int(traffic_quantity_gb * (1024**3)) # Convert GB to bytes + user_traffic_bought_gb = traffic_quantity_gb + user_time_bought_days = 0 # No explicit time added for GB plans + elif purchase_type == 'month': + time_quantity_months = float(transaction['quantity']) + expire_days_for_candy = int(time_quantity_months * 30) + quantity_for_candy = int(1024 * (1024**3)) # Default high traffic for time-based plans (1TB) + user_traffic_bought_gb = 0 # No explicit traffic added for month plans + user_time_bought_days = expire_days_for_candy + elif purchase_type == 'custom': + time_quantity_months = float(transaction['time_quantity']) + traffic_quantity_gb = float(transaction['traffic_quantity']) + expire_days_for_candy = int(time_quantity_months * 30) + quantity_for_candy = int(traffic_quantity_gb * (1024**3)) # Convert GB to bytes + user_traffic_bought_gb = traffic_quantity_gb + user_time_bought_days = expire_days_for_candy + else: + return error_response("Invalid purchase_type in transaction record.", 500) + + # Get user from bot's DB + user_in_bot_db = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': transaction['telegram_id']}) + if not user_in_bot_db: + print(f"Warning: User {transaction['telegram_id']} not found in bot_db during transaction approval.") + return error_response(f"User {transaction['telegram_id']} not found in bot's database. Cannot approve.", 404) + + client_name = user_in_bot_db.get('candy_client_name') + if not client_name: + # Generate a unique client name if none exists (e.g., "user_") + # Use a more stable client name, maybe just based on telegram_id if unique enough + client_name = f"tguser_{transaction['telegram_id']}" + # Ensure uniqueness by appending timestamp if a client with this name already exists in CandyPanel + existing_client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': client_name}) + if existing_client: + client_name = f"tguser_{transaction['telegram_id']}_{int(datetime.now().timestamp())}" + + current_expires_str = None + current_traffic_str = None + candy_client_exists = False + + # Check if client exists in CandyPanel DB + existing_candy_client = await asyncio.to_thread(candy_panel.db.get, 'clients', where={'name': client_name}) + if existing_candy_client: + candy_client_exists = True + current_expires_str = existing_candy_client.get('expires') + current_traffic_str = existing_candy_client.get('traffic') + current_used_traffic = json.loads(existing_candy_client.get('used_trafic', '{"download":0,"upload":0,"last_wg_rx":0,"last_wg_tx":0}')) + current_total_used_bytes = current_used_traffic.get('download',0) + current_used_traffic.get('upload',0) + + # Calculate new expiry date based on existing one if present, otherwise from now + new_expires_dt = datetime.now() + if current_expires_str: + try: + current_expires_dt = datetime.fromisoformat(current_expires_str) + if current_expires_dt > new_expires_dt: # If current expiry is in future, extend from that point + new_expires_dt = current_expires_dt + except ValueError: + print(f"Warning: Invalid existing expiry date format for client '{client_name}'. Recalculating from now.") + + new_expires_dt += timedelta(days=expire_days_for_candy) + new_expires_iso = new_expires_dt.isoformat() + + # Calculate new total traffic limit for CandyPanel: add the new traffic to existing total + new_total_traffic_bytes_for_candy = quantity_for_candy # Start with newly bought traffic + if candy_client_exists and current_traffic_str: + try: + # If the new plan is traffic-based, add to previous traffic limit. + # If the previous plan was time-based with a large dummy traffic, overwrite it. + # This logic can be refined if there are complex plan combinations. + # For simplicity, if the new purchase is traffic-based, we add to existing. + # If it's time-based, we set to a large default unless previous was larger and explicitly traffic-limited. + previous_traffic_limit_bytes = int(current_traffic_str) + if purchase_type == 'gb' or (purchase_type == 'custom' and traffic_quantity_gb > 0): + new_total_traffic_bytes_for_candy += previous_traffic_limit_bytes + elif purchase_type == 'month' and previous_traffic_limit_bytes < 1024 * (1024**3): # If previous was not already a large default + new_total_traffic_bytes_for_candy = int(1024 * (1024**3)) # Set to 1TB if buying time + except ValueError: + print(f"Warning: Invalid existing traffic limit format for client '{client_name}'. Overwriting.") + + # Ensure new traffic limit is at least the current used traffic + if candy_client_exists and new_total_traffic_bytes_for_candy < current_total_used_bytes: + new_total_traffic_bytes_for_candy = current_total_used_bytes + quantity_for_candy # Ensure it's not less than already used + new purchase. + + client_config = None # Will store the config if a new client is created + + if not candy_client_exists: + # Create client in CandyPanel + success_cp, message_cp = await asyncio.to_thread( + candy_panel._new_client, + client_name, + new_expires_iso, + str(new_total_traffic_bytes_for_candy), # CandyPanel expects string + 0, # Assuming default wg0 for now, can be made configurable via admin settings + f"Bot User: {transaction['telegram_id']} - Order: {order_id}" + ) + if not success_cp: + return error_response(f"Failed to create client in CandyPanel: {message_cp}", 500) + client_config = message_cp # _new_client returns config on success + else: + # Update existing client in CandyPanel + # Ensure status is True when updating (unbanning if it was banned) + success_cp, message_cp = await asyncio.to_thread( + candy_panel._edit_client, + client_name, + expires=new_expires_iso, + traffic=str(new_total_traffic_bytes_for_candy), # Update traffic quota + status=True # Ensure client is active + ) + if not success_cp: + return error_response(f"Failed to update client in CandyPanel: {message_cp}", 500) + # If client was updated, user needs to get config again. + # Fetch the config explicitly here, as _edit_client doesn't return it + success_config, fetched_config = await asyncio.to_thread(candy_panel._get_client_config, client_name) + if success_config: + client_config = fetched_config + else: + print(f"Warning: Could not fetch updated config for existing client {client_name}: {fetched_config}") + + # Update bot's user table + # Accumulate bought traffic and time + await asyncio.to_thread(candy_panel.db.update, 'users', { + 'candy_client_name': client_name, + 'traffic_bought_gb': user_in_bot_db.get('traffic_bought_gb', 0) + user_traffic_bought_gb, + 'time_bought_days': user_in_bot_db.get('time_bought_days', 0) + user_time_bought_days, + 'status': 'active' # Ensure bot user status is active + }, {'telegram_id': transaction['telegram_id']}) + + # Update transaction status + await asyncio.to_thread(candy_panel.db.update, 'transactions', { + 'status': 'approved', + 'approved_at': datetime.now().isoformat(), + 'admin_note': admin_note + }, {'order_id': order_id}) + + return success_response(f"Transaction {order_id} approved. Client '{client_name}' {'created' if not candy_client_exists else 'updated'} in CandyPanel.", data={ + "client_config": client_config, # Send config back to bot for user + "telegram_id": transaction['telegram_id'], # For bot to send message to user + "client_name": client_name, # Pass client name for user message + "new_traffic_gb": user_traffic_bought_gb, # For bot message to user + "new_time_days": user_time_bought_days # For bot message to user + }) + +@app.post("/bot_api/admin/reject_transaction") +async def bot_admin_reject_transaction(): + data = request.json + telegram_id = data.get('telegram_id') + order_id = data.get('order_id') + admin_note = data.get('admin_note', '') + + if not all([telegram_id, order_id]): + return error_response("Missing telegram_id or order_id.", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if str(telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + transaction = await asyncio.to_thread(candy_panel.db.get, 'transactions', where={'order_id': order_id}) + if not transaction: + return error_response("Transaction not found.", 404) + if transaction['status'] != 'pending': + return error_response("Transaction is not pending. It has been already processed.", 400) + + await asyncio.to_thread(candy_panel.db.update, 'transactions', { + 'status': 'rejected', + 'approved_at': datetime.now().isoformat(), + 'admin_note': admin_note + }, {'order_id': order_id}) + + return success_response(f"Transaction {order_id} rejected.", data={ + "telegram_id": transaction['telegram_id'] # For bot to send message to user + }) + +@app.post("/bot_api/admin/manage_user") +async def bot_admin_manage_user(): + data = request.json + admin_telegram_id = data.get('admin_telegram_id') + target_telegram_id = data.get('target_telegram_id') + action = data.get('action') # 'ban', 'unban', 'update_traffic', 'update_time' + value = data.get('value') # For update_traffic/time + + if not all([admin_telegram_id, target_telegram_id, action]): + return error_response("Missing required fields.", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if str(admin_telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + user = await asyncio.to_thread(candy_panel.db.get, 'users', where={'telegram_id': target_telegram_id}) + if not user: + return error_response("Target user not found.", 404) + + update_data = {} + message = "" + success_status = True + + if action == 'ban': + update_data['status'] = 'banned' + message = f"User {target_telegram_id} has been banned." + # Also disable in CandyPanel if linked + if user.get('candy_client_name'): + success, msg = await asyncio.to_thread( + candy_panel._edit_client, user['candy_client_name'], status=False + ) + if not success: + message += f" (Failed to disable client in CandyPanel: {msg})" + success_status = False + elif action == 'unban': + update_data['status'] = 'active' + message = f"User {target_telegram_id} has been unbanned." + # Also enable in CandyPanel if linked + if user.get('candy_client_name'): + success, msg = await asyncio.to_thread( + candy_panel._edit_client, user['candy_client_name'], status=True + ) + if not success: + message += f" (Failed to enable client in CandyPanel: {msg})" + success_status = False + elif action == 'update_traffic' and value is not None: + try: + new_traffic_gb = float(value) + update_data['traffic_bought_gb'] = new_traffic_gb + message = f"User {target_telegram_id} traffic updated to {new_traffic_gb} GB." + # Update in CandyPanel + if user.get('candy_client_name'): + traffic_bytes = int(new_traffic_gb * (1024**3)) + success, msg = await asyncio.to_thread( + candy_panel._edit_client, user['candy_client_name'], traffic=str(traffic_bytes) + ) + if not success: + message += f" (Failed to update traffic in CandyPanel: {msg})" + success_status = False + except ValueError: + return error_response("Invalid value for traffic. Must be a number.", 400) + elif action == 'update_time' and value is not None: + try: + new_time_days = int(value) + update_data['time_bought_days'] = new_time_days + message = f"User {target_telegram_id} time updated to {new_time_days} days." + # Update in CandyPanel (this is more complex, as CandyPanel uses expiry date) + # For simplicity, we'll just update the bot's record for now. + # A full implementation would recalculate expiry based on new_time_days from current date + # or extend the existing expiry. For now, this is a placeholder. + message += " (Note: Time update in CandyPanel requires manual expiry date calculation or a dedicated API endpoint in CandyPanel.)" + except ValueError: + return error_response("Invalid value for time. Must be an integer.", 400) + else: + return error_response("Invalid action or missing value.", 400) + + if update_data: + await asyncio.to_thread(candy_panel.db.update, 'users', update_data, {'telegram_id': target_telegram_id}) + + if success_status: + return success_response(message) + else: + return error_response(message, 500) + + +@app.post("/bot_api/admin/send_message_to_all") +async def bot_admin_send_message_to_all(): + data = request.json + telegram_id = data.get('telegram_id') + message_text = data.get('message') + + if not all([telegram_id, message_text]): + return error_response("Missing telegram_id or message.", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if str(telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + all_users = await asyncio.to_thread(candy_panel.db.select, 'users') + user_ids = [user['telegram_id'] for user in all_users] + + # This API endpoint just prepares the list of users. + # The Telegram bot itself will handle the actual sending to avoid blocking the API. + return success_response("Broadcast message prepared.", data={"target_user_ids": user_ids, "message": message_text}) +@app.get("/bot_api/admin/data") +async def bot_admin_data(): + try: + # Fetch all data concurrently + dashboard_stats_task = asyncio.to_thread(candy_panel._dashboard_stats) + clients_data_task = asyncio.to_thread(candy_panel._get_all_clients) + interfaces_data_task = asyncio.to_thread(candy_panel.db.select, 'interfaces') + settings_data_task = asyncio.to_thread(candy_panel.db.select, 'settings') + + dashboard_stats, clients_data, interfaces_data, settings_raw = await asyncio.gather( + dashboard_stats_task, clients_data_task, interfaces_data_task, settings_data_task + ) + + # Process client data (parse used_trafic) + for client in clients_data: + try: + client['used_trafic'] = json.loads(client['used_trafic']) + except (json.JSONDecodeError, TypeError): + client['used_trafic'] = {"download": 0, "upload": 0} + + # Process settings data (convert to dict) + settings_data = {setting['key']: setting['value'] for setting in settings_raw} + + return success_response("All data retrieved successfully.", data={ + "dashboard": dashboard_stats, + "clients": clients_data, + "interfaces": interfaces_data, + "settings": settings_data + }) + except Exception as e: + return error_response(f"Failed to retrieve all data: {e}", 500) +@app.post("/bot_api/admin/server_control") +async def bot_admin_server_control(): + data = request.json + admin_telegram_id = data.get('admin_telegram_id') + resource = data.get('resource') + action = data.get('action') + payload_data = data.get('data', {}) # Additional data for the CandyPanel API call + + if not all([admin_telegram_id, resource, action]): + return error_response("Missing admin_telegram_id, resource, or action.", 400) + + admin_telegram_id_setting = await asyncio.to_thread(candy_panel.db.get, 'settings', where={'key': 'telegram_bot_admin_id'}) + admin_telegram_id = admin_telegram_id_setting['value'] if admin_telegram_id_setting else '0' + + if str(admin_telegram_id) != admin_telegram_id: + return error_response("Unauthorized", 403) + + # Direct internal calls to CandyPanel methods + success = False + message = "Invalid operation." + candy_data = {} + + if resource == 'client': + if action == 'create': + name = payload_data.get('name') + expires = payload_data.get('expires') + traffic = payload_data.get('traffic') + wg_id = payload_data.get('wg_id', 0) + note = payload_data.get('note', '') + if all([name, expires, traffic]): + success, message = await asyncio.to_thread(candy_panel._new_client, name, expires, traffic, wg_id, note) + if success: + candy_data = {"client_config": message} # _new_client returns config on success + elif action == 'update': + name = payload_data.get('name') + expires = payload_data.get('expires') + traffic = payload_data.get('traffic') + status = payload_data.get('status') + note = payload_data.get('note') + if name: + success, message = await asyncio.to_thread(candy_panel._edit_client, name, expires, traffic, status, note) + elif action == 'delete': + name = payload_data.get('name') + if name: + success, message = await asyncio.to_thread(candy_panel._delete_client, name) + elif action == 'get_config': + name = payload_data.get('name') + if name: + success, message = await asyncio.to_thread(candy_panel._get_client_config, name) + if success: + candy_data = {"config": message} + elif resource == 'interface': + if action == 'create': + address_range = payload_data.get('address_range') + ipv6_address_range = payload_data.get('ipv6_address_range') + port = payload_data.get('port') + if all([address_range, port]): + success, message = await asyncio.to_thread(candy_panel._new_interface_wg, address_range, port, ipv6_address_range) + elif action == 'update': + name = payload_data.get('name') + address = payload_data.get('address') + port = payload_data.get('port') + status = payload_data.get('status') + if name: + success, message = await asyncio.to_thread(candy_panel._edit_interface, name, address, port, status) + elif action == 'delete': + wg_id = payload_data.get('wg_id') + if wg_id is not None: + success, message = await asyncio.to_thread(candy_panel._delete_interface, wg_id) + elif resource == 'setting': + if action == 'update': + key = payload_data.get('key') + value = payload_data.get('value') + if all([key, value is not None]): + success, message = await asyncio.to_thread(candy_panel._change_settings, key, value) + elif resource == 'sync': + if action == 'trigger': + await asyncio.to_thread(candy_panel._sync) + success = True + message = "Synchronization process initiated successfully." + else: + return error_response(f"Unknown resource type: {resource}", 400) + + if success: + return success_response(f"CandyPanel: {message}", data=candy_data) + else: + return error_response(f"CandyPanel Error: {message}", 500) + + +@app.route('/') +def serve_root_index(): + return send_file(os.path.join(app.static_folder, 'index.html')) + +@app.route('/') +def catch_all_frontend_routes(path): + static_file_path = os.path.join(app.static_folder, path) + if os.path.exists(static_file_path) and os.path.isfile(static_file_path): + return send_file(static_file_path) + else: + return send_file(os.path.join(app.static_folder, 'index.html')) +# This is for development purposes only. For production, use a WSGI server like Gunicorn. +if __name__ == '__main__': app.run(debug=True, host="0.0.0.0", port=int(os.environ.get('AP_PORT',3446))) \ No newline at end of file diff --git a/Backend/requirements.txt b/Backend/requirements.txt new file mode 100644 index 0000000..3c642cb --- /dev/null +++ b/Backend/requirements.txt @@ -0,0 +1,9 @@ +flask[async] +flask-cors +psutil +netifaces +pyrogram +tgcrypto +httpx +nanoid +werkzeug diff --git a/readme.md b/readme.md index c0fb71d..b6a5551 100644 --- a/readme.md +++ b/readme.md @@ -1,198 +1,195 @@ -# 🍭 Candy Panel - WireGuard Management System - -A modern, beautiful web interface for managing WireGuard VPN servers with comprehensive backend integration. Built with React, TypeScript, and a powerful Python Flask backend. - -![Candy Panel Dashboard](https://github.com/AmiRCandy/Candy-Panel/blob/15d1fa6852bb187ccbfcc5712c481cc3d00235cc/image.png) - -## ✨ Features - -- 🎨 **Beautiful UI**: Modern glassmorphism design with smooth animations -- πŸ” **Secure Authentication**: JWT-based authentication system -- πŸ‘₯ **Client Management**: Create, edit, delete, and monitor WireGuard clients -- πŸ–₯️ **Server Control**: Comprehensive WireGuard server management -- βš™οΈ **Interface Configuration**: Manage multiple WireGuard interfaces (wg0, wg1, etc.) -- πŸ“Š **Real-time Statistics**: Live bandwidth monitoring and analytics -- πŸ”‘ **API Management**: Generate and manage API tokens -- ⏰ **Auto Reset**: Scheduled server resets with configurable intervals -- πŸ› οΈ **Installation Wizard**: Guided setup for first-time users -- πŸ“± **Responsive Design**: Works perfectly on desktop, tablet, and mobile - -## πŸš€ Quick Start - -### πŸš€ One line command install - -```bash -sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/AmiRCandy/Candy-Panel/main/setup.sh)" -``` -- Panel Default Port : 3446 -- API Default Port : 3446 - -### Prerequisites - -- Node.js 20+ and npm -- Python 3.8+ -- WireGuard installed on your server - -### Frontend Setup - -1. **Clone the repository** -```bash -git clone https://github.com/AmiRCandy/Candy-Panel.git -cd candy-panel -``` - -2. **Install dependencies** -```bash -npm install -``` - -3. **Configure environment** -```bash -cp .env.example .env -``` - -4. **Start development server** -```bash -npm run dev -``` - -### Backend Setup - -1. **Navigate to backend directory** -```bash -cd backend -``` - -2. **Install Python dependencies** -```bash -pip install fastapi uvicorn sqlite3 subprocess psutil -``` - -3. **Start the backend server** -```bash -python main.py -``` - -4. **Access the application** - - Frontend: `http://localhost:3445` - - Backend API: `http://localhost:3445` - -## πŸ—οΈ Architecture - -### Frontend Stack -- **React 18** with TypeScript -- **Vite** for fast development and building -- **Tailwind CSS** for styling -- **Framer Motion** for animations -- **React Router** for navigation - -### Backend Stack -- **Flask** for high-performance API -- **SQLite** for database management -- **Pydantic** for data validation -- **WireGuard** integration for VPN management - -## πŸ”§ Configuration - -### Environment Variables - -For installing Telegram BOT you must enter your api_id , api_hash , so put them in var and export on env: - -```env -export TELEGRAM_API_ID=1 -export TELEGRAM_API_HASH=ab12 -``` - -### Backend Configuration - -The backend automatically creates a SQLite database and initializes default settings on first run. - -## 🎯 Usage - -### First Time Setup - -1. **Access the application** at `http://localhost:3446` -2. **Run the installation wizard** to configure your server -3. **Set up admin credentials** and server settings -4. **Create your first WireGuard interface** -5. **Add clients** and start managing your VPN - -### Managing Clients - -1. Navigate to the **Clients** page -2. Click **"Add Client"** to create a new VPN user -3. Configure traffic limits, expiration dates, and notes -4. Download the configuration file or share it with users -5. Monitor client usage and connection status in real-time - -### Server Configuration - -1. Go to the **Settings** page to configure global settings -2. Set DNS servers, MTU values, and reset schedules -3. Enable/disable auto-backup functionality -4. Monitor server statistics and performance - -## πŸ”’ Security Features - -- **JWT Authentication**: Secure token-based authentication -- **Row Level Security**: Database-level access control -- **API Token Management**: Granular API access control -- **Auto Session Timeout**: Configurable session management -- **Secure Key Generation**: Cryptographically secure WireGuard keys - -## 🀝 Contributing - -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## πŸ“ License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## πŸ™ Acknowledgments - -- [WireGuard](https://www.wireguard.com/) for the amazing VPN technology -- [shadcn/ui](https://ui.shadcn.com/) for the beautiful UI components -- [FastAPI](https://fastapi.tiangolo.com/) for the excellent Python framework -- [React](https://reactjs.org/) and [Vite](https://vitejs.dev/) for the frontend tools (Frontend built by [BoltAI](https://bolt.new)) - -## πŸ“ž Support - -- πŸ“§ Email: amirhosen.1385.cmo@gmail.com -- πŸ’¬ Discord: [Join our community](https://discord.gg/candypanel) -- πŸ› Issues: [GitHub Issues](https://github.com/AmiRCandy/Candy-Panel/issues) -- πŸ“– Documentation: [Wiki](https://github.com/AmiRCandy/Candy-Panel) -- πŸ“– X (Twiiter): [AmiR](https://x.com/BeNamKhodaHastm) - -## πŸ—ΊοΈ Roadmap - -- [x] Telegram bot integration for automated sales -- [x] IPV6 Support -- [ ] Advanced analytics and reporting -- [ ] Docker containerization -- [x] Manual Port for panel and api -- [ ] Automatic tunnel installation -- [ ] Theme customization - ---- -## Credits - -Thanks to [@Byte-Aura](https://github.com/Byte-Aura) for help with planning and testing. - - - -
-

Built with πŸ’œ for WireGuard Enthusiasts

-

- ⭐ Star us on GitHub β€’ - πŸ› Report Bug β€’ - ✨ Request Feature -

-
- - +# 🍭 Candy Panel - WireGuard Management System + +A modern, beautiful web interface for managing WireGuard VPN servers with comprehensive backend integration. Built with React, TypeScript, and a powerful Python Flask backend. + +![Candy Panel Dashboard](https://github.com/AmiRCandy/Candy-Panel/blob/15d1fa6852bb187ccbfcc5712c481cc3d00235cc/image.png) + +## ✨ Features + +- 🎨 **Beautiful UI**: Modern glassmorphism design with smooth animations +- πŸ” **Secure Authentication**: JWT-based authentication system +- πŸ‘₯ **Client Management**: Create, edit, delete, and monitor WireGuard clients +- πŸ–₯️ **Server Control**: Comprehensive WireGuard server management +- βš™οΈ **Interface Configuration**: Manage multiple WireGuard interfaces (wg0, wg1, etc.) +- πŸ“Š **Real-time Statistics**: Live bandwidth monitoring and analytics +- πŸ”‘ **API Management**: Generate and manage API tokens +- ⏰ **Auto Reset**: Scheduled server resets with configurable intervals +- πŸ› οΈ **Installation Wizard**: Guided setup for first-time users +- πŸ“± **Responsive Design**: Works perfectly on desktop, tablet, and mobile + +## πŸš€ Quick Start + +### πŸš€ One line command install + +```bash +sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/AmiRCandy/Candy-Panel/main/setup.sh)" +``` +- Panel Default Port : 3446 +- API Default Port : 3446 + +### Prerequisites + +- Node.js 20+ and npm +- Python 3.10+ +- WireGuard installed on your server + +### Frontend Setup + +1. **Clone the repository** +```bash +git clone https://github.com/AmiRCandy/Candy-Panel.git +cd candy-panel +``` + +2. **Install dependencies** +```bash +npm install +``` + +3. **Configure environment** +```bash +cp .env.example .env +``` + +4. **Start development server** +```bash +npm run dev +``` + +### Backend Setup + +1. **Navigate to backend directory** +```bash +cd Backend +``` + +2. **Install Python dependencies** +```bash +pip install -r requirements.txt +``` + +3. **Start the backend server** +```bash +python main.py +``` + +4. **Access the application** + - Frontend: `http://localhost:3446` + - Backend API: `http://localhost:3446` + +## πŸ—οΈ Architecture + +### Frontend Stack +- **React 18** with TypeScript +- **Vite** for fast development and building +- **Tailwind CSS** for styling +- **Framer Motion** for animations +- **React Router** for navigation + +### Backend Stack +- **Flask** for high-performance API +- **SQLite** for database management +- **WireGuard** integration for VPN management + +## πŸ”§ Configuration + +### Environment Variables + +For installing Telegram BOT you must enter your api_id , api_hash , so put them in var and export on env: + +```env +export TELEGRAM_API_ID=1 +export TELEGRAM_API_HASH=ab12 +``` + +### Backend Configuration + +The backend automatically creates a SQLite database and initializes default settings on first run. + +## 🎯 Usage + +### First Time Setup + +1. **Access the application** at `http://localhost:3446` +2. **Run the installation wizard** to configure your server +3. **Set up admin credentials** and server settings +4. **Create your first WireGuard interface** +5. **Add clients** and start managing your VPN + +### Managing Clients + +1. Navigate to the **Clients** page +2. Click **"Add Client"** to create a new VPN user +3. Configure traffic limits, expiration dates, and notes +4. Download the configuration file or share it with users +5. Monitor client usage and connection status in real-time + +### Server Configuration + +1. Go to the **Settings** page to configure global settings +2. Set DNS servers, MTU values, and reset schedules +3. Enable/disable auto-backup functionality +4. Monitor server statistics and performance + +## πŸ”’ Security Features + +- **JWT Authentication**: Secure token-based authentication +- **Row Level Security**: Database-level access control +- **API Token Management**: Granular API access control +- **Auto Session Timeout**: Configurable session management +- **Secure Key Generation**: Cryptographically secure WireGuard keys + +## 🀝 Contributing + +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## πŸ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## πŸ™ Acknowledgments + +- [WireGuard](https://www.wireguard.com/) for the amazing VPN technology +- [shadcn/ui](https://ui.shadcn.com/) for the beautiful UI components +- [Flask](https://flask.palletsprojects.com/) for the excellent Python framework +- [React](https://reactjs.org/) and [Vite](https://vitejs.dev/) for the frontend tools (Frontend built by [BoltAI](https://bolt.new)) + +## πŸ“ž Support + +- πŸ“§ Email: amirhosen.1385.cmo@gmail.com +- πŸ’¬ Discord: [Join our community](https://discord.gg/candypanel) +- πŸ› Issues: [GitHub Issues](https://github.com/AmiRCandy/Candy-Panel/issues) +- πŸ“– Documentation: [Wiki](https://github.com/AmiRCandy/Candy-Panel) +- πŸ“– X (Twiiter): [AmiR](https://x.com/BeNamKhodaHastm) + +## πŸ—ΊοΈ Roadmap + +- [x] Telegram bot integration for automated sales +- [x] IPV6 Support +- [ ] Advanced analytics and reporting +- [ ] Docker containerization +- [x] Manual Port for panel and api +- [ ] Automatic tunnel installation +- [ ] Theme customization + +--- +## Credits + +Thanks to [@Byte-Aura](https://github.com/Byte-Aura) for help with planning and testing. + + + +
+

Built with πŸ’œ for WireGuard Enthusiasts

+

+ ⭐ Star us on GitHub β€’ + πŸ› Report Bug β€’ + ✨ Request Feature +

+