diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml new file mode 100644 index 0000000..93547b4 --- /dev/null +++ b/.github/workflows/ci-linux.yml @@ -0,0 +1,125 @@ +name: CI (Linux) + +on: + push: + branches: [main] + paths: + - 'platforms/linux/**' + - 'portkiller-core/**' + - '.github/workflows/ci-linux.yml' + tags-ignore: + - 'v*' + pull_request: + branches: [main] + paths: + - 'platforms/linux/**' + - 'portkiller-core/**' + - '.github/workflows/ci-linux.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Rust Core (Linux) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + portkiller-core/target + key: ${{ runner.os }}-cargo-${{ hashFiles('portkiller-core/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install scan tools + # The scanner shells out to these; without them the integration + # tests fail rather than exercising the real code paths. + run: sudo apt-get update -qq && sudo apt-get install -y -qq procps iproute2 lsof + + - name: Build + working-directory: portkiller-core + run: cargo build --verbose + + - name: Test + working-directory: portkiller-core + run: cargo test --verbose + + - name: Clippy + working-directory: portkiller-core + run: cargo clippy -- -D warnings + + - name: Format check + working-directory: portkiller-core + run: cargo fmt --check + + python: + name: Linux Tray App + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Syntax check + run: python3 -m compileall -q platforms/linux/src platforms/linux/port-killer.py + + - name: Run parser tests + # These cover pure string parsing only, so no GTK stack is needed. + run: python3 -m unittest discover -s platforms/linux/tests -v + + - name: Validate installer + run: | + bash -n platforms/linux/install.sh + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck platforms/linux/install.sh + + smoke: + name: Import Smoke Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install GTK stack + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + python3-gi python3-gi-cairo gir1.2-gtk-3.0 \ + gir1.2-ayatanaappindicator3-0.1 xvfb + + - name: Import all modules + # Catches broken imports and syntax errors that the parser tests, + # which never touch the GTK modules, would miss. + run: | + cd platforms/linux + xvfb-run -a python3 -c " + import src.config, src.scanner + import src.services.clipboard, src.services.cloudflare, src.services.k8s + import src.ui.dialogs, src.ui.window, src.ui.tray + print('All modules import cleanly') + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0e8732..3237a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -448,3 +448,123 @@ jobs: $version = "${{ env.RELEASE_VERSION }}" gh release upload $version "PortKiller-${version}-windows-x64.zip" --clobber gh release upload $version "PortKiller-${version}-windows-arm64.zip" --clobber + + build-linux: + name: Build Linux + needs: create-release + # Skip for PR builds (macOS only) + if: ${{ always() && !inputs.is_pr_build && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }} + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + python3 python3-gi python3-gi-cairo \ + gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 \ + librsvg2-bin desktop-file-utils file + + - name: Build AppImage + run: | + VERSION="${{ env.RELEASE_VERSION }}" + VERSION="${VERSION#v}" + APPDIR=AppDir + + # Lay out the AppDir + mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/port-killer" + mkdir -p "$APPDIR/usr/share/applications" + mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps" + + cp -r platforms/linux/src "$APPDIR/usr/share/port-killer/" + cp platforms/linux/port-killer.py "$APPDIR/usr/share/port-killer/" + chmod +x "$APPDIR/usr/share/port-killer/port-killer.py" + + # Icon: AppImage needs it at the AppDir root as well as in the theme + cp platforms/macos/Resources/AppIcon.svg "$APPDIR/usr/share/icons/hicolor/scalable/apps/port-killer.svg" + cp platforms/macos/Resources/AppIcon.svg "$APPDIR/port-killer.svg" + # Some desktop environments will not read an SVG-only AppImage icon + rsvg-convert -w 256 -h 256 platforms/macos/Resources/AppIcon.svg -o "$APPDIR/port-killer.png" + + cat > "$APPDIR/usr/share/applications/port-killer.desktop" <<'DESKTOP' + [Desktop Entry] + Type=Application + Name=PortKiller + Comment=Monitor listening ports and terminate processes from system tray + Exec=port-killer + Icon=port-killer + Terminal=false + Categories=Development; + StartupNotify=false + StartupWMClass=port-killer + DESKTOP + cp "$APPDIR/usr/share/applications/port-killer.desktop" "$APPDIR/port-killer.desktop" + desktop-file-validate "$APPDIR/port-killer.desktop" + + # Launcher. The GTK stack is taken from the host rather than bundled: + # PyGObject binds tightly to the system GLib/GTK, and bundling them + # breaks on distros whose versions differ from the build image. + cat > "$APPDIR/usr/bin/port-killer" <<'LAUNCH' + #!/bin/bash + HERE="$(dirname "$(readlink -f "${0}")")" + APPROOT="$(dirname "$(dirname "$HERE")")" + + for dep in "gi:python3-gi" "gi.repository.Gtk:gir1.2-gtk-3.0"; do + mod="${dep%%:*}" + if ! python3 -c "import ${mod%%.*}" 2>/dev/null; then + echo "PortKiller: missing ${dep##*:}." >&2 + echo "Install the GTK Python bindings, e.g.:" >&2 + echo " Debian/Ubuntu: sudo apt install python3-gi gir1.2-ayatanaappindicator3-0.1" >&2 + echo " Fedora: sudo dnf install python3-gobject libayatana-appindicator-gtk3" >&2 + echo " Arch: sudo pacman -S python-gobject libayatana-appindicator" >&2 + exit 1 + fi + done + + exec python3 "$APPROOT/usr/share/port-killer/port-killer.py" "$@" + LAUNCH + chmod +x "$APPDIR/usr/bin/port-killer" + + # AppRun -> launcher + ln -s usr/bin/port-killer "$APPDIR/AppRun" + + # Package + curl -fsSL -o appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x appimagetool + + ARCH=x86_64 ./appimagetool --appimage-extract-and-run \ + "$APPDIR" "PortKiller-${{ env.RELEASE_VERSION }}-linux-x86_64.AppImage" + + - name: Verify AppImage + run: | + FILE="PortKiller-${{ env.RELEASE_VERSION }}-linux-x86_64.AppImage" + [ -f "$FILE" ] || { echo "AppImage not produced"; exit 1; } + chmod +x "$FILE" + file "$FILE" + + # Confirm the payload is actually in there + ./"$FILE" --appimage-extract >/dev/null + [ -f squashfs-root/usr/share/port-killer/port-killer.py ] || { echo "Missing entry point"; exit 1; } + [ -f squashfs-root/usr/share/port-killer/src/ui/tray.py ] || { echo "Missing src/"; exit 1; } + [ -x squashfs-root/AppRun ] || { echo "AppRun not executable"; exit 1; } + rm -rf squashfs-root + echo "AppImage verified" + + - name: Upload to Release + if: ${{ !inputs.dry_run }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ env.RELEASE_VERSION }}" \ + "PortKiller-${{ env.RELEASE_VERSION }}-linux-x86_64.AppImage" --clobber + + - name: Upload artifact (dry run) + if: ${{ inputs.dry_run }} + uses: actions/upload-artifact@v4 + with: + name: linux-appimage + path: PortKiller-*-linux-x86_64.AppImage diff --git a/.gitignore b/.gitignore index 598ffa1..ac66dca 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,12 @@ bun.lockb # Rust **/target/ + +# Python (Linux tray app) +__pycache__/ +*.py[cod] + +# Linux packaging +AppDir/ +*.AppImage +appimagetool diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ff5d70..a5e926a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,9 +2,11 @@ ## Requirements -- **macOS 15.0+** / **Windows 10+** +- **macOS 15.0+** / **Windows 10+** / **Linux** - **Xcode 16+** with Swift 6.0 (for macOS) - **.NET 9 SDK** (for Windows) +- **Python 3.9+**, PyGObject (GTK 3) and libayatana-appindicator (for Linux) +- **Rust stable** (for `portkiller-core`, used by the Linux app only) ## Setup @@ -37,6 +39,32 @@ cd platforms/windows/PortKiller dotnet run ``` +### Linux + +A native system tray app built with Python, GTK 3 and AppIndicator. + +```bash +# Run directly +./platforms/linux/port-killer.py & + +# Or install it (registers a launcher and autostart on login) +./platforms/linux/install.sh +``` + +Install the dependencies first — `install.sh` checks for them and stops with +hints if any are missing: + +```bash +# Debian/Ubuntu +sudo apt install python3 python3-gi gir1.2-ayatanaappindicator3-0.1 + +# Fedora +sudo dnf install python3 python3-gobject libayatana-appindicator-gtk3 + +# Arch +sudo pacman -S python python-gobject libayatana-appindicator +``` + ## Building ### macOS @@ -56,6 +84,33 @@ dotnet build # Debug dotnet publish -c Release -r win-x64 # Release ``` +### Linux + +The tray app is plain Python, so there is no build step. The Rust core is +built separately: + +```bash +cd portkiller-core +cargo build # Debug +cargo build --release # Release +``` + +Releases ship an AppImage, produced by the `build-linux` job in +`.github/workflows/release.yml`. + +## Tests + +```bash +# macOS +cd platforms/macos && swift test + +# Linux tray app (parser tests, no GTK needed) +python3 -m unittest discover -s platforms/linux/tests + +# Rust core (Linux only) +cd portkiller-core && cargo test +``` + ## Pull Requests 1. Fork the repo @@ -76,6 +131,11 @@ dotnet publish -c Release -r win-x64 # Release - C# with WPF - MVVM pattern +### Linux +- Python 3 with GTK 3 (PyGObject) +- Scans run on a worker thread; UI updates go back through `GLib.idle_add` +- Parsers are pure functions, kept testable without a GTK stack + ## Project Structure ``` @@ -88,6 +148,19 @@ platforms/ │ │ └── Views/ # SwiftUI views │ ├── Resources/ # Assets, Info.plist │ └── scripts/ # Build scripts -└── windows/ - └── PortKiller/ # .NET WPF project +├── windows/ +│ └── PortKiller/ # .NET WPF project +└── linux/ + ├── port-killer.py # Entry point + ├── install.sh # Launcher + autostart installer + ├── src/ + │ ├── scanner.py # ss/lsof parsing, process killing + │ ├── config.py # Persisted preferences + │ ├── services/ # Clipboard, Cloudflare, k8s + │ └── ui/ # Tray, window, dialogs + └── tests/ # Parser tests + +portkiller-core/ # Rust core (Linux only) +├── src/scanner/ # Port scanning +└── src/process/ # Process termination ``` diff --git a/platforms/linux/install.sh b/platforms/linux/install.sh new file mode 100755 index 0000000..76fecb5 --- /dev/null +++ b/platforms/linux/install.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -e + +# Linux Installer for PortKiller Native Tray App +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_DIR="$HOME/.local/share/port-killer" +AUTOSTART_DIR="$HOME/.config/autostart" +APPLICATIONS_DIR="$HOME/.local/share/applications" +ICONS_DIR="$HOME/.local/share/icons/hicolor/scalable/apps" + +echo "Installing PortKiller for Linux..." + +# Verify runtime dependencies before installing anything +missing="" + +if ! command -v python3 >/dev/null 2>&1; then + missing="$missing python3" +fi + +if ! python3 -c "import gi; gi.require_version('Gtk', '3.0')" >/dev/null 2>&1; then + missing="$missing python3-gi/GTK3" +fi + +if ! python3 -c " +import gi +try: + gi.require_version('AppIndicator3', '0.1') +except ValueError: + gi.require_version('AyatanaAppIndicator3', '0.1') +" >/dev/null 2>&1; then + missing="$missing libappindicator3/libayatana-appindicator3" +fi + +if [ -n "$missing" ]; then + echo "Error: missing required dependencies:$missing" >&2 + echo "" >&2 + echo "Install them first, e.g.:" >&2 + echo " Debian/Ubuntu: sudo apt install python3 python3-gi gir1.2-ayatanaappindicator3-0.1" >&2 + echo " Fedora: sudo dnf install python3 python3-gobject libayatana-appindicator-gtk3" >&2 + echo " Arch: sudo pacman -S python python-gobject libayatana-appindicator" >&2 + exit 1 +fi + +# Create install directories +mkdir -p "$INSTALL_DIR" +mkdir -p "$AUTOSTART_DIR" +mkdir -p "$APPLICATIONS_DIR" + +# Copy python script and assets +cp -r "$SCRIPT_DIR/src" "$INSTALL_DIR/" +cp "$SCRIPT_DIR/port-killer.py" "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/port-killer.py" + +# Install the app icon. Never create a placeholder here: get_icon_path() only +# rejects a missing file, so an empty AppIcon.svg would be handed to +# AppIndicator instead of falling back to the system icon. +SOURCE_ICON="$SCRIPT_DIR/../macos/Resources/AppIcon.svg" +ICON_NAME="port-killer" + +if [ -s "$SOURCE_ICON" ]; then + cp "$SOURCE_ICON" "$INSTALL_DIR/AppIcon.svg" + + # Also register in the hicolor theme so the icon resolves by name + mkdir -p "$ICONS_DIR" + cp "$SOURCE_ICON" "$ICONS_DIR/$ICON_NAME.svg" + DESKTOP_ICON="$ICON_NAME" + + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true + fi +else + echo "Warning: $SOURCE_ICON not found or empty; using a system fallback icon." >&2 + rm -f "$INSTALL_DIR/AppIcon.svg" + DESKTOP_ICON="utilities-system-monitor" +fi + +# Generate desktop file +DESKTOP_FILE="$APPLICATIONS_DIR/port-killer.desktop" +AUTOSTART_FILE="$AUTOSTART_DIR/port-killer.desktop" + +# Generate launcher desktop entry +cat < "$DESKTOP_FILE" +[Desktop Entry] +Type=Application +Name=PortKiller +Comment=Monitor listening ports and terminate processes from system tray +Exec=$INSTALL_DIR/port-killer.py +Icon=$DESKTOP_ICON +Terminal=false +Categories=Development; +StartupNotify=false +StartupWMClass=port-killer +EOF + +# Copy desktop file to autostart so it starts on login +cp "$DESKTOP_FILE" "$AUTOSTART_FILE" +chmod +x "$DESKTOP_FILE" +chmod +x "$AUTOSTART_FILE" + +echo "✓ PortKiller installed successfully!" +echo "You can now find PortKiller in your application launcher, or start it immediately by running:" +echo " $INSTALL_DIR/port-killer.py &" +echo "" +echo "It will also start automatically every time you log in." diff --git a/platforms/linux/port-killer.py b/platforms/linux/port-killer.py new file mode 100755 index 0000000..fee934b --- /dev/null +++ b/platforms/linux/port-killer.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +import os +import sys + +# Add the directory containing the 'src' package to sys.path +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +from src.main import main + +if __name__ == '__main__': + main() diff --git a/platforms/linux/src/__init__.py b/platforms/linux/src/__init__.py new file mode 100644 index 0000000..b3ecc28 --- /dev/null +++ b/platforms/linux/src/__init__.py @@ -0,0 +1 @@ +# PortKiller Linux Package diff --git a/platforms/linux/src/config.py b/platforms/linux/src/config.py new file mode 100644 index 0000000..98fbb20 --- /dev/null +++ b/platforms/linux/src/config.py @@ -0,0 +1,89 @@ +import os +import json + +CONFIG_DIR = os.path.expanduser("~/.config/port-killer") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CONFIG = { + "use_tree_view": True, + "hide_system_processes": True, + "favorites": [] +} + +class AppConfig: + def __init__(self): + self.data = DEFAULT_CONFIG.copy() + self.load() + + def load(self): + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + self.data.update(json.load(f)) + except Exception as e: + print(f"Error loading config: {e}") + + def save(self): + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(self.data, f, indent=4) + except Exception as e: + print(f"Error saving config: {e}") + + @property + def use_tree_view(self): + return self.data.get("use_tree_view", True) + + @use_tree_view.setter + def use_tree_view(self, val): + self.data["use_tree_view"] = bool(val) + self.save() + + @property + def hide_system_processes(self): + return self.data.get("hide_system_processes", True) + + @hide_system_processes.setter + def hide_system_processes(self, val): + self.data["hide_system_processes"] = bool(val) + self.save() + + @property + def favorites(self): + return self.data.get("favorites", []) + + def add_favorite(self, port): + if port not in self.data["favorites"]: + self.data["favorites"].append(port) + self.save() + + def remove_favorite(self, port): + if port in self.data["favorites"]: + self.data["favorites"].remove(port) + self.save() + + def is_favorite(self, port): + return port in self.data["favorites"] + +# Global config instance +config = AppConfig() + +def get_icon_path(): + import sys + src_dir = os.path.dirname(os.path.abspath(__file__)) # /path/to/src + parent_dir = os.path.dirname(src_dir) # /path/to + + candidates = [ + os.path.join(parent_dir, "AppIcon.svg"), + os.path.join(src_dir, "AppIcon.svg"), + os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "AppIcon.svg"), + ] + # Require a non-empty file: an empty AppIcon.svg (e.g. left by an older + # installer) would otherwise be passed to AppIndicator instead of letting + # the caller fall back to a system icon name. + for p in candidates: + if os.path.isfile(p) and os.path.getsize(p) > 0: + return p + return None + diff --git a/platforms/linux/src/main.py b/platforms/linux/src/main.py new file mode 100644 index 0000000..0987d5f --- /dev/null +++ b/platforms/linux/src/main.py @@ -0,0 +1,46 @@ +import os +import sys +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +def main(): + # Set application details for desktop integration (so taskbar/dock maps correctly) + GLib.set_prgname('port-killer') + GLib.set_application_name('PortKiller') + + # Load custom stylesheet + script_dir = os.path.dirname(os.path.abspath(__file__)) + css_path = os.path.join(script_dir, "ui", "styles.css") + + if os.path.exists(css_path): + css_provider = Gtk.CssProvider() + try: + css_provider.load_from_path(css_path) + screen = Gdk.Screen.get_default() + Gtk.StyleContext.add_provider_for_screen( + screen, + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + except Exception as e: + print(f"Warning: Could not load CSS styles: {e}") + + # Set default window icon globally + from .config import get_icon_path + icon_path = get_icon_path() + if icon_path: + try: + Gtk.Window.set_default_icon_from_file(icon_path) + except Exception as e: + print(f"Warning: Could not set default window icon: {e}") + + # Import and start the tray app + from .ui.tray import PortKillerTrayApp + app = PortKillerTrayApp() + + # Run Gtk main loop + Gtk.main() + +if __name__ == '__main__': + main() diff --git a/platforms/linux/src/scanner.py b/platforms/linux/src/scanner.py new file mode 100644 index 0000000..2e67463 --- /dev/null +++ b/platforms/linux/src/scanner.py @@ -0,0 +1,279 @@ +import os +import signal +import subprocess +import time + +class PortScanner: + @staticmethod + def scan_ports(): + ports = [] + try: + # Try ss first (more complete on Linux as it shows all ports, even of other users) + result = subprocess.run( + ["ss", "-tlnp"], + capture_output=True, + text=True, + check=True + ) + ports = PortScanner.parse_ss_output(result.stdout) + except (subprocess.SubprocessError, FileNotFoundError): + # Fall back to lsof + try: + result = subprocess.run( + ["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"], + capture_output=True, + text=True, + check=True + ) + ports = PortScanner.parse_lsof_output(result.stdout) + except (subprocess.SubprocessError, FileNotFoundError): + pass + return ports + + @staticmethod + def parse_lsof_output(output): + ports = [] + seen = set() + lines = output.strip().split('\n') + if len(lines) <= 1: + return ports + + commands = PortScanner.get_process_commands() + + for line in lines[1:]: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 9: + continue + + process_name = parts[0] + try: + pid = int(parts[1]) + except ValueError: + continue + + # Find the name column with colon + address_str = None + for p in reversed(parts[8:]): + if ':' in p and not p.startswith('0x') and not p.startswith('0t'): + address_str = p + break + + if not address_str: + continue + + addr_port = PortScanner.parse_address(address_str) + if not addr_port: + continue + address, port = addr_port + + command = commands.get(pid, process_name) + if len(command) > 200: + command = command[:200] + "..." + + if (port, pid) not in seen: + seen.add((port, pid)) + ports.append({ + 'port': port, + 'pid': pid, + 'process_name': process_name, + 'command': command, + 'address': address + }) + + ports.sort(key=lambda x: x['port']) + return ports + + @staticmethod + def parse_ss_output(output): + ports = [] + seen = set() + lines = output.strip().split('\n') + + commands = PortScanner.get_process_commands() + + # Skip the header by position: matching on "State" breaks under + # locales where ss translates its column titles. + for line in lines[1:]: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 4: + continue + + local_addr = parts[3] + last_colon = local_addr.rfind(':') + if last_colon == -1: + continue + + address = local_addr[:last_colon] + if not address: + address = "*" + try: + port = int(local_addr[last_colon + 1:]) + except ValueError: + continue + + pid = 0 + process_name = "Unknown" + found_proc = False + + if len(parts) >= 6: + proc_col = " ".join(parts[5:]) + users = PortScanner.parse_ss_users(proc_col) + for name, p in users: + found_proc = True + command = commands.get(p, name) + if len(command) > 200: + command = command[:200] + "..." + if (port, p) not in seen: + seen.add((port, p)) + ports.append({ + 'port': port, + 'pid': p, + 'process_name': name, + 'command': command, + 'address': address + }) + + if not found_proc: + if (port, pid) not in seen: + seen.add((port, pid)) + ports.append({ + 'port': port, + 'pid': pid, + 'process_name': process_name, + 'command': "Unknown", + 'address': address + }) + + ports.sort(key=lambda x: x['port']) + return ports + + @staticmethod + def parse_ss_users(users_str): + results = [] + if "users:(" in users_str: + content = users_str[users_str.find("users:(") + 7 : -1] + for part in content.split("),("): + clean = part.lstrip('(').rstrip(')') + fields = clean.split(',') + if len(fields) >= 2: + name = fields[0].strip('"') + pid_str = fields[1].strip() + if pid_str.startswith("pid="): + try: + pid = int(pid_str[4:]) + results.append((name, pid)) + except ValueError: + pass + return results + + @staticmethod + def parse_address(address_str): + if address_str.startswith('['): + bracket_end = address_str.find(']') + if bracket_end == -1 or bracket_end + 1 >= len(address_str): + return None + after = address_str[bracket_end + 1:] + if not after.startswith(':'): + return None + try: + port = int(after[1:]) + return address_str[:bracket_end + 1], port + except ValueError: + return None + else: + last_colon = address_str.rfind(':') + if last_colon == -1: + return None + try: + port = int(address_str[last_colon + 1:]) + addr = address_str[:last_colon] + if not addr: + addr = "*" + return addr, port + except ValueError: + return None + + @staticmethod + def get_process_commands(): + commands = {} + try: + result = subprocess.run( + ["ps", "-axo", "pid,command"], + capture_output=True, + text=True, + check=True + ) + lines = result.stdout.strip().split('\n') + for line in lines[1:]: + trimmed = line.strip() + if not trimmed: + continue + parts = trimmed.split(None, 1) + if len(parts) < 2: + continue + try: + pid = int(parts[0]) + commands[pid] = parts[1].strip() + except ValueError: + continue + except subprocess.SubprocessError: + pass + return commands + + @staticmethod + def is_process_running(pid): + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + # Exists but owned by another user + return True + + @staticmethod + def kill_process(pid, force=False): + """ + Terminate a process. + + force=False follows the project convention: SIGTERM, wait 500ms, then + SIGKILL if it is still alive. force=True sends SIGKILL immediately. + + Returns True on success, False if the signal could not be delivered + (e.g. the process is owned by another user). + """ + if pid <= 0: + return False + + try: + if force: + os.kill(pid, signal.SIGKILL) + return True + + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + # Already gone + return True + except (PermissionError, OSError) as e: + print(f"Error killing process {pid}: {e}") + return False + + # Grace period, then escalate to SIGKILL if still running + time.sleep(0.5) + if not PortScanner.is_process_running(pid): + return True + + try: + os.kill(pid, signal.SIGKILL) + return True + except ProcessLookupError: + return True + except (PermissionError, OSError) as e: + print(f"Error force-killing process {pid}: {e}") + return False diff --git a/platforms/linux/src/services/__init__.py b/platforms/linux/src/services/__init__.py new file mode 100644 index 0000000..e1754b0 --- /dev/null +++ b/platforms/linux/src/services/__init__.py @@ -0,0 +1 @@ +# PortKiller Linux Services diff --git a/platforms/linux/src/services/clipboard.py b/platforms/linux/src/services/clipboard.py new file mode 100644 index 0000000..c110c68 --- /dev/null +++ b/platforms/linux/src/services/clipboard.py @@ -0,0 +1,42 @@ +import shutil +import subprocess + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk + +def copy_to_clipboard(text): + try: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) + return True + except Exception as e: + print(f"Error copying to clipboard: {e}") + return False + + +# Resolved once: warn at startup rather than failing silently on every action. +_NOTIFY_SEND = shutil.which("notify-send") +if not _NOTIFY_SEND: + print( + "Warning: notify-send not found; desktop notifications are disabled. " + "Install libnotify-bin (Debian/Ubuntu) or libnotify (Fedora/Arch)." + ) + + +def notify(title, message): + """ + Show a desktop notification. Runs without blocking the GTK main loop. + """ + if not _NOTIFY_SEND: + return False + try: + subprocess.Popen( + [_NOTIFY_SEND, "-a", "PortKiller", title, message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except OSError as e: + print(f"Error sending notification: {e}") + return False diff --git a/platforms/linux/src/services/cloudflare.py b/platforms/linux/src/services/cloudflare.py new file mode 100644 index 0000000..b2aa3a8 --- /dev/null +++ b/platforms/linux/src/services/cloudflare.py @@ -0,0 +1,191 @@ +import os +import re +import shutil +import subprocess +import threading +import time + +class CloudflareTunnel: + def __init__(self, port): + self.port = port + self.url = None + self.status = "starting" # starting, active, error, stopping + self.error = None + self.process = None + self.thread = None + + def start(self): + cloudflared_bin = shutil.which("cloudflared") + if not cloudflared_bin: + # Check common paths + for path in ["/usr/local/bin/cloudflared", "/usr/bin/cloudflared", "/opt/bin/cloudflared"]: + if os.path.exists(path): + cloudflared_bin = path + break + + if not cloudflared_bin: + self.status = "error" + self.error = "cloudflared binary not found in PATH or standard locations." + return False + + try: + # Start cloudflared quick tunnel + self.process = subprocess.Popen( + [cloudflared_bin, "tunnel", "--url", f"localhost:{self.port}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + # Start background thread to read output + self.thread = threading.Thread(target=self._read_output, daemon=True) + self.thread.start() + return True + except Exception as e: + self.status = "error" + self.error = str(e) + return False + + def _read_output(self): + pattern = r"https://[a-zA-Z0-9-]+\.trycloudflare\.com" + last_error_line = None + + while self.process and self.process.poll() is None: + line = self.process.stdout.readline() + if not line: + break + + # Parse URL + match = re.search(pattern, line) + if match: + self.url = match.group(0) + self.status = "active" + + # Remember the most recent error-looking line, but do not change + # status yet: cloudflared logs transient connection retries during + # startup that contain "error"/"failed" and still go on to connect. + line_lower = line.lower() + if "error" in line_lower or "failed" in line_lower: + last_error_line = line.strip() + + # Only the process actually exiting is treated as a failure. + if self.process and self.process.poll() is not None: + returncode = self.process.returncode + if self.status not in ("stopping", "stopped"): + self.status = "error" + self.error = last_error_line or f"Process exited with code {returncode}" + + def stop(self): + self.status = "stopping" + if self.process: + try: + self.process.terminate() + # Wait up to 1 second + for _ in range(10): + if self.process.poll() is not None: + break + time.sleep(0.1) + + if self.process.poll() is None: + self.process.kill() + + # Reap the child so it does not linger as a zombie + self.process.wait() + except Exception: + pass + self.status = "stopped" + +class CloudflareService: + def __init__(self): + self.active_tunnels = {} # port -> CloudflareTunnel + + @property + def is_installed(self): + return shutil.which("cloudflared") is not None or any( + os.path.exists(p) for p in ["/usr/local/bin/cloudflared", "/usr/bin/cloudflared"] + ) + + def start_tunnel(self, port): + if port in self.active_tunnels: + tunnel = self.active_tunnels[port] + if tunnel.status != "error": + return tunnel + + tunnel = CloudflareTunnel(port) + self.active_tunnels[port] = tunnel + tunnel.start() + return tunnel + + def stop_tunnel(self, port): + if port in self.active_tunnels: + self.active_tunnels[port].stop() + del self.active_tunnels[port] + + def stop_all(self): + for port in list(self.active_tunnels.keys()): + self.stop_tunnel(port) + + def get_tunnel(self, port): + return self.active_tunnels.get(port) + + def scan_running_tunnels_from_ps(self): + """ + Scan the operating system process list to see if any cloudflared tunnels + were launched externally. + """ + external_tunnels = [] + try: + # Query running processes matching cloudflared + result = subprocess.run( + ["ps", "-axo", "pid,command"], + capture_output=True, + text=True + ) + if result.returncode != 0: + return external_tunnels + + # Pattern to parse cloudflared tunnel command + # e.g., cloudflared tunnel --url localhost:3000 + # Output format is "PID COMMAND", so split off the PID and keep + # the rest of the line as the command. + for line in result.stdout.splitlines()[1:]: + trimmed = line.strip() + if not trimmed: + continue + + parts = trimmed.split(None, 1) + if len(parts) < 2: + continue + + pid_str, cmd = parts[0], parts[1] + if "cloudflared" not in cmd or "tunnel" not in cmd or "--url" not in cmd: + continue + + # Find local port + port_match = re.search(r"localhost:(\d+)", cmd) + if not port_match: + continue + + try: + pid = int(pid_str) + except ValueError: + continue + + port = int(port_match.group(1)) + # Check if we already manage this port. If not, add as external + if port not in self.active_tunnels: + external_tunnels.append({ + "port": port, + "pid": pid, + "status": "active", + "url": "External (check terminal)", + "command": cmd, + }) + except Exception as e: + print(f"Error scanning cloudflared processes: {e}") + + return external_tunnels + +# Global service instance +cloudflare_service = CloudflareService() diff --git a/platforms/linux/src/services/k8s.py b/platforms/linux/src/services/k8s.py new file mode 100644 index 0000000..672c590 --- /dev/null +++ b/platforms/linux/src/services/k8s.py @@ -0,0 +1,152 @@ +import os +import re +import shutil +import signal +import subprocess + +# kubectl flags whose value is a separate following token +VALUE_FLAGS = ("-n", "--namespace", "--kubeconfig", "--context", "--address") + + +class K8sPortForward: + def __init__(self, pid, namespace, resource, local_port, remote_port, raw_cmd): + self.pid = pid + self.namespace = namespace + self.resource = resource + self.local_port = local_port + self.remote_port = remote_port + self.raw_cmd = raw_cmd + +class K8sService: + @property + def is_installed(self): + return shutil.which("kubectl") is not None + + def scan_active_forwards(self): + """ + Scans running processes to find active kubectl port-forward sessions. + Returns a list of K8sPortForward objects. + """ + forwards = [] + if not self.is_installed: + return forwards + + try: + # Query running processes matching kubectl + result = subprocess.run( + ["ps", "-axo", "pid,command"], + capture_output=True, + text=True + ) + if result.returncode != 0: + return forwards + + # Parse output + for line in result.stdout.splitlines(): + trimmed = line.strip() + if not trimmed or "ps -axo" in trimmed: + continue + + parts = trimmed.split(None, 1) + if len(parts) < 2: + continue + + pid_str, cmd = parts[0], parts[1] + if "kubectl" in cmd and "port-forward" in cmd: + try: + pid = int(pid_str) + fw = self._parse_port_forward_cmd(pid, cmd) + if fw: + forwards.append(fw) + except ValueError: + continue + except Exception as e: + print(f"Error scanning K8s port-forwards: {e}") + + return forwards + + def _parse_port_forward_cmd(self, pid, cmd): + """ + Parses a kubectl port-forward command line. + e.g., "kubectl port-forward svc/my-service 8080:80 -n dev" + """ + # Exclude grep, self-processes + if "grep" in cmd: + return None + + # Parse Namespace + namespace = "default" + ns_match = re.search(r"(?:-n|--namespace)(?:=|\s+)([^\s]+)", cmd) + if ns_match: + namespace = ns_match.group(1) + + # Parse Local and Remote Port + # E.g. "8080:80" or "8080" (if target port is same) or " :80" (random local port) + local_port = 0 + remote_port = 0 + port_match = re.search(r"(\d+):(\d+)", cmd) + if port_match: + local_port = int(port_match.group(1)) + remote_port = int(port_match.group(2)) + else: + # Maybe single port mapping, e.g. "8080" + single_port_match = re.search(r"\s+(\d+)(?:\s+|$)", cmd) + if single_port_match: + local_port = int(single_port_match.group(1)) + remote_port = local_port + + # Parse Resource Name + # E.g. "svc/my-service", "pod/my-pod", "deployment/my-dep", or just "my-pod" + resource = "Unknown Resource" + cmd_tokens = cmd.split() + if "port-forward" in cmd_tokens: + start = cmd_tokens.index("port-forward") + 1 + for j in range(start, len(cmd_tokens)): + t = cmd_tokens[j] + # Skip flags themselves + if t.startswith("-"): + continue + # Skip the value of a flag that takes a separate argument + if cmd_tokens[j - 1] in VALUE_FLAGS: + continue + # Skip port mappings ("8080:80") and bare port numbers + if ":" in t or t.isdigit(): + continue + resource = t + break + + return K8sPortForward(pid, namespace, resource, local_port, remote_port, cmd) + + def stop_port_forward(self, pid): + """ + Kill a specific port forward process by PID. + """ + if pid <= 0: + return False + try: + os.kill(pid, signal.SIGTERM) + return True + except ProcessLookupError: + # Already gone; treat as success + return True + except (PermissionError, OSError) as e: + print(f"Error stopping port-forward {pid}: {e}") + return False + + def kill_all(self): + """ + Kill all active kubectl port-forward processes. + + Only PIDs identified by scan_active_forwards() are signalled. A broad + `pkill -f "kubectl.*port-forward"` would match any process whose command + line merely contains that text (an editor, a shell script, a grep) and + kill it too. + """ + ok = True + for fw in self.scan_active_forwards(): + if not self.stop_port_forward(fw.pid): + ok = False + return ok + +# Global service instance +k8s_service = K8sService() diff --git a/platforms/linux/src/ui/__init__.py b/platforms/linux/src/ui/__init__.py new file mode 100644 index 0000000..a2ddd0a --- /dev/null +++ b/platforms/linux/src/ui/__init__.py @@ -0,0 +1 @@ +# PortKiller Linux UI Components diff --git a/platforms/linux/src/ui/dialogs.py b/platforms/linux/src/ui/dialogs.py new file mode 100644 index 0000000..09acdb8 --- /dev/null +++ b/platforms/linux/src/ui/dialogs.py @@ -0,0 +1,138 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GdkPixbuf + +class PortDetailsDialog(Gtk.Dialog): + def __init__(self, parent, p): + super().__init__(title=f"Port {p['port']} Management", transient_for=parent, flags=0) + self.set_default_size(440, 360) + self.set_resizable(False) + + # Apply CSS class + self.get_style_context().add_class("port-dialog") + + # Set window icon for taskbar/dock + from ..config import get_icon_path + icon_path = get_icon_path() + if icon_path: + try: + self.set_icon_from_file(icon_path) + except Exception: + pass + + # Hide default action area to prevent drawing a square box at the very bottom + self.get_action_area().hide() + + # Main layout box + content_area = self.get_content_area() + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + content_area.pack_start(main_box, True, True, 0) + + # Header box with title + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + header_box.get_style_context().add_class("header-box") + main_box.pack_start(header_box, False, False, 0) + + # Try to load custom icon + image = Gtk.Image() + icon_loaded = False + if icon_path: + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 48, 48, True) + image.set_from_pixbuf(pixbuf) + icon_loaded = True + except Exception: + pass + + if not icon_loaded: + image.set_from_icon_name("utilities-system-monitor", Gtk.IconSize.DIALOG) + + header_box.pack_start(image, False, False, 0) + + # Header text + header_text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title_label = Gtk.Label(label=f"Port {p['port']}") + title_label.get_style_context().add_class("header-title") + title_label.set_xalign(0) + + subtitle_label = Gtk.Label(label=f"Process: {p['process_name']}") + subtitle_label.get_style_context().add_class("header-subtitle") + subtitle_label.set_xalign(0) + + header_text_box.pack_start(title_label, True, True, 0) + header_text_box.pack_start(subtitle_label, True, True, 0) + header_box.pack_start(header_text_box, True, True, 0) + + # Body box + body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + body_box.get_style_context().add_class("content-box") + body_box.set_margin_start(16) + body_box.set_margin_end(16) + body_box.set_margin_top(16) + main_box.pack_start(body_box, True, True, 0) + + # Details text + details = ( + f"PID: {p['pid'] if p['pid'] != 0 else 'Unknown'}\n" + f"Address: {p['address']}\n" + f"Command: {p['command']}" + ) + details_label = Gtk.Label(label=details) + details_label.get_style_context().add_class("detail-label") + details_label.set_xalign(0) + details_label.set_line_wrap(True) + body_box.pack_start(details_label, True, True, 0) + + # Actions box + actions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + actions_box.set_margin_bottom(16) + body_box.pack_start(actions_box, False, False, 0) + + # Create a size group for homogeneous button widths + button_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) + + # Row 1: Kill actions + if p['pid'] != 0: + kill_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_kill = Gtk.Button(label="Kill Process (SIGTERM)") + btn_kill.get_style_context().add_class("btn-kill") + btn_kill.connect("clicked", lambda w: self.response(1)) + button_size_group.add_widget(btn_kill) + + btn_force = Gtk.Button(label="Force Kill (SIGKILL)") + btn_force.get_style_context().add_class("btn-force") + btn_force.connect("clicked", lambda w: self.response(2)) + button_size_group.add_widget(btn_force) + + kill_row.pack_start(btn_kill, True, True, 0) + kill_row.pack_start(btn_force, True, True, 0) + actions_box.pack_start(kill_row, False, False, 0) + + # Row 2: Copy actions + utils_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_copy_pid = Gtk.Button(label="Copy PID") + btn_copy_pid.get_style_context().add_class("btn-secondary") + btn_copy_pid.connect("clicked", lambda w: self.response(3)) + button_size_group.add_widget(btn_copy_pid) + + btn_copy_port = Gtk.Button(label="Copy Port") + btn_copy_port.get_style_context().add_class("btn-secondary") + btn_copy_port.connect("clicked", lambda w: self.response(4)) + button_size_group.add_widget(btn_copy_port) + + utils_row.pack_start(btn_copy_pid, True, True, 0) + utils_row.pack_start(btn_copy_port, True, True, 0) + actions_box.pack_start(utils_row, False, False, 0) + else: + btn_copy_port = Gtk.Button(label="Copy Port") + btn_copy_port.get_style_context().add_class("btn-secondary") + btn_copy_port.connect("clicked", lambda w: self.response(4)) + actions_box.pack_start(btn_copy_port, True, True, 0) + + # Row 3: Close + btn_close = Gtk.Button(label="Close") + btn_close.get_style_context().add_class("btn-close") + btn_close.connect("clicked", lambda w: self.response(Gtk.ResponseType.CLOSE)) + actions_box.pack_start(btn_close, True, True, 0) + + self.show_all() diff --git a/platforms/linux/src/ui/styles.css b/platforms/linux/src/ui/styles.css new file mode 100644 index 0000000..d257c64 --- /dev/null +++ b/platforms/linux/src/ui/styles.css @@ -0,0 +1,258 @@ +/* General window & dialog styles */ +window.port-dialog, dialog, dialog > box, dialog .content-area, dialog .dialog-action-area, +window.menu-bar-window, window.menu-bar-window > box { + background-color: #1e1e2e; + color: #cdd6f4; + border: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +window.port-dialog, dialog > box, dialog .content-area, +window.menu-bar-window, window.menu-bar-window > box { + border-radius: 12px; +} + +window.port-dialog, window.menu-bar-window { + border: 1px solid #313244; +} + +/* Header bar for dialogs */ +headerbar { + background-color: #1e1e2e; + border-bottom: 1px solid #313244; + box-shadow: none; + border-radius: 12px 12px 0 0; +} + +headerbar .title { + color: #cdd6f4; + font-weight: bold; +} + +headerbar button.close { + background-color: transparent; + border: none; + box-shadow: none; + padding: 4px; + min-width: 16px; + min-height: 16px; + border-radius: 50%; + color: #a6adc8; +} + +headerbar button.close:hover { + background-color: #f38ba8; + color: #11111b; +} + +/* Dialog content styles */ +.header-box { + background-color: #1e1e2e; + padding: 20px; + border-radius: 12px 12px 0 0; +} + +.header-title { + font-family: 'Ubuntu', 'Liberation Sans', sans-serif; + font-size: 24px; + font-weight: 800; + color: #89b4fa; +} + +.header-subtitle { + font-family: 'Ubuntu', 'Liberation Sans', sans-serif; + font-size: 14px; + font-weight: 500; + color: #a6adc8; +} + +.content-box { + padding: 16px; + background-color: #1e1e2e; + border-radius: 0 0 12px 12px; +} + +.detail-label { + font-family: 'Liberation Mono', 'Fira Code', 'DejaVu Sans Mono', monospace; + font-size: 12px; + color: #cdd6f4; + background-color: transparent; + padding: 0; +} + +/* Buttons in dialogs */ +button { + font-family: 'Ubuntu', 'Liberation Sans', sans-serif; + font-size: 13px; + font-weight: bold; + padding: 10px 16px; + border-radius: 8px; + box-shadow: none; + background-color: transparent; + margin: 4px; + transition: all 0.2s ease; +} + +.btn-kill { + color: #f38ba8; + border: 1.5px solid #f38ba8; +} + +.btn-kill:hover { + background-color: rgba(243, 139, 168, 0.1); +} + +.btn-force { + color: #fab387; + border: 1.5px solid #fab387; +} + +.btn-force:hover { + background-color: rgba(250, 179, 135, 0.1); +} + +.btn-secondary { + color: #cdd6f4; + border: 1.5px solid #45475a; +} + +.btn-secondary:hover { + background-color: rgba(205, 214, 244, 0.05); +} + +.btn-close { + color: #a6adc8; + border: 1.5px solid #313244; +} + +.btn-close:hover { + background-color: rgba(166, 172, 200, 0.05); +} + +/* Menu Bar Popup Window specific styles */ +.search-box { + padding: 12px; + background-color: #1e1e2e; + border-radius: 12px 12px 0 0; +} + +/* Custom Search Entry style */ +.search-entry { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 8px; + padding: 8px 12px; + caret-color: #89b4fa; +} + +.search-entry:focus { + border-color: #89b4fa; +} + +/* Section Header Style */ +.section-header { + background-color: rgba(255, 255, 255, 0.03); + padding: 6px 12px; +} + +.section-label { + font-size: 11px; + font-weight: 700; + color: #89b4fa; +} + +/* Scrollable port list items */ +.port-list-container { + background-color: #1e1e2e; +} + +/* List Row layout */ +.list-row { + padding: 8px 12px; + border-bottom: 1px solid #181825; + transition: background-color 0.15s ease; +} + +.list-row:hover { + background-color: #313244; +} + +/* Connection rows: Tunnels, K8s */ +.connection-row { + padding: 6px 12px; + border-bottom: 1px solid #181825; +} + +.connection-title { + font-weight: 600; + font-size: 12px; + color: #cdd6f4; +} + +.connection-detail { + font-size: 11px; + color: #a6adc8; +} + +.row-port { + font-family: monospace; + font-size: 13px; + font-weight: 700; + color: #89b4fa; +} + +.row-process { + font-size: 13px; + font-weight: 600; + color: #cdd6f4; +} + +.row-pid { + font-size: 11px; + color: #a6adc8; +} + +/* Tree expand/collapse toggle */ +.tree-expand-btn { + background: transparent; + border: none; + padding: 2px; + margin: 0; + min-width: 16px; + min-height: 16px; + color: #a6adc8; +} + +/* Bottom Action Buttons bar */ +.actions-box { + padding: 8px; + background-color: #181825; + border-radius: 0 0 12px 12px; + border-top: 1px solid #313244; +} + +/* Small icons inside the menu bar popup buttons */ +.menu-btn { + font-size: 12px; + padding: 6px 10px; + margin: 2px; + border-radius: 6px; + font-weight: 500; + color: #cdd6f4; + border: 1px solid transparent; +} + +.menu-btn:hover { + background-color: #313244; + border-color: #45475a; +} + +.menu-btn-destructive { + color: #f38ba8; +} + +.menu-btn-destructive:hover { + background-color: rgba(243, 139, 168, 0.1); + border-color: #f38ba8; +} diff --git a/platforms/linux/src/ui/tray.py b/platforms/linux/src/ui/tray.py new file mode 100644 index 0000000..6f7adf6 --- /dev/null +++ b/platforms/linux/src/ui/tray.py @@ -0,0 +1,285 @@ +import sys +import threading +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GLib + +# Import AppIndicator/AyatanaAppIndicator +try: + gi.require_version('AppIndicator3', '0.1') + from gi.repository import AppIndicator3 as appindicator +except (ValueError, ImportError): + try: + gi.require_version('AyatanaAppIndicator3', '0.1') + from gi.repository import AyatanaAppIndicator3 as appindicator + except (ValueError, ImportError): + print("Error: AppIndicator3 or AyatanaAppIndicator3 is required.") + sys.exit(1) + +from .dialogs import PortDetailsDialog +from .window import MenuBarWindow +from ..scanner import PortScanner +from ..services.cloudflare import cloudflare_service +from ..services.k8s import k8s_service +from ..services.clipboard import copy_to_clipboard, notify + +APPINDICATOR_ID = 'portkiller' + +class PortKillerTrayApp: + def __init__(self): + # Locate AppIcon.svg + from ..config import get_icon_path + icon_path = get_icon_path() + if not icon_path: + icon_path = "utilities-system-monitor" # Fallback system icon + + self.indicator = appindicator.Indicator.new( + APPINDICATOR_ID, + icon_path, + appindicator.IndicatorCategory.SYSTEM_SERVICES + ) + self.indicator.set_status(appindicator.IndicatorStatus.ACTIVE) + + self.menu = Gtk.Menu() + self.indicator.set_menu(self.menu) + + # Cache variables to detect changes and prevent menu flickering/autoclose + self.last_state = None + + # Searchable browser window, created on first use + self.window = None + + # Build initial tray menu + self.refresh_and_build() + + # Set up auto-refresh timer (every 5 seconds) + GLib.timeout_add_seconds(5, self.auto_refresh) + + @staticmethod + def _collect_state(): + """ + Run the system scans. Spawns several subprocesses, so this must never + run on the GTK main thread. + """ + ports = PortScanner.scan_ports() + k8s_forwards = k8s_service.scan_active_forwards() + + # Get cloudflare tunnels + cf_tunnels = list(cloudflare_service.active_tunnels.values()) + external_cf = cloudflare_service.scan_running_tunnels_from_ps() + for ext in external_cf: + if not any( + (t.port if hasattr(t, 'port') else t.get('port', 0)) == ext['port'] + for t in cf_tunnels + ): + cf_tunnels.append(ext) + + return ports, k8s_forwards, cf_tunnels + + def refresh_and_build(self): + # Scan off the main thread, then rebuild the menu back on it. + def worker(): + try: + ports, k8s_forwards, cf_tunnels = self._collect_state() + except Exception as e: + print(f"Error refreshing port data: {e}") + return + + def apply(): + self.build_menu_with_data(ports, k8s_forwards, cf_tunnels) + return False + + GLib.idle_add(apply) + + threading.Thread(target=worker, daemon=True).start() + + def build_menu_with_data(self, ports, k8s_forwards, cf_tunnels): + # Clear previous items + for child in self.menu.get_children(): + self.menu.remove(child) + + # 1. Cloudflare Tunnels Submenu + cf_label = f"☁️ Cloudflare Tunnels ({len(cf_tunnels)})" + cf_item = Gtk.MenuItem(label=cf_label) + cf_submenu = Gtk.Menu() + cf_item.set_submenu(cf_submenu) + + if not cf_tunnels: + no_cf = Gtk.MenuItem(label="No active tunnels") + no_cf.set_sensitive(False) + cf_submenu.append(no_cf) + else: + for t in cf_tunnels: + port_val = t.port if hasattr(t, 'port') else t.get('port', 0) + url_val = t.url if hasattr(t, 'url') else t.get('url', '') + t_label = f"Port {port_val} → {url_val if url_val else 'Starting...'}" + + # Single tunnel options submenu + single_t_item = Gtk.MenuItem(label=t_label) + single_t_submenu = Gtk.Menu() + single_t_item.set_submenu(single_t_submenu) + + if url_val: + copy_url_item = Gtk.MenuItem(label="📋 Copy Tunnel URL") + copy_url_item.connect("activate", lambda w, u=url_val: self.copy_and_notify(u, "Tunnel URL copied to clipboard!")) + single_t_submenu.append(copy_url_item) + + stop_tunnel_item = Gtk.MenuItem(label="💀 Stop Tunnel") + stop_tunnel_item.connect("activate", lambda w, p=port_val: self.stop_cf_tunnel_and_notify(p)) + single_t_submenu.append(stop_tunnel_item) + + cf_submenu.append(single_t_item) + self.menu.append(cf_item) + + # 2. K8s Port Forwards Submenu + k8s_label = f"☸️ K8s Port Forward ({len(k8s_forwards)})" + k8s_item = Gtk.MenuItem(label=k8s_label) + k8s_submenu = Gtk.Menu() + k8s_item.set_submenu(k8s_submenu) + + if not k8s_forwards: + no_k8s = Gtk.MenuItem(label="No active port forwards") + no_k8s.set_sensitive(False) + k8s_submenu.append(no_k8s) + else: + for k in k8s_forwards: + k_label = f"{k.resource} → {k.local_port}:{k.remote_port} ({k.namespace})" + + # Single k8s options submenu + single_k_item = Gtk.MenuItem(label=k_label) + single_k_submenu = Gtk.Menu() + single_k_item.set_submenu(single_k_submenu) + + copy_port_item = Gtk.MenuItem(label="📋 Copy Local Port") + copy_port_item.connect("activate", lambda w, p=k.local_port: self.copy_and_notify(str(p), f"Port {p} copied to clipboard!")) + single_k_submenu.append(copy_port_item) + + stop_forward_item = Gtk.MenuItem(label="💀 Stop Port Forward") + stop_forward_item.connect("activate", lambda w, p=k.pid, r=k.resource: self.stop_k8s_forward_and_notify(p, r)) + single_k_submenu.append(stop_forward_item) + + k8s_submenu.append(single_k_item) + self.menu.append(k8s_item) + + # 3. Local Ports Submenu (Restored direct clicks to open management Dialog) + local_label = f"🌐 Local Ports ({len(ports)})" + local_item = Gtk.MenuItem(label=local_label) + local_submenu = Gtk.Menu() + local_item.set_submenu(local_submenu) + + if not ports: + no_ports = Gtk.MenuItem(label="No open ports") + no_ports.set_sensitive(False) + local_submenu.append(no_ports) + else: + for p in ports: + port_label = f"{p['port']} → {p['process_name']}" + if p['pid'] != 0: + port_label += f" (PID {p['pid']})" + + # Port item clicking opens the Dialog modal + port_item = Gtk.MenuItem(label=port_label) + port_item.connect("activate", lambda w, port_info=p: self.open_port_dialog(port_info)) + local_submenu.append(port_item) + self.menu.append(local_item) + + self.menu.append(Gtk.SeparatorMenuItem()) + + # Item 4: Open the searchable port browser window + window_item = Gtk.MenuItem(label="Open PortKiller Window") + window_item.connect("activate", lambda w: self.open_main_window()) + self.menu.append(window_item) + + # Item 5: Refresh Data + refresh_item = Gtk.MenuItem(label="Refresh Now") + refresh_item.connect("activate", lambda w: self.refresh_and_build()) + self.menu.append(refresh_item) + + # Item 6: Quit + quit_item = Gtk.MenuItem(label="Quit PortKiller") + quit_item.connect("activate", lambda w: self.quit()) + self.menu.append(quit_item) + + self.menu.show_all() + + def open_main_window(self): + # Created lazily and reused so window state (search, expanded rows) + # survives between openings. + if self.window is None: + self.window = MenuBarWindow() + self.window.show_near_pointer() + + def quit(self): + # Tear down any tunnels we own so cloudflared children are not orphaned + cloudflare_service.stop_all() + Gtk.main_quit() + + def open_port_dialog(self, p): + # Open port details dialog + dialog = PortDetailsDialog(None, p) + response = dialog.run() + + if response == 1: # Kill Process (SIGTERM) + if PortScanner.kill_process(p['pid'], force=False): + notify("Process Terminated", f"Process on port {p['port']} terminated (SIGTERM).") + else: + notify("Kill Failed", f"Could not terminate PID {p['pid']} on port {p['port']}.") + elif response == 2: # Force Kill (SIGKILL) + if PortScanner.kill_process(p['pid'], force=True): + notify("Process Killed", f"Process on port {p['port']} force killed (SIGKILL).") + else: + notify("Kill Failed", f"Could not kill PID {p['pid']} on port {p['port']}.") + elif response == 3: # Copy PID + self.copy_and_notify(str(p['pid']), f"PID {p['pid']} copied to clipboard!") + elif response == 4: # Copy Port + self.copy_and_notify(str(p['port']), f"Port {p['port']} copied to clipboard!") + + dialog.destroy() + # Refresh lists soon after closing/killing + GLib.timeout_add(200, self.refresh_and_build) + + def copy_and_notify(self, text, message): + copy_to_clipboard(text) + notify("Action Done", message) + + def stop_cf_tunnel_and_notify(self, port): + cloudflare_service.stop_tunnel(port) + notify("Tunnel Stopped", f"Cloudflare Tunnel on port {port} stopped!") + GLib.timeout_add(200, self.refresh_and_build) + + def stop_k8s_forward_and_notify(self, pid, resource): + if k8s_service.stop_port_forward(pid): + notify("Port Forward Stopped", f"Kubernetes port-forward for {resource} stopped!") + else: + notify("Port Forward Failed", f"Could not stop port-forward for {resource} (PID {pid}).") + GLib.timeout_add(200, self.refresh_and_build) + + def auto_refresh(self): + # Scan off the main thread so the tray stays responsive. + def worker(): + try: + ports, k8s_forwards, cf_tunnels = self._collect_state() + except Exception as e: + print(f"Error during auto-refresh: {e}") + return + + # Hash/Represent current state to compare with previous state + current_state = { + 'ports': [(p['port'], p['pid'], p['process_name']) for p in ports], + 'k8s': [(k.pid, k.local_port, k.remote_port, k.resource) for k in k8s_forwards], + 'cf': [(t.port if hasattr(t, 'port') else t.get('port', 0), + t.url if hasattr(t, 'url') else t.get('url', '')) for t in cf_tunnels] + } + + def apply(): + # Rebuild only if something changed (prevents the menu from + # closing while the user is reading it) + if self.last_state != current_state: + self.last_state = current_state + self.build_menu_with_data(ports, k8s_forwards, cf_tunnels) + return False + + GLib.idle_add(apply) + + threading.Thread(target=worker, daemon=True).start() + return True diff --git a/platforms/linux/src/ui/window.py b/platforms/linux/src/ui/window.py new file mode 100644 index 0000000..6e523f8 --- /dev/null +++ b/platforms/linux/src/ui/window.py @@ -0,0 +1,472 @@ +import threading +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +from ..config import config +from ..scanner import PortScanner +from ..services.cloudflare import cloudflare_service +from ..services.k8s import k8s_service +from ..services.clipboard import copy_to_clipboard, notify + +class MenuBarWindow(Gtk.Window): + def __init__(self): + super().__init__(type=Gtk.WindowType.TOPLEVEL) + self.set_keep_above(True) + self.set_decorated(False) + self.set_default_size(340, 400) + self.set_resizable(False) + + # Style classes + self.get_style_context().add_class("menu-bar-window") + + # Focus loss hiding + self.add_events(Gdk.EventMask.FOCUS_CHANGE_MASK) + self.connect("focus-out-event", self._on_focus_out) + + # Search state + self.search_query = "" + self.confirming_kill_all = False + self.expanded_processes = set() # Set of PIDs expanded in tree view + + # Main layout + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.add(main_box) + + # 1. Search Bar Area + search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + search_box.get_style_context().add_class("search-box") + main_box.pack_start(search_box, False, False, 0) + + self.search_entry = Gtk.SearchEntry() + self.search_entry.set_placeholder_text("Search ports or processes...") + self.search_entry.get_style_context().add_class("search-entry") + self.search_entry.connect("search-changed", self._on_search_changed) + search_box.pack_start(self.search_entry, True, True, 0) + + # 2. Scrollable Content Area + self.scroll = Gtk.ScrolledWindow() + self.scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scroll.get_style_context().add_class("port-list-container") + main_box.pack_start(self.scroll, True, True, 0) + + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.scroll.add(self.content_box) + + # Initial scan & render + self.local_ports = [] + self.k8s_forwards = [] + self.cf_tunnels = [] + self.refresh_data() + + def _on_focus_out(self, widget, event): + # Hide the window when the user clicks outside + self.hide() + return True + + def show_near_pointer(self): + # Get mouse pointer position to place window + display = Gdk.Display.get_default() + seat = display.get_default_seat() + pointer = seat.get_pointer() + + if pointer: + screen, x, y = pointer.get_position() + width, height = self.get_size() + + # Find current monitor geometry + monitor = display.get_monitor_at_point(x, y) + geom = monitor.get_geometry() + + # Position horizontally centered around mouse, clamped to screen bounds + win_x = max(geom.x, min(x - width // 2, geom.x + geom.width - width)) + + # Detect top or bottom panel alignment + if y < geom.y + geom.height / 2: + win_y = geom.y + 36 # Offset below top bar + else: + win_y = y - height - 12 # Offset above bottom bar + + self.move(win_x, win_y) + + self.show_all() + self.present() + self.grab_focus() + + # Trigger refresh on show + self.refresh_data() + + def _on_search_changed(self, entry): + self.search_query = entry.get_text().strip().lower() + self.render_list() + + def refresh_data(self): + # Scanning spawns subprocesses; keep it off the GTK main thread so the + # window does not freeze while it runs. + def worker(): + try: + local_ports = PortScanner.scan_ports() + k8s_forwards = k8s_service.scan_active_forwards() + + # Get managed cloudflare tunnels + external ones + cf_tunnels = list(cloudflare_service.active_tunnels.values()) + external_cf = cloudflare_service.scan_running_tunnels_from_ps() + for ext in external_cf: + # Avoid duplicate ports + if not any( + (t.port if hasattr(t, 'port') else t.get('port', 0)) == ext['port'] + for t in cf_tunnels + ): + cf_tunnels.append(ext) + except Exception as e: + print(f"Error refreshing port data: {e}") + return + + def apply(): + self.local_ports = local_ports + self.k8s_forwards = k8s_forwards + self.cf_tunnels = cf_tunnels + self.render_list() + return False + + GLib.idle_add(apply) + + threading.Thread(target=worker, daemon=True).start() + + def render_list(self): + # Clear previous items + for child in self.content_box.get_children(): + self.content_box.remove(child) + + # Filter Local Ports + filtered_ports = [] + for p in self.local_ports: + # Search query matching + query_match = ( + not self.search_query or + self.search_query in str(p['port']) or + self.search_query in p['process_name'].lower() or + self.search_query in p['command'].lower() + ) + + # Hide system process filtering + is_system = p.get('process_name', '').lower() in ["systemd", "init", "sshd", "dbus-daemon", "systemd-resolved"] + system_match = not config.hide_system_processes or not is_system + + if query_match and system_match: + filtered_ports.append(p) + + # Filter K8s Forwards + filtered_k8s = [] + for k in self.k8s_forwards: + query_match = ( + not self.search_query or + self.search_query in str(k.local_port) or + self.search_query in k.resource.lower() or + self.search_query in k.namespace.lower() + ) + if query_match: + filtered_k8s.append(k) + + # Filter CF Tunnels + filtered_cf = [] + for t in self.cf_tunnels: + port_val = t.port if hasattr(t, 'port') else t.get('port', 0) + url_val = t.url if hasattr(t, 'url') else t.get('url', '') + query_match = ( + not self.search_query or + self.search_query in str(port_val) or + self.search_query in str(url_val).lower() + ) + if query_match: + filtered_cf.append(t) + + # Render Cloudflare Tunnels Section + if filtered_cf: + self.add_section_header("Cloudflare Tunnels", "cloud") + for t in filtered_cf: + self.add_tunnel_row(t) + + # Render K8s Port Forwards Section + if filtered_k8s: + self.add_section_header("K8s Port Forward", "network-workgroup") + for k in filtered_k8s: + self.add_k8s_row(k) + + # Render Local Ports Section + if filtered_ports: + self.add_section_header("Local Ports", "network-transmit-receive") + + if config.use_tree_view: + # Group by process name / PID + groups = {} + for p in filtered_ports: + pid = p['pid'] + if pid not in groups: + groups[pid] = { + 'pid': pid, + 'name': p['process_name'], + 'ports': [] + } + groups[pid]['ports'].append(p) + + # Render tree rows + for pid, group in sorted(groups.items(), key=lambda x: x[1]['name'].lower()): + self.add_tree_group_row(group) + else: + # Flat List View + # Sort favorites to top, then by port number + sorted_flat = sorted( + filtered_ports, + key=lambda x: (not config.is_favorite(x['port']), x['port']) + ) + for p in sorted_flat: + self.add_flat_port_row(p) + + # Empty State + if not filtered_ports and not filtered_k8s and not filtered_cf: + empty_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + empty_box.set_margin_top(40) + empty_box.set_margin_bottom(40) + + image = Gtk.Image.new_from_icon_name("network-error", Gtk.IconSize.DIALOG) + label = Gtk.Label(label="No listening ports found") + label.get_style_context().add_class("header-subtitle") + + empty_box.pack_start(image, False, False, 0) + empty_box.pack_start(label, False, False, 0) + self.content_box.pack_start(empty_box, True, True, 0) + + self.content_box.show_all() + + def add_section_header(self, title, icon_name): + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + hbox.get_style_context().add_class("section-header") + + icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU) + label = Gtk.Label(label=title.upper()) + label.get_style_context().add_class("section-label") + + hbox.pack_start(icon, False, False, 0) + hbox.pack_start(label, False, False, 0) + self.content_box.pack_start(hbox, False, False, 0) + + def add_tunnel_row(self, t): + port_val = t.port if hasattr(t, 'port') else t.get('port', 0) + url_val = t.url if hasattr(t, 'url') else t.get('url', '') + status_val = t.status if hasattr(t, 'status') else t.get('status', 'active') + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.get_style_context().add_class("connection-row") + + details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label(label=f"Port {port_val}") + title.get_style_context().add_class("connection-title") + title.set_xalign(0) + + detail_txt = f"{url_val} ({status_val})" + detail = Gtk.Label(label=detail_txt) + detail.get_style_context().add_class("connection-detail") + detail.set_line_wrap(True) + detail.set_xalign(0) + + details_vbox.pack_start(title, False, False, 0) + details_vbox.pack_start(detail, False, False, 0) + row.pack_start(details_vbox, True, True, 0) + + # Stop Button + btn_stop = Gtk.Button() + btn_stop.get_style_context().add_class("menu-btn") + btn_stop.get_style_context().add_class("menu-btn-destructive") + btn_stop.set_image(Gtk.Image.new_from_icon_name("media-playback-stop", Gtk.IconSize.MENU)) + btn_stop.connect("clicked", lambda w, p=port_val: self.stop_cf_tunnel(p)) + row.pack_start(btn_stop, False, False, 0) + + self.content_box.pack_start(row, False, False, 0) + + def stop_cf_tunnel(self, port): + cloudflare_service.stop_tunnel(port) + self.refresh_data() + + def add_k8s_row(self, k): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.get_style_context().add_class("connection-row") + + details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label(label=f"{k.resource} → {k.local_port}:{k.remote_port}") + title.get_style_context().add_class("connection-title") + title.set_xalign(0) + + detail = Gtk.Label(label=f"Namespace: {k.namespace} | PID: {k.pid}") + detail.get_style_context().add_class("connection-detail") + detail.set_xalign(0) + + details_vbox.pack_start(title, False, False, 0) + details_vbox.pack_start(detail, False, False, 0) + row.pack_start(details_vbox, True, True, 0) + + # Stop Button + btn_stop = Gtk.Button() + btn_stop.get_style_context().add_class("menu-btn") + btn_stop.get_style_context().add_class("menu-btn-destructive") + btn_stop.set_image(Gtk.Image.new_from_icon_name("media-playback-stop", Gtk.IconSize.MENU)) + btn_stop.connect("clicked", lambda w, p=k.pid: self.stop_k8s_forward(p)) + row.pack_start(btn_stop, False, False, 0) + + self.content_box.pack_start(row, False, False, 0) + + def stop_k8s_forward(self, pid): + k8s_service.stop_port_forward(pid) + self.refresh_data() + + def add_flat_port_row(self, p): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.get_style_context().add_class("list-row") + + # Favorite Icon Button + is_fav = config.is_favorite(p['port']) + btn_fav = Gtk.Button() + btn_fav.get_style_context().add_class("tree-expand-btn") + fav_icon_name = "starred" if is_fav else "non-starred" + btn_fav.set_image(Gtk.Image.new_from_icon_name(fav_icon_name, Gtk.IconSize.MENU)) + btn_fav.connect("clicked", lambda w, port=p['port']: self.toggle_favorite(port)) + row.pack_start(btn_fav, False, False, 0) + + # Port info + lbl_port = Gtk.Label(label=f"{p['port']}") + lbl_port.get_style_context().add_class("row-port") + lbl_port.set_xalign(0) + row.pack_start(lbl_port, False, False, 4) + + # Arrow separator + lbl_arrow = Gtk.Label(label="→") + lbl_arrow.set_xalign(0) + row.pack_start(lbl_arrow, False, False, 4) + + # Process Name + lbl_proc = Gtk.Label(label=f"{p['process_name']}") + lbl_proc.get_style_context().add_class("row-process") + lbl_proc.set_xalign(0) + row.pack_start(lbl_proc, True, True, 4) + + # Action buttons + btn_copy = Gtk.Button(label="Copy") + btn_copy.get_style_context().add_class("menu-btn") + btn_copy.connect("clicked", lambda w, port=p['port']: self.copy_port_direct(port)) + row.pack_start(btn_copy, False, False, 0) + + if p['pid'] != 0: + btn_kill = Gtk.Button(label="Kill") + btn_kill.get_style_context().add_class("menu-btn") + btn_kill.get_style_context().add_class("menu-btn-destructive") + btn_kill.connect("clicked", lambda w, pid=p['pid'], port=p['port']: self.kill_process_direct(pid, port)) + row.pack_start(btn_kill, False, False, 0) + + self.content_box.pack_start(row, False, False, 0) + + def toggle_favorite(self, port): + if config.is_favorite(port): + config.remove_favorite(port) + else: + config.add_favorite(port) + self.render_list() + + def add_tree_group_row(self, group): + pid = group['pid'] + is_expanded = pid in self.expanded_processes + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.get_style_context().add_class("list-row") + + # Expand/Collapse arrow + btn_toggle = Gtk.Button() + btn_toggle.get_style_context().add_class("tree-expand-btn") + arrow_icon = "pan-down-symbolic" if is_expanded else "pan-end-symbolic" + btn_toggle.set_image(Gtk.Image.new_from_icon_name(arrow_icon, Gtk.IconSize.MENU)) + btn_toggle.connect("clicked", lambda w, p=pid: self.toggle_expand_tree(p)) + row.pack_start(btn_toggle, False, False, 0) + + # Process Name & PID + proc_title = f"{group['name']} (PID {pid if pid != 0 else '?'})" + lbl_proc = Gtk.Label(label=proc_title) + lbl_proc.get_style_context().add_class("row-process") + lbl_proc.set_xalign(0) + row.pack_start(lbl_proc, True, True, 4) + + # Kill Process Button + if pid != 0: + btn_kill = Gtk.Button(label="Kill") + btn_kill.get_style_context().add_class("menu-btn") + btn_kill.get_style_context().add_class("menu-btn-destructive") + btn_kill.connect("clicked", lambda w, p=pid: self.kill_entire_process(p)) + row.pack_start(btn_kill, False, False, 0) + + self.content_box.pack_start(row, False, False, 0) + + # Render child ports if expanded + if is_expanded: + for p in group['ports']: + child_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + child_row.get_style_context().add_class("list-row") + child_row.set_margin_start(24) # Indent + + # Favorite Star + is_fav = config.is_favorite(p['port']) + btn_fav = Gtk.Button() + btn_fav.get_style_context().add_class("tree-expand-btn") + fav_icon_name = "starred" if is_fav else "non-starred" + btn_fav.set_image(Gtk.Image.new_from_icon_name(fav_icon_name, Gtk.IconSize.MENU)) + btn_fav.connect("clicked", lambda w, port=p['port']: self.toggle_favorite(port)) + child_row.pack_start(btn_fav, False, False, 0) + + # Port + lbl_port = Gtk.Label(label=f"Port {p['port']}") + lbl_port.get_style_context().add_class("row-port") + lbl_port.set_xalign(0) + child_row.pack_start(lbl_port, True, True, 4) + + # Action buttons + btn_copy = Gtk.Button(label="Copy") + btn_copy.get_style_context().add_class("menu-btn") + btn_copy.connect("clicked", lambda w, port=p['port']: self.copy_port_direct(port)) + child_row.pack_start(btn_copy, False, False, 0) + + if p['pid'] != 0: + btn_kill = Gtk.Button(label="Kill") + btn_kill.get_style_context().add_class("menu-btn") + btn_kill.get_style_context().add_class("menu-btn-destructive") + btn_kill.connect("clicked", lambda w, pid=p['pid'], port=p['port']: self.kill_process_direct(pid, port)) + child_row.pack_start(btn_kill, False, False, 0) + + self.content_box.pack_start(child_row, False, False, 0) + + def toggle_expand_tree(self, pid): + if pid in self.expanded_processes: + self.expanded_processes.remove(pid) + else: + self.expanded_processes.add(pid) + self.render_list() + + def kill_entire_process(self, pid): + # Graceful by default (SIGTERM, then SIGKILL after a grace period), + # matching the behaviour documented for the other platforms. + if PortScanner.kill_process(pid, force=False): + notify("Process Terminated", f"Process (PID {pid}) has been terminated.") + else: + notify("Kill Failed", f"Could not terminate PID {pid}.") + GLib.timeout_add(200, self.refresh_data) + + def copy_port_direct(self, port): + copy_to_clipboard(str(port)) + notify("Port Copied", f"Port {port} copied to clipboard!") + + def kill_process_direct(self, pid, port=None): + if pid != 0: + target = f"on port {port}" if port else f"(PID {pid})" + if PortScanner.kill_process(pid, force=False): + notify("Process Terminated", f"Process {target} has been terminated.") + else: + notify("Kill Failed", f"Could not terminate process {target}.") + GLib.timeout_add(200, self.refresh_data) + + diff --git a/platforms/linux/tests/test_k8s.py b/platforms/linux/tests/test_k8s.py new file mode 100644 index 0000000..fbce72f --- /dev/null +++ b/platforms/linux/tests/test_k8s.py @@ -0,0 +1,89 @@ +""" +Tests for kubectl port-forward command-line parsing. + +Run with: python3 -m unittest discover -s platforms/linux/tests +""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +from src.services.k8s import K8sService + + +class TestParsePortForwardCmd(unittest.TestCase): + def setUp(self): + self.svc = K8sService() + + def parse(self, cmd): + return self.svc._parse_port_forward_cmd(1234, cmd) + + def test_service_with_namespace(self): + fw = self.parse("kubectl port-forward svc/my-service 8080:80 -n dev") + self.assertEqual(fw.resource, "svc/my-service") + self.assertEqual(fw.local_port, 8080) + self.assertEqual(fw.remote_port, 80) + self.assertEqual(fw.namespace, "dev") + + def test_default_namespace(self): + fw = self.parse("kubectl port-forward pod/my-pod 5432:5432") + self.assertEqual(fw.resource, "pod/my-pod") + self.assertEqual(fw.namespace, "default") + + def test_namespace_with_equals(self): + fw = self.parse("kubectl port-forward svc/api 3000:3000 --namespace=prod") + self.assertEqual(fw.namespace, "prod") + self.assertEqual(fw.resource, "svc/api") + + def test_namespace_flag_value_not_mistaken_for_resource(self): + # "-n dev" comes before the resource; "dev" must not win + fw = self.parse("kubectl port-forward -n dev deployment/web 8000:80") + self.assertEqual(fw.namespace, "dev") + self.assertEqual(fw.resource, "deployment/web") + + def test_context_flag_value_not_mistaken_for_resource(self): + fw = self.parse("kubectl port-forward --context staging svc/api 9090:90") + self.assertEqual(fw.resource, "svc/api") + + def test_bare_pod_name(self): + fw = self.parse("kubectl port-forward mypod 8080:80") + self.assertEqual(fw.resource, "mypod") + + def test_single_port_maps_to_itself(self): + fw = self.parse("kubectl port-forward svc/redis 6379") + self.assertEqual(fw.local_port, 6379) + self.assertEqual(fw.remote_port, 6379) + + def test_grep_lines_are_ignored(self): + self.assertIsNone(self.parse("grep kubectl port-forward")) + + def test_raw_cmd_is_preserved(self): + cmd = "kubectl port-forward svc/my-service 8080:80 -n dev" + self.assertEqual(self.parse(cmd).raw_cmd, cmd) + + +class TestKillAllSafety(unittest.TestCase): + def test_kill_all_only_targets_scanned_pids(self): + """kill_all must not fall back to a broad pkill pattern.""" + svc = K8sService() + killed = [] + + svc.scan_active_forwards = lambda: [ + type("FW", (), {"pid": 111})(), + type("FW", (), {"pid": 222})(), + ] + svc.stop_port_forward = lambda pid: (killed.append(pid), True)[1] + + self.assertTrue(svc.kill_all()) + self.assertEqual(killed, [111, 222]) + + def test_kill_all_reports_failure(self): + svc = K8sService() + svc.scan_active_forwards = lambda: [type("FW", (), {"pid": 111})()] + svc.stop_port_forward = lambda pid: False + self.assertFalse(svc.kill_all()) + + +if __name__ == "__main__": + unittest.main() diff --git a/platforms/linux/tests/test_scanner.py b/platforms/linux/tests/test_scanner.py new file mode 100644 index 0000000..9180aca --- /dev/null +++ b/platforms/linux/tests/test_scanner.py @@ -0,0 +1,145 @@ +""" +Tests for the Linux port scanner output parsers. + +These cover the pure string-parsing paths only; nothing here shells out. + +Run with: python3 -m unittest discover -s platforms/linux/tests +""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +from src.scanner import PortScanner + + +class TestParseAddress(unittest.TestCase): + def test_ipv4(self): + self.assertEqual(PortScanner.parse_address("127.0.0.1:3000"), ("127.0.0.1", 3000)) + self.assertEqual(PortScanner.parse_address("0.0.0.0:443"), ("0.0.0.0", 443)) + + def test_wildcard(self): + self.assertEqual(PortScanner.parse_address("*:8080"), ("*", 8080)) + + def test_empty_address_becomes_wildcard(self): + self.assertEqual(PortScanner.parse_address(":8080"), ("*", 8080)) + + def test_ipv6(self): + self.assertEqual(PortScanner.parse_address("[::1]:3000"), ("[::1]", 3000)) + self.assertEqual(PortScanner.parse_address("[fe80::1]:8080"), ("[fe80::1]", 8080)) + + def test_invalid(self): + self.assertIsNone(PortScanner.parse_address("no-colon-here")) + self.assertIsNone(PortScanner.parse_address("127.0.0.1:notaport")) + self.assertIsNone(PortScanner.parse_address("[::1]")) + + +class TestParseSsUsers(unittest.TestCase): + def test_single(self): + res = PortScanner.parse_ss_users('users:(("node",pid=1234,fd=23))') + self.assertEqual(res, [("node", 1234)]) + + def test_multiple(self): + res = PortScanner.parse_ss_users( + 'users:(("nginx",pid=1235,fd=6),("nginx",pid=1234,fd=6))' + ) + self.assertEqual(res, [("nginx", 1235), ("nginx", 1234)]) + + def test_no_process_info(self): + self.assertEqual(PortScanner.parse_ss_users(""), []) + self.assertEqual(PortScanner.parse_ss_users("0.0.0.0:*"), []) + + def test_malformed_pid_is_skipped(self): + self.assertEqual(PortScanner.parse_ss_users('users:(("x",pid=abc,fd=1))'), []) + + +class TestParseSsOutput(unittest.TestCase): + def _scan(self, output): + # get_process_commands() shells out to ps; stub it for these tests. + original = PortScanner.get_process_commands + PortScanner.get_process_commands = staticmethod(lambda: {}) + try: + return PortScanner.parse_ss_output(output) + finally: + PortScanner.get_process_commands = original + + def test_parses_rows_and_skips_header(self): + output = ( + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n" + 'LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=1234,fd=23))\n' + 'LISTEN 0 511 [::1]:8080 [::]:* users:(("nginx",pid=555,fd=6))\n' + ) + ports = self._scan(output) + self.assertEqual(len(ports), 2) + self.assertEqual(ports[0]["port"], 3000) + self.assertEqual(ports[0]["pid"], 1234) + self.assertEqual(ports[0]["process_name"], "node") + self.assertEqual(ports[0]["address"], "127.0.0.1") + self.assertEqual(ports[1]["port"], 8080) + self.assertEqual(ports[1]["address"], "[::1]") + + def test_localised_header_is_still_skipped(self): + # A translated header must not be parsed as a data row. + output = ( + "Estado Recv-Q Send-Q Direccion local:Puerto Peer Address:Port\n" + 'LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=1,fd=2))\n' + ) + ports = self._scan(output) + self.assertEqual(len(ports), 1) + self.assertEqual(ports[0]["port"], 3000) + + def test_row_without_process_column(self): + # Unprivileged ss omits users:(...) for other users' sockets + output = ( + "State Recv-Q Send-Q Local Address:Port Peer Address:Port\n" + "LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n" + ) + ports = self._scan(output) + self.assertEqual(len(ports), 1) + self.assertEqual(ports[0]["port"], 22) + self.assertEqual(ports[0]["pid"], 0) + self.assertEqual(ports[0]["process_name"], "Unknown") + + def test_results_are_sorted_by_port(self): + output = ( + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n" + 'LISTEN 0 0 127.0.0.1:9000 0.0.0.0:* users:(("b",pid=2,fd=1))\n' + 'LISTEN 0 0 127.0.0.1:3000 0.0.0.0:* users:(("a",pid=1,fd=1))\n' + ) + ports = self._scan(output) + self.assertEqual([p["port"] for p in ports], [3000, 9000]) + + +class TestParseLsofOutput(unittest.TestCase): + def _scan(self, output): + original = PortScanner.get_process_commands + PortScanner.get_process_commands = staticmethod(lambda: {1234: "node server.js"}) + try: + return PortScanner.parse_lsof_output(output) + finally: + PortScanner.get_process_commands = original + + def test_parses_and_dedupes(self): + output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "node 1234 user 23u IPv4 56789 0t0 TCP 127.0.0.1:3000 (LISTEN)\n" + "node 1234 user 24u IPv4 56790 0t0 TCP 127.0.0.1:3000 (LISTEN)\n" + "nginx 555 root 6u IPv6 11111 0t0 TCP [::1]:8080 (LISTEN)\n" + ) + ports = self._scan(output) + self.assertEqual(len(ports), 2) + self.assertEqual(ports[0]["port"], 3000) + self.assertEqual(ports[0]["command"], "node server.js") + self.assertEqual(ports[1]["port"], 8080) + self.assertEqual(ports[1]["address"], "[::1]") + # Falls back to the process name when ps has no entry for the pid + self.assertEqual(ports[1]["command"], "nginx") + + def test_empty_output(self): + self.assertEqual(self._scan(""), []) + self.assertEqual(self._scan("COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/portkiller-core/.gitignore b/portkiller-core/.gitignore new file mode 100644 index 0000000..ca98cd9 --- /dev/null +++ b/portkiller-core/.gitignore @@ -0,0 +1,2 @@ +/target/ +Cargo.lock diff --git a/portkiller-core/Cargo.toml b/portkiller-core/Cargo.toml new file mode 100644 index 0000000..aab3380 --- /dev/null +++ b/portkiller-core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "portkiller-core" +version = "0.1.0" +edition = "2021" +authors = ["PortKiller Team"] +description = "Port scanning and process management library for the Linux app" +license = "MIT" + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["rt-multi-thread", "process", "sync", "time", "macros"] } +thiserror = "2.0" +tracing = "0.1" +uuid = { version = "1.0", features = ["v4"] } + +[dev-dependencies] +tokio-test = "0.4" + +[features] +default = [] +ffi = [] # Enable when building for FFI diff --git a/portkiller-core/rust-toolchain.toml b/portkiller-core/rust-toolchain.toml new file mode 100644 index 0000000..2905adb --- /dev/null +++ b/portkiller-core/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc"] diff --git a/portkiller-core/src/lib.rs b/portkiller-core/src/lib.rs new file mode 100644 index 0000000..1fddeb5 --- /dev/null +++ b/portkiller-core/src/lib.rs @@ -0,0 +1,74 @@ +//! PortKiller Core - port scanning and process management for Linux +//! +//! This library provides: +//! - Scanning TCP listening ports (via `lsof`, falling back to `ss`) +//! - Killing processes by PID (SIGTERM, then SIGKILL) +//! +//! macOS and Windows are served by their own native apps (Swift and .NET) +//! and do not depend on this crate. + +pub mod models; +pub mod process; +pub mod scanner; + +// Re-export main types +pub use models::{PortInfo, ProcessType}; +pub use process::{kill_process, kill_process_gracefully, ProcessManager}; +pub use scanner::{scan_ports, PortScanner}; + +/// Library version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Main entry point for the PortKiller core functionality +pub struct PortKillerCore { + scanner: scanner::PlatformScanner, + process_manager: process::PlatformProcessManager, +} + +impl PortKillerCore { + /// Create a new PortKillerCore instance + pub fn new() -> Self { + Self { + scanner: scanner::PlatformScanner::new(), + process_manager: process::PlatformProcessManager::new(), + } + } + + /// Scan for all listening TCP ports + pub async fn scan_ports(&self) -> Result, scanner::ScanError> { + self.scanner.scan_ports().await + } + + /// Kill a process by PID with graceful shutdown (SIGTERM then SIGKILL) + pub async fn kill_process_gracefully(&self, pid: u32) -> Result { + self.process_manager.kill_gracefully(pid).await + } + + /// Kill a process by PID immediately (force kill) + pub async fn kill_process_force(&self, pid: u32) -> Result { + self.process_manager.kill_force(pid).await + } + + /// Get PIDs of processes using a specific port + pub async fn get_pids_on_port(&self, port: u16) -> Result, scanner::ScanError> { + self.scanner.get_pids_on_port(port).await + } +} + +impl Default for PortKillerCore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_scan_ports() { + let core = PortKillerCore::new(); + let result = core.scan_ports().await; + assert!(result.is_ok()); + } +} diff --git a/portkiller-core/src/models.rs b/portkiller-core/src/models.rs new file mode 100644 index 0000000..3eea601 --- /dev/null +++ b/portkiller-core/src/models.rs @@ -0,0 +1,361 @@ +//! Core data models for PortKiller +//! +//! These models are platform-agnostic and used across all platforms. + +use serde::{Deserialize, Serialize}; + +/// Information about a listening port and its associated process +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortInfo { + /// The port number + pub port: u16, + + /// Process ID + pub pid: u32, + + /// Short process name (e.g., "node", "python") + pub process_name: String, + + /// Full command line + pub command: String, + + /// Listening address (e.g., "127.0.0.1", "*", "::1") + pub address: String, + + /// Categorized process type + pub process_type: ProcessType, + + /// Whether the port is currently active/listening + pub is_active: bool, +} + +impl PortInfo { + /// Create a new PortInfo + pub fn new( + port: u16, + pid: u32, + process_name: String, + command: String, + address: String, + ) -> Self { + let process_type = ProcessType::detect(&process_name, &command); + Self { + port, + pid, + process_name, + command, + address, + process_type, + is_active: true, + } + } + + /// Create with explicit process type + pub fn with_type( + port: u16, + pid: u32, + process_name: String, + command: String, + address: String, + process_type: ProcessType, + ) -> Self { + Self { + port, + pid, + process_name, + command, + address, + process_type, + is_active: true, + } + } +} + +/// Categorization of process types for filtering and display +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum ProcessType { + /// Web servers: nginx, apache, caddy, etc. + WebServer = 0, + /// Databases: postgres, mysql, redis, mongo, etc. + Database = 1, + /// Development tools: node, python, cargo, vite, etc. + Development = 2, + /// System processes: launchd, kernel, etc. + System = 3, + /// Everything else + #[default] + Other = 4, +} + +impl ProcessType { + /// Detect process type from process name and command + pub fn detect(process_name: &str, command: &str) -> Self { + let name_lower = process_name.to_lowercase(); + let cmd_lower = command.to_lowercase(); + + // Check both name and command for patterns + let check = |pattern: &str| name_lower.contains(pattern) || cmd_lower.contains(pattern); + + // Web servers + if check("nginx") + || check("apache") + || check("httpd") + || check("caddy") + || check("traefik") + || check("lighttpd") + || check("haproxy") + { + return ProcessType::WebServer; + } + + // Databases + if check("postgres") + || check("mysql") + || check("mariadb") + || check("redis") + || check("mongo") + || check("sqlite") + || check("cockroach") + || check("clickhouse") + || check("cassandra") + || check("elasticsearch") + || check("memcached") + { + return ProcessType::Database; + } + + // Development tools + if check("node") + || check("npm") + || check("yarn") + || check("pnpm") + || check("bun") + || check("deno") + || check("python") + || check("ruby") + || check("php") + || check("java") + || check("kotlin") + || check("scala") + || check("go") + || check("cargo") + || check("rustc") + || check("swift") + || check("dotnet") + || check("vite") + || check("webpack") + || check("esbuild") + || check("next") + || check("nuxt") + || check("remix") + || check("turbo") + || check("expo") + || check("flutter") + { + return ProcessType::Development; + } + + // System processes (macOS) + if check("launchd") + || check("rapportd") + || check("sharingd") + || check("airplay") + || check("control") + || check("kernel") + || check("mds") + || check("spotlight") + || check("coreaudio") + || check("windowserver") + { + return ProcessType::System; + } + + // System processes (Windows) + if check("svchost") + || check("system") + || check("lsass") + || check("csrss") + || check("services") + || check("wininit") + || check("smss") + { + return ProcessType::System; + } + + ProcessType::Other + } + + /// Get display name for the process type + pub fn display_name(&self) -> &'static str { + match self { + ProcessType::WebServer => "Web Server", + ProcessType::Database => "Database", + ProcessType::Development => "Development", + ProcessType::System => "System", + ProcessType::Other => "Other", + } + } + + /// Get icon name for the process type + pub fn icon_name(&self) -> &'static str { + match self { + ProcessType::WebServer => "globe", + ProcessType::Database => "cylinder", + ProcessType::Development => "hammer", + ProcessType::System => "gearshape", + ProcessType::Other => "questionmark.circle", + } + } +} + +impl From for ProcessType { + fn from(value: u8) -> Self { + match value { + 0 => ProcessType::WebServer, + 1 => ProcessType::Database, + 2 => ProcessType::Development, + 3 => ProcessType::System, + _ => ProcessType::Other, + } + } +} + +impl From for u8 { + fn from(value: ProcessType) -> Self { + value as u8 + } +} + +/// Filter criteria for ports +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PortFilter { + /// Text search (matches port, process name, command) + pub search_text: Option, + + /// Filter by process type + pub process_type: Option, + + /// Only show ports in this range + pub port_range: Option<(u16, u16)>, + + /// Only show favorite ports + pub favorites_only: bool, +} + +impl PortFilter { + /// Check if a port matches this filter + pub fn matches(&self, port: &PortInfo, favorites: &[u16]) -> bool { + // Search text filter + if let Some(ref text) = self.search_text { + let text_lower = text.to_lowercase(); + let port_str = port.port.to_string(); + if !port_str.contains(&text_lower) + && !port.process_name.to_lowercase().contains(&text_lower) + && !port.command.to_lowercase().contains(&text_lower) + { + return false; + } + } + + // Process type filter + if let Some(ref ptype) = self.process_type { + if port.process_type != *ptype { + return false; + } + } + + // Port range filter + if let Some((min, max)) = self.port_range { + if port.port < min || port.port > max { + return false; + } + } + + // Favorites filter + if self.favorites_only && !favorites.contains(&port.port) { + return false; + } + + true + } +} + +/// A watched port configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchedPort { + /// The port number to watch + pub port: u16, + + /// Whether to send notifications when status changes + pub notify_on_change: bool, + + /// Last known state + pub last_active: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_type_detection() { + assert_eq!( + ProcessType::detect("nginx", "nginx -g daemon off"), + ProcessType::WebServer + ); + assert_eq!( + ProcessType::detect("postgres", "/usr/bin/postgres"), + ProcessType::Database + ); + assert_eq!( + ProcessType::detect("node", "node server.js"), + ProcessType::Development + ); + assert_eq!( + ProcessType::detect("launchd", "/sbin/launchd"), + ProcessType::System + ); + assert_eq!( + ProcessType::detect("unknown", "some random process"), + ProcessType::Other + ); + } + + #[test] + fn test_port_filter() { + let port = PortInfo::new( + 3000, + 1234, + "node".to_string(), + "node server.js".to_string(), + "127.0.0.1".to_string(), + ); + + let filter = PortFilter { + search_text: Some("node".to_string()), + ..Default::default() + }; + assert!(filter.matches(&port, &[])); + + let filter = PortFilter { + search_text: Some("python".to_string()), + ..Default::default() + }; + assert!(!filter.matches(&port, &[])); + + let filter = PortFilter { + process_type: Some(ProcessType::Development), + ..Default::default() + }; + assert!(filter.matches(&port, &[])); + } + + #[test] + fn test_process_type_conversion() { + assert_eq!(u8::from(ProcessType::WebServer), 0); + assert_eq!(u8::from(ProcessType::Database), 1); + assert_eq!(ProcessType::from(0u8), ProcessType::WebServer); + assert_eq!(ProcessType::from(255u8), ProcessType::Other); + } +} diff --git a/portkiller-core/src/process/linux.rs b/portkiller-core/src/process/linux.rs new file mode 100644 index 0000000..012d89d --- /dev/null +++ b/portkiller-core/src/process/linux.rs @@ -0,0 +1,171 @@ +//! Linux-specific process management implementation +//! +//! Uses the following system commands: +//! - `kill -15 PID` for SIGTERM (graceful) +//! - `kill -9 PID` for SIGKILL (force) +//! - `ps -p PID` to check if process is running + +use super::{KillError, ProcessManager}; +use tokio::process::Command; +use tokio::time::{sleep, Duration}; +use tracing::{debug, warn}; + +/// Grace period to wait between SIGTERM and SIGKILL (500ms) +const GRACEFUL_KILL_TIMEOUT_MS: u64 = 500; + +/// Linux process manager implementation +/// +/// This implementation uses the standard Unix signals: +/// - SIGTERM (15): Graceful termination request +/// - SIGKILL (9): Immediate forced termination +#[derive(Debug, Default)] +pub struct LinuxProcessManager; + +impl LinuxProcessManager { + /// Create a new LinuxProcessManager instance + pub fn new() -> Self { + Self + } + + /// Send a signal to a process using kill + async fn send_signal(&self, pid: u32, signal: &str) -> Result { + debug!(pid = pid, signal = signal, "Sending signal to process"); + + let output = Command::new("kill") + .arg(format!("-{}", signal)) + .arg(pid.to_string()) + .output() + .await?; + + if output.status.success() { + debug!(pid = pid, signal = signal, "Signal sent successfully"); + return Ok(true); + } + + // Check stderr for common error conditions + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("No such process") { + debug!(pid = pid, "Process not found"); + return Err(KillError::ProcessNotFound(pid)); + } + + if stderr.contains("Operation not permitted") || stderr.contains("Permission denied") { + warn!(pid = pid, "Permission denied to kill process"); + return Err(KillError::PermissionDenied(pid)); + } + + // Exit code 1 often means the process doesn't exist + if output.status.code() == Some(1) { + debug!(pid = pid, "Process may not exist (exit code 1)"); + return Ok(false); + } + + Err(KillError::CommandFailed(format!( + "kill -{} {} failed: {}", + signal, + pid, + stderr.trim() + ))) + } +} + +impl ProcessManager for LinuxProcessManager { + /// Kill a process gracefully with fallback to force kill + async fn kill_gracefully(&self, pid: u32) -> Result { + debug!(pid = pid, "Attempting graceful kill"); + + // Step 1: Send SIGTERM + match self.send_signal(pid, "15").await { + Ok(true) => { + debug!(pid = pid, "SIGTERM sent, waiting for process to terminate"); + } + Ok(false) => { + debug!(pid = pid, "SIGTERM returned false, process may be gone"); + } + Err(KillError::ProcessNotFound(_)) => { + debug!(pid = pid, "Process not found, already terminated"); + return Ok(true); + } + Err(e) => { + warn!(pid = pid, error = %e, "Failed to send SIGTERM"); + return Err(e); + } + } + + // Step 2: Wait for grace period + sleep(Duration::from_millis(GRACEFUL_KILL_TIMEOUT_MS)).await; + + // Step 3: Check if process is still running + if !self.is_process_running(pid).await { + debug!(pid = pid, "Process terminated after SIGTERM"); + return Ok(true); + } + + // Step 4: Force kill with SIGKILL + debug!(pid = pid, "Process still running, sending SIGKILL"); + self.kill_force(pid).await + } + + /// Force kill a process with SIGKILL (signal 9) + async fn kill_force(&self, pid: u32) -> Result { + debug!(pid = pid, "Force killing process with SIGKILL"); + + match self.send_signal(pid, "9").await { + Ok(true) => { + debug!(pid = pid, "SIGKILL sent successfully"); + Ok(true) + } + Ok(false) => { + debug!(pid = pid, "SIGKILL returned false"); + Ok(false) + } + Err(KillError::ProcessNotFound(_)) => { + debug!(pid = pid, "Process not found during force kill"); + Ok(true) + } + Err(e) => Err(e), + } + } + + /// Check if a process is currently running using `ps -p PID` + async fn is_process_running(&self, pid: u32) -> bool { + let result = Command::new("ps") + .arg("-p") + .arg(pid.to_string()) + .output() + .await; + + match result { + Ok(output) => { + let running = output.status.success(); + debug!(pid = pid, running = running, "Process running check"); + running + } + Err(e) => { + warn!(pid = pid, error = %e, "Failed to check if process is running"); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_is_process_running_current_process() { + let manager = LinuxProcessManager::new(); + let current_pid = std::process::id(); + + assert!(manager.is_process_running(current_pid).await); + } + + #[tokio::test] + async fn test_is_process_running_nonexistent() { + let manager = LinuxProcessManager::new(); + let fake_pid = 999999999; + assert!(!manager.is_process_running(fake_pid).await); + } +} diff --git a/portkiller-core/src/process/mod.rs b/portkiller-core/src/process/mod.rs new file mode 100644 index 0000000..6d69fbc --- /dev/null +++ b/portkiller-core/src/process/mod.rs @@ -0,0 +1,191 @@ +//! Process management module for killing processes by PID +//! +//! Supports both graceful (SIGTERM) and forced (SIGKILL) termination. +//! +//! # Graceful Kill Pattern +//! +//! The graceful kill follows this pattern: +//! 1. Send SIGTERM to request graceful shutdown +//! 2. Wait 500ms for the process to clean up +//! 3. Check if process is still running +//! 4. If still running, send SIGKILL for immediate termination + +use thiserror::Error; + +// Only Linux is implemented here. macOS and Windows have their own native +// apps (Swift and .NET) that do not use this crate. +#[cfg(target_os = "linux")] +mod linux; + +/// Errors that can occur during process killing +#[derive(Debug, Error)] +pub enum KillError { + /// The specified process was not found + #[error("Process with PID {0} not found")] + ProcessNotFound(u32), + + /// Permission denied to kill the process + #[error("Permission denied to kill process {0}")] + PermissionDenied(u32), + + /// Failed to execute the kill command + #[error("Failed to execute kill command: {0}")] + CommandFailed(String), + + /// The process could not be terminated + #[error("Failed to terminate process {0}: {1}")] + TerminationFailed(u32, String), + + /// An I/O error occurred + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), +} + +/// Trait for platform-specific process management +/// +/// This trait defines the interface for killing processes and checking +/// their running status. +#[allow(async_fn_in_trait)] +pub trait ProcessManager: Send + Sync { + /// Kill a process gracefully with fallback to force kill + /// + /// This method: + /// 1. Sends SIGTERM for graceful shutdown + /// 2. Waits 500ms for the process to terminate + /// 3. If still running, sends SIGKILL + /// + /// # Arguments + /// + /// * `pid` - The process ID to kill + /// + /// # Returns + /// + /// * `Ok(true)` - The process was successfully terminated + /// * `Ok(false)` - The kill command executed but process may still be running + /// * `Err(KillError)` - An error occurred during the kill operation + async fn kill_gracefully(&self, pid: u32) -> Result; + + /// Force kill a process immediately (SIGKILL / taskkill /F) + /// + /// This method immediately terminates the process without giving it + /// a chance to clean up. Use `kill_gracefully` when possible to allow + /// the process to: + /// - Close file handles properly + /// - Flush buffers to disk + /// - Send shutdown notifications + /// - Clean up temporary resources + /// + /// # Arguments + /// + /// * `pid` - The process ID to kill + /// + /// # Returns + /// + /// * `Ok(true)` - The process was successfully terminated + /// * `Ok(false)` - The kill command executed but process may still be running + /// * `Err(KillError)` - An error occurred during the kill operation + async fn kill_force(&self, pid: u32) -> Result; + + /// Check if a process is currently running + /// + /// # Arguments + /// + /// * `pid` - The process ID to check + /// + /// # Returns + /// + /// * `true` - The process is running + /// * `false` - The process is not running or doesn't exist + async fn is_process_running(&self, pid: u32) -> bool; +} + +// Platform-specific exports +#[cfg(target_os = "linux")] +pub use linux::LinuxProcessManager as PlatformProcessManager; + +// Fallback for unsupported platforms (compile-time check) +#[cfg(not(target_os = "linux"))] +compile_error!("Unsupported platform: portkiller-core currently targets Linux only"); + +/// Kill a process by PID with graceful shutdown, falling back to force kill +/// +/// This is a convenience function that creates a default process manager +/// and calls `kill_gracefully`. +/// +/// # Arguments +/// +/// * `pid` - The process ID to kill +/// +/// # Returns +/// +/// * `Ok(true)` - The process was successfully terminated +/// * `Ok(false)` - The kill command executed but process may still be running +/// * `Err(KillError)` - An error occurred during the kill operation +/// +/// # Example +/// +/// ```no_run +/// use portkiller_core::process::kill_process_gracefully; +/// +/// # async fn example() -> Result<(), Box> { +/// let success = kill_process_gracefully(1234).await?; +/// if success { +/// println!("Process terminated successfully"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub async fn kill_process_gracefully(pid: u32) -> Result { + let manager = PlatformProcessManager::new(); + manager.kill_gracefully(pid).await +} + +/// Kill a process by PID immediately (force kill) +/// +/// This is a convenience function that creates a default process manager +/// and calls `kill_force`. +/// +/// # Arguments +/// +/// * `pid` - The process ID to kill +/// +/// # Returns +/// +/// * `Ok(true)` - The process was successfully terminated +/// * `Ok(false)` - The kill command executed but process may still be running +/// * `Err(KillError)` - An error occurred during the kill operation +/// +/// # Example +/// +/// ```no_run +/// use portkiller_core::process::kill_process; +/// +/// # async fn example() -> Result<(), Box> { +/// let success = kill_process(1234).await?; +/// if success { +/// println!("Process terminated successfully"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub async fn kill_process(pid: u32) -> Result { + let manager = PlatformProcessManager::new(); + manager.kill_force(pid).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kill_error_display() { + let err = KillError::ProcessNotFound(1234); + assert!(err.to_string().contains("1234")); + + let err = KillError::PermissionDenied(5678); + assert!(err.to_string().contains("5678")); + + let err = KillError::CommandFailed("test error".to_string()); + assert!(err.to_string().contains("test error")); + } +} diff --git a/portkiller-core/src/scanner/linux.rs b/portkiller-core/src/scanner/linux.rs new file mode 100644 index 0000000..1a8f7d0 --- /dev/null +++ b/portkiller-core/src/scanner/linux.rs @@ -0,0 +1,570 @@ +//! Linux-specific port scanner implementation +//! +//! Uses `lsof` as the primary scanner and falls back to `ss` if `lsof` is not installed. +//! Process names and full commands are enriched using `ps`. + +use super::{PortScanner, ScanError}; +use crate::models::PortInfo; +use std::collections::{HashMap, HashSet}; +use tokio::process::Command; +use tracing::debug; + +/// Linux port scanner implementation +#[derive(Debug, Default)] +pub struct LinuxScanner; + +impl LinuxScanner { + /// Create a new LinuxScanner instance + pub fn new() -> Self { + Self + } + + /// Get PIDs of processes using a specific port + /// + /// Tries `lsof -ti tcp:` first, and falls back to `ss` if `lsof` is not available. + pub async fn get_pids_on_port(&self, port: u16) -> Result, ScanError> { + // Try lsof first + match Command::new("lsof") + .args(["-ti", &format!("tcp:{}", port)]) + .output() + .await + { + Ok(output) => { + if output.status.success() || !output.stdout.is_empty() { + let stdout = String::from_utf8_lossy(&output.stdout); + let pids: Vec = stdout + .lines() + .filter_map(|line| line.trim().parse().ok()) + .collect(); + return Ok(pids); + } + } + Err(e) => { + debug!(error = %e, "lsof unavailable, falling back to ss"); + } + } + + // Fallback to ss + self.get_pids_on_port_ss(port).await + } + + /// Fallback implementation to get PIDs using ss + async fn get_pids_on_port_ss(&self, port: u16) -> Result, ScanError> { + let output = Command::new("ss").args(["-tlnp"]).output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ScanError::CommandError(format!("ss failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut pids = Vec::new(); + + for line in stdout.lines() { + if line.is_empty() { + continue; + } + + let columns: Vec<&str> = line.split_whitespace().collect(); + if columns.len() < 4 { + continue; + } + + // Local address is in column 3 (0-indexed) + let local_addr = columns[3]; + let last_colon = match local_addr.rfind(':') { + Some(idx) => idx, + None => continue, + }; + let port_str = &local_addr[last_colon + 1..]; + let line_port: u16 = match port_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + + if line_port == port && columns.len() >= 6 { + // Process info is in the last column + let proc_col = columns[5]; + for (_, pid) in Self::parse_ss_users(proc_col) { + pids.push(pid); + } + } + } + + pids.sort(); + pids.dedup(); + Ok(pids) + } + + /// Get full command line information for all processes using `ps` + /// + /// Executes: `ps -axo pid,command` + async fn get_process_commands(&self) -> Result, ScanError> { + let output = Command::new("ps") + .args(["-axo", "pid,command"]) + .output() + .await?; + + if !output.status.success() { + return Err(ScanError::CommandError(format!( + "ps failed with status: {}", + output.status + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut commands = HashMap::new(); + + // Skip header line + for line in stdout.lines().skip(1) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Parse "PID COMMAND" format + let parts: Vec<&str> = trimmed.splitn(2, ' ').collect(); + if parts.len() < 2 { + continue; + } + + let pid_str = parts[0].trim(); + let pid: u32 = match pid_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + + let full_command = parts[1].trim(); + let command = if full_command.len() > 200 { + format!("{}...", &full_command[..200]) + } else { + full_command.to_string() + }; + + commands.insert(pid, command); + } + + Ok(commands) + } + + /// Decode escaped characters in lsof output (e.g. `\x20` -> space) + /// + /// Decoded bytes are accumulated as raw bytes rather than pushed as `char`s: + /// lsof escapes each byte of a multi-byte UTF-8 sequence separately, so + /// `byte as char` would map e.g. `\xc3\xa9` to "é" instead of "é". + fn decode_escaped(input: &str) -> String { + let mut bytes: Vec = Vec::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + if chars.peek() == Some(&'x') { + chars.next(); // consume 'x' + + let mut hex = String::with_capacity(2); + for _ in 0..2 { + if let Some(&c) = chars.peek() { + if c.is_ascii_hexdigit() { + hex.push(chars.next().unwrap()); + } else { + break; + } + } + } + + if hex.len() == 2 { + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + bytes.push(byte); + continue; + } + } + + bytes.push(b'\\'); + bytes.push(b'x'); + bytes.extend_from_slice(hex.as_bytes()); + } else { + let mut buf = [0u8; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } else { + let mut buf = [0u8; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + + String::from_utf8_lossy(&bytes).into_owned() + } + + /// Parse an address:port string into (address, port) + fn parse_address(address: &str) -> Option<(String, u16)> { + if address.starts_with('[') { + let bracket_end = address.find(']')?; + if bracket_end + 1 >= address.len() { + return None; + } + + let after_bracket = &address[bracket_end + 1..]; + if !after_bracket.starts_with(':') { + return None; + } + + let addr = address[..=bracket_end].to_string(); + let port_str = &after_bracket[1..]; + let port: u16 = port_str.parse().ok()?; + + Some((addr, port)) + } else { + let last_colon = address.rfind(':')?; + let addr = &address[..last_colon]; + let port_str = &address[last_colon + 1..]; + let port: u16 = port_str.parse().ok()?; + + let addr = if addr.is_empty() { "*" } else { addr }; + Some((addr.to_string(), port)) + } + } + + /// Parse users from ss output process column (e.g. `users:(("nginx",pid=1234,fd=5))`) + fn parse_ss_users(users_str: &str) -> Vec<(String, u32)> { + let mut results = Vec::new(); + if let Some(start_idx) = users_str.find("users:(") { + let content = &users_str[start_idx + 7..users_str.len() - 1]; + for part in content.split("),(") { + let clean_part = part.trim_start_matches('(').trim_end_matches(')'); + let fields: Vec<&str> = clean_part.split(',').collect(); + if fields.len() >= 2 { + let process_name = fields[0].trim_matches('"').to_string(); + let pid_str = fields[1].trim(); + if let Some(digits) = pid_str.strip_prefix("pid=") { + if let Ok(pid) = digits.parse::() { + results.push((process_name, pid)); + } + } + } + } + } + results + } + + /// Parse lsof output into PortInfo objects + fn parse_lsof_output( + output: &str, + commands: &HashMap, + ) -> Result, ScanError> { + let mut ports = Vec::new(); + let mut seen = HashSet::new(); + + for line in output.lines().skip(1) { + if line.is_empty() { + continue; + } + + let columns: Vec<&str> = line.split_whitespace().collect(); + if columns.len() < 9 { + continue; + } + + let process_name = Self::decode_escaped(columns[0]); + let pid: u32 = match columns[1].parse() { + Ok(p) => p, + Err(_) => continue, + }; + + let mut address_part = None; + for i in (8..columns.len()).rev() { + let col = columns[i]; + if col.contains(':') && !col.starts_with("0x") && !col.starts_with("0t") { + address_part = Some(col); + break; + } + } + + let address_str = match address_part { + Some(a) => a, + None => continue, + }; + + let (address, port) = match Self::parse_address(address_str) { + Some((a, p)) => (a, p), + None => continue, + }; + + let command = commands + .get(&pid) + .cloned() + .unwrap_or_else(|| process_name.clone()); + + if !seen.insert((port, pid)) { + continue; + } + + ports.push(PortInfo::new(port, pid, process_name, command, address)); + } + + ports.sort_by_key(|p| p.port); + Ok(ports) + } + + /// Parse ss output into PortInfo objects + fn parse_ss_output(&self, output: &str, commands: &HashMap) -> Vec { + let mut ports = Vec::new(); + let mut seen = HashSet::new(); + + for line in output.lines() { + if line.is_empty() { + continue; + } + + let columns: Vec<&str> = line.split_whitespace().collect(); + if columns.len() < 4 { + continue; + } + + // Local address is in column 3 (0-indexed) + let local_addr = columns[3]; + let last_colon = match local_addr.rfind(':') { + Some(idx) => idx, + None => continue, + }; + let port_str = &local_addr[last_colon + 1..]; + let port: u16 = match port_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + + let address = &local_addr[..last_colon]; + let address = if address.is_empty() { "*" } else { address }; + + // Check if process column (5) exists + if columns.len() >= 6 { + let proc_col = columns[5]; + let users = Self::parse_ss_users(proc_col); + if users.is_empty() { + // Fallback to active port without process info + if seen.insert((port, 0)) { + ports.push(PortInfo::new( + port, + 0, + "Unknown".to_string(), + "Unknown".to_string(), + address.to_string(), + )); + } + } else { + for (process_name, pid) in users { + if !seen.insert((port, pid)) { + continue; + } + + let command = commands + .get(&pid) + .cloned() + .unwrap_or_else(|| process_name.clone()); + ports.push(PortInfo::new( + port, + pid, + process_name, + command, + address.to_string(), + )); + } + } + } else { + // No process info available + if seen.insert((port, 0)) { + ports.push(PortInfo::new( + port, + 0, + "Unknown".to_string(), + "Unknown".to_string(), + address.to_string(), + )); + } + } + } + + ports.sort_by_key(|p| p.port); + ports + } +} + +impl PortScanner for LinuxScanner { + async fn scan_ports(&self) -> Result, ScanError> { + let commands = self.get_process_commands().await.unwrap_or_default(); + + // Try lsof first + match Command::new("lsof") + .args(["-iTCP", "-sTCP:LISTEN", "-P", "-n", "+c", "0"]) + .output() + .await + { + Ok(output) => { + if output.status.success() || !output.stdout.is_empty() { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.is_empty() { + return Self::parse_lsof_output(&stdout, &commands); + } + } + } + Err(e) => { + debug!(error = %e, "lsof unavailable, falling back to ss"); + } + } + + // Fall back to ss + let output = Command::new("ss").args(["-tlnp"]).output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ScanError::CommandError(format!("ss failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(self.parse_ss_output(&stdout, &commands)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ss_users_single() { + let input = "users:((\"github-desktop\",pid=19282,fd=49))"; + let res = LinuxScanner::parse_ss_users(input); + assert_eq!(res.len(), 1); + assert_eq!(res[0].0, "github-desktop"); + assert_eq!(res[0].1, 19282); + } + + #[test] + fn test_parse_ss_users_multiple() { + let input = "users:((\"nginx\",pid=1234,fd=5),(\"nginx\",pid=1235,fd=5))"; + let res = LinuxScanner::parse_ss_users(input); + assert_eq!(res.len(), 2); + assert_eq!(res[0].0, "nginx"); + assert_eq!(res[0].1, 1234); + assert_eq!(res[1].0, "nginx"); + assert_eq!(res[1].1, 1235); + } + + #[test] + fn test_parse_address_ipv4() { + assert_eq!( + LinuxScanner::parse_address("127.0.0.1:3000"), + Some(("127.0.0.1".to_string(), 3000)) + ); + assert_eq!( + LinuxScanner::parse_address("*:8080"), + Some(("*".to_string(), 8080)) + ); + assert_eq!( + LinuxScanner::parse_address("0.0.0.0:443"), + Some(("0.0.0.0".to_string(), 443)) + ); + } + + #[test] + fn test_parse_address_ipv6() { + assert_eq!( + LinuxScanner::parse_address("[::1]:3000"), + Some(("[::1]".to_string(), 3000)) + ); + assert_eq!( + LinuxScanner::parse_address("[fe80::1]:8080"), + Some(("[fe80::1]".to_string(), 8080)) + ); + } + + #[test] + fn test_parse_address_invalid() { + assert_eq!(LinuxScanner::parse_address("no-colon-here"), None); + assert_eq!(LinuxScanner::parse_address("127.0.0.1:notaport"), None); + assert_eq!(LinuxScanner::parse_address("[::1]"), None); + } + + #[test] + fn test_decode_escaped_ascii() { + assert_eq!(LinuxScanner::decode_escaped("my\\x20app"), "my app"); + assert_eq!(LinuxScanner::decode_escaped("plain"), "plain"); + } + + #[test] + fn test_decode_escaped_multibyte_utf8() { + // lsof escapes each byte of a UTF-8 sequence separately; "\xc3\xa9" is "é" + assert_eq!(LinuxScanner::decode_escaped("caf\\xc3\\xa9"), "café"); + } + + #[test] + fn test_decode_escaped_incomplete_sequence_is_preserved() { + assert_eq!(LinuxScanner::decode_escaped("bad\\xzz"), "bad\\xzz"); + } + + #[test] + fn test_parse_ss_users_no_process_info() { + assert!(LinuxScanner::parse_ss_users("").is_empty()); + assert!(LinuxScanner::parse_ss_users("0.0.0.0:*").is_empty()); + } + + #[test] + fn test_parse_ss_output_skips_header_and_parses_rows() { + let scanner = LinuxScanner::new(); + let output = "\ +State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:((\"node\",pid=1234,fd=23)) +LISTEN 0 511 [::1]:8080 [::]:* users:((\"nginx\",pid=555,fd=6)) +"; + let commands = HashMap::new(); + let ports = scanner.parse_ss_output(output, &commands); + + assert_eq!(ports.len(), 2); + assert_eq!(ports[0].port, 3000); + assert_eq!(ports[0].pid, 1234); + assert_eq!(ports[0].process_name, "node"); + assert_eq!(ports[0].address, "127.0.0.1"); + assert_eq!(ports[1].port, 8080); + assert_eq!(ports[1].pid, 555); + assert_eq!(ports[1].address, "[::1]"); + } + + #[test] + fn test_parse_ss_output_without_process_column() { + let scanner = LinuxScanner::new(); + // Unprivileged `ss` omits users:(...) for sockets owned by other users + let output = "\ +State Recv-Q Send-Q Local Address:Port Peer Address:Port +LISTEN 0 128 0.0.0.0:22 0.0.0.0:* +"; + let commands = HashMap::new(); + let ports = scanner.parse_ss_output(output, &commands); + + assert_eq!(ports.len(), 1); + assert_eq!(ports[0].port, 22); + assert_eq!(ports[0].pid, 0); + assert_eq!(ports[0].process_name, "Unknown"); + } + + #[test] + fn test_parse_lsof_output_parses_and_dedupes() { + let output = "\ +COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 1234 user 23u IPv4 56789 0t0 TCP 127.0.0.1:3000 (LISTEN) +node 1234 user 24u IPv4 56790 0t0 TCP 127.0.0.1:3000 (LISTEN) +nginx 555 root 6u IPv6 11111 0t0 TCP [::1]:8080 (LISTEN) +"; + let mut commands = HashMap::new(); + commands.insert(1234u32, "node server.js".to_string()); + + let ports = LinuxScanner::parse_lsof_output(output, &commands).unwrap(); + + // The duplicate (port, pid) row is collapsed + assert_eq!(ports.len(), 2); + assert_eq!(ports[0].port, 3000); + assert_eq!(ports[0].pid, 1234); + assert_eq!(ports[0].command, "node server.js"); + assert_eq!(ports[1].port, 8080); + assert_eq!(ports[1].address, "[::1]"); + // Falls back to the process name when ps had no entry for the pid + assert_eq!(ports[1].command, "nginx"); + } +} diff --git a/portkiller-core/src/scanner/mod.rs b/portkiller-core/src/scanner/mod.rs new file mode 100644 index 0000000..a271e70 --- /dev/null +++ b/portkiller-core/src/scanner/mod.rs @@ -0,0 +1,96 @@ +//! Port scanning module +//! +//! This module provides platform-specific implementations for scanning +//! TCP listening ports on the system. + +use crate::models::PortInfo; +use thiserror::Error; + +// Platform-specific implementations +// +// Only Linux is implemented here. macOS and Windows have their own native +// apps (Swift and .NET) that do not use this crate. +#[cfg(target_os = "linux")] +mod linux; + +// Re-export platform-specific scanner as PlatformScanner +#[cfg(target_os = "linux")] +pub use linux::LinuxScanner as PlatformScanner; + +// Stub for unsupported platforms +#[cfg(not(target_os = "linux"))] +pub struct PlatformScanner; + +#[cfg(not(target_os = "linux"))] +impl PlatformScanner { + pub fn new() -> Self { + Self + } +} + +#[cfg(not(target_os = "linux"))] +impl PlatformScanner { + pub async fn get_pids_on_port(&self, _port: u16) -> Result, ScanError> { + Err(ScanError::PlatformNotSupported) + } +} + +#[cfg(not(target_os = "linux"))] +impl PortScanner for PlatformScanner { + async fn scan_ports(&self) -> Result, ScanError> { + Err(ScanError::PlatformNotSupported) + } +} + +/// Errors that can occur during port scanning +#[derive(Debug, Error)] +pub enum ScanError { + /// Failed to execute the scanning command + #[error("Failed to execute scan command: {0}")] + CommandError(String), + + /// Failed to parse command output + #[error("Failed to parse output: {0}")] + ParseError(String), + + /// Permission denied to scan ports + #[error("Permission denied: {0}")] + PermissionDenied(String), + + /// I/O error occurred + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Platform not supported + #[error("Platform not supported")] + PlatformNotSupported, +} + +/// Trait for platform-specific port scanners +pub trait PortScanner: Send + Sync { + /// Scan for all listening TCP ports + /// + /// Returns a list of PortInfo objects representing all processes + /// that are listening on TCP ports. + fn scan_ports( + &self, + ) -> impl std::future::Future, ScanError>> + Send; +} + +/// Convenience function to scan ports using the platform-specific scanner +pub async fn scan_ports() -> Result, ScanError> { + let scanner = PlatformScanner::new(); + scanner.scan_ports().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_scan_ports() { + let result = scan_ports().await; + assert!(result.is_ok()); + // We can't guarantee any ports are listening, but the scan should succeed + } +}