├── Tele_Pi ├── requirements.txt ├── telegram_bot.env └── README.md ├── auto_backup_config.md ├── README.md ├── Pwny-Tailscale └── README.md ├── Pwny-WG └── README.md ├── auto_backup.py ├── tailscale.py ├── Config.toml_Options.md ├── wireguard.py ├── web2ssh.py ├── discord.py ├── wiglelocator.py └── Tele_Pi.py /Tele_Pi/requirements.txt: -------------------------------------------------------------------------------- 1 | python-telegram-bot==21.2 2 | psutil 3 | requests -------------------------------------------------------------------------------- /Tele_Pi/telegram_bot.env: -------------------------------------------------------------------------------- 1 | TELEGRAM_TOKEN=YOUR_TELEGRAM_BOT_TOKEN 2 | WEATHER_API_KEY=YOUR_OPENWEATHERMAP_API_KEY 3 | ALLOWED_USER_ID=YOUR_TELEGRAM_USER_ID -------------------------------------------------------------------------------- /auto_backup_config.md: -------------------------------------------------------------------------------- 1 | Old style 2 | ``` 3 | main.plugins.auto_backup.enabled = true 4 | main.plugins.auto_backup.interval = "60" 5 | main.plugins.auto_backup.max_tries = 3 6 | main.plugins.auto_backup.backup_location = "/home/pi/" 7 | main.plugins.auto_backup.files = [ 8 | "/root/settings.yaml", 9 | "/root/client_secrets.json", 10 | "/root/.api-report.json", 11 | "/root/.ssh", 12 | "/root/.bashrc", 13 | "/root/.profile", 14 | "/home/pi/handshakes", 15 | "/root/peers", 16 | "/etc/pwnagotchi/", 17 | "/usr/local/share/pwnagotchi/custom-plugins", 18 | "/etc/ssh/", 19 | "/home/pi/.bashrc", 20 | "/home/pi/.profile", 21 | "/home/pi/.wpa_sec_uploads", 22 | ] 23 | main.plugins.auto_backup.exclude = [ 24 | "/etc/pwnagotchi/logs/*", 25 | "*.bak", 26 | "*.tmp", 27 | ] 28 | 29 | ``` 30 | New style 31 | ``` 32 | [main.plugins.auto_backup] 33 | enabled = true 34 | interval = "60" 35 | max_tries = 0 36 | backup_location = "/home/pi/" 37 | files = [ 38 | "/root/settings.yaml", 39 | "/root/client_secrets.json", 40 | "/root/.api-report.json", 41 | "/root/.ssh", 42 | "/root/.bashrc", 43 | "/root/.profile", 44 | "/home/pi/handshakes", 45 | "/root/peers", 46 | "/etc/pwnagotchi/", 47 | "/usr/local/share/pwnagotchi/custom-plugins", 48 | "/etc/ssh/", 49 | "/home/pi/.bashrc", 50 | "/home/pi/.profile", 51 | "/home/pi/.wpa_sec_uploads" 52 | ] 53 | exclude = [ 54 | "/etc/pwnagotchi/logs/*" 55 | ] 56 | # NO commands line is needed here either. 57 | ``` 58 | 59 | 60 | To restore after flashing 61 | ``` 62 | sudo tar xzf /home/pi/NAME-backup.tar.gz -C / 63 | ``` 64 | 65 | 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Pwnagotchi Power-Up Plugins 2 | 3 | A curated collection of powerful plugins designed to enhance your Pwnagotchi experience. These tools add functionality ranging from remote command execution and automated backups to rich Discord notifications and VPN connectivity. Supercharge your Pwnagotchi and make it an even more formidable (and convenient) Wi-Fi companion! 4 | 5 | --- 6 | 7 | ## 🔌 Universal Installation 8 | 9 | 1. **Download:** Click on the `.py` file you want (e.g., `wireguard.py`, `discord.py`) from the file list above. 10 | 2. **Install:** Move the file to your Pwnagotchi's custom plugin directory: `/usr/local/share/pwnagotchi/custom-plugins/`. 11 | 3. **Configure:** Edit your `/etc/pwnagotchi/config.toml` file to include the settings listed below. 12 | 4. **Activate:** Restart your Pwnagotchi: 13 | ```bash 14 | sudo systemctl restart pwnagotchi 15 | ``` 16 | 17 | --- 18 | 19 | ## 📂 The Plugin Collection 20 | 21 | | Plugin | Description | Setup Guide | 22 | | :--- | :--- | :--- | 23 | | **🛡️ AutoBackup** | **v2.0** - Automated backups with retention policy (garbage collection). | [Scroll Down](#autobackup) | 24 | | **🔔 Discord** | **v2.5.0** - Uploads **.pcap files**, maps locations, and tracks sessions without lag. | [Scroll Down](#discord) | 25 | | **🔒 Pwny-WG** | Connect to home **WireGuard VPN** and sync handshakes automatically via SSH. | [View Guide](./Pwny-WG/README.md) | 26 | | **🦎 Pwny-Tailscale** | Easy **Tailscale** integration for remote access without port forwarding. | [View Guide](./Pwny-Tailscale/README.md) | 27 | | **📡 Tele_Pi** | Telegram control and notifications for your Pwnagotchi. | [View Guide](./Tele_Pi/README.md) | 28 | | **🌐 web2ssh** | A lightweight web interface for executing shell commands from your browser. | [Scroll Down](#web2ssh) | 29 | | **📍 WigleLocator** | Automatically queries WiGLE to find GPS coordinates for handshakes. | [Scroll Down](#wiglelocator) | 30 | 31 | --- 32 | 33 | 34 | ## 🛡️ AutoBackup: Your Digital Guardian 35 | *(Updated to v2.0 - Now with Garbage Collection!)* 36 | 37 | The **AutoBackup** plugin automatically creates compressed `.tar.gz` backups of your specified files whenever an internet connection is available. It features a smart retention policy to delete old backups so your SD card never fills up. 38 | 39 | ### ⚙️ Configuration 40 | ```toml 41 | [main.plugins.auto_backup] 42 | enabled = true 43 | interval = "daily" # Options: "hourly", "daily", or minutes (e.g., "60") 44 | max_tries = 0 45 | backup_location = "/home/pi/backups/" 46 | max_backups_to_keep = 5 # Keeps the 5 newest files, deletes the rest 47 | files = [ 48 | "/root/settings.yaml", 49 | "/root/client_secrets.json", 50 | "/root/.api-report.json", 51 | "/root/.ssh", 52 | "/root/.bashrc", 53 | "/root/.profile", 54 | "/home/pi/handshakes", 55 | "/root/peers", 56 | "/etc/pwnagotchi/", 57 | "/usr/local/share/pwnagotchi/custom-plugins", 58 | "/etc/ssh/", 59 | "/home/pi/.bashrc", 60 | "/home/pi/.profile", 61 | "/home/pi/.wpa_sec_uploads", 62 | ] 63 | exclude = [ 64 | "*.tmp", 65 | "*.bak", 66 | "/etc/pwnagotchi/logs/*" 67 | ] 68 | ``` 69 | 70 | ### 🚀 Restore Command 71 | To restore files after a fresh flash: 72 | ```bash 73 | sudo tar xzf /home/pi/backups/YOUR_BACKUP_FILENAME.tar.gz -C / 74 | ``` 75 | 76 | --- 77 | 78 | 79 | ## 🔔 Discord: The Ultimate Exfiltration Tool 80 | *(Updated to v2.5.0 - High Performance)* 81 | 82 | Get instant, beautifully formatted notifications about your Pwnagotchi's conquests via Discord. 83 | 84 | **New in v2.5.0:** 85 | * **📁 Automatic Exfiltration:** The plugin now **uploads the captured `.pcap` file directly to Discord**. You can download and crack handshakes immediately—no SSH required. 86 | * **⚡ Zero-Lag Queue:** Uses a threaded background worker to prevent the Pwnagotchi from freezing or slowing down during captures. Perfect for high-speed scanning. 87 | * **📍 Intelligence:** Enriches alerts with direct Google Maps links via WiGLE. 88 | * **📊 Session Tracking:** Reports uptime and handshake counts when the session starts and ends. 89 | 90 | ### ⚙️ Configuration 91 | ```toml 92 | [main.plugins.discord] 93 | enabled = true 94 | webhook_url = "YOUR_DISCORD_WEBHOOK_URL" 95 | wigle_api_key = "ENCODED_API_KEY" 96 | ``` 97 | *Note: `wigle_api_key` must be the Base64 encoded string of `Username:Token`.* 98 | 99 | --- 100 | 101 | 102 | ## 🌐 web2ssh: Command Center in Your Browser 103 | 104 | Control your Pwnagotchi from anywhere on your network! **web2ssh** provides a lightweight, password-protected web interface for executing shell commands directly from your browser. 105 | 106 | **Requirement:** You must install Flask first: 107 | ```bash 108 | sudo apt update && sudo apt install python3-flask 109 | ``` 110 | 111 | ### ⚙️ Configuration 112 | ```toml 113 | [main.plugins.web2ssh] 114 | enabled = true 115 | username = "admin" 116 | password = "changeme" 117 | port = 8082 118 | ``` 119 | 120 | ### 🖼️ In Action 121 | 122 | ![WEB-SSH Command Executor](https://github.com/user-attachments/assets/9d3829ea-a64b-4892-ace7-5c88159ebe57) 123 | *The main command input screen with convenient shortcuts.* 124 | 125 | ![Command Output](https://github.com/user-attachments/assets/82084845-8e6a-40be-bd2d-47398b5887ea) 126 | *The clean output view after executing a command.* 127 | 128 | --- 129 | 130 | 131 | ## 📍 WigleLocator: Pinpoint Your Pwns 132 | 133 | # WigleLocator: Pinpoint Your Pwns (v2.0) 134 | 135 | The **WigleLocator** plugin automatically queries the WiGLE database to find the geographic coordinates for every access point you capture a handshake from. 136 | 137 | **New in v2.0+:** 138 | * **Async Processing:** No more UI freezing! Lookups happen in the background. 139 | * **Offline Queueing:** Wardriving without internet? No problem. It queues handshakes and processes them automatically when you connect to WiFi. 140 | * **Local Maps:** View your finds on an interactive map at `http://pwnagotchi.local:8080/plugins/wigle_locator/`. 141 | * **Data Export:** Download KML (Google Earth) and CSV files directly from the Web UI. 142 | 143 | *(Note: The Discord plugin v2.5.0 now handles its own lookups, but this plugin is useful if you want to save GPS data locally, generate maps, or use Google Earth without Discord.)* 144 | 145 | ### ⚙️ Configuration 146 | 147 | Add your WiGLE API key to `/etc/pwnagotchi/config.toml`. 148 | 149 | ```toml 150 | main.plugins.wiglelocator.enabled = true 151 | main.plugins.wiglelocator.api_key = "ENCODED_API_KEY" 152 | # OR 153 | [main.plugins.wiglelocator] 154 | enabled = true 155 | api_key = "ENCODED_API_KEY" 156 | ``` 157 | -------------------------------------------------------------------------------- /Pwny-Tailscale/README.md: -------------------------------------------------------------------------------- 1 | # Pwnagotchi Tailscale Plugin 2 | 3 | This plugin allows your Pwnagotchi to automatically connect to your Tailscale network using an authentication key. Once connected, it enables secure remote access (SSH, Web UI) and periodically synchronizes the `handshakes` directory to a server on your Tailnet using `rsync` over SSH. 4 | 5 | The plugin provides on-screen status updates for the Tailscale connection and sync activity. 6 | 7 | ## Table of Contents 8 | 1. [Prerequisites](#prerequisites) 9 | 2. [Step 1: Server Setup](#step-1-server-setup) 10 | 3. [Step 2: Pwnagotchi Setup](#step-2-pwnagotchi-setup) 11 | 4. [Step 3: Get a Tailscale Auth Key](#step-3-get-a-tailscale-auth-key) 12 | 5. [Step 4: Plugin Installation](#step-4-plugin-installation) 13 | 6. [Step 5: Pwnagotchi Configuration](#step-5-pwnagotchi-configuration) 14 | 7. [Step 6: Enable Handshake Sync (SSH Key Setup)](#step-6-enable-handshake-sync-ssh-key-setup) 15 | 8. [Step 7: Final Restart and Verification](#step-7-final-restart-and-verification) 16 | 9. [Troubleshooting](#troubleshooting) 17 | 18 | --- 19 | 20 | ### Prerequisites 21 | 22 | * A Pwnagotchi device with network access for the initial setup. 23 | * A free [Tailscale](https://tailscale.com/) account. 24 | * A server or PC on your Tailscale network to receive the handshake files. This server must have an SSH server running. 25 | 26 | --- 27 | 28 | ### Step 1: Server Setup 29 | 30 | 1. **Install Tailscale on your Server:** Follow the official instructions to install Tailscale on the machine that will receive the handshakes. 31 | 2. **Log In and Find its IP:** Run `sudo tailscale up` on your server to connect it to your network. Then, run `tailscale ip -4` or visit the [Tailscale Admin Console](https://login.tailscale.com/admin/machines) to find the machine's **Tailscale IP address** (e.g., `100.X.X.X`). You will need this for the configuration. 32 | 33 | --- 34 | 35 | ### Step 2: Pwnagotchi Setup 36 | 37 | Log into your Pwnagotchi via SSH or a direct connection. 38 | 39 | 1. **Install `rsync` and `curl`:** 40 | ```bash 41 | sudo apt-get update 42 | sudo apt-get install -y rsync curl 43 | ``` 44 | 45 | 2. **Install Tailscale:** 46 | ```bash 47 | curl -fsSL [https://tailscale.com/install.sh](https://tailscale.com/install.sh) | sh 48 | ``` 49 | **Important:** You do **not** need to run `sudo tailscale up` manually. The plugin will handle authentication using an auth key. 50 | 51 | --- 52 | 53 | ### Step 3: Get a Tailscale Auth Key 54 | 55 | The plugin will use an auth key to add your Pwnagotchi to your Tailnet automatically. 56 | 57 | 1. Go to the [**Keys** page](https://login.tailscale.com/admin/settings/keys) in the Tailscale admin console. 58 | 2. Click **Generate auth key**. 59 | 3. **Recommended Settings:** 60 | * **Reusable:** No (for better security, generate a new key if you need to re-authenticate). 61 | * **Ephemeral:** Yes (the Pwnagotchi will be removed from your Tailnet if it's disconnected for a period of time, preventing clutter). 62 | * **Tags:** Optional (e.g., `tag:pwnagotchi`). 63 | 4. Click **Generate key** and copy the key (it looks like `tskey-auth-k123...`). You will not be able to see it again. 64 | 65 | --- 66 | 67 | ### Step 4: Plugin Installation 68 | 69 | 1. Place the `tailscale.py` script into the Pwnagotchi's custom plugins directory. 70 | ```bash 71 | # Make sure the directory exists 72 | sudo mkdir -p /usr/local/share/pwnagotchi/custom-plugins/ 73 | 74 | # Move the plugin file (adjust the source path if needed) 75 | sudo mv /path/to/your/tailscale.py /usr/local/share/pwnagotchi/custom-plugins/ 76 | ``` 77 | 78 | --- 79 | 80 | ### Step 5: Pwnagotchi Configuration 81 | 82 | 1. Open the main Pwnagotchi config file: 83 | ```bash 84 | sudo nano /etc/pwnagotchi/config.toml 85 | ``` 86 | 87 | 2. Add the following configuration block, filling in your specific details. 88 | ```toml 89 | main.plugins.tailscale.enabled = true 90 | main.plugins.tailscale.auth_key = "tskey-auth-YOUR-KEY-HERE" 91 | main.plugins.tailscale.server_tailscale_ip = "100.X.X.X" # <-- Your server's Tailscale IP 92 | main.plugins.tailscale.server_user = "your-server-user" # <-- The SSH username on your server 93 | main.plugins.tailscale.handshake_dir = "/path/to/remote/handshakes/" # <-- The destination folder on your server 94 | 95 | # Optional: Set a custom device name for the Pwnagotchi in your Tailnet 96 | main.plugins.tailscale.hostname = "pwnagotchi" 97 | ``` 98 | 99 | --- 100 | 101 | ### Step 6: Enable Handshake Sync (SSH Key Setup) 102 | 103 | For the plugin to automatically sync handshakes with `rsync`, the Pwnagotchi's `root` user needs an SSH key that your server trusts. This allows for passwordless login. 104 | 105 | 1. **On the Pwnagotchi**, generate an SSH key for the `root` user. Press Enter at all prompts to accept the defaults. 106 | ```bash 107 | sudo ssh-keygen -t rsa -b 4096 108 | ``` 109 | 110 | 2. **On the Pwnagotchi**, display the new public key and copy the entire output. 111 | ```bash 112 | sudo cat /root/.ssh/id_rsa.pub 113 | ``` 114 | 115 | 3. **On your Server**, add the Pwnagotchi's public key to the `authorized_keys` file for the user you specified in `server_user`. 116 | ```bash 117 | # Replace 'your-server-user' with the actual username if different 118 | echo "PASTE_PWNAGOTCHI_PUBLIC_KEY_HERE" >> /home/your-server-user/.ssh/authorized_keys 119 | ``` 120 | 121 | --- 122 | 123 | ### Step 7: Final Restart and Verification 124 | 125 | 1. **On the Pwnagotchi**, restart the service to load all new configurations. 126 | ```bash 127 | sudo systemctl restart pwnagotchi 128 | ``` 129 | 130 | 2. **Verify:** 131 | * Watch the Pwnagotchi's screen. You should see the status change from `TS: Starting` -> `TS: Connecting` -> `TS: Up`. 132 | * Check your [Tailscale Admin Console](https://login.tailscale.com/admin/machines). The Pwnagotchi should appear as a new device. 133 | * After a sync interval (default 10 minutes), the status will briefly show `TS: Synced: X`, where X is the number of new handshake files transferred. 134 | * From your server or any other device on your Tailnet, you should be able to SSH into the Pwnagotchi using its new hostname: `ssh pi@pwnagotchi`. 135 | 136 | --- 137 | 138 | ### Troubleshooting 139 | 140 | * **Plugin doesn't load:** Check the Pwnagotchi logs with `sudo journalctl -u pwnagotchi -f`. Look for errors related to `[Tailscale]`. Common issues are missing `config.toml` options or `rsync`/`tailscale` not being installed. 141 | * **Connection Fails:** Ensure your `auth_key` is correct and has not expired. If you used a non-reusable key, you will need to generate a new one. 142 | * **Sync Fails:** 143 | * Manually test the `rsync` command from the Pwnagotchi as the root user: `sudo rsync -av /home/pi/handshakes/ your-server-user@100.X.X.X:/path/to/remote/handshakes/`. This will help diagnose SSH key or path issues. 144 | * Verify the SSH key was copied correctly and that file permissions on your server's `.ssh` directory and `authorized_keys` file are correct (`700` for `.ssh`, `600` for `authorized_keys`). 145 | -------------------------------------------------------------------------------- /Pwny-WG/README.md: -------------------------------------------------------------------------------- 1 | # Pwnagotchi WireGuard Plugin (v1.8) 2 | 3 | This plugin allows your Pwnagotchi to automatically connect to a home WireGuard VPN server. 4 | 5 | * **Secure Access:** SSH into your Pwnagotchi from anywhere in the world. 6 | * **Gapless Sync:** Automatically syncs **only new** handshakes to your server. It uses a smart "checkpoint" system, so the sync takes seconds even if you have 50,000 old files on your SD card. 7 | * **Resilient:** Automatically retries connections if the network is spotty. 8 | 9 | ## Table of Contents 10 | 1. [Prerequisites](#prerequisites) 11 | 2. [Step 1: Server Setup](#step-1-server-setup) 12 | 3. [Step 2: Pwnagotchi Dependency Installation](#step-2-pwnagotchi-dependency-installation) 13 | 4. [Step 3: Plugin Installation](#step-3-plugin-installation) 14 | 5. [Step 4: Pwnagotchi Configuration](#step-4-pwnagotchi-configuration) 15 | 6. [Step 5: Enable Passwordless Sync (The Easy Way)](#step-5-enable-passwordless-sync-the-easy-way) 16 | 7. [Step 6: Enable Full Remote Access](#step-6-enable-full-remote-access) 17 | 8. [Troubleshooting](#troubleshooting) 18 | 19 | --- 20 | 21 | ### Prerequisites 22 | 23 | * A Pwnagotchi device. 24 | * A working WireGuard VPN server (e.g., PiVPN running on a Raspberry Pi at home). 25 | * **Important:** If you have multiple Pwnagotchis, **every device needs its own unique WireGuard Client Profile**. Do not share keys between devices. 26 | 27 | --- 28 | 29 | ### Step 1: Server Setup 30 | 31 | On your WireGuard server, create a new client profile for your Pwnagotchi. 32 | 33 | 1. **Create the Client Profile:** 34 | ```bash 35 | # If using PiVPN, run: 36 | pivpn add 37 | ``` 38 | Name it something unique (e.g., `pwnagotchi-01`). 39 | 40 | 2. **Get the Configuration:** 41 | Open the generated `.conf` file (e.g., `cat configs/pwnagotchi-01.conf`). You will need the **Private Key**, **Address**, and **Public Key** from this file for Step 4. 42 | 43 | --- 44 | 45 | ### Step 2: Pwnagotchi Dependency Installation 46 | 47 | Log into your Pwnagotchi via SSH and install the required system tools. 48 | 49 | ```bash 50 | sudo apt-get update 51 | # Install rsync (for syncing) and WireGuard tools (for the connection) 52 | sudo apt-get install rsync wireguard wireguard-tools openresolv -y 53 | ``` 54 | 55 | --- 56 | 57 | ### Step 3: Plugin Installation 58 | 59 | 1. Place the `wireguard.py` script into the Pwnagotchi's custom plugins directory. 60 | ```bash 61 | # Make sure the directory exists 62 | sudo mkdir -p /usr/local/share/pwnagotchi/custom-plugins/ 63 | 64 | # Move the plugin file (adjust the source path if needed) 65 | sudo mv wireguard.py /usr/local/share/pwnagotchi/custom-plugins/ 66 | ``` 67 | 68 | --- 69 | 70 | ### Step 4: Pwnagotchi Configuration 71 | 72 | Open the config file: `sudo nano /etc/pwnagotchi/config.toml` 73 | 74 | Add the following block. **You must replace the values with data from your Step 1 `.conf` file.** 75 | 76 | ```toml 77 | main.plugins.wireguard.enabled = true 78 | main.plugins.wireguard.wg_config_path = "/tmp/wg0.conf" 79 | 80 | # --- SECURITY KEYS (From your .conf file) --- 81 | main.plugins.wireguard.private_key = "PASTE_YOUR_PRIVATE_KEY_HERE" 82 | main.plugins.wireguard.peer_public_key = "PASTE_SERVER_PUBLIC_KEY_HERE" 83 | # Only if your .conf has a PresharedKey: 84 | main.plugins.wireguard.preshared_key = "PASTE_PRESHARED_KEY_HERE" 85 | 86 | # --- NETWORK SETTINGS --- 87 | # The IP *inside* the VPN. MUST be unique for every Pwnagotchi (e.g., .5, .6, .7) 88 | main.plugins.wireguard.address = "10.x.x.x/24" 89 | main.plugins.wireguard.dns = "9.9.9.9" 90 | 91 | # Your home IP/DDNS. Example: "myhome.duckdns.org:51820" 92 | main.plugins.wireguard.peer_endpoint = "YOUR_PUBLIC_IP:51820" 93 | 94 | # --- SYNC SETTINGS --- 95 | main.plugins.wireguard.server_user = "pi" 96 | main.plugins.wireguard.server_port = 22 97 | main.plugins.wireguard.handshake_dir = "/home/pi/handshakes/" 98 | 99 | # Increase delay if you see DNS errors on boot (default 60) 100 | main.plugins.wireguard.startup_delay_secs = 60 101 | ``` 102 | 103 | --- 104 | 105 | ### Step 5: Enable Passwordless Sync (The Easy Way) 106 | 107 | We need to give your Pwnagotchi a "key" to unlock your server without typing a password. 108 | 109 | **1. Generate the Key (One Command)** 110 | Run this on your Pwnagotchi. It creates the key and sets an empty password automatically. 111 | ```bash 112 | sudo ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N "" 113 | ``` 114 | 115 | **2. Send Key to Server** 116 | Run this to copy the key to your server. 117 | * **CRITICAL:** Use your server's **Local LAN IP** here (e.g., `192.168.1.50`), NOT the WireGuard IP. The VPN isn't on yet! 118 | 119 | ```bash 120 | # Syntax: sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub -p @ 121 | sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub -p 22 pi@192.168.1.50 122 | ``` 123 | *(Enter your server password when asked. It should say "Number of key(s) added: 1".)* 124 | 125 | **3. Test It** 126 | ```bash 127 | sudo ssh -p 22 -i /root/.ssh/id_ed25519 pi@192.168.1.50 "echo Success" 128 | ``` 129 | *If it says "Success" without asking for a password, proceed.* 130 | 131 | --- 132 | 133 | ### Step 6: Enable Full Remote Access 134 | 135 | To access the Pwnagotchi from your phone or PC over the VPN, your server needs to know how to route the traffic. 136 | 137 | 1. **On your Server**, enable IP forwarding: 138 | ```bash 139 | sudo nano /etc/sysctl.conf 140 | # Uncomment: net.ipv4.ip_forward=1 141 | sudo sysctl -p 142 | ``` 143 | 144 | 2. **Add Firewall Rules:** 145 | Edit your server config (`/etc/wireguard/wg0.conf`) and add these lines under `[Interface]`: 146 | ```ini 147 | PostUp = iptables -A FORWARD -i %i -o %i -j ACCEPT 148 | PostDown = iptables -D FORWARD -i %i -o %i -j ACCEPT 149 | ``` 150 | *Restart the server WireGuard service after saving (`sudo systemctl restart wg-quick@wg0`).* 151 | 152 | --- 153 | 154 | ### Step 7: Final Restart 155 | 156 | Restart your Pwnagotchi: 157 | ```bash 158 | sudo systemctl restart pwnagotchi 159 | ``` 160 | 161 | **What to look for:** 162 | 1. **Screen:** Status changes from `WG: Conn...` to `WG: Up`. 163 | 2. **Sync:** After a few minutes, it may show `WG: Sync: X` (where X is the number of **new** handshakes sent). 164 | 3. **Logs:** `tail -f /var/log/pwnagotchi.log | grep WireGuard` 165 | 166 | --- 167 | 168 | ### Troubleshooting 169 | 170 | * **DNS Errors / "No address associated with hostname":** 171 | * This happens on boot if the internet isn't ready yet. The plugin will auto-retry. 172 | * Fix: Increase `main.plugins.wireguard.startup_delay_secs` to `60` or `120`. 173 | * **Permission denied (publickey):** 174 | * You likely generated the SSH keys as the default user `pi` but the plugin runs as `root`. Rerun **Step 5** exactly as written using `sudo`. 175 | * **Devices kicking each other off:** 176 | * You are using the same `Address` (IP) or `PrivateKey` on two different Pwnagotchis. 177 | * **Fix:** Generate a new client (`pivpn add`) on the server for the second device and update its `config.toml`. 178 | * **Sync is slow:** 179 | * Ensure you are using plugin **v1.8**. It uses a "Checkpoint" system to ignore old files. 180 | * Check logs: `Plugin loaded. Checkpoint system active.` 181 | -------------------------------------------------------------------------------- /auto_backup.py: -------------------------------------------------------------------------------- 1 | import pwnagotchi.plugins as plugins 2 | from pwnagotchi.utils import StatusFile 3 | import logging 4 | import os 5 | import subprocess 6 | import time 7 | import socket 8 | import threading 9 | import glob 10 | 11 | class AutoBackup(plugins.Plugin): 12 | __author__ = 'WPA2' 13 | __version__ = '2.0' 14 | __license__ = 'GPL3' 15 | __description__ = 'Backs up files and cleans up old backups to save space.' 16 | 17 | def __init__(self): 18 | self.ready = False 19 | self.tries = 0 20 | self.last_not_due_logged = 0 21 | self.status_file = '/root/.auto-backup' 22 | self.status = StatusFile(self.status_file) 23 | self.lock = threading.Lock() 24 | 25 | def on_loaded(self): 26 | required_options = ['files', 'interval', 'backup_location', 'max_tries'] 27 | for opt in required_options: 28 | if opt not in self.options or self.options[opt] is None: 29 | logging.error(f"AUTO-BACKUP: Option '{opt}' is not set.") 30 | return 31 | 32 | if 'commands' not in self.options or not self.options['commands']: 33 | self.options['commands'] = ["tar", "czf"] 34 | 35 | # Default to keeping 10 backups if not specified 36 | self.options.setdefault('max_backups_to_keep', 10) 37 | 38 | self.ready = True 39 | logging.info("AUTO-BACKUP: Plugin loaded.") 40 | 41 | def get_interval_seconds(self): 42 | interval = self.options['interval'] 43 | if isinstance(interval, str): 44 | if interval.lower() == "daily": 45 | return 24 * 60 * 60 46 | elif interval.lower() == "hourly": 47 | return 60 * 60 48 | else: 49 | try: 50 | return float(interval) * 60 51 | except ValueError: 52 | return 24 * 60 * 60 53 | elif isinstance(interval, (int, float)): 54 | return float(interval) * 60 55 | return 24 * 60 * 60 56 | 57 | def is_backup_due(self): 58 | interval_sec = self.get_interval_seconds() 59 | try: 60 | last_backup = os.path.getmtime(self.status_file) 61 | except OSError: 62 | return True 63 | return (time.time() - last_backup) >= interval_sec 64 | 65 | def _cleanup_old_backups(self): 66 | """Deletes the oldest backups if we exceed the limit.""" 67 | try: 68 | backup_dir = self.options['backup_location'] 69 | max_keep = self.options['max_backups_to_keep'] 70 | pwnagotchi_name = socket.gethostname() # or get from config if available 71 | 72 | # Find all tar.gz files in the backup directory 73 | # We filter by name to make sure we don't delete other stuff 74 | search_pattern = os.path.join(backup_dir, f"*-backup-*.tar.gz") 75 | files = glob.glob(search_pattern) 76 | 77 | # Sort files by creation time (Oldest first) 78 | files.sort(key=os.path.getmtime) 79 | 80 | # Calculate how many to delete 81 | if len(files) > max_keep: 82 | num_to_delete = len(files) - max_keep 83 | logging.info(f"AUTO-BACKUP: Cleaning up {num_to_delete} old backups...") 84 | 85 | for i in range(num_to_delete): 86 | try: 87 | os.remove(files[i]) 88 | logging.debug(f"AUTO-BACKUP: Deleted old file {files[i]}") 89 | except OSError as e: 90 | logging.error(f"AUTO-BACKUP: Failed to delete {files[i]}: {e}") 91 | 92 | except Exception as e: 93 | logging.error(f"AUTO-BACKUP: Cleanup error: {e}") 94 | 95 | def _run_backup_thread(self, agent, existing_files): 96 | with self.lock: 97 | global_config = getattr(agent, 'config', None) 98 | if callable(global_config): 99 | global_config = global_config() 100 | if global_config is None: 101 | global_config = {} 102 | 103 | pwnagotchi_name = global_config.get('main', {}).get('name', socket.gethostname()) 104 | backup_location = self.options['backup_location'] 105 | 106 | if not os.path.exists(backup_location): 107 | try: 108 | os.makedirs(backup_location) 109 | except OSError: 110 | return 111 | 112 | # Add timestamp to filename so they don't overwrite each other 113 | timestamp = time.strftime("%Y%m%d%H%M%S") 114 | backup_file = os.path.join(backup_location, f"{pwnagotchi_name}-backup-{timestamp}.tar.gz") 115 | 116 | display = agent.view() 117 | try: 118 | logging.info("AUTO-BACKUP: Starting backup process...") 119 | display.set('status', 'Backing up...') 120 | display.update() 121 | 122 | command_list = list(self.options['commands']) 123 | command_list.append(backup_file) 124 | 125 | for pattern in self.options.get('exclude', []): 126 | command_list.append(f"--exclude={pattern}") 127 | 128 | command_list.extend(existing_files) 129 | 130 | process = subprocess.Popen( 131 | command_list, 132 | shell=False, 133 | stdin=None, 134 | stdout=open("/dev/null", "w"), 135 | stderr=subprocess.PIPE 136 | ) 137 | _, stderr_output = process.communicate() 138 | 139 | if process.returncode > 0: 140 | raise OSError(f"Command failed: {stderr_output.decode('utf-8').strip()}") 141 | 142 | logging.info(f"AUTO-BACKUP: Backup successful: {backup_file}") 143 | 144 | # RUN CLEANUP AFTER SUCCESS 145 | self._cleanup_old_backups() 146 | 147 | display.set('status', 'Backup done!') 148 | display.update() 149 | self.status.update() 150 | self.tries = 0 151 | 152 | except Exception as e: 153 | self.tries += 1 154 | logging.error(f"AUTO-BACKUP: Backup error: {e}") 155 | display.set('status', 'Backup failed!') 156 | display.update() 157 | 158 | def on_internet_available(self, agent): 159 | if not self.ready: return 160 | if self.options['max_tries'] and self.tries >= self.options['max_tries']: return 161 | 162 | if not self.is_backup_due(): 163 | now = time.time() 164 | if now - self.last_not_due_logged > 3600: 165 | logging.debug("AUTO-BACKUP: Backup not due yet.") 166 | self.last_not_due_logged = now 167 | return 168 | 169 | existing_files = list(filter(os.path.exists, self.options['files'])) 170 | if not existing_files: return 171 | 172 | if self.lock.locked(): return 173 | 174 | backup_thread = threading.Thread(target=self._run_backup_thread, args=(agent, existing_files)) 175 | backup_thread.start() 176 | -------------------------------------------------------------------------------- /tailscale.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | import time 5 | 6 | import pwnagotchi.plugins as plugins 7 | import pwnagotchi.ui.fonts as fonts 8 | from pwnagotchi.ui.components import LabeledValue 9 | from pwnagotchi.ui.view import BLACK 10 | 11 | class Tailscale(plugins.Plugin): 12 | __author__ = 'WPA2' 13 | __version__ = '1.0.0' 14 | __license__ = 'GPL3' 15 | __description__ = 'A configurable plugin to connect to a Tailscale network and sync handshakes.' 16 | 17 | def __init__(self): 18 | self.ready = False 19 | self.status = "Starting" 20 | 21 | def on_loaded(self): 22 | logging.info("[Tailscale] Plugin loaded.") 23 | 24 | # Set default options if they're not in config.toml 25 | self.options.setdefault('sync_interval_secs', 600) 26 | self.options.setdefault('source_handshake_path', '/home/pi/handshakes/') 27 | self.options.setdefault('hostname', 'pwnagotchi') 28 | 29 | # Validate that all required options are present 30 | required = ['auth_key', 'server_tailscale_ip', 'server_user', 'handshake_dir'] 31 | missing = [key for key in required if key not in self.options] 32 | if missing: 33 | logging.error(f"[Tailscale] Missing required config options: {', '.join(missing)}") 34 | return 35 | 36 | if not os.path.exists('/usr/bin/tailscale') or not os.path.exists('/usr/bin/rsync'): 37 | logging.error("[Tailscale] tailscale or rsync is not installed.") 38 | return 39 | 40 | self.ready = True 41 | self.last_sync_time = 0 42 | 43 | def on_ui_setup(self, ui): 44 | self.ui = ui 45 | self.ui.add_element('ts_status', LabeledValue( 46 | color=BLACK, 47 | label='TS:', 48 | value=self.status, 49 | position=(175, 76), 50 | label_font=fonts.Small, 51 | text_font=fonts.Small 52 | )) 53 | 54 | def _update_status(self, new_status, temporary=False, duration=15): 55 | """Helper method to update the UI status.""" 56 | original_status = self.status 57 | self.status = new_status 58 | self.ui.set('ts_status', self.status) 59 | self.ui.update() 60 | 61 | if temporary: 62 | time.sleep(duration) 63 | # Only revert if the status hasn't changed to something else in the meantime 64 | if self.status == new_status: 65 | self.status = original_status 66 | self.ui.set('ts_status', self.status) 67 | self.ui.update() 68 | 69 | def _connect(self): 70 | max_retries = 3 71 | retry_delay = 15 72 | 73 | for attempt in range(max_retries): 74 | logging.info(f"[Tailscale] Attempting to connect (Attempt {attempt + 1}/{max_retries})...") 75 | self._update_status("Connecting") 76 | 77 | try: 78 | # Check current Tailscale status 79 | status_result = subprocess.run(["tailscale", "status"], capture_output=True, text=True) 80 | if "Logged in as" in status_result.stdout: 81 | self._update_status("Up") 82 | logging.info("[Tailscale] Already connected to Tailscale.") 83 | return True 84 | 85 | # Attempt to connect 86 | connect_command = [ 87 | "tailscale", "up", 88 | f"--authkey={self.options['auth_key']}", 89 | f"--hostname={self.options['hostname']}" 90 | ] 91 | subprocess.run(connect_command, check=True, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) 92 | 93 | # Verify connection 94 | time.sleep(5) # Give Tailscale a moment to establish the connection 95 | status_check = subprocess.run(["tailscale", "status"], check=True, capture_output=True, text=True) 96 | if "Logged in as" in status_check.stdout: 97 | self._update_status("Up") 98 | logging.info("[Tailscale] Connection established.") 99 | return True 100 | else: 101 | raise subprocess.CalledProcessError(1, "tailscale up", stderr="Failed to verify connection.") 102 | 103 | except subprocess.CalledProcessError as e: 104 | logging.error(f"[Tailscale] Connection failed: {e.stderr.strip()}") 105 | self._update_status("Error") 106 | time.sleep(retry_delay) 107 | 108 | logging.error("[Tailscale] Failed to establish connection after multiple retries.") 109 | self._update_status("Failed") 110 | return False 111 | 112 | def _sync_handshakes(self): 113 | logging.info("[Tailscale] Starting handshake sync...") 114 | self._update_status("Syncing...") 115 | 116 | source_dir = self.options['source_handshake_path'] 117 | remote_dir = self.options['handshake_dir'] 118 | server_user = self.options['server_user'] 119 | server_ip = self.options['server_tailscale_ip'] 120 | 121 | command = [ 122 | "rsync", "-avz", "--stats", "-e", 123 | "ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o UserKnownHostsFile=/dev/null", 124 | source_dir, f"{server_user}@{server_ip}:{remote_dir}" 125 | ] 126 | 127 | try: 128 | result = subprocess.run(command, check=True, capture_output=True, text=True) 129 | new_files = 0 130 | for line in result.stdout.splitlines(): 131 | if "Number of created files:" in line: 132 | new_files = int(line.split(":")[1].strip().split(" ")[0]) 133 | break 134 | 135 | logging.info(f"[Tailscale] Sync complete. Transferred {new_files} new files.") 136 | self._update_status(f"Synced: {new_files}", temporary=True) 137 | self.last_sync_time = time.time() 138 | 139 | except (subprocess.CalledProcessError, FileNotFoundError) as e: 140 | logging.error(f"[Tailscale] Handshake sync failed: {e}") 141 | if hasattr(e, 'stderr'): 142 | logging.error(f"[Tailscale] Stderr: {e.stderr.strip()}") 143 | self._update_status("Sync Failed", temporary=True) 144 | 145 | def on_internet_available(self, agent): 146 | if not self.ready: 147 | return 148 | 149 | if self.status not in ["Up", "Connecting"]: 150 | self._connect() 151 | 152 | if self.status == "Up": 153 | now = time.time() 154 | if now - self.last_sync_time > self.options['sync_interval_secs']: 155 | self._sync_handshakes() 156 | 157 | def on_unload(self, ui): 158 | logging.info("[Tailscale] Unloading plugin.") 159 | # We might not want to disconnect from Tailscale on unload, 160 | # as it could be used by other services. This can be enabled if desired. 161 | # logging.info("[Tailscale] Disconnecting from Tailscale.") 162 | # try: 163 | # subprocess.run(["tailscale", "down"], check=True, capture_output=True) 164 | # except (subprocess.CalledProcessError, FileNotFoundError): 165 | # pass 166 | 167 | with ui._lock: 168 | try: 169 | ui.remove_element('ts_status') 170 | except KeyError: 171 | pass -------------------------------------------------------------------------------- /Config.toml_Options.md: -------------------------------------------------------------------------------- 1 | # Pwnagotchi Configuration Guide 🚀 2 | 3 | Welcome to the ultimate **Pwnagotchi Configuration Guide**! This `README.md` explains every option in the `config.toml` file, helping you customize your AI-powered Wi-Fi buddy. 😎 4 | 5 | > Pwnagotchi is a DIY A2C-based AI that learns from its surrounding Wi-Fi environment to capture WPA2 handshakes. This file is its brain—let's dive in! 🛠️ 6 | 7 | --- 8 | 9 | ### 📍 Where is the Configuration File? 10 | Before you start, you need to find and edit the main configuration file. It's located at: 11 | ``` 12 | /etc/pwnagotchi/config.toml 13 | ``` 14 | You'll need `sudo` privileges to edit it (e.g., `sudo nano /etc/pwnagotchi/config.toml`). 15 | 16 | --- 17 | 18 | ## 📋 Table of Contents 19 | - [Main Configuration](#main-configuration) 20 | - [Plugins](#plugins) 21 | - [Logging](#logging) 22 | - [Personality](#personality) 23 | - [User Interface](#user-interface) 24 | - [Bettercap](#bettercap) 25 | - [Filesystem Memory](#filesystem-memory) 26 | - [Tips for Beginners](#tips-for-beginners) 27 | 28 | --- 29 | 30 | ## Main Configuration 31 | 32 | The `[main]` section sets up the core of your Pwnagotchi. Think of it as the brain’s basic settings. 🧠 33 | 34 | | Setting | Description | Default | Example | 35 | |---|---|---|---| 36 | | `name` | Your Pwnagotchi’s name, shown on the screen and used for identification. | `pwnagotchi` | `name = "MyPwnagotchi"` | 37 | | `lang` | Language for the interface (e.g., `en`, `es`). | `en` | `lang = "es"` | 38 | | `iface` | **Monitor mode interface**. This is *not* your physical Wi-Fi card (`wlan0`), but the virtual interface used for monitoring (`wlan0mon`). | `wlan0mon` | `iface = "wlan1mon"` | 39 | | `mon_start_cmd` | Command to put the Wi-Fi card into monitor mode. | `/usr/bin/monstart` | `/usr/local/bin/start_monitor.sh` | 40 | | `mon_stop_cmd` | Command to stop monitor mode. | `/usr/bin/monstop` | `/usr/local/bin/stop_monitor.sh` | 41 | | `no_restart` | Set to `true` to prevent Pwnagotchi from automatically restarting on errors. | `false` | `no_restart = true` | 42 | | `whitelist` | List of SSIDs or BSSIDs (MAC addresses) to ignore. | `[]` | `whitelist = ["MyHomeWiFi", "00:11:22:33:44:55"]` | 43 | | `custom_plugins` | Directory for locally stored custom plugins. | `/usr/local/share/pwnagotchi/custom-plugins/` | `custom_plugins = "/home/pi/my_plugins/"` | 44 | 45 | > 💡 **Tip:** Always `whitelist` your home and office Wi-Fi networks to prevent accidentally capturing your own handshakes! 46 | 47 | --- 48 | 49 | ## Plugins 50 | 51 | Plugins add superpowers to your Pwnagotchi! 🦸 Each plugin under `[main.plugins.*]` has an `enabled = true/false` setting and its own options. 52 | 53 | ### General & System Plugins 54 | | Plugin | Description | 55 | |---|---| 56 | | `auto-tune` | **(Recommended)** Automatically optimizes Wi-Fi channel hopping for better performance. Default: `enabled = true`. | 57 | | `auto-update` | Keeps Pwnagotchi and plugins up to date from their Git repositories. Set an `interval` in days. Default: `enabled = true`. | 58 | | `webcfg` | **(Recommended)** Provides a web interface to view status and configure your Pwnagotchi from a browser. Default: `enabled = true`. | 59 | | `fix_services` | ⚠️ **For built-in Wi-Fi cards only.** Ensures critical services are running correctly. Default: `enabled = true`. | 60 | 61 | ### Data Backup & Cracking Plugins 62 | | Plugin | Description | 63 | |---|---| 64 | | `auto_backup` | Automatically backs up important files. Set an `interval`, `backup_location`, and a list of `files` to save. | 65 | | `gdrivesync` | Syncs backups to a `backup_folder` in Google Drive. Requires setup. | 66 | | `pwncrack` | Automatically sends handshakes to an online cracking service. Requires an `api_key`. | 67 | | `wpa-sec` | Uploads handshakes to wpa-sec.stanev.org for cracking and community sharing. Requires an `api_key`. | 68 | | `wigle` | Uploads Wi-Fi network data to Wigle.net. Requires a Wigle `api_key`. | 69 | | `session-stats` | Tracks session data like handshakes captured and time elapsed. | 70 | 71 | > ⚠️ **Warning**: The `pwncrack`, `wpa-sec`, and `wigle` plugins share your data with third-party services. Understand their terms of service before enabling. 72 | 73 | ### Hardware & Connectivity Plugins 74 | | Plugin | Description | 75 | |---|---| 76 | | `bt-tether` | Shares internet from a phone via Bluetooth. Requires configuring your phone's `mac` address and `name`. | 77 | | `gps` | Tracks location with a GPS module. Set the `device` path (e.g., `/dev/ttyUSB0`) or `gpsd` server (`localhost:2947`). | 78 | | `gpio_buttons` | Allows you to use physical GPIO buttons to interact with the Pwnagotchi. | 79 | | `grid` | Connects to the Pwnagotchi community grid to share data and see nearby peers. Default: `enabled = true`. | 80 | 81 | ### Battery Management Plugins 82 | | Plugin | Description | 83 | |---|---| 84 | | `pisugar` | Manages PiSugar battery modules. Set a `lowpower_shutdown_level` to safely power off. | 85 | | `ups_hat_c` | Manages Waveshare UPS Hat (C) battery module. | 86 | | `ups_lite` | Manages UPS-Lite battery module. | 87 | 88 | ### UI & Display Plugins 89 | | Plugin | Description | 90 | |---|---| 91 | | `logtail` | Shows recent log entries on the Pwnagotchi's screen. | 92 | | `memtemp` | Displays CPU temperature and memory usage stats on the screen. | 93 | | `pwndroid` | Displays GPS coordinates and altitude on the screen when paired with the PwnDroid app. | 94 | | `webgpsmap` | Shows GPS data on a map in the web UI. | 95 | 96 | --- 97 | 98 | ## Logging 99 | 100 | The `[main.log]` section controls where logs are stored and how they are rotated. 101 | 102 | | Setting | Description | Default | 103 | |---|---|---| 104 | | `path` | Location for the main log file. | `"/var/log/pwnagotchi.log"` | 105 | | `path-debug` | Location for the more verbose debug log. | `"/tmp/pwnagotchi-debug.log"` | 106 | | `rotation` | **(Recommended)** `enabled = true` prevents logs from filling your SD card. Set a max `size` (e.g., `10M`). | `enabled = true` | 107 | 108 | --- 109 | 110 | ## Personality 111 | 112 | The `[personality]` section defines your Pwnagotchi’s "mood" and hacking tactics. 😈 113 | 114 | | Setting | Description | Default | 115 | |---|---|---| 116 | | `deauth` | `true` allows performing deauth attacks to capture handshakes. | `true` | 117 | | `associate` | `true` allows connecting to access points to gather data. | `true` | 118 | | `channels` | Wi-Fi channels to scan (e.g., `[1, 6, 11]`). Empty `[]` means all channels. | `[]` | 119 | | `min_rssi` | Minimum signal strength (dBm) required to interact with a network. `-200` means no limit. A realistic value is `-75`. | `-200` | 120 | | `bond_encounters_factor`| How quickly your Pwnagotchi "bonds" with other units it encounters. | `20000` | 121 | | `bored_num_epochs` | Number of cycles without activity before getting "bored." | `15` | 122 | | `sad_num_epochs` | Number of cycles without activity before getting "sad." | `25` | 123 | 124 | > 😺 **Fun Fact**: Pwnagotchi changes moods (and face expressions) based on its activity. Tweak these settings to make it more aggressive or passive! 125 | 126 | --- 127 | 128 | ## User Interface 129 | 130 | The `[ui]` section controls what you see on the screen and in the web browser. 131 | 132 | | Setting | Description | Default | 133 | |---|---|---| 134 | | `invert` | `true` for white background, `false` for black. | `false` | 135 | | `font.name` | Display font (e.g., `DejaVuSansMono`). | `DejaVuSansMono` | 136 | | `faces` | Customize the ASCII art for different moods. | `(various)` | 137 | | `web.enabled` | `true` to enable the web UI. | `true` | 138 | | `web.address` | Listening address. `0.0.0.0` is recommended for general access. | `::` | 139 | | `web.port` | Web server port. | `8080` | 140 | | `display.enabled` | **(Required for screens)** `true` to activate the physical display. | `false` | 141 | | `display.type` | The model of your e-ink screen (e.g., `waveshare_v3`, `inkyphat`). | `waveshare_v2` | 142 | | `display.rotation`| Screen rotation in degrees (`0`, `90`, `180`, `270`). | `180` | 143 | 144 | > 🖼️ **Display Tip**: To use a screen, you *must* set `ui.display.enabled = true` and `ui.display.type` to match your hardware. Check the official Pwnagotchi docs for a list of supported display types! 145 | 146 | --- 147 | 148 | ## Bettercap 149 | 150 | The `[bettercap]` section configures Bettercap, the underlying packet sniffing tool. 151 | 152 | | Setting | Description | Default | 153 | |---|---|---| 154 | | `handshakes` | Directory where captured handshake `.pcap` files are stored. | `"/home/pi/handshakes"` | 155 | | `silence` | Bettercap events to hide from the log to reduce noise (e.g., `wifi.ap.new`, `wifi.client.probe`). | `(various)` | 156 | 157 | --- 158 | 159 | ## Filesystem Memory 160 | 161 | The `[fs.memory]` section helps reduce SD card wear by using RAM for temporary files. This is a key feature for longevity! 162 | 163 | | Setting | Description | Benefit | 164 | |---|---|---| 165 | | `log.enabled` | `true` to store logs in RAM instead of directly on the SD card. | Reduces constant write cycles. | 166 | | `data.enabled`| `true` to store temporary data (like session info) in RAM. | Extends the life of your SD card. | 167 | 168 | --- 169 | 170 | ## Tips for Beginners 171 | 172 |
173 | 🆕 **New to Pwnagotchi? Click here for quick tips!** 174 | 175 | - **Start Simple**: At first, only enable a few plugins like `auto-tune` and `webcfg`. 176 | - **Use the Web UI**: Set `ui.web.enabled = true` and visit `http://:8080` in your browser (the default IP over USB is often `10.0.0.2`). 177 | - **Check the Logs**: If something isn't working, check the log file at `/var/log/pwnagotchi.log` for clues. 178 | - **Join the Community**: Visit the official [pwnagotchi.ai](https://pwnagotchi.ai/) website, Discord, or community forums for help and ideas. 179 | 180 |
181 | 182 | --- 183 | 184 | ## Contributing 185 | Got ideas to improve this guide? Please submit a pull request or open an issue! 186 | 187 | ## License 188 | This project is licensed under the MIT License. See the `LICENSE` file for details. 189 | 190 | --- 191 | 192 | Happy hacking with your Pwnagotchi! 🎉 193 | -------------------------------------------------------------------------------- /Tele_Pi/README.md: -------------------------------------------------------------------------------- 1 | # Pi Manager Bot for Telegram 🤖 2 | 3 | A powerful, asynchronous Telegram bot designed to securely monitor and manage a Linux-based system (like a Raspberry Pi) from anywhere. 4 | 5 | ![Bot Screenshot](https://placehold.co/600x400/1e1e2e/d4d4d4?text=Bot+Interface+Screenshot) 6 | 7 | This bot provides a convenient menu-driven interface to run system commands, check network status, get system metrics, and use various utilities, all from the comfort of your Telegram chat. 8 | 9 | --- 10 | 11 | ## ✨ Features 12 | 13 | The bot's commands are neatly organized into three categories for ease of use. 14 | 15 | ### ⚙️ System Management 16 | - **System Updates:** Update and upgrade system packages with `apt`. 17 | - **Power Control:** Safely reboot or shut down the system. 18 | - **Resource Monitoring:** 19 | - Check disk usage (`df -h`). 20 | - View free memory (`free -m`). 21 | - Get current CPU temperature. 22 | - List all running processes (`ps -ef`). 23 | - List systemd services. 24 | - **Automated Alerts:** Set up recurring jobs to monitor CPU temperature and RAM usage, receiving alerts if they exceed predefined thresholds. 25 | - **Status Overview:** Get a quick, clean summary of the system's vital signs (CPU temp, RAM/disk usage, uptime). 26 | 27 | ### 🌐 Network Tools 28 | - **IP Information:** Display local and external IP addresses. 29 | - **Network Scanning:** Scan for available Wi-Fi networks using `nmcli`. 30 | - **Connectivity Check:** Ping any remote host to check for connectivity. 31 | - **Detailed Stats:** Show detailed statistics for network interfaces. 32 | 33 | ### 🛠️ Utilities 34 | - **Speed Test:** Run an internet speed test using `speedtest-cli`. 35 | - **System Uptime:** Check how long the system has been running. 36 | - **Weather Forecast:** Get the current weather for any city using the OpenWeatherMap API. 37 | - **Random Joke:** Fetch a random programming joke to lighten the mood. 38 | 39 | --- 40 | 41 | ## 🚀 Setup and Installation 42 | 43 | Follow these steps to get your bot up and running. 44 | 45 | ### 1. Prerequisites 46 | - **Python 3.8+** 47 | - The `python3-venv` package. Install it with: `sudo apt install python3-venv` 48 | - A **Telegram Bot Token**. Get one from [@BotFather](https://t.me/BotFather) on Telegram. 49 | - An **OpenWeatherMap API Key**. Get a free key from [OpenWeatherMap](https://openweathermap.org/appid). 50 | - Your **Telegram User ID**. Get it from a bot like [@userinfobot](https://t.me/userinfobot). 51 | 52 | ### 2. Clone the Repository 53 | ```bash 54 | git clone 55 | cd 56 | ``` 57 | 58 | ### 3. Create and Activate a Virtual Environment 59 | Using a virtual environment is the recommended way to manage Python packages and avoid conflicts with system packages. 60 | 61 | > **Important Note on Permissions:** If you are installing in a system-owned directory like `/opt`, you may encounter permission errors. To fix this, first assign ownership of the directory to your user. **Run this command before creating the virtual environment:** 62 | > ```bash 63 | > # Replace /opt with your chosen directory if different 64 | > sudo chown -R $(whoami) /opt 65 | > ``` 66 | 67 | Now, create and activate the virtual environment: 68 | ```bash 69 | # Create the virtual environment (do NOT use sudo) 70 | python3 -m venv .venv 71 | 72 | # Activate the virtual environment 73 | source .venv/bin/activate 74 | ``` 75 | *Your terminal prompt should now be prefixed with `(.venv)`.* 76 | 77 | ### 4. Install Dependencies 78 | With the virtual environment active, install the required Python libraries. 79 | ```bash 80 | pip install -r requirements.txt 81 | ``` 82 | *(If you don't have a `requirements.txt` file, create one with the following content:)* 83 | ``` 84 | python-telegram-bot==21.2 85 | psutil 86 | requests 87 | ``` 88 | *Once done, you can leave the virtual environment for now by typing `deactivate`.* 89 | 90 | ### 5. Configure Passwordless Sudo (Recommended) 91 | For commands like `/reboot`, `/shutdown`, and `/update` to work seamlessly, you need to grant passwordless `sudo` permissions for those specific commands to the user running the script. 92 | 93 | 1. Open the `sudoers` file for editing by running: 94 | ```bash 95 | sudo visudo 96 | ``` 97 | 2. Add the following lines at the end of the file, replacing `your_username` with your actual Linux username: 98 | ``` 99 | # Allow the bot user to run specific commands without a password 100 | your_username ALL=(ALL) NOPASSWD: /usr/bin/reboot 101 | your_username ALL=(ALL) NOPASSWD: /usr/bin/shutdown 102 | your_username ALL=(ALL) NOPASSWD: /usr/bin/apt update 103 | your_username ALL=(ALL) NOPASSWD: /usr/bin/apt upgrade -y 104 | ``` 105 | 3. Save and exit the editor. 106 | 107 | --- 108 | 109 | ## ⚙️ Running the Bot 110 | 111 | You can run the bot manually for testing or set it up as a `systemd` service to run automatically on boot. 112 | 113 | ### Manually (for Testing) 114 | 1. Activate the virtual environment: 115 | ```bash 116 | source .venv/bin/activate 117 | ``` 118 | 2. Set your environment variables: 119 | ```bash 120 | export TELEGRAM_TOKEN="YOUR_TELEGRAM_BOT_TOKEN" 121 | export WEATHER_API_KEY="YOUR_OPENWEATHERMAP_API_KEY" 122 | export ALLOWED_USER_ID="YOUR_TELEGRAM_USER_ID" 123 | ``` 124 | 3. Run the script: 125 | ```bash 126 | python3 pi_bot.py 127 | ``` 128 | 129 | ### As a Systemd Service (Recommended for Production) 130 | 131 | This method will ensure your bot starts automatically on boot and restarts if it crashes. 132 | 133 | **Step 1: Create an Environment File for Secrets** 134 | 135 | Create a file to hold your secret keys. This is more secure and works reliably with `systemd`. 136 | 137 | ```bash 138 | # Create and open the file 139 | nano /opt/telegram_bot.env 140 | ``` 141 | 142 | Add your secrets to this file in the following format ( **do not use `export` or quotes**): 143 | ``` 144 | TELEGRAM_TOKEN=YOUR_TELEGRAM_BOT_TOKEN 145 | WEATHER_API_KEY=YOUR_OPENWEATHERMAP_API_KEY 146 | ALLOWED_USER_ID=YOUR_TELEGRAM_USER_ID 147 | ``` 148 | Save the file (`Ctrl+O`, `Enter`) and exit (`Ctrl+X`). Secure it so only the owner can read it: 149 | ```bash 150 | chmod 600 /opt/telegram_bot.env 151 | ``` 152 | 153 | **Step 2: Create the Systemd Service File** 154 | 155 | Create a new service file for your bot: 156 | ```bash 157 | sudo nano /etc/systemd/system/telegram-bot.service 158 | ``` 159 | 160 | Paste the following configuration into the file. **Make sure to replace `your_username` with your actual Linux username** (e.g., `pi`, `wpa2`). 161 | 162 | ```ini 163 | [Unit] 164 | Description=My Telegram Pi Manager Bot 165 | After=network.target 166 | 167 | [Service] 168 | User=your_username 169 | Group=your_username 170 | WorkingDirectory=/opt 171 | EnvironmentFile=/opt/telegram_bot.env 172 | ExecStart=/opt/.venv/bin/python3 /opt/pi_bot.py 173 | Restart=on-failure 174 | RestartSec=5s 175 | 176 | [Install] 177 | WantedBy=multi-user.target 178 | ``` 179 | Save and exit the editor. 180 | 181 | **Step 3: Enable and Start the Service** 182 | 183 | Now, tell `systemd` to reload, enable, and start your new service. 184 | ```bash 185 | sudo systemctl daemon-reload 186 | sudo systemctl enable telegram-bot.service 187 | sudo systemctl start telegram-bot.service 188 | ``` 189 | 190 | **Step 4: Check the Status** 191 | 192 | You can check if the service is running correctly: 193 | ```bash 194 | sudo systemctl status telegram-bot.service 195 | ``` 196 | To see the live logs from your bot, use: 197 | ```bash 198 | sudo journalctl -u telegram-bot.service -f 199 | ``` 200 | 201 | --- 202 | 203 | ## 📖 Command Reference 204 | 205 | | Command | Description | Arguments | 206 | | ------------------------ | ------------------------------------------------ | ----------------- | 207 | | **System** | | | 208 | | `/update` | Updates and upgrades system packages. | None | 209 | | `/reboot` | Reboots the system. | None | 210 | | `/shutdown` | Shuts down the system. | None | 211 | | `/disk_usage` | Shows file system disk space usage. | None | 212 | | `/free_memory` | Shows system memory usage. | None | 213 | | `/show_processes` | Lists all currently running processes. | None | 214 | | `/show_system_services` | Lists all systemd services. | None | 215 | | `/start_monitoring` | Starts monitoring CPU temperature. | None | 216 | | `/stop_monitoring` | Stops monitoring CPU temperature. | None | 217 | | `/start_monitoring_ram` | Starts monitoring RAM usage. | None | 218 | | `/stop_monitoring_ram` | Stops monitoring RAM usage. | None | 219 | | `/temp` | Shows the current CPU temperature. | None | 220 | | `/status` | Shows a brief overview of system status. | None | 221 | | **Network** | | | 222 | | `/ip` | Shows all IP addresses for all interfaces. | None | 223 | | `/external_ip` | Gets the public-facing IP address. | None | 224 | | `/wifi` | Scans for and lists available WiFi networks. | None | 225 | | `/ping` | Pings a remote host to check connectivity. | `` | 226 | | `/show_network_info` | Shows detailed network interface statistics. | None | 227 | | **Utility** | | | 228 | | `/speedtest` | Runs an internet speed test. | None | 229 | | `/uptime` | Shows how long the system has been running. | None | 230 | | `/weather` | Gets the current weather for a specified city. | `` | 231 | | `/joke` | Tells a random programming joke. | None | 232 | 233 | --- 234 | 235 | ## 🔒 Security 236 | - **User Restriction:** Access is strictly limited to the `ALLOWED_USER_ID`. 237 | - **Secure Key Storage:** API keys and tokens are managed via an environment file with restricted permissions. 238 | - **Safe Subprocess Calls:** All external commands are run using `asyncio.create_subprocess_exec` with arguments passed as a list (`shell=False`), preventing shell injection vulnerabilities. 239 | 240 | --- 241 | 242 | ## 🤝 Contributing 243 | Contributions are welcome! If you have ideas for new features or improvements, feel free to fork the repository, make your changes, and submit a pull request. 244 | 245 | --- 246 | 247 | ## 📄 License 248 | This project is licensed under the MIT License. See the `LICENSE` file for details. -------------------------------------------------------------------------------- /wireguard.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | import time 5 | import threading 6 | 7 | import pwnagotchi.plugins as plugins 8 | import pwnagotchi.ui.fonts as fonts 9 | from pwnagotchi.ui.components import LabeledValue 10 | from pwnagotchi.ui.view import BLACK 11 | 12 | class WireGuard(plugins.Plugin): 13 | __author__ = 'WPA2' 14 | __version__ = '1.9' 15 | __license__ = 'GPL3' 16 | __description__ = 'VPN Sync: Full backup on first run, then incremental only.' 17 | 18 | def __init__(self): 19 | self.ready = False 20 | self.status = "Init" 21 | self.wg_config_path = "/tmp/wg0.conf" 22 | self.last_sync_time = 0 23 | self.sync_interval = 600 24 | self.initial_boot = True 25 | self.lock = threading.Lock() 26 | # This file acts as the "Bookmark". We only sync files newer than this file. 27 | self.marker_file = "/root/.wg_last_sync_marker" 28 | 29 | def on_loaded(self): 30 | required_ops = ['private_key', 'address', 'peer_public_key', 'peer_endpoint', 'handshake_dir', 'server_user'] 31 | missing = [op for op in required_ops if op not in self.options] 32 | 33 | if missing: 34 | logging.error(f"[WireGuard] Missing config: {', '.join(missing)}") 35 | return 36 | 37 | if not os.path.exists('/usr/bin/rsync'): 38 | logging.error("[WireGuard] rsync is not installed. Run: sudo apt-get install rsync") 39 | return 40 | 41 | self.options.setdefault('startup_delay_secs', 60) 42 | self.options.setdefault('server_port', 22) 43 | 44 | # Calculate server VPN IP (assume .1 if not specified) 45 | if 'server_vpn_ip' not in self.options: 46 | try: 47 | ip_part = self.options['address'].split('/')[0] 48 | base_ip = ".".join(ip_part.split('.')[:3]) + ".1" 49 | self.options['server_vpn_ip'] = base_ip 50 | except: 51 | self.options['server_vpn_ip'] = "10.0.0.1" 52 | 53 | # --- CHECKPOINT LOGIC --- 54 | # If marker doesn't exist, this is a fresh install. 55 | if not os.path.exists(self.marker_file): 56 | try: 57 | # Create the file 58 | with open(self.marker_file, 'a'): 59 | pass 60 | # Set timestamp to Epoch (1970). 61 | # This ensures the "find -newer" command catches ALL historical files on the first run. 62 | os.utime(self.marker_file, (0, 0)) 63 | logging.info("[WireGuard] First run detected. Full sync scheduled.") 64 | except Exception as e: 65 | logging.error(f"[WireGuard] Marker creation failed: {e}") 66 | # ------------------------ 67 | 68 | self.ready = True 69 | logging.info("[WireGuard] Plugin loaded. Checkpoint system active.") 70 | 71 | def on_ui_setup(self, ui): 72 | self.ui = ui 73 | try: 74 | ui.add_element('wg_status', LabeledValue( 75 | color=BLACK, 76 | label='WG:', 77 | value=self.status, 78 | position=(ui.width() // 2 - 25, 0), 79 | label_font=fonts.Small, 80 | text_font=fonts.Small 81 | )) 82 | except Exception as e: 83 | logging.error(f"[WireGuard] UI Setup Error: {e}") 84 | 85 | def update_status(self, text): 86 | self.status = text 87 | if hasattr(self, 'ui'): 88 | try: 89 | self.ui.set('wg_status', text) 90 | except Exception: 91 | pass 92 | 93 | def _cleanup_interface(self): 94 | try: 95 | subprocess.run(["wg-quick", "down", self.wg_config_path], 96 | stdout=subprocess.DEVNULL, 97 | stderr=subprocess.DEVNULL) 98 | subprocess.run(["ip", "link", "delete", "dev", "wg0"], 99 | stdout=subprocess.DEVNULL, 100 | stderr=subprocess.DEVNULL) 101 | except: 102 | pass 103 | 104 | def _connect(self): 105 | if self.lock.locked(): 106 | return 107 | 108 | logging.info("[WireGuard] Attempting to connect...") 109 | self.update_status("Conn...") 110 | 111 | self._cleanup_interface() 112 | 113 | conf = f"""[Interface] 114 | PrivateKey = {self.options['private_key']} 115 | Address = {self.options['address']} 116 | """ 117 | if 'dns' in self.options: 118 | conf += f"DNS = {self.options['dns']}\n" 119 | 120 | conf += f""" 121 | [Peer] 122 | PublicKey = {self.options['peer_public_key']} 123 | Endpoint = {self.options['peer_endpoint']} 124 | AllowedIPs = {self.options['server_vpn_ip']}/32 125 | PersistentKeepalive = 25 126 | """ 127 | if 'preshared_key' in self.options: 128 | conf += f"PresharedKey = {self.options['preshared_key']}\n" 129 | 130 | try: 131 | with open(self.wg_config_path, "w") as f: 132 | f.write(conf) 133 | os.chmod(self.wg_config_path, 0o600) 134 | 135 | process = subprocess.run( 136 | ["wg-quick", "up", self.wg_config_path], 137 | capture_output=True, 138 | text=True 139 | ) 140 | 141 | if process.returncode == 0: 142 | self.update_status("Up") 143 | logging.info("[WireGuard] Connection established.") 144 | return True 145 | else: 146 | self.update_status("Err") 147 | clean_err = process.stderr.replace('\n', ' ') 148 | logging.error(f"[WireGuard] Connect fail: {clean_err}") 149 | return False 150 | 151 | except Exception as e: 152 | self.update_status("Err") 153 | logging.error(f"[WireGuard] Critical Error: {e}") 154 | return False 155 | 156 | def _get_ssh_key_path(self): 157 | if os.path.exists("/root/.ssh/id_ed25519"): 158 | return "/root/.ssh/id_ed25519" 159 | elif os.path.exists("/root/.ssh/id_rsa"): 160 | return "/root/.ssh/id_rsa" 161 | return None 162 | 163 | def _sync_handshakes(self): 164 | with self.lock: 165 | source_dir = '/home/pi/handshakes/' 166 | if not os.path.exists(source_dir): 167 | source_dir = '/root/handshakes/' 168 | 169 | remote_dir = self.options['handshake_dir'] 170 | server_user = self.options['server_user'] 171 | server_ip = self.options['server_vpn_ip'] 172 | ssh_port = self.options['server_port'] 173 | 174 | if not os.path.exists(source_dir): 175 | return 176 | 177 | key_path = self._get_ssh_key_path() 178 | if not key_path: 179 | self.update_status("KeyErr") 180 | return 181 | 182 | # --- GAPLESS SYNC LOGIC --- 183 | # 1. We create a list of files that are NEWER than our marker file. 184 | file_list_path = "/tmp/wg_sync_list.txt" 185 | 186 | # Use 'find' with -newer. This is instant. 187 | # It finds everything captured since the last successful sync. 188 | try: 189 | with open(file_list_path, "w") as f: 190 | subprocess.run( 191 | ["find", source_dir, "-type", "f", "-newer", self.marker_file, "-printf", "%P\\n"], 192 | stdout=f 193 | ) 194 | 195 | # If nothing new, we are done. Fast exit. 196 | if os.path.getsize(file_list_path) == 0: 197 | self.last_sync_time = time.time() 198 | return 199 | 200 | except Exception as e: 201 | logging.error(f"[WireGuard] List Gen Error: {e}") 202 | return 203 | # --- END LOGIC --- 204 | 205 | logging.info("[WireGuard] Syncing new handshakes...") 206 | ssh_cmd = f"ssh -p {ssh_port} -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" 207 | 208 | command = [ 209 | "rsync", "-avz", "--timeout=30", 210 | "--files-from=" + file_list_path, 211 | "-e", ssh_cmd, 212 | source_dir, 213 | f"{server_user}@{server_ip}:{remote_dir}" 214 | ] 215 | 216 | try: 217 | result = subprocess.run(command, capture_output=True, text=True) 218 | 219 | if result.returncode == 0: 220 | # SUCCESS: Update the marker to NOW so we don't sync these files again 221 | os.utime(self.marker_file, None) 222 | 223 | try: 224 | with open(file_list_path, 'r') as f: 225 | count = sum(1 for _ in f) 226 | msg = f"Sync: {count}" 227 | except: 228 | msg = "Sync: OK" 229 | 230 | self.update_status(msg) 231 | if count > 0: 232 | logging.info(f"[WireGuard] Synced {count} new handshakes.") 233 | 234 | threading.Timer(10.0, self.update_status, ["Up"]).start() 235 | else: 236 | if result.returncode not in [23, 24]: 237 | logging.error(f"[WireGuard] Sync Error: {result.stderr}") 238 | 239 | if "Connection refused" in result.stderr or "unreachable" in result.stderr: 240 | logging.warning("[WireGuard] Connection appears dead. Resetting...") 241 | self.update_status("Down") 242 | self._cleanup_interface() 243 | 244 | except Exception as e: 245 | logging.error(f"[WireGuard] Sync Exception: {e}") 246 | 247 | finally: 248 | self.last_sync_time = time.time() 249 | 250 | def on_internet_available(self, agent): 251 | if not self.ready: 252 | return 253 | 254 | if self.initial_boot: 255 | delay = self.options['startup_delay_secs'] 256 | logging.debug(f"[WireGuard] Waiting {delay}s startup delay...") 257 | time.sleep(delay) 258 | self.initial_boot = False 259 | 260 | if self.status in ["Init", "Down", "Err"]: 261 | self._connect() 262 | 263 | elif self.status == "Up" or self.status.startswith("Sync"): 264 | if self.lock.locked(): 265 | return 266 | 267 | now = time.time() 268 | if now - self.last_sync_time > self.sync_interval: 269 | threading.Thread(target=self._sync_handshakes).start() 270 | 271 | def on_unload(self, ui): 272 | logging.info("[WireGuard] Unloading...") 273 | self._cleanup_interface() 274 | if os.path.exists(self.wg_config_path): 275 | try: 276 | os.remove(self.wg_config_path) 277 | except: pass 278 | try: 279 | ui.remove_element('wg_status') 280 | except: pass 281 | -------------------------------------------------------------------------------- /web2ssh.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import logging 3 | from flask import Flask, request, render_template_string, Response 4 | import pwnagotchi.plugins as plugins 5 | from functools import wraps 6 | 7 | class web2ssh(plugins.Plugin): 8 | __author__ = 'WPA2' 9 | __version__ = '0.1.0' 10 | __license__ = 'GPL3' 11 | __description__ = 'A Plugin to issue SSH commands via a browser' 12 | 13 | def __init__(self, config=None): 14 | super().__init__() 15 | logging.debug("web2ssh created") 16 | self.app = Flask(__name__) 17 | self.config = config or {} 18 | self.options = {} 19 | 20 | def on_loaded(self): 21 | """Called when the plugin is loaded.""" 22 | logging.info("web2ssh loaded") 23 | 24 | # Initialize self.options with default values 25 | self.options = { 26 | "username": self.config.get("main.plugins.web2ssh.username", "changeme"), 27 | "password": self.config.get("main.plugins.web2ssh.password", "changeme"), 28 | "port": self.config.get("main.plugins.web2ssh.port", 8082), 29 | } 30 | 31 | logging.debug(f"web2ssh config: {self.options}") 32 | 33 | # Set up Flask routes and start the server 34 | self.app.before_request(self.requires_auth) # Attach auth check to all routes 35 | self._register_routes() 36 | self.app.run(host='::', port=self.options["port"]) 37 | 38 | def _register_routes(self): 39 | """Register Flask routes.""" 40 | @self.app.route('/') 41 | def index(): 42 | """Home page for SSH command input.""" 43 | return render_template_string(""" 44 | 45 | 46 | 47 | 48 | WEB2SSH Command Executor 49 | 114 | 115 | 116 |
117 |

