diff --git a/.dockerignore b/.dockerignore index 0758864..7d88c0b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,7 +21,8 @@ Dockerfile /frontend/node_modules/ /backend/modules/cppregex /backend/modules/proxy -docker-compose.yml +compose.yml +.firegex-compose.yml # misc **/.DS_Store diff --git a/.github/workflows/docker-rootfs-asset.yml b/.github/workflows/docker-rootfs-asset.yml new file mode 100644 index 0000000..dedcb80 --- /dev/null +++ b/.github/workflows/docker-rootfs-asset.yml @@ -0,0 +1,87 @@ +name: Create Docker rootfs assets + +on: + workflow_run: + workflows: ["Create and publish a Docker image"] + types: + - completed + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + create-rootfs-assets: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: write + packages: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@master + with: + platforms: all + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@master + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get latest release tag + id: get_tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name') + echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "Latest release tag: $LATEST_TAG" + + - name: Export rootfs for amd64 + run: | + echo "Creating and exporting amd64 container..." + CONTAINER_ID=$(docker create --platform linux/amd64 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.tag }}) + docker export $CONTAINER_ID --output="firegex-rootfs-amd64.tar" + docker rm $CONTAINER_ID + echo "Compressing amd64 rootfs..." + gzip firegex-rootfs-amd64.tar + ls -lh firegex-rootfs-amd64.tar.gz + + - name: Export rootfs for arm64 + run: | + echo "Creating and exporting arm64 container..." + CONTAINER_ID=$(docker create --platform linux/arm64 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.tag }}) + docker export $CONTAINER_ID --output="firegex-rootfs-arm64.tar" + docker rm $CONTAINER_ID + echo "Compressing arm64 rootfs..." + gzip firegex-rootfs-arm64.tar + ls -lh firegex-rootfs-arm64.tar.gz + + - name: Calculate checksums + run: | + echo "Calculating checksums..." + sha256sum firegex-rootfs-amd64.tar.gz > firegex-rootfs-amd64.tar.gz.sha256 + sha256sum firegex-rootfs-arm64.tar.gz > firegex-rootfs-arm64.tar.gz.sha256 + cat *.sha256 + + - name: Upload rootfs assets to release + run: | + echo "Uploading assets to release ${{ steps.get_tag.outputs.tag }}..." + gh release upload ${{ steps.get_tag.outputs.tag }} \ + firegex-rootfs-amd64.tar.gz \ + firegex-rootfs-amd64.tar.gz.sha256 \ + firegex-rootfs-arm64.tar.gz \ + firegex-rootfs-arm64.tar.gz.sha256 \ + --clobber + echo "Assets uploaded successfully!" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 2ccae68..59602b0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,13 @@ /backend/modules/proxy /docker-compose.yml /firegex-compose.yml -/firegex-compose-tmp-file.yml +/.firegex-compose.yml + +/.firegex-standalone.pid + +/firegexfs/** +/firegexfs + /firegex.py /tests/benchmark.csv /tests/comparemark.csv diff --git a/README.md b/README.md index 8137997..b6655b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ + +# Rootless container and standalone install + +- Check if docker is in rootless mode + +``` +> docker info -f "{{println .SecurityOptions}}" +[name=seccomp,profile=builtin name=rootless name=cgroupns] +``` + +- Start firegex without a container in case docker is not available or in rootless mode + +


[Fi]*regex 🔥

