mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
328 lines
12 KiB
Python
328 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Interactive generator for RustSploit Docker-Compose stack.
|
|
Produces:
|
|
docker-compose.rustsploit.yml (with embedded Dockerfile)
|
|
.env.rustsploit-docker
|
|
and prints the command to bring the stack up.
|
|
|
|
This variant includes runtime fixes to avoid permission-denied on /app/data
|
|
and ensures the container starts as root briefly to fix ownership, then
|
|
executes the rustsploit binary as the less-privileged `rustsploit` user.
|
|
"""
|
|
import secrets
|
|
import os
|
|
import stat
|
|
import socket
|
|
import ipaddress
|
|
import subprocess
|
|
import pwd
|
|
from pathlib import Path
|
|
|
|
# Fix: Use parent.parent since script is in scripts/ directory
|
|
repo = Path(__file__).resolve().parent.parent
|
|
if not (repo / "Cargo.toml").exists():
|
|
print("[-] Error: Run this script from the RustSploit repository root.")
|
|
print(f" Expected Cargo.toml at: {repo / 'Cargo.toml'}")
|
|
exit(1)
|
|
|
|
# ---------- Helper functions ----------
|
|
def ask(prompt, default=None, validator=None):
|
|
"""Interactive prompt with validation."""
|
|
suffix = f" [{default}]" if default is not None else ""
|
|
while True:
|
|
val = input(f"{prompt}{suffix}: ").strip()
|
|
if not val and default is not None:
|
|
val = default
|
|
if not val:
|
|
print("Value cannot be empty.")
|
|
continue
|
|
if validator:
|
|
try:
|
|
validator(val)
|
|
except ValueError as e:
|
|
print(f"Invalid input: {e}")
|
|
continue
|
|
return val
|
|
|
|
|
|
def ask_yes_no(prompt, default=True):
|
|
"""Yes/No prompt."""
|
|
hint = "Y/n" if default else "y/N"
|
|
while True:
|
|
val = input(f"{prompt} ({hint}): ").strip().lower()
|
|
if not val:
|
|
return default
|
|
if val in ("y", "yes"):
|
|
return True
|
|
if val in ("n", "no"):
|
|
return False
|
|
print("Please answer 'y' or 'n'.")
|
|
|
|
|
|
def validate_host(host):
|
|
"""Validate host/IP address."""
|
|
host = host.strip()
|
|
if not host:
|
|
raise ValueError("Host cannot be empty")
|
|
# Allow localhost
|
|
if host == "localhost":
|
|
return
|
|
# Try to parse as IP
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
except ValueError:
|
|
# Not a valid IP, check if it's a valid hostname
|
|
if len(host) > 253:
|
|
raise ValueError("Hostname too long (max 253 chars)")
|
|
if any(c.isspace() for c in host):
|
|
raise ValueError("Hostname cannot contain whitespace")
|
|
|
|
|
|
def validate_port(port_str):
|
|
"""Validate port number."""
|
|
try:
|
|
port = int(port_str)
|
|
if not (1 <= port <= 65535):
|
|
raise ValueError("Port must be between 1 and 65535")
|
|
except ValueError as e:
|
|
if "invalid literal" in str(e):
|
|
raise ValueError("Port must be a number")
|
|
raise
|
|
|
|
|
|
def detect_private_ip():
|
|
"""Try to detect private IP address."""
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
sock.connect(("192.0.2.1", 80))
|
|
return sock.getsockname()[0]
|
|
except OSError:
|
|
return None
|
|
|
|
# ---------- Interactive prompts ----------
|
|
print("\n[+] RustSploit Docker Setup\n")
|
|
|
|
# Host selection
|
|
print("Select bind address:")
|
|
print(" [1] 127.0.0.1 (localhost only)")
|
|
print(" [2] 0.0.0.0 (all interfaces)")
|
|
print(" [3] Private LAN IP (auto-detect)")
|
|
print(" [4] Custom IP/hostname")
|
|
|
|
choice = ask("Choice", "2")
|
|
if choice == "1":
|
|
host = "127.0.0.1"
|
|
elif choice == "2":
|
|
host = "0.0.0.0"
|
|
elif choice == "3":
|
|
detected = detect_private_ip()
|
|
if detected:
|
|
print(f"[+] Detected private IP: {detected}")
|
|
use_detected = ask_yes_no("Use detected IP?", True)
|
|
host = detected if use_detected else ask("Enter IP/hostname", validator=validate_host)
|
|
else:
|
|
print("[-] Could not auto-detect private IP")
|
|
host = ask("Enter IP/hostname", validator=validate_host)
|
|
else:
|
|
host = ask("Enter IP/hostname", validator=validate_host)
|
|
|
|
# Port
|
|
# Default changed to 9000 as this is a common API port and matches user's prior usage
|
|
port_str = ask("Host port to expose", "9000", validator=validate_port)
|
|
port = int(port_str)
|
|
|
|
# API Key
|
|
print("\n[+] API Key Configuration")
|
|
generate_key = ask_yes_no("Generate random API key?", True)
|
|
if generate_key:
|
|
api_key = secrets.token_urlsafe(32)
|
|
print(f"[+] Generated API key: {api_key}")
|
|
else:
|
|
api_key = ask("Enter API key (ASCII, max 128 chars)", validator=lambda k: None if (len(k) <= 128 and all(32 <= ord(c) <= 126 for c in k)) else ValueError("API key must be printable ASCII, max 128 chars"))
|
|
|
|
# Hardening
|
|
print("\n[+] Security Hardening")
|
|
harden = ask_yes_no("Enable API hardening (auto-rotate key on suspicious activity)?", False)
|
|
ip_limit = 10
|
|
if harden:
|
|
ip_limit_str = ask("Max unique IPs before rotation", "10", validator=lambda v: validate_port(v) if v else None)
|
|
ip_limit = int(ip_limit_str)
|
|
|
|
# ---------- File generation ----------
|
|
env_file = ".env.rustsploit-docker"
|
|
compose_file = "docker-compose.rustsploit.yml"
|
|
|
|
env_path = repo / env_file
|
|
compose_path = repo / compose_file
|
|
|
|
# Check for existing .env file
|
|
if env_path.exists():
|
|
print(f"\n[!] Warning: {env_path.relative_to(repo)} already exists.")
|
|
if not ask_yes_no("Overwrite .env file?", False):
|
|
print("[-] Aborted.")
|
|
exit(0)
|
|
|
|
# Check for existing docker-compose file
|
|
if compose_path.exists():
|
|
print(f"\n[!] Warning: {compose_path.relative_to(repo)} already exists.")
|
|
if not ask_yes_no("Overwrite docker-compose file?", False):
|
|
print("[-] Aborted.")
|
|
exit(0)
|
|
|
|
# ---- .env ----
|
|
container_interface = f"0.0.0.0:{port}"
|
|
env_content = f"""RUSTSPLOIT_INTERFACE={container_interface}
|
|
RUSTSPLOIT_API_KEY={api_key}
|
|
RUSTSPLOIT_HARDEN={"true" if harden else "false"}
|
|
RUSTSPLOIT_IP_LIMIT={ip_limit}
|
|
"""
|
|
env_path.write_text(env_content)
|
|
# Set permissions: owner read/write only (0600) to keep API key private while still readable by docker-compose
|
|
os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
print(f"\n[+] Generated: {env_path}")
|
|
|
|
# Fix ownership if file was created with sudo (owned by root)
|
|
if env_path.stat().st_uid == 0:
|
|
print("[!] File was created as root. Fixing ownership...")
|
|
try:
|
|
# Get the actual user (SUDO_USER if running via sudo, otherwise current user)
|
|
user = os.environ.get('SUDO_USER') or os.environ.get('USER')
|
|
if not user:
|
|
user = pwd.getpwuid(os.getuid()).pw_name
|
|
|
|
# If we're running as root (via sudo), we can chown directly
|
|
if os.geteuid() == 0:
|
|
user_info = pwd.getpwnam(user)
|
|
os.chown(env_path, user_info.pw_uid, user_info.pw_gid)
|
|
print(f"[+] Ownership fixed: {user}:{user}")
|
|
else:
|
|
# Not root, need to use sudo
|
|
subprocess.run(['sudo', 'chown', f'{user}:{user}', str(env_path)], check=True)
|
|
print(f"[+] Ownership fixed: {user}:{user}")
|
|
except (subprocess.CalledProcessError, FileNotFoundError, KeyError) as e:
|
|
print(f"[!] Warning: Could not automatically fix ownership: {e}")
|
|
print(f" Please run manually: sudo chown $USER:$USER {env_path}")
|
|
|
|
# ---- docker-compose.yml with embedded Dockerfile ----
|
|
# Note: The serve stage runs the entrypoint as root so it can fix ownership of
|
|
# /app/data at container startup, then it drops privileges and executes the
|
|
# rustsploit binary as the less-privileged `rustsploit` user via runuser.
|
|
|
|
dockerfile_content = """FROM rust:1.83-slim AS builder
|
|
WORKDIR /workspace
|
|
ENV CARGO_TERM_COLOR=always
|
|
|
|
RUN apt-get update \\
|
|
&& apt-get install -y --no-install-recommends \\
|
|
build-essential \\
|
|
pkg-config \\
|
|
libssl-dev \\
|
|
libclang-dev \\
|
|
libpcap-dev \\
|
|
libsqlite3-dev \\
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY Cargo.toml ./
|
|
COPY Cargo.lock* ./
|
|
|
|
COPY . .
|
|
RUN cargo build --release --bin rustsploit
|
|
|
|
FROM debian:bookworm-slim AS serve
|
|
RUN apt-get update \\
|
|
&& apt-get install -y --no-install-recommends ca-certificates util-linux \\
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# create non-root user
|
|
RUN useradd --system --home /app --shell /usr/sbin/nologin rustsploit
|
|
WORKDIR /app
|
|
|
|
# create data dir (image-level), but runtime entrypoint will chown the volume target
|
|
RUN mkdir -p /app/data && chown rustsploit:rustsploit /app/data
|
|
|
|
COPY --from=builder /workspace/target/release/rustsploit /usr/local/bin/rustsploit
|
|
|
|
# entrypoint runs as root (default) so it can fix ownership of mounted volumes
|
|
RUN echo '#!/bin/sh' > /entrypoint.sh && \\
|
|
echo 'set -e' >> /entrypoint.sh && \\
|
|
echo 'ARGS="--api --api-key \"${RUSTSPLOIT_API_KEY}\" --interface \"${RUSTSPLOIT_INTERFACE}\""' >> /entrypoint.sh && \\
|
|
echo 'if [ "\"$RUSTSPLOIT_HARDEN\"" = "\"true\"" ]; then' >> /entrypoint.sh && \\
|
|
echo ' ARGS="$ARGS --harden"' >> /entrypoint.sh && \\
|
|
echo ' if [ -n "\"$RUSTSPLOIT_IP_LIMIT\"" ]; then' >> /entrypoint.sh && \\
|
|
echo ' ARGS="$ARGS --ip-limit $RUSTSPLOIT_IP_LIMIT"' >> /entrypoint.sh && \\
|
|
echo ' fi' >> /entrypoint.sh && \\
|
|
echo 'fi' >> /entrypoint.sh && \\
|
|
# ensure data dir exists and is owned by rustsploit so that non-root process can write
|
|
echo 'mkdir -p /app/data' >> /entrypoint.sh && \\
|
|
echo 'mkdir -p /app/data/logs || true' >> /entrypoint.sh && \\
|
|
echo 'chown -R rustsploit:rustsploit /app/data || true' >> /entrypoint.sh && \\
|
|
echo 'LOG_FILE=/app/data/logs/rustsploit_api.log' >> /entrypoint.sh && \\
|
|
echo 'touch "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
|
echo 'chown rustsploit:rustsploit "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
|
echo 'ln -sf "$LOG_FILE" /app/rustsploit_api.log || true' >> /entrypoint.sh && \\
|
|
# finally, execute rustsploit as the rustsploit user using runuser
|
|
echo 'exec runuser -u rustsploit -- /usr/local/bin/rustsploit $ARGS' >> /entrypoint.sh && \\
|
|
chmod +x /entrypoint.sh && \\
|
|
chown root:root /entrypoint.sh && \\
|
|
chown root:root /usr/local/bin/rustsploit
|
|
|
|
# keep default user root so entrypoint can perform ownership fixes; runtime drops to rustsploit
|
|
EXPOSE 8080
|
|
ENTRYPOINT ["/entrypoint.sh"]
|
|
"""
|
|
|
|
# Create Dockerfile in repo root (hardcoded in script, not in docker/ folder)
|
|
dockerfile_path = repo / "Dockerfile.rustsploit"
|
|
dockerfile_path.write_text(dockerfile_content)
|
|
print(f"[+] Generated: {dockerfile_path}")
|
|
|
|
# Generate docker-compose.yml
|
|
compose_content = f"""# Generated by {Path(__file__).name}
|
|
services:
|
|
rustsploit-api:
|
|
container_name: rustsploit-api
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.rustsploit
|
|
target: serve
|
|
restart: unless-stopped
|
|
env_file:
|
|
- {env_file}
|
|
ports:
|
|
- "{host}:{port}:{port}"
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
tmpfs:
|
|
- /tmp
|
|
volumes:
|
|
- rustsploit-data:/app/data
|
|
|
|
volumes:
|
|
rustsploit-data:
|
|
name: rustsploit-data
|
|
"""
|
|
|
|
compose_path.write_text(compose_content)
|
|
print(f"[+] Generated: {compose_path}")
|
|
|
|
print("\n[+] Setup complete!")
|
|
print("\n[+] Generated files:")
|
|
print(f" - {env_path.relative_to(repo)}")
|
|
print(f" - {compose_path.relative_to(repo)}")
|
|
print(f" - {dockerfile_path.relative_to(repo)}")
|
|
print(f"\n[!] Note: Run docker compose commands from the repository root:")
|
|
print(f" cd {repo}")
|
|
print("\n[+] To start the stack, run:")
|
|
print(f" cd {repo} && docker compose -f {compose_file} up -d --build")
|
|
print(f" # OR from any directory:")
|
|
print(f" docker compose -f {compose_path} up -d --build")
|
|
print("\n[+] To view logs:")
|
|
print(f" cd {repo} && docker compose -f {compose_file} logs -f")
|
|
print(f" # OR from any directory:")
|
|
print(f" docker compose -f {compose_path} logs -f")
|
|
print("\n[+] To stop the stack:")
|
|
print(f" cd {repo} && docker compose -f {compose_file} down")
|
|
print(f" # OR from any directory:")
|
|
print(f" docker compose -f {compose_path} down")
|