WEB2SSH Command Executor

118 |
119 | 120 | 121 |
122 |
123 |

Command Shortcuts

124 |
125 | 126 | 127 |
128 |
129 | 130 | 131 |
132 |
133 | 134 | 135 |
136 |
137 | 138 | 139 |
140 |
141 | 142 | 143 |
144 |
145 | 146 | 147 |
148 |
149 |
150 | 151 | 152 | """) 153 | 154 | @self.app.route('/execute', methods=['POST']) 155 | def execute_command(): 156 | """Execute SSH command and return output.""" 157 | command = request.form['command'] 158 | output = self.ssh_execute_command(command) 159 | return render_template_string(""" 160 | 161 | 162 | 163 | 164 | Command Output 165 | 203 | 204 | 205 |
206 |

Command Output

207 |
{{ output }}
208 | Back 209 |
210 | 211 | 212 | """, output=output) 213 | 214 | def ssh_execute_command(self, command): 215 | """Executes the SSH command on the local device.""" 216 | try: 217 | result = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT) 218 | return result.decode('utf-8') 219 | except subprocess.CalledProcessError as e: 220 | return f"Error executing command: {e.output.decode('utf-8')}" 221 | 222 | def check_auth(self, username, password): 223 | """Check if username and password match.""" 224 | return username == self.options["username"] and password == self.options["password"] 225 | 226 | def requires_auth(self, f=None): 227 | """Enforce basic authentication.""" 228 | @wraps(f) 229 | def decorated(*args, **kwargs): 230 | auth = request.authorization 231 | if not auth or not self.check_auth(auth.username, auth.password): 232 | return self._unauthorized_response() 233 | return f(*args, **kwargs) 234 | 235 | # If no function is passed (e.g., as a before_request handler), just check auth 236 | if f is None: 237 | auth = request.authorization 238 | if not auth or not self.check_auth(auth.username, self.options["password"]): 239 | return self._unauthorized_response() 240 | return None 241 | 242 | return decorated 243 | 244 | def _unauthorized_response(self): 245 | """Generate a 401 Unauthorized response with the WWW-Authenticate header.""" 246 | response = Response( 247 | 'Unauthorized access. Please provide valid credentials.', 248 | status=401 249 | ) 250 | response.headers['WWW-Authenticate'] = 'Basic realm="web2ssh"' 251 | return response 252 | 253 | def on_unload(self, ui): 254 | """Called when the plugin is unloaded.""" 255 | logging.info("web2ssh unloaded") 256 | -------------------------------------------------------------------------------- /discord.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import time 5 | import threading 6 | import queue 7 | import atexit 8 | from typing import Any, Dict, List, Optional 9 | 10 | import requests 11 | from requests import RequestException 12 | 13 | import pwnagotchi.plugins as plugins 14 | from pwnagotchi.agent import Agent 15 | 16 | # ---------------------------------------------------------------------------- 17 | # Constants 18 | # ---------------------------------------------------------------------------- 19 | LOG_DIR = "/etc/pwnagotchi/log" 20 | LOG_FILE = os.path.join(LOG_DIR, "discord_plugin.log") 21 | CACHE_FILE = "/home/pi/handshakes/discord_wigle_cache.json" 22 | 23 | # Ensure directories exist 24 | os.makedirs(LOG_DIR, exist_ok=True) 25 | os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) 26 | 27 | # ---------------------------------------------------------------------------- 28 | # Logging Setup 29 | # ---------------------------------------------------------------------------- 30 | logger = logging.getLogger("pwnagotchi.plugins.discord") 31 | logger.setLevel(logging.DEBUG) 32 | file_handler = logging.FileHandler(LOG_FILE) 33 | file_handler.setLevel(logging.DEBUG) 34 | formatter = logging.Formatter( 35 | fmt="%(asctime)s [%(levelname)s] %(name)s - %(message)s", 36 | datefmt="%Y-%m-%d %H:%M:%S" 37 | ) 38 | file_handler.setFormatter(formatter) 39 | logger.addHandler(file_handler) 40 | 41 | 42 | class Discord(plugins.Plugin): 43 | __author__ = "WPA2" 44 | __version__ = '2.6.0' 45 | __license__ = 'GPL3' 46 | __description__ = 'Sends Pwnagotchi handshakes (w/ pcap) and reports Last Session stats on boot/mode switch.' 47 | 48 | def __init__(self): 49 | super().__init__() 50 | self.webhook_url: Optional[str] = None 51 | self.api_key: Optional[str] = None 52 | self.http_session = requests.Session() 53 | self.wigle_cache: Dict[str, Dict[str, str]] = {} 54 | 55 | # Deduplication 56 | self.recent_handshakes = set() 57 | self.recent_handshakes_limit = 200 58 | 59 | # Threading & Queue 60 | self._event_queue = queue.Queue() 61 | self._stop_event = threading.Event() 62 | self._worker_thread = None 63 | 64 | # Session Stats (Current) 65 | self.session_handshakes = 0 66 | self.start_time = time.time() 67 | self.session_id = os.urandom(4).hex() 68 | 69 | # Register the safety net for Hard Shutdowns (Poweroff/Systemctl Stop) 70 | atexit.register(self._on_exit_cleanup) 71 | 72 | def on_loaded(self): 73 | logger.info("Discord plugin loaded.") 74 | self.webhook_url = self.options.get("webhook_url", None) 75 | self.api_key = self.options.get("wigle_api_key", None) 76 | 77 | self._load_wigle_cache() 78 | 79 | if not self.webhook_url or not self.api_key: 80 | logger.error("Discord plugin: Missing webhook_url or wigle_api_key.") 81 | return 82 | 83 | # Start the background worker 84 | self._stop_event.clear() 85 | self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True) 86 | self._worker_thread.start() 87 | logger.info(f"Discord plugin: Worker thread started. Session ID: {self.session_id}") 88 | 89 | def on_unload(self, ui): 90 | self._on_exit_cleanup() 91 | 92 | def _on_exit_cleanup(self): 93 | if self._stop_event.is_set(): return 94 | 95 | logger.info("Discord plugin: Cleaning up...") 96 | self._save_wigle_cache() 97 | 98 | # Stop thread 99 | self._stop_event.set() 100 | if self._worker_thread and self._worker_thread.is_alive(): 101 | self._worker_thread.join(timeout=5.0) 102 | 103 | # ------------------------------------------------------------------------ 104 | # Event Triggers 105 | # ------------------------------------------------------------------------ 106 | 107 | def on_ready(self, agent: Agent): 108 | self.start_time = time.time() 109 | logger.info("Discord plugin: Pwnagotchi is ready.") 110 | 111 | try: 112 | unit_name = agent.config()['main']['name'] 113 | except: 114 | unit_name = "Pwnagotchi" 115 | 116 | # 1. Send Online Message 117 | self.queue_notification( 118 | content="🟢 **Pwnagotchi is Online!**", 119 | embed={ 120 | 'title': f"{unit_name} is Ready", 121 | 'description': f"Unit is ready and sniffing.\n**Plugin Session ID:** `{self.session_id}`", 122 | 'color': 5763719 # Green 123 | } 124 | ) 125 | 126 | # 2. Report PREVIOUS Session Stats (The Persistence Fix) 127 | # This catches the stats from the session that just ended (Manual Switch / Reboot) 128 | if hasattr(agent, 'last_session') and agent.last_session: 129 | last = agent.last_session 130 | # Only report if it actually lasted some time to avoid empty spam on fresh flashes 131 | if hasattr(last, 'duration') and str(last.duration) != "0:00:00": 132 | logger.info(f"Discord plugin: Found last session stats. Duration: {last.duration}") 133 | 134 | # Attempt to get handshake count safely 135 | hs_count = getattr(last, 'handshakes', 0) 136 | 137 | self.queue_notification( 138 | content="📋 **Previous Session Report**", 139 | embed={ 140 | 'title': 'Session Summary', 141 | 'description': (f"**Handshakes:** {hs_count}\n" 142 | f"**Duration:** {last.duration}\n" 143 | f"**Epochs:** {getattr(last, 'epochs', 0)}"), 144 | 'color': 12370112 # Orange/Grey 145 | } 146 | ) 147 | 148 | def on_handshake(self, agent: Agent, filename: str, access_point: Dict[str, Any], client_station: Dict[str, Any]): 149 | bssid = access_point.get("mac", "00:00:00:00:00:00") 150 | client_mac = client_station.get("mac", "00:00:00:00:00:00") 151 | handshake_key = (filename, bssid.lower(), client_mac.lower()) 152 | 153 | if handshake_key in self.recent_handshakes: 154 | return 155 | 156 | self.recent_handshakes.add(handshake_key) 157 | if len(self.recent_handshakes) > self.recent_handshakes_limit: 158 | self.recent_handshakes.pop() 159 | 160 | self.session_handshakes += 1 161 | 162 | self._event_queue.put({ 163 | 'type': 'handshake', 164 | 'filename': filename, 165 | 'access_point': access_point, 166 | 'client_station': client_station 167 | }) 168 | 169 | # ------------------------------------------------------------------------ 170 | # Worker Logic 171 | # ------------------------------------------------------------------------ 172 | 173 | def _worker_loop(self): 174 | while not self._stop_event.is_set() or not self._event_queue.empty(): 175 | try: 176 | timeout = 1.0 if not self._stop_event.is_set() else 0.1 177 | event = self._event_queue.get(timeout=timeout) 178 | except queue.Empty: 179 | if self._stop_event.is_set(): break 180 | continue 181 | 182 | try: 183 | if event.get('type') == 'handshake': 184 | self._process_handshake(event) 185 | elif event.get('type') == 'notification': 186 | self._send_discord_payload(event['content'], event.get('embeds')) 187 | 188 | if not self._stop_event.is_set(): 189 | time.sleep(2) 190 | 191 | except Exception as e: 192 | logger.error(f"Discord plugin: Error in worker loop: {e}") 193 | finally: 194 | self._event_queue.task_done() 195 | 196 | def queue_notification(self, content: str, embed: Optional[Dict] = None): 197 | payload = { 198 | 'type': 'notification', 199 | 'content': content, 200 | 'embeds': [embed] if embed else [] 201 | } 202 | self._event_queue.put(payload) 203 | 204 | def _process_handshake(self, event): 205 | filename = event['filename'] 206 | ap = event['access_point'] 207 | client = event['client_station'] 208 | bssid = ap.get("mac", "") 209 | 210 | location = self._get_location_from_wigle(bssid) 211 | if location: 212 | loc_str = (f"Latitude: {location['lat']}, Longitude: {location['lon']}\n" 213 | f"[View on Map](https://www.google.com/maps/search/?api=1&query={location['lat']},{location['lon']})") 214 | else: 215 | loc_str = "Location not available." 216 | 217 | embed = { 218 | 'title': '🔐 New Handshake Captured!', 219 | 'description': (f"**Access Point:** {ap.get('hostname', 'Unknown')}\n" 220 | f"**Client Station:** {client.get('mac', 'Unknown')}"), 221 | 'fields': [ 222 | {'name': '🗂 Handshake File', 'value': os.path.basename(filename), 'inline': False}, 223 | {'name': '📍 Location', 'value': loc_str, 'inline': False}, 224 | ], 225 | 'footer': {'text': f"Total Session Handshakes: {self.session_handshakes} | ID: {self.session_id}"}, 226 | 'timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), 227 | 'color': 16776960 # Yellow 228 | } 229 | 230 | self._send_discord_payload( 231 | content=f"🤝 New handshake from {ap.get('hostname', 'Unknown')}", 232 | embeds=[embed], 233 | file_path=filename 234 | ) 235 | 236 | # ------------------------------------------------------------------------ 237 | # Discord Sender 238 | # ------------------------------------------------------------------------ 239 | 240 | def _send_discord_payload(self, content: str, embeds: List[Dict], file_path: Optional[str] = None): 241 | if not self.webhook_url: return 242 | 243 | payload_dict = {"content": content, "embeds": embeds} 244 | 245 | try: 246 | if file_path and os.path.exists(file_path): 247 | with open(file_path, 'rb') as f: 248 | files = {'file': (os.path.basename(file_path), f, 'application/octet-stream')} 249 | data = {'payload_json': json.dumps(payload_dict)} 250 | self.http_session.post(self.webhook_url, files=files, data=data, timeout=30) 251 | else: 252 | self.http_session.post(self.webhook_url, json=payload_dict, 253 | headers={'Content-Type': 'application/json'}, timeout=10) 254 | except Exception as e: 255 | logger.error(f"Discord plugin: Send error: {e}") 256 | 257 | # ------------------------------------------------------------------------ 258 | # WiGLE Logic 259 | # ------------------------------------------------------------------------ 260 | 261 | def _get_location_from_wigle(self, bssid: str) -> Optional[Dict[str, Any]]: 262 | if not bssid: return None 263 | normalized = bssid.lower() 264 | 265 | if normalized in self.wigle_cache: 266 | return self.wigle_cache[normalized] 267 | 268 | if not self.api_key: return None 269 | 270 | headers = {'Authorization': f'Basic {self.api_key}'} 271 | params = {'netid': normalized} 272 | try: 273 | res = self.http_session.get('https://api.wigle.net/api/v2/network/detail', 274 | headers=headers, params=params, timeout=10) 275 | 276 | if res.status_code == 200: 277 | data = res.json() 278 | if data.get('success') and data.get('results'): 279 | r = data['results'][0] 280 | loc = {'lat': r.get('trilat', 'N/A'), 'lon': r.get('trilong', 'N/A')} 281 | self.wigle_cache[normalized] = loc 282 | return loc 283 | except Exception as e: 284 | logger.error(f"WiGLE lookup failed: {e}") 285 | return None 286 | 287 | def _load_wigle_cache(self): 288 | if os.path.exists(CACHE_FILE): 289 | try: 290 | with open(CACHE_FILE, "r") as f: 291 | self.wigle_cache = json.load(f) 292 | except: 293 | self.wigle_cache = {} 294 | 295 | def _save_wigle_cache(self): 296 | try: 297 | with open(CACHE_FILE, "w") as f: 298 | json.dump(self.wigle_cache, f) 299 | except Exception as e: 300 | logger.error(f"Error saving cache: {e}") 301 | -------------------------------------------------------------------------------- /wiglelocator.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import logging 4 | import json 5 | import threading 6 | import time 7 | from datetime import datetime 8 | from pwnagotchi.plugins import Plugin 9 | from flask import send_from_directory, Response 10 | 11 | class WigleLocator(Plugin): 12 | __author__ = 'WPA2' 13 | __version__ = '2.1.11' 14 | __license__ = 'GPL3' 15 | __description__ = 'Async WiGLE locator with flush capability and absolute path routing' 16 | 17 | def __init__(self): 18 | self.api_key = None 19 | self.data_dir = '/home/pi/wigle_locator_data' 20 | self.cache_file = os.path.join(self.data_dir, 'wigle_cache.json') 21 | self.queue_file = os.path.join(self.data_dir, 'pending_queue.json') 22 | self.status_file = os.path.join(self.data_dir, 'api_status.json') 23 | self.cache = {} 24 | self.pending_queue = [] 25 | self.lock = threading.Lock() 26 | self.processing = False 27 | self.last_queue_process_time = 0 28 | self.api_limit_hit = False 29 | self.api_limit_reset_time = 0 30 | 31 | def on_loaded(self): 32 | if not os.path.exists(self.data_dir): 33 | try: 34 | os.makedirs(self.data_dir) 35 | os.chown(self.data_dir, 1000, 1000) 36 | except Exception as e: 37 | logging.warning(f"[WigleLocator] Could not set folder permissions: {e}") 38 | 39 | self._load_data() 40 | 41 | # Check cooldown on load 42 | if self.api_limit_hit and time.time() < self.api_limit_reset_time: 43 | remaining = int((self.api_limit_reset_time - time.time()) / 60) 44 | logging.warning(f"[WigleLocator] Plugin loaded but API is in cooldown for {remaining} mins.") 45 | 46 | # Regenerate map on load to ensure JS is up to date 47 | self._generate_outputs() 48 | logging.info(f"[WigleLocator] Plugin loaded. Cache: {len(self.cache)}, Queue: {len(self.pending_queue)}") 49 | 50 | def on_config_changed(self, config): 51 | if 'main' in config and 'plugins' in config['main'] and 'wiglelocator' in config['main']['plugins']: 52 | self.api_key = config['main']['plugins']['wiglelocator'].get('api_key') 53 | 54 | if not self.api_key: 55 | logging.error('[WigleLocator] No API key set in config.toml!') 56 | 57 | def on_webhook(self, path, request): 58 | try: 59 | # Normalize path: strip leading/trailing slashes and handle None 60 | if not path: 61 | path = '' 62 | path = path.strip('/') 63 | 64 | # Serve Map (Root) 65 | if path == '' or path == 'index.html': 66 | return send_from_directory(self.data_dir, 'wigle_map.html') 67 | 68 | # Serve Data Files 69 | elif path == 'kml': 70 | return send_from_directory(self.data_dir, 'wigle_locations.kml', as_attachment=True) 71 | elif path == 'csv': 72 | return send_from_directory(self.data_dir, 'locations.csv', as_attachment=True) 73 | elif path == 'json': 74 | return send_from_directory(self.data_dir, 'wigle_cache.json', as_attachment=True) 75 | 76 | # Flush Command 77 | elif path == 'flush': 78 | with self.lock: 79 | count = len(self.pending_queue) 80 | self.pending_queue = [] 81 | self._save_data() 82 | 83 | # Reset 429 status manually 84 | self.api_limit_hit = False 85 | self._save_status() 86 | 87 | logging.info(f"[WigleLocator] Queue flushed by user. Removed {count} items.") 88 | return f"Queue flushed! Removed {count} items.", 200 89 | 90 | return "File not found", 404 91 | except Exception as e: 92 | logging.error(f"[WigleLocator] Webhook error: {e}") 93 | return f"Error: {e}", 500 94 | 95 | def on_handshake(self, agent, filename, access_point, client_station): 96 | if not self.api_key: 97 | return 98 | 99 | if self.api_limit_hit and time.time() < self.api_limit_reset_time: 100 | return 101 | 102 | bssid = access_point["mac"] 103 | essid = access_point["hostname"] 104 | 105 | threading.Thread(target=self._process_candidate, args=(agent, bssid, essid)).start() 106 | 107 | def on_internet_available(self, agent): 108 | now = time.time() 109 | 110 | if self.api_limit_hit: 111 | if now > self.api_limit_reset_time: 112 | self.api_limit_hit = False 113 | self._save_status() 114 | logging.info("[WigleLocator] API limit cooldown expired. Resuming operations.") 115 | else: 116 | return 117 | 118 | if self.pending_queue and not self.processing: 119 | if now - self.last_queue_process_time > 300: # 5 minutes between batch attempts 120 | logging.info(f"[WigleLocator] Internet available. Processing {len(self.pending_queue)} pending items...") 121 | threading.Thread(target=self._process_queue, args=(agent,)).start() 122 | 123 | def _process_candidate(self, agent, bssid, essid): 124 | with self.lock: 125 | if bssid in self.cache: 126 | # Already processed (either found or failed) 127 | return 128 | 129 | result = self._fetch_wigle_location(bssid) 130 | 131 | if isinstance(result, dict): 132 | self._handle_success(agent, bssid, essid, result) 133 | elif result == 'LIMIT_EXCEEDED': 134 | pass 135 | elif result is False: 136 | self._cache_failure(bssid, essid) 137 | else: 138 | self._add_to_queue(bssid, essid) 139 | 140 | def _process_queue(self, agent): 141 | self.processing = True 142 | self.last_queue_process_time = time.time() 143 | 144 | # Work on a copy of the queue 145 | queue_copy = list(self.pending_queue) 146 | 147 | for item in queue_copy: 148 | if self.api_limit_hit: 149 | logging.warning("[WigleLocator] 429 Limit Hit - Aborting Queue Processing immediately.") 150 | break 151 | 152 | bssid = item['bssid'] 153 | essid = item['essid'] 154 | retries = item.get('retries', 0) 155 | 156 | # Check cache again in case another thread grabbed it 157 | if bssid in self.cache: 158 | self._remove_from_queue(bssid) 159 | continue 160 | 161 | result = self._fetch_wigle_location(bssid) 162 | 163 | if isinstance(result, dict): 164 | self._handle_success(agent, bssid, essid, result) 165 | self._remove_from_queue(bssid) 166 | time.sleep(5) # Be nice to the API 167 | elif result == 'LIMIT_EXCEEDED': 168 | break 169 | elif result is False: 170 | # Permanent failure (404 etc) 171 | self._cache_failure(bssid, essid) 172 | self._remove_from_queue(bssid) 173 | time.sleep(1) 174 | else: 175 | # Temporary failure, retry later 176 | retries += 1 177 | if retries >= 3: 178 | logging.warning(f"[WigleLocator] Max retries reached for {essid}. Dropping from queue.") 179 | self._remove_from_queue(bssid) 180 | else: 181 | item['retries'] = retries 182 | self._save_data() 183 | time.sleep(1) 184 | 185 | self.processing = False 186 | 187 | def _handle_success(self, agent, bssid, essid, location): 188 | logging.info(f"[WigleLocator] Located {essid}: {location['lat']}, {location['lon']}") 189 | 190 | if agent: 191 | try: 192 | view = agent.view() 193 | view.set("status", f"Loc: {location['lat']},{location['lon']}") 194 | except Exception: 195 | pass 196 | 197 | with self.lock: 198 | self.cache[bssid] = { 199 | 'essid': essid, 200 | 'lat': location['lat'], 201 | 'lon': location['lon'], 202 | 'timestamp': datetime.now().isoformat() 203 | } 204 | self._save_data() 205 | 206 | self._generate_outputs() 207 | 208 | def _cache_failure(self, bssid, essid): 209 | with self.lock: 210 | self.cache[bssid] = { 211 | 'essid': essid, 212 | 'lat': None, 213 | 'lon': None, 214 | 'timestamp': datetime.now().isoformat() 215 | } 216 | self._save_data() 217 | 218 | def _fetch_wigle_location(self, bssid): 219 | if self.api_limit_hit and time.time() < self.api_limit_reset_time: 220 | return 'LIMIT_EXCEEDED' 221 | 222 | headers = {'Authorization': 'Basic ' + self.api_key} 223 | params = {'netid': bssid} 224 | 225 | try: 226 | response = requests.get( 227 | 'https://api.wigle.net/api/v2/network/detail', 228 | headers=headers, 229 | params=params, 230 | timeout=10 231 | ) 232 | 233 | if response.status_code == 200: 234 | data = response.json() 235 | if data.get('success') and data.get('results'): 236 | result = data['results'][0] 237 | return {'lat': result.get('trilat'), 'lon': result.get('trilong')} 238 | else: 239 | return False 240 | elif response.status_code == 404: 241 | return False 242 | elif response.status_code == 429: 243 | logging.error("[WigleLocator] ⚠️ 429 TOO MANY REQUESTS. Daily Limit Hit? Pausing 6 hours.") 244 | self.api_limit_hit = True 245 | self.api_limit_reset_time = time.time() + (3600 * 6) 246 | self._save_status() 247 | return 'LIMIT_EXCEEDED' 248 | elif response.status_code == 401: 249 | logging.error("[WigleLocator] WiGLE Auth failed.") 250 | return False 251 | 252 | except Exception as e: 253 | logging.debug(f"[WigleLocator] API request failed: {e}") 254 | 255 | return None 256 | 257 | def _add_to_queue(self, bssid, essid): 258 | with self.lock: 259 | if not any(x['bssid'] == bssid for x in self.pending_queue): 260 | self.pending_queue.append({ 261 | 'bssid': bssid, 262 | 'essid': essid, 263 | 'retries': 0 264 | }) 265 | self._save_data() 266 | 267 | def _remove_from_queue(self, bssid): 268 | with self.lock: 269 | self.pending_queue = [x for x in self.pending_queue if x['bssid'] != bssid] 270 | self._save_data() 271 | 272 | def _load_data(self): 273 | try: 274 | if os.path.exists(self.cache_file): 275 | with open(self.cache_file, 'r') as f: 276 | self.cache = json.load(f) 277 | if os.path.exists(self.queue_file): 278 | with open(self.queue_file, 'r') as f: 279 | self.pending_queue = json.load(f) 280 | if os.path.exists(self.status_file): 281 | with open(self.status_file, 'r') as f: 282 | status = json.load(f) 283 | self.api_limit_hit = status.get('limit_hit', False) 284 | self.api_limit_reset_time = status.get('reset_time', 0) 285 | except Exception as e: 286 | logging.error(f"[WigleLocator] Error loading data: {e}") 287 | 288 | def _save_data(self): 289 | try: 290 | with open(self.cache_file, 'w') as f: 291 | json.dump(self.cache, f) 292 | with open(self.queue_file, 'w') as f: 293 | json.dump(self.pending_queue, f) 294 | os.chmod(self.cache_file, 0o666) 295 | os.chmod(self.queue_file, 0o666) 296 | except Exception: 297 | pass 298 | 299 | def _save_status(self): 300 | try: 301 | with open(self.status_file, 'w') as f: 302 | json.dump({ 303 | 'limit_hit': self.api_limit_hit, 304 | 'reset_time': self.api_limit_reset_time 305 | }, f) 306 | os.chmod(self.status_file, 0o666) 307 | except Exception: 308 | pass 309 | 310 | def _generate_outputs(self): 311 | try: 312 | self._generate_kml() 313 | self._generate_html_map() 314 | self._generate_csv() 315 | except Exception as e: 316 | logging.error(f"[WigleLocator] Map generation error: {e}") 317 | 318 | def _generate_csv(self): 319 | csv_file = os.path.join(self.data_dir, 'locations.csv') 320 | with open(csv_file, 'w') as f: 321 | f.write("BSSID,ESSID,Latitude,Longitude,Timestamp\n") 322 | for bssid, data in self.cache.items(): 323 | if data.get('lat') is not None: 324 | f.write(f"{bssid},{data['essid']},{data['lat']},{data['lon']},{data['timestamp']}\n") 325 | try: os.chmod(csv_file, 0o666) 326 | except: pass 327 | 328 | def _generate_kml(self): 329 | kml_file = os.path.join(self.data_dir, 'wigle_locations.kml') 330 | kml_content = """ 331 | 332 | 333 | Pwnagotchi WiGLE Locations 334 | """ 335 | for bssid, data in self.cache.items(): 336 | if data.get('lat') is not None: 337 | kml_content += f""" 338 | {data['essid']} 339 | BSSID: {bssid} 340 | 341 | {data['lon']},{data['lat']},0 342 | 343 | 344 | """ 345 | kml_content += " \n" 346 | with open(kml_file, 'w') as f: f.write(kml_content) 347 | try: os.chmod(kml_file, 0o666) 348 | except: pass 349 | 350 | def _generate_html_map(self): 351 | html_file = os.path.join(self.data_dir, 'wigle_map.html') 352 | lats = [d['lat'] for d in self.cache.values() if d.get('lat') is not None] 353 | lons = [d['lon'] for d in self.cache.values() if d.get('lon') is not None] 354 | 355 | if lats: 356 | center_lat = sum(lats) / len(lats) 357 | center_lon = sum(lons) / len(lons) 358 | else: 359 | center_lat, center_lon = 0, 0 360 | 361 | markers_js = "var locations = [\n" 362 | for bssid, data in self.cache.items(): 363 | if data.get('lat') is not None: 364 | safe_essid = data['essid'].replace("'", "\\'") 365 | markers_js += f" ['{safe_essid} ({bssid})', {data['lat']}, {data['lon']}],\n" 366 | markers_js += "];" 367 | 368 | html_content = f""" 369 | 370 | 371 | Pwnagotchi WiGLE Map 372 | 373 | 374 | 375 | 383 | 384 | 385 |
386 |