GitHub release (latest by date) GitHub GitHub top language Code diff --git a/start.py b/start.py index 805242f..f4de128 100755 --- a/start.py +++ b/start.py @@ -8,12 +8,17 @@ import os import multiprocessing import subprocess import getpass +import shutil +import tarfile pref = "\033[" reset = f"{pref}0m" class g: - composefile = "firegex-compose-tmp-file.yml" + composefile = ".firegex-compose.yml" build = False + standalone_mode = False + rootfs_path = "./firegexfs" + pid_file = "./.firegex-standalone.pid" os.chdir(os.path.dirname(os.path.realpath(__file__))) if os.path.isfile("./Dockerfile"): @@ -90,6 +95,7 @@ def gen_args(args_to_parse: list[str]|None = None): #Main parser parser = argparse.ArgumentParser(description="Firegex Manager") parser.add_argument('--clear', dest="bef_clear", required=False, action="store_true", help='Delete docker volume associated to firegex resetting all the settings', default=False) + parser.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) subcommands = parser.add_subparsers(dest="command", help="Command to execute [Default start if not running]") @@ -106,13 +112,20 @@ def gen_args(args_to_parse: list[str]|None = None): parser_start.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False) parser_start.add_argument('--version', '-v', required=False, type=str , help='Version of the firegex image to use', default=None) parser_start.add_argument('--prebuilt', required=False, action="store_true", help='Use prebuilt docker image', default=False) + parser_start.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) #Stop Command parser_stop = subcommands.add_parser('stop', help='Stop the firewall') parser_stop.add_argument('--clear', required=False, action="store_true", help='Delete docker volume associated to firegex resetting all the settings', default=False) + parser_stop.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) parser_restart = subcommands.add_parser('restart', help='Restart the firewall') parser_restart.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False) + parser_restart.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) + + #Status Command + parser_status = subcommands.add_parser('status', help='Show firewall status') + parser_status.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) args = parser.parse_args(args=args_to_parse) if "version" in args and args.version and g.build: @@ -125,9 +138,18 @@ def gen_args(args_to_parse: list[str]|None = None): if "prebuilt" in args and args.prebuilt: g.build = False + if "psw_on_web" not in args: + args.psw_on_web = False + + if "startup_psw" not in args: + args.startup_psw = None + if "clear" not in args: args.clear = False + if "standalone" not in args: + args.standalone = False + if "threads" not in args or args.threads < 1: args.threads = multiprocessing.cpu_count() @@ -226,7 +248,7 @@ def write_compose(skip_password = True): })) def get_password(): - if volume_exists() or args.psw_on_web: + if volume_exists() or args.psw_on_web or (g.standalone_mode and os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db"))): return None if args.startup_psw: return args.startup_psw @@ -291,8 +313,506 @@ def nfqueue_exists(): def delete_volume(): return cmd_check("docker volume rm firegex_firegex_data") +def write_pid_file(pid): + """Write PID to file""" + try: + with open(g.pid_file, 'w') as f: + f.write(str(pid)) + return True + except Exception as e: + puts(f"Failed to write PID file: {e}", color=colors.red) + return False + +def read_pid_file(): + """Read PID from file""" + try: + if os.path.exists(g.pid_file): + with open(g.pid_file, 'r') as f: + return int(f.read().strip()) + return None + except Exception: + return None + +def remove_pid_file(): + """Remove PID file""" + try: + if os.path.exists(g.pid_file): + os.remove(g.pid_file) + except Exception: + pass + +def is_process_running(pid): + """Check if process with given PID is running""" + if pid is None: + return False + try: + # Send signal 0 to check if process exists + os.kill(pid, 0) + return True + except (OSError, ProcessLookupError): + return False + +def is_standalone_running(): + """Check if standalone Firegex is already running""" + pid = read_pid_file() + if pid and is_process_running(pid): + return True + else: + # Clean up stale PID file + remove_pid_file() + return False + +def stop_standalone_process(): + """Stop the standalone Firegex process""" + pid = read_pid_file() + if pid and is_process_running(pid): + try: + puts(f"Stopping Firegex process (PID: {pid})...", color=colors.yellow) + os.kill(pid, 15) # SIGTERM + + # Wait a bit for graceful shutdown + import time + for _ in range(10): + if not is_process_running(pid): + break + time.sleep(0.5) + + # Force kill if still running + if is_process_running(pid): + puts("Process didn't stop gracefully, forcing termination...", color=colors.yellow) + os.kill(pid, 9) # SIGKILL + time.sleep(1) + + if not is_process_running(pid): + puts("Firegex process stopped", color=colors.green) + return True + else: + puts("Failed to stop Firegex process", color=colors.red) + return False + + except Exception as e: + puts(f"Error stopping process: {e}", color=colors.red) + return False + else: + puts("No running Firegex process found", color=colors.yellow) + return True + +def is_docker_rootless(): + """Check if Docker is running in rootless mode""" + try: + output = cmd_check('docker info -f "{{println .SecurityOptions}}"', get_output=True) + return "rootless" in output.lower() + except Exception: + return False + +def should_use_standalone(): + """Determine if standalone mode should be used""" + # Check if standalone mode is forced + if args.standalone: + return True + + if is_standalone_running(): + return True + + # Check if Docker exists + if not cmd_check("docker --version"): + return True + + # Check if Docker Compose exists + if not cmd_check("docker-compose --version") and not cmd_check("docker compose --version"): + return True + + # Check if Docker is accessible + if not cmd_check("docker ps"): + return True + + # Check if Docker is in rootless mode + if is_docker_rootless(): + return True + + return False + +def is_root(): + """Check if running as root""" + return os.geteuid() == 0 + +def get_sudo_prefix(): + """Get sudo prefix if needed, empty string if already root""" + return "" if is_root() else "sudo " + +def run_privileged_commands(commands, description="operations"): + """Run a batch of privileged commands efficiently""" + if not commands: + return True + + if is_root(): + # If already root, run commands directly + for cmd in commands: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if result.returncode != 0: + puts(f"Command failed: {cmd}", color=colors.red) + puts(f"Error: {result.stderr}", color=colors.red) + return False + return True + else: + # If not root, create a script and run it with sudo once + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as script_file: + script_file.write("#!/bin/sh\nset -e\n") + for cmd in commands: + script_file.write(f"{cmd}\n") + script_path = script_file.name + + try: + os.chmod(script_path, 0o755) + result = subprocess.run(f"sudo sh {script_path}", shell=True, capture_output=True, text=True) + if result.returncode != 0: + puts(f"Failed to execute {description}", color=colors.red) + puts(f"Error: {result.stderr}", color=colors.red) + return False + return True + finally: + os.unlink(script_path) + +def safe_run_command(cmd, check_result=True, use_sudo=False): + """Run a command safely with proper error handling""" + if use_sudo: + cmd = f"{get_sudo_prefix()}{cmd}" + + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if check_result and result.returncode != 0: + puts(f"Command failed: {cmd}", color=colors.red) + puts(f"Error: {result.stderr}", color=colors.red) + return False + return result.returncode == 0 + except Exception as e: + puts(f"Error running command: {cmd}", color=colors.red) + puts(f"Exception: {e}", color=colors.red) + return False + +def cleanup_standalone_mounts(): + """Cleanup any existing mounts for standalone mode""" + mount_points = [ + f"{g.rootfs_path}/dev", + f"{g.rootfs_path}/proc", + f"{g.rootfs_path}/sys_host/net.ipv4.conf.all.route_localnet", + f"{g.rootfs_path}/sys_host/net.ipv4.ip_forward", + f"{g.rootfs_path}/sys_host/net.ipv4.conf.all.forwarding", + f"{g.rootfs_path}/sys_host/net.ipv6.conf.all.forwarding" + ] + + # Create umount commands (with || true to ignore errors) + umount_commands = [f"umount {mount_point} || true" for mount_point in mount_points] + + # Run all umount commands in one batch + run_privileged_commands(umount_commands, "cleanup mounts") + +def get_latest_release_tag(): + """Get the latest release tag from GitHub API""" + import urllib.request + import json + + try: + url = "https://api.github.com/repos/Pwnzer0tt1/firegex/releases/latest" + with urllib.request.urlopen(url) as response: + data = json.loads(response.read().decode()) + return data.get('tag_name') + except Exception as e: + puts(f"Failed to get latest release tag: {e}", color=colors.red) + return None + +def get_architecture(): + """Get current architecture (amd64 or arm64)""" + import platform + arch = platform.machine().lower() + if arch in ['x86_64', 'amd64']: + return 'amd64' + elif arch in ['aarch64', 'arm64']: + return 'arm64' + else: + puts(f"Unsupported architecture: {arch}", color=colors.red) + return None + +def download_file(url, filename): + """Download a file using urllib""" + import urllib.request + + try: + puts(f"Downloading {filename}...", color=colors.green) + urllib.request.urlretrieve(url, filename) + return True + except Exception as e: + puts(f"Failed to download {filename}: {e}", color=colors.red) + return False + +def setup_standalone_rootfs(): + """Set up the standalone rootfs""" + puts("Setting up standalone mode...", color=colors.green) + + # Remove and recreate rootfs directory + if os.path.exists(g.rootfs_path): + puts("Rootfs already exists, skipping download...", color=colors.yellow) + # Clean up any existing mounts + cleanup_standalone_mounts() + return True + + puts("Creating rootfs directory...", color=colors.green) + try: + os.makedirs(g.rootfs_path, exist_ok=True) + except Exception as e: + puts(f"Failed to create rootfs directory: {e}", color=colors.red) + return False + + # Get latest release tag + release_tag = get_latest_release_tag() + if not release_tag: + puts("Failed to get latest release tag", color=colors.red) + return False + + # Get current architecture + arch = get_architecture() + if not arch: + return False + + # Download rootfs from GitHub releases + puts(f"Downloading rootfs for {arch} architecture from GitHub releases...", color=colors.green) + + # Construct download URL + rootfs_filename = f"firegex-rootfs-{arch}.tar.gz" + download_url = f"https://github.com/Pwnzer0tt1/firegex/releases/download/{release_tag}/{rootfs_filename}" + tar_path = os.path.join(g.rootfs_path, rootfs_filename) + + # Download the rootfs archive + if not download_file(download_url, tar_path): + return False + + try: + # Extract tar.gz file + puts("Extracting rootfs...", color=colors.green) + with tarfile.open(tar_path, 'r:gz') as tar: + tar.extractall(path=g.rootfs_path, filter=lambda _: False) + + # Remove tar.gz file + os.remove(tar_path) + + # Create necessary directories + os.makedirs(os.path.join(g.rootfs_path, "dev"), exist_ok=True) + os.makedirs(os.path.join(g.rootfs_path, "proc"), exist_ok=True) + os.makedirs(os.path.join(g.rootfs_path, "sys_host"), exist_ok=True) + + puts("Rootfs setup completed", color=colors.green) + return True + + except Exception as e: + puts(f"Failed to extract rootfs: {e}", color=colors.red) + # Clean up partial extraction + if os.path.exists(tar_path): + os.remove(tar_path) + return False + +def setup_standalone_mounts(): + """Set up bind mounts for standalone mode""" + puts("Setting up bind mounts...", color=colors.green) + + # Create mount point files + mount_files = [ + "net.ipv4.conf.all.route_localnet", + "net.ipv4.ip_forward", + "net.ipv4.conf.all.forwarding", + "net.ipv6.conf.all.forwarding" + ] + + sys_host_dir = os.path.join(g.rootfs_path, "sys_host") + + # Prepare all privileged commands + privileged_commands = [] + + # Touch commands for mount point files + for mount_file in mount_files: + file_path = os.path.join(sys_host_dir, mount_file) + privileged_commands.append(f"touch {file_path}") + + # Mount commands + privileged_commands.extend([ + f"mount --bind /dev {g.rootfs_path}/dev", + f"mount --bind /proc {g.rootfs_path}/proc", + f"mount --bind /proc/sys/net/ipv4/conf/all/route_localnet {g.rootfs_path}/sys_host/net.ipv4.conf.all.route_localnet", + f"mount --bind /proc/sys/net/ipv4/ip_forward {g.rootfs_path}/sys_host/net.ipv4.ip_forward", + f"mount --bind /proc/sys/net/ipv4/conf/all/forwarding {g.rootfs_path}/sys_host/net.ipv4.conf.all.forwarding", + f"mount --bind /proc/sys/net/ipv6/conf/all/forwarding {g.rootfs_path}/sys_host/net.ipv6.conf.all.forwarding" + ]) + + # Run all privileged commands in one batch + if not run_privileged_commands(privileged_commands, "setup bind mounts"): + puts("Failed to set up bind mounts", color=colors.red) + return False + + return True + +def run_standalone(): + """Run Firegex in standalone mode as a daemon""" + puts("Starting Firegex in standalone mode...", color=colors.green) + + # Check if already running + if is_standalone_running(): + puts("Firegex is already running in standalone mode!", color=colors.yellow) + pid = read_pid_file() + puts(f"Process PID: {pid}", color=colors.cyan) + return + + # Set up environment variables + env_vars = [ + f"PORT={args.port}", + f"NTHREADS={args.threads}", + ] + + # Add password if set + psw_set = get_password() + if psw_set: + env_vars.append(f"HEX_SET_PSW={psw_set.encode().hex()}") + + # Prepare environment string for chroot + env_string = " ".join([f"{var}" for var in env_vars]) + + # Run chroot command in background + chroot_cmd = f"{get_sudo_prefix()}env {env_string} chroot --userspec=root:root {g.rootfs_path} /bin/python3 /execute/app.py DOCKER" + + puts(f"Running: {chroot_cmd}", color=colors.cyan) + puts("Starting as daemon...", color=colors.green) + + try: + # Start process in background + process = subprocess.Popen( + chroot_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + preexec_fn=os.setsid # Create new session + ) + + # Write PID to file + if write_pid_file(process.pid): + puts(f"Firegex started successfully (PID: {process.pid})", color=colors.green) + puts(f"PID saved to: {g.pid_file}", color=colors.cyan) + + if is_process_running(process.pid): + puts("Firegex is running in background", color=colors.green) + puts(f"Web interface should be available at: http://localhost:{args.port}", color=colors.cyan) + else: + puts("Firegex process failed to start", color=colors.red) + remove_pid_file() + cleanup_standalone_mounts() + else: + puts("Failed to save PID file", color=colors.red) + process.terminate() + cleanup_standalone_mounts() + + except Exception as e: + puts(f"Failed to start Firegex: {e}", color=colors.red) + cleanup_standalone_mounts() + +def stop_standalone(): + """Stop standalone mode by stopping the process and cleaning up mounts""" + puts("Stopping standalone mode...", color=colors.green) + + # Stop the process + if stop_standalone_process(): + # Clean up mounts + cleanup_standalone_mounts() + # Remove PID file + remove_pid_file() + puts("Standalone mode stopped", color=colors.green) + else: + # Clean up anyway + cleanup_standalone_mounts() + remove_pid_file() + puts("Cleanup completed", color=colors.yellow) + +def clear_standalone(): + """Clear standalone rootfs""" + puts("Clearing standalone rootfs...", color=colors.green) + cleanup_standalone_mounts() + if os.path.exists(g.rootfs_path): + # If permission denied, use privileged command + if run_privileged_commands([f"chmod ugo+rw -R {g.rootfs_path}", f"rm -rf {g.rootfs_path}"], "remove rootfs"): + puts("Standalone rootfs cleared", color=colors.green) + else: + puts("Failed to clear standalone rootfs", color=colors.red) + else: + puts("Standalone rootfs not found", color=colors.yellow) + +def status_standalone(): + """Show standalone mode status""" + puts("Standalone mode status:", color=colors.cyan, is_bold=True) + + # Check if running + if is_standalone_running(): + pid = read_pid_file() + puts(f"Status: Running (PID: {pid})", color=colors.green) + puts(f"Web interface: http://localhost:{args.port}", color=colors.cyan) + else: + puts("Status: Not running", color=colors.red) + if os.path.exists(g.rootfs_path): + puts(f"Rootfs: Available ({g.rootfs_path})", color=colors.white) + else: + puts("Rootfs: Not available", color=colors.yellow) + def main(): + # Check if we should use standalone mode + if should_use_standalone(): + if not is_linux(): + puts("Standalone mode only works on Linux!", color=colors.red) + puts("Please install Docker and Docker Compose.", color=colors.red) + exit(1) + + g.standalone_mode = True + if args.standalone: + puts("Standalone mode forced by --standalone option", color=colors.cyan) + elif is_standalone_running(): + puts("Standalone mode already running, using it", color=colors.cyan) + else: + puts("Docker not available or in rootless mode, using standalone mode", color=colors.yellow) + + # Ensure we have root privileges for standalone mode operations + if not is_root(): + puts("Standalone mode requires root privileges. 'sudo' will be used.", color=colors.yellow) + + if args.command == "start" or args.command is None: + # Check if already running + if is_standalone_running(): + pid = read_pid_file() + puts(f"Firegex is already running in standalone mode! (PID: {pid})", color=colors.yellow) + puts(f"Web interface available at: http://localhost:{args.port}", color=colors.cyan) + return + + if not setup_standalone_rootfs(): + exit(1) + if not setup_standalone_mounts(): + exit(1) + run_standalone() + elif args.command == "stop": + stop_standalone() + elif args.command == "status": + status_standalone() + elif args.command == "restart": + stop_standalone() + if not setup_standalone_mounts(): + exit(1) + run_standalone() + else: + puts("Command not supported in standalone mode", color=colors.red) + exit(1) + + # Handle clear option for standalone mode + if args.clear: + clear_standalone() + + return + + # Original Docker-based logic if not cmd_check("docker --version"): puts("Docker not found! please install docker and docker compose!", color=colors.red) exit() @@ -348,6 +868,12 @@ def main(): composecmd("down", g.composefile) else: puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True) + case "status": + if check_already_running(): + puts("Firegex is running in Docker mode", color=colors.green) + puts(f"Web interface: http://localhost:{args.port}", color=colors.cyan) + else: + puts("Firegex is not running", color=colors.red) write_compose() @@ -363,10 +889,6 @@ def main(): if __name__ == "__main__": try: - try: - main() - finally: - if os.path.isfile(g.composefile): - os.remove(g.composefile) + main() except KeyboardInterrupt: print()