Data Export

387 | Download .KML (Google Earth) 388 | Download .CSV (Excel) 389 | Download .JSON (Raw) 390 |
391 | 392 |
393 |
394 | 395 | 432 | 433 | """ 434 | with open(html_file, 'w') as f: f.write(html_content) 435 | try: os.chmod(html_file, 0o666) 436 | except: pass 437 | -------------------------------------------------------------------------------- /Tele_Pi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import logging 5 | import asyncio 6 | import time 7 | import requests 8 | import psutil 9 | from functools import wraps 10 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update 11 | from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes 12 | 13 | # --- Script Metadata --- 14 | __version__ = "1.3.1" 15 | __author__ = "WPA2" 16 | 17 | # --- Configuration --- 18 | # Set logging level from environment variable or default to INFO 19 | log_level = os.environ.get("TELEGRAM_LOG_LEVEL", "INFO").upper() 20 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=log_level) 21 | logger = logging.getLogger(__name__) 22 | 23 | # Load sensitive data from environment variables 24 | TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN") 25 | WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY") 26 | ALLOWED_USER_ID = int(os.environ.get("ALLOWED_USER_ID", 0)) 27 | 28 | if not all([TELEGRAM_TOKEN, WEATHER_API_KEY, ALLOWED_USER_ID]): 29 | logger.error("FATAL: Missing one or more environment variables: TELEGRAM_TOKEN, WEATHER_API_KEY, ALLOWED_USER_ID") 30 | exit(1) 31 | 32 | # Monitoring thresholds 33 | THRESHOLD_TEMP = 62.0 # Celsius 34 | THRESHOLD_RAM = 80.0 # Percent 35 | 36 | # Cooldown settings 37 | COOLDOWN_SECONDS = 60 38 | last_command_time = {} 39 | 40 | # --- Command Definitions --- 41 | system_commands = [ 42 | {"name": "/update", "description": "Update the system", "emoji": "🔄"}, 43 | {"name": "/reboot", "description": "Reboots the system", "emoji": "🔄"}, 44 | {"name": "/shutdown", "description": "Shuts the system down", "emoji": "⏹️"}, 45 | {"name": "/disk_usage", "description": "Show disk usage", "emoji": "💾"}, 46 | {"name": "/free_memory", "description": "Show free memory", "emoji": "🧠"}, 47 | {"name": "/show_processes", "description": "Show all processes", "emoji": "⚙️"}, 48 | {"name": "/show_system_services", "description": "Show system services", "emoji": "🛠️"}, 49 | {"name": "/start_monitoring", "description": "Start CPU temp monitoring", "emoji": "🌡️"}, 50 | {"name": "/stop_monitoring", "description": "Stop CPU temp monitoring", "emoji": "❄️"}, 51 | {"name": "/start_monitoring_ram", "description": "Start RAM usage monitoring", "emoji": "📈"}, 52 | {"name": "/stop_monitoring_ram", "description": "Stop RAM usage monitoring", "emoji": "📉"}, 53 | {"name": "/temp", "description": "Show current CPU temperature", "emoji": "🌡️"}, 54 | {"name": "/status", "description": "Show a brief system status", "emoji": "📊"} 55 | ] 56 | 57 | network_commands = [ 58 | {"name": "/ip", "description": "Show IP addresses", "emoji": "📍"}, 59 | {"name": "/external_ip", "description": "Show external IP address", "emoji": "🌍"}, 60 | {"name": "/wifi", "description": "Scan for WiFi networks", "emoji": "📶"}, 61 | {"name": "/ping", "description": "Ping a remote host", "emoji": "🏓"}, 62 | {"name": "/show_network_info", "description": "Show detailed network stats", "emoji": "�"} 63 | ] 64 | 65 | utility_commands = [ 66 | {"name": "/speedtest", "description": "Run an internet speed test", "emoji": "⚡"}, 67 | {"name": "/uptime", "description": "Show system uptime", "emoji": "⏰"}, 68 | {"name": "/weather", "description": "Get weather for a city", "emoji": "☁️"}, 69 | {"name": "/joke", "description": "Tell a random joke", "emoji": "😂"} 70 | ] 71 | 72 | # --- Helper Functions and Decorators --- 73 | def restricted(func): 74 | """Decorator to restrict access to the allowed user ID.""" 75 | @wraps(func) 76 | async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): 77 | user = update.effective_user 78 | if not user or user.id != ALLOWED_USER_ID: 79 | if update.callback_query: 80 | await update.callback_query.answer("🚫 You are not authorized to use this bot.", show_alert=True) 81 | logger.warning(f"Unauthorized access attempt by user {user.id if user else 'Unknown'}.") 82 | return 83 | return await func(update, context, *args, **kwargs) 84 | return wrapped 85 | 86 | def cooldown(seconds: int): 87 | """Decorator to enforce a cooldown on a command.""" 88 | def decorator(func): 89 | @wraps(func) 90 | async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): 91 | command_key = func.__name__ 92 | now = time.time() 93 | last_time = last_command_time.get(command_key, 0) 94 | 95 | if now - last_time < seconds: 96 | remaining = int(seconds - (now - last_time)) 97 | await context.bot.send_message( 98 | chat_id=update.effective_chat.id, 99 | text=f"Please wait {remaining} more seconds to use this command again." 100 | ) 101 | return 102 | 103 | last_command_time[command_key] = now 104 | return await func(update, context, *args, **kwargs) 105 | return wrapped 106 | return decorator 107 | 108 | async def run_subprocess(command: list[str]) -> tuple[str, str]: 109 | """Asynchronously run a subprocess and return its output.""" 110 | process = await asyncio.create_subprocess_exec( 111 | *command, 112 | stdout=asyncio.subprocess.PIPE, 113 | stderr=asyncio.subprocess.PIPE 114 | ) 115 | stdout, stderr = await process.communicate() 116 | return stdout.decode('utf-8').strip(), stderr.decode('utf-8').strip() 117 | 118 | async def send_message_in_chunks(context: ContextTypes.DEFAULT_TYPE, chat_id: int, text: str, header: str = ""): 119 | """Sends a message in chunks if it's too long.""" 120 | full_text = f"*{header}*\n```\n{text}\n```" if header else f"```\n{text}\n```" 121 | 122 | if len(full_text) <= 4096: 123 | await context.bot.send_message(chat_id=chat_id, text=full_text, parse_mode="Markdown") 124 | else: 125 | # Send header separately if it exists 126 | if header: 127 | await context.bot.send_message(chat_id=chat_id, text=f"*{header}*", parse_mode="Markdown") 128 | 129 | # Send the rest of the text in chunks 130 | for i in range(0, len(text), 4000): 131 | chunk = text[i:i+4000] 132 | await context.bot.send_message(chat_id=chat_id, text=f"```\n{chunk}\n```", parse_mode="Markdown") 133 | await asyncio.sleep(0.2) 134 | 135 | 136 | # --- Command Handlers --- 137 | @restricted 138 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 139 | """Displays the main menu.""" 140 | keyboard = [ 141 | [InlineKeyboardButton("⚙️ System", callback_data="system_menu")], 142 | [InlineKeyboardButton("🌐 Network", callback_data="network_menu")], 143 | [InlineKeyboardButton("🛠️ Utility", callback_data="utility_menu")], 144 | [InlineKeyboardButton("📋 All Commands", callback_data="help_menu")] 145 | ] 146 | reply_markup = InlineKeyboardMarkup(keyboard) 147 | start_message = ( 148 | f"🤖 *Pi Manager Bot v{__version__}*\n" 149 | f"Created by {__author__}\n\n" 150 | "Choose a category to get started." 151 | ) 152 | # If called from a button, edit the message. Otherwise, send a new one. 153 | if update.callback_query: 154 | await update.callback_query.edit_message_text(start_message, reply_markup=reply_markup, parse_mode="Markdown") 155 | else: 156 | await update.message.reply_text(start_message, reply_markup=reply_markup, parse_mode="Markdown") 157 | 158 | @restricted 159 | @cooldown(300) # 5 minute cooldown 160 | async def update_system(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 161 | chat_id = update.effective_chat.id 162 | await context.bot.send_message(chat_id=chat_id, text="🔄 Updating system packages... This may take a while.") 163 | 164 | update_out, update_err = await run_subprocess(["sudo", "apt", "update"]) 165 | if "Err:" in update_out or update_err: 166 | await send_message_in_chunks(context, chat_id, update_out + "\n" + update_err, "Update Error") 167 | return 168 | 169 | await context.bot.send_message(chat_id=chat_id, text="✅ Update check complete. Now upgrading...") 170 | upgrade_out, upgrade_err = await run_subprocess(["sudo", "apt", "upgrade", "-y"]) 171 | response = f"--- UPDATE ---\n{update_out}\n\n--- UPGRADE ---\n{upgrade_out or 'No packages to upgrade.'}" 172 | if upgrade_err: 173 | response += f"\n\n--- UPGRADE ERROR ---\n{upgrade_err}" 174 | 175 | await send_message_in_chunks(context, chat_id, response, "System Update Complete") 176 | 177 | @restricted 178 | async def reboot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 179 | await context.bot.send_message(chat_id=update.effective_chat.id, text="🔄 System is rebooting now...") 180 | await run_subprocess(["sudo", "reboot"]) 181 | 182 | @restricted 183 | async def shutdown(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 184 | await context.bot.send_message(chat_id=update.effective_chat.id, text="⏹️ System is shutting down now...") 185 | await run_subprocess(["sudo", "shutdown", "-h", "now"]) 186 | 187 | @restricted 188 | async def disk_usage(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 189 | output, _ = await run_subprocess(["df", "-h"]) 190 | await send_message_in_chunks(context, update.effective_chat.id, output, "💾 Disk Usage") 191 | 192 | @restricted 193 | async def free_memory(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 194 | output, _ = await run_subprocess(["free", "-m"]) 195 | await send_message_in_chunks(context, update.effective_chat.id, output, "🧠 Free Memory") 196 | 197 | @restricted 198 | async def show_processes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 199 | output, _ = await run_subprocess(["ps", "-ef"]) 200 | await send_message_in_chunks(context, update.effective_chat.id, output, "⚙️ Processes") 201 | 202 | @restricted 203 | async def show_system_services(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 204 | output, _ = await run_subprocess(["systemctl", "list-units", "--type=service", "--no-pager"]) 205 | await send_message_in_chunks(context, update.effective_chat.id, output, "🛠️ System Services") 206 | 207 | @restricted 208 | async def temp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 209 | chat_id = update.effective_chat.id 210 | try: 211 | temps = psutil.sensors_temperatures() 212 | for key in ["cpu_thermal", "coretemp", "k10temp", "zenpower"]: 213 | if key in temps: 214 | cpu_temp = temps[key][0] 215 | await context.bot.send_message(chat_id=chat_id, text=f"🌡️ CPU Temperature: {cpu_temp.current}°C") 216 | return 217 | await context.bot.send_message(chat_id=chat_id, text="Could not read CPU temperature (sensor not found).") 218 | except Exception as e: 219 | await context.bot.send_message(chat_id=chat_id, text=f"Error reading temperature: {e}") 220 | 221 | @restricted 222 | async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 223 | try: 224 | cpu_temp_str = "N/A" 225 | temps = psutil.sensors_temperatures() 226 | for key in ["cpu_thermal", "coretemp", "k10temp", "zenpower"]: 227 | if key in temps: 228 | cpu_temp_str = f"{temps[key][0].current}°C" 229 | break 230 | 231 | ram = f"{psutil.virtual_memory().percent}%" 232 | disk = f"{psutil.disk_usage('/').percent}%" 233 | 234 | uptime_seconds = time.time() - psutil.boot_time() 235 | days = int(uptime_seconds // (24 * 3600)) 236 | hours = int((uptime_seconds % (24 * 3600)) // 3600) 237 | minutes = int((uptime_seconds % 3600) // 60) 238 | uptime_str = f"{days}d {hours}h {minutes}m" 239 | 240 | status_text = ( 241 | f"🌡️ CPU Temp : {cpu_temp_str}\n" 242 | f"🧠 RAM Usage: {ram}\n" 243 | f"💾 Disk Usage: {disk}\n" 244 | f"⏰ Uptime : {uptime_str}" 245 | ) 246 | await send_message_in_chunks(context, update.effective_chat.id, status_text, "📊 System Status") 247 | except Exception as e: 248 | await send_message_in_chunks(context, update.effective_chat.id, f"Error retrieving status: {e}") 249 | 250 | # --- Network Commands --- 251 | @restricted 252 | async def ip(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 253 | output, _ = await run_subprocess(["ip", "a"]) 254 | await send_message_in_chunks(context, update.effective_chat.id, output, "📍 IP Addresses") 255 | 256 | @restricted 257 | async def external_ip(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 258 | chat_id = update.effective_chat.id 259 | try: 260 | response = requests.get('https://api.ipify.org', timeout=10) 261 | response.raise_for_status() 262 | await context.bot.send_message(chat_id=chat_id, text=f"🌍 External IP: {response.text}") 263 | except requests.RequestException as e: 264 | await context.bot.send_message(chat_id=chat_id, text=f"Could not retrieve external IP: {e}") 265 | 266 | @restricted 267 | async def wifi(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 268 | chat_id = update.effective_chat.id 269 | await context.bot.send_message(chat_id=chat_id, text="📶 Scanning for WiFi networks...") 270 | output, error = await run_subprocess(["nmcli", "dev", "wifi", "list", "--rescan", "yes"]) 271 | if error: 272 | await context.bot.send_message(chat_id=chat_id, text=f"Error scanning WiFi: {error}") 273 | elif not output: 274 | await context.bot.send_message(chat_id=chat_id, text="No WiFi networks found.") 275 | else: 276 | await send_message_in_chunks(context, chat_id, output, "Available WiFi Networks") 277 | 278 | @restricted 279 | async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 280 | chat_id = update.effective_chat.id 281 | if not context.args: 282 | await context.bot.send_message(chat_id=chat_id, text="Please provide a host to ping, e.g., `/ping 8.8.8.8`") 283 | return 284 | host = context.args[0] 285 | output, error = await run_subprocess(["ping", "-c", "4", host]) 286 | await send_message_in_chunks(context, chat_id, output or error, f"🏓 Pinging {host}") 287 | 288 | @restricted 289 | async def show_network_info(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 290 | output, _ = await run_subprocess(["ip", "-s", "link"]) 291 | await send_message_in_chunks(context, update.effective_chat.id, output, "🌐 Network Info") 292 | 293 | # --- Utility Commands --- 294 | @restricted 295 | @cooldown(60) 296 | async def speedtest(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 297 | chat_id = update.effective_chat.id 298 | await context.bot.send_message(chat_id=chat_id, text="⚡ Running speedtest... This can take a minute.") 299 | output, error = await run_subprocess(["speedtest-cli", "--secure"]) 300 | await send_message_in_chunks(context, chat_id, output or error, "Speedtest Results") 301 | 302 | @restricted 303 | async def uptime(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 304 | output, _ = await run_subprocess(["uptime"]) 305 | await send_message_in_chunks(context, update.effective_chat.id, output, "⏰ Uptime") 306 | 307 | @restricted 308 | async def weather(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 309 | chat_id = update.effective_chat.id 310 | if not context.args: 311 | await context.bot.send_message(chat_id=chat_id, text="Usage: /weather ") 312 | return 313 | city = " ".join(context.args) 314 | url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={WEATHER_API_KEY}&units=metric" 315 | try: 316 | response = requests.get(url, timeout=10).json() 317 | if response.get("cod") == 200: 318 | main = response["main"] 319 | weather_desc = response["weather"][0]["description"] 320 | await context.bot.send_message( 321 | chat_id=chat_id, 322 | text=f"☁️ Weather in {city.title()}: {main['temp']}°C, {weather_desc.capitalize()}" 323 | ) 324 | else: 325 | await context.bot.send_message(chat_id=chat_id, text="City not found.") 326 | except requests.RequestException as e: 327 | await context.bot.send_message(chat_id=chat_id, text=f"Error fetching weather: {e}") 328 | 329 | @restricted 330 | async def joke(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 331 | chat_id = update.effective_chat.id 332 | try: 333 | response = requests.get("https://official-joke-api.appspot.com/random_joke", timeout=10).json() 334 | await context.bot.send_message(chat_id=chat_id, text=f"😂\n{response['setup']}\n\n{response['punchline']}") 335 | except requests.RequestException as e: 336 | await context.bot.send_message(chat_id=chat_id, text=f"Error fetching joke: {e}") 337 | 338 | # --- Monitoring --- 339 | async def monitor_cpu_job(context: ContextTypes.DEFAULT_TYPE) -> None: 340 | try: 341 | temps = psutil.sensors_temperatures() 342 | for key in ["cpu_thermal", "coretemp", "k10temp", "zenpower"]: 343 | if key in temps and temps[key][0].current > THRESHOLD_TEMP: 344 | await context.bot.send_message( 345 | chat_id=context.job.chat_id, 346 | text=f"🚨 HIGH CPU TEMP ALERT: {temps[key][0].current}°C" 347 | ) 348 | return 349 | except Exception as e: 350 | logger.error(f"Error in CPU monitoring job: {e}") 351 | 352 | async def monitor_ram_job(context: ContextTypes.DEFAULT_TYPE) -> None: 353 | try: 354 | ram_usage = psutil.virtual_memory().percent 355 | if ram_usage > THRESHOLD_RAM: 356 | await context.bot.send_message( 357 | chat_id=context.job.chat_id, 358 | text=f"🚨 HIGH RAM USAGE ALERT: {ram_usage}%" 359 | ) 360 | except Exception as e: 361 | logger.error(f"Error in RAM monitoring job: {e}") 362 | 363 | @restricted 364 | async def start_monitoring(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 365 | chat_id = update.effective_chat.id 366 | for job in context.job_queue.get_jobs_by_name("monitor_cpu"): 367 | job.schedule_removal() 368 | 369 | context.job_queue.run_repeating(monitor_cpu_job, interval=60, first=10, chat_id=chat_id, name="monitor_cpu") 370 | await context.bot.send_message(chat_id=chat_id, text="🌡️ Started monitoring CPU temperature.") 371 | 372 | @restricted 373 | async def stop_monitoring(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 374 | chat_id = update.effective_chat.id 375 | jobs = context.job_queue.get_jobs_by_name("monitor_cpu") 376 | if not jobs: 377 | await context.bot.send_message(chat_id=chat_id, text="CPU monitoring is not running.") 378 | return 379 | for job in jobs: 380 | job.schedule_removal() 381 | await context.bot.send_message(chat_id=chat_id, text="❄️ Stopped monitoring CPU temperature.") 382 | 383 | @restricted 384 | async def start_monitoring_ram(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 385 | chat_id = update.effective_chat.id 386 | for job in context.job_queue.get_jobs_by_name("monitor_ram"): 387 | job.schedule_removal() 388 | 389 | context.job_queue.run_repeating(monitor_ram_job, interval=60, first=10, chat_id=chat_id, name="monitor_ram") 390 | await context.bot.send_message(chat_id=chat_id, text="📈 Started monitoring RAM usage.") 391 | 392 | @restricted 393 | async def stop_monitoring_ram(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 394 | chat_id = update.effective_chat.id 395 | jobs = context.job_queue.get_jobs_by_name("monitor_ram") 396 | if not jobs: 397 | await context.bot.send_message(chat_id=chat_id, text="RAM monitoring is not running.") 398 | return 399 | for job in jobs: 400 | job.schedule_removal() 401 | await context.bot.send_message(chat_id=chat_id, text="📉 Stopped monitoring RAM usage.") 402 | 403 | # --- Help and Menu Callbacks --- 404 | @restricted 405 | async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 406 | """Displays a formatted help message with all commands.""" 407 | message = "*Available Commands*\n\n" 408 | 409 | message += "*System Commands*\n" 410 | for cmd in system_commands: 411 | message += f"{cmd['emoji']} {cmd['name']} - {cmd['description']}\n" 412 | 413 | message += "\n*Network Commands*\n" 414 | for cmd in network_commands: 415 | message += f"{cmd['emoji']} {cmd['name']} - {cmd['description']}\n" 416 | 417 | message += "\n*Utility Commands*\n" 418 | for cmd in utility_commands: 419 | message += f"{cmd['emoji']} {cmd['name']} - {cmd['description']}\n" 420 | 421 | await context.bot.send_message( 422 | chat_id=update.effective_chat.id, 423 | text=message, 424 | parse_mode="Markdown" 425 | ) 426 | 427 | async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 428 | """Handles all button presses from inline keyboards.""" 429 | query = update.callback_query 430 | await query.answer() 431 | 432 | data = query.data 433 | 434 | menu_map = { 435 | "system_menu": ("⚙️ System Commands:", system_commands), 436 | "network_menu": ("🌐 Network Commands:", network_commands), 437 | "utility_menu": ("🛠️ Utility Commands:", utility_commands), 438 | } 439 | 440 | if data in menu_map: 441 | title, commands = menu_map[data] 442 | keyboard = [[InlineKeyboardButton(f"{cmd['emoji']} {cmd['name']}", callback_data=cmd["name"][1:])] for cmd in commands] 443 | keyboard.append([InlineKeyboardButton("⬅️ Back to Main Menu", callback_data="main_menu")]) 444 | await query.edit_message_text(title, reply_markup=InlineKeyboardMarkup(keyboard)) 445 | 446 | elif data == "main_menu": 447 | await start(update, context) 448 | 449 | elif data == "help_menu": 450 | await help_command(update, context) 451 | 452 | else: 453 | command_func = COMMAND_MAP.get(data) 454 | if command_func: 455 | await command_func(update, context) 456 | else: 457 | await query.edit_message_text("Unknown command.") 458 | 459 | def main() -> None: 460 | """Start the bot.""" 461 | application = Application.builder().token(TELEGRAM_TOKEN).build() 462 | 463 | global COMMAND_MAP 464 | COMMAND_MAP = { 465 | "update": update_system, "reboot": reboot, "shutdown": shutdown, 466 | "disk_usage": disk_usage, "free_memory": free_memory, "show_processes": show_processes, 467 | "show_system_services": show_system_services, "start_monitoring": start_monitoring, 468 | "stop_monitoring": stop_monitoring, "start_monitoring_ram": start_monitoring_ram, 469 | "stop_monitoring_ram": stop_monitoring_ram, "temp": temp, "status": status, 470 | "ip": ip, "external_ip": external_ip, "wifi": wifi, "ping": ping, 471 | "show_network_info": show_network_info, "speedtest": speedtest, 472 | "uptime": uptime, "weather": weather, "joke": joke 473 | } 474 | 475 | application.add_handler(CommandHandler("start", start)) 476 | application.add_handler(CommandHandler("help", help_command)) 477 | 478 | # Register handlers for both underscore and non-underscore commands 479 | for cmd_name, cmd_func in COMMAND_MAP.items(): 480 | application.add_handler(CommandHandler(cmd_name, cmd_func)) 481 | if "_" in cmd_name: 482 | application.add_handler(CommandHandler(cmd_name.replace("_", ""), cmd_func)) 483 | 484 | application.add_handler(CallbackQueryHandler(button_callback)) 485 | 486 | logger.info(f"Bot v{__version__} is running...") 487 | application.run_polling() 488 | 489 | if __name__ == "__main__": 490 | main() --------------------------------------------------------------------------------