Initial commit: Bash script generator application

This commit is contained in:
Avni Ademi 2026-01-27 18:39:52 +01:00
parent 079e0c031c
commit 39248f754c
7 changed files with 1036 additions and 0 deletions

39
.dockerignore Normal file
View File

@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
.venv/
venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
*.md
README*
# Logs
*.log

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# Dockerfile for bash script generator FastAPI app
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better layer caching
COPY bashgen/requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY bashgen/app.py .
COPY bashgen/templates/ ./templates/
# Copy banner markdown files
COPY workingscope/loginbanner.md workingscope/postloginbanner.md workingscope/
# Expose port
EXPOSE 8080
# Run the application
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

174
app.py Normal file
View File

@ -0,0 +1,174 @@
# FastAPI backend for bash script generator
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from datetime import datetime
from pathlib import Path
# Initialize FastAPI app
app = FastAPI(title="Bash Script Generator")
templates = Jinja2Templates(directory="templates")
# Jinja2 environment for script template rendering
jinja_env = Environment(
loader=FileSystemLoader("templates"),
undefined=StrictUndefined,
autoescape=False,
trim_blocks=True,
lstrip_blocks=True,
)
def _bool(v: str | None) -> bool:
# HTML checkbox sends value only when checked
return v is not None
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""Serve the main HTML form page"""
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/generate")
def generate(
# feature flags - System Setup
system_update: str | None = Form(default=None),
auto_security_updates: str | None = Form(default=None),
setup_timezone: str | None = Form(default=None),
setup_hostname: str | None = Form(default=None),
setup_ntp: str | None = Form(default=None),
setup_swap: str | None = Form(default=None),
# feature flags - Security
ssh_harden: str | None = Form(default=None),
install_fail2ban: str | None = Form(default=None),
prelogin_banner: str | None = Form(default=None),
postlogin_banner: str | None = Form(default=None),
ssh_2fa: str | None = Form(default=None),
# feature flags - Docker & Services
install_docker: str | None = Form(default=None),
docker_admin_user: str | None = Form(default=None),
open_ports: str | None = Form(default=None),
combine_lan: str | None = Form(default=None),
# feature flags - User Management
create_admin_user: str | None = Form(default=None),
# feature flags - Monitoring
install_monitoring_tools: str | None = Form(default=None),
install_build_tools: str | None = Form(default=None),
# params
admin_username: str = Form(default="datamng"),
ssh_port: int = Form(default=22),
hostname: str = Form(default=""),
timezone: str = Form(default="UTC"),
new_admin_username: str = Form(default="admin"),
swap_size_gb: int = Form(default=2),
docker_data_dir: str = Form(default="/opt/docker"),
# firewall ports (comma-separated)
ports_csv: str = Form(default="22,80,81,443"),
# netplan params
lan_interfaces_csv: str = Form(default="eth0,eth1"),
static_ip_cidr: str = Form(default="192.168.1.9/24"),
gateway_ip: str = Form(default="192.168.1.1"),
dns_csv: str = Form(default="1.1.1.1,8.8.8.8"),
# owner information
owner_name: str = Form(default="Scardus"),
owner_website: str = Form(default="https://scardustech.com"),
owner_email: str = Form(default="info@scardustech.com"),
# SSH keys (multiline)
ssh_public_keys: str = Form(default=""),
):
"""Generate bash script based on form inputs"""
# Load banner templates from markdown files
# Try multiple paths to support both local development and Docker container
base_path = Path(__file__).parent.parent
prelogin_banner_path = base_path / "workingscope" / "loginbanner.md"
postlogin_banner_path = base_path / "workingscope" / "postloginbanner.md"
# If not found, try relative to current working directory (for Docker)
if not prelogin_banner_path.exists():
prelogin_banner_path = Path("workingscope") / "loginbanner.md"
if not postlogin_banner_path.exists():
postlogin_banner_path = Path("workingscope") / "postloginbanner.md"
# Read banner content from markdown files
prelogin_text = ""
postlogin_text = ""
if prelogin_banner_path.exists():
with open(prelogin_banner_path, "r", encoding="utf-8") as f:
prelogin_text = f.read()
if postlogin_banner_path.exists():
with open(postlogin_banner_path, "r", encoding="utf-8") as f:
postlogin_text = f.read()
# Build context for Jinja2 template
ctx = {
"meta": {
"generated_at": datetime.utcnow().isoformat() + "Z",
},
"flags": {
# System Setup
"system_update": _bool(system_update),
"auto_security_updates": _bool(auto_security_updates),
"setup_timezone": _bool(setup_timezone),
"setup_hostname": _bool(setup_hostname),
"setup_ntp": _bool(setup_ntp),
"setup_swap": _bool(setup_swap),
# Security
"ssh_harden": _bool(ssh_harden),
"install_fail2ban": _bool(install_fail2ban),
"prelogin_banner": _bool(prelogin_banner),
"postlogin_banner": _bool(postlogin_banner),
"ssh_2fa": _bool(ssh_2fa),
# Docker & Services
"install_docker": _bool(install_docker),
"docker_admin_user": _bool(docker_admin_user),
"open_ports": _bool(open_ports),
"combine_lan": _bool(combine_lan),
# User Management
"create_admin_user": _bool(create_admin_user),
# Monitoring
"install_monitoring_tools": _bool(install_monitoring_tools),
"install_build_tools": _bool(install_build_tools),
},
"params": {
"admin_username": admin_username.strip(),
"ssh_port": int(ssh_port),
"hostname": hostname.strip(),
"timezone": timezone.strip(),
"new_admin_username": new_admin_username.strip(),
"swap_size_gb": int(swap_size_gb),
"docker_data_dir": docker_data_dir.strip(),
"ports": [p.strip() for p in ports_csv.split(",") if p.strip()],
"lan_ifaces": [i.strip() for i in lan_interfaces_csv.split(",") if i.strip()],
"static_ip_cidr": static_ip_cidr.strip(),
"gateway_ip": gateway_ip.strip(),
"dns": [d.strip() for d in dns_csv.split(",") if d.strip()],
"owner_name": owner_name.strip(),
"owner_website": owner_website.strip(),
"owner_email": owner_email.strip(),
"ssh_public_keys": [k.strip() for k in ssh_public_keys.split("\n") if k.strip()],
"prelogin_text": prelogin_text,
"postlogin_text": postlogin_text,
},
}
# Render bash script from template
tpl = jinja_env.get_template("script.sh.j2")
script = tpl.render(**ctx)
# Return as downloadable file
filename = "setup-server.sh"
return Response(
content=script,
media_type="application/x-sh",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
# Docker Compose configuration for bash script generator
services:
bashgen:
build:
context: ..
dockerfile: bashgen/Dockerfile
container_name: bashgen-app
ports:
- "8083:8080" # Map host port 8083 to container port 8080
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
jinja2==3.1.4
python-multipart==0.0.9

160
templates/index.html Normal file
View File

@ -0,0 +1,160 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Bash Script Generator</title>
<style>
body { font-family: Arial, sans-serif; max-width: 980px; margin: 40px auto; padding: 0 16px; }
fieldset { margin: 18px 0; padding: 14px; }
label { display: block; margin: 8px 0; }
input[type="text"], input[type="number"], textarea { width: 100%; padding: 8px; }
textarea { height: 120px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn { padding: 10px 14px; cursor: pointer; }
.note { color: #555; font-size: 0.95em; line-height: 1.5; }
code { background: #f4f4f4; padding: 2px 4px; font-family: 'Courier New', monospace; }
.note code { background: #e8e8e8; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>Server Setup Script Generator</h1>
<p class="note">Select options, then download a single <code>.sh</code> script.</p>
<form action="/generate" method="post">
<fieldset>
<legend>System Setup</legend>
<label><input type="checkbox" name="system_update" checked /> Initial system update & upgrade</label>
<label><input type="checkbox" name="auto_security_updates" checked /> Enable automatic security updates</label>
<label><input type="checkbox" name="setup_timezone" /> Configure timezone</label>
<label><input type="checkbox" name="setup_hostname" /> Set hostname</label>
<label><input type="checkbox" name="setup_ntp" checked /> Configure NTP time sync</label>
<label><input type="checkbox" name="setup_swap" /> Configure swap file</label>
</fieldset>
<fieldset>
<legend>Security & Hardening</legend>
<label><input type="checkbox" name="ssh_harden" checked /> SSH hardening (disable root, key-only option)</label>
<label><input type="checkbox" name="install_fail2ban" checked /> Install Fail2ban (intrusion prevention)</label>
<label><input type="checkbox" name="prelogin_banner" /> Pre-login banner (SSH)</label>
<label><input type="checkbox" name="postlogin_banner" /> Post-login banner (MOTD)</label>
<label><input type="checkbox" name="ssh_2fa" /> SSH 2FA (Google Authenticator PAM)</label>
</fieldset>
<fieldset>
<legend>Docker & Services</legend>
<label><input type="checkbox" name="install_docker" checked /> Install Docker + Docker Compose</label>
<label><input type="checkbox" name="docker_admin_user" checked /> Create admin user for docker operations (non-login)</label>
<label><input type="checkbox" name="open_ports" checked /> Allow ports (UFW)</label>
<label><input type="checkbox" name="combine_lan" /> Combine LAN ports (netplan bond + bridge, static IP)</label>
</fieldset>
<fieldset>
<legend>User Management</legend>
<label><input type="checkbox" name="create_admin_user" /> Create admin user with sudo access</label>
</fieldset>
<fieldset>
<legend>Monitoring & Utilities</legend>
<label><input type="checkbox" name="install_monitoring_tools" checked /> Install monitoring tools (htop, iotop, netstat, etc.)</label>
<label><input type="checkbox" name="install_build_tools" /> Install build tools (build-essential, git, etc.)</label>
</fieldset>
<fieldset>
<legend>General Configuration</legend>
<div class="row">
<label>
Docker admin username
<input type="text" name="admin_username" value="datamng" />
</label>
<label>
SSH port
<input type="number" name="ssh_port" value="22" min="1" max="65535" />
</label>
</div>
<div class="row">
<label>
Hostname
<input type="text" name="hostname" value="" placeholder="Leave empty to skip" />
</label>
<label>
Timezone
<input type="text" name="timezone" value="UTC" placeholder="e.g. America/New_York" />
</label>
</div>
<div class="row">
<label>
Admin username (for new user)
<input type="text" name="new_admin_username" value="admin" />
</label>
<label>
Swap size (GB)
<input type="number" name="swap_size_gb" value="2" min="1" max="32" />
<span class="note">Swap is virtual memory - used when RAM is full. Prevents out-of-memory crashes.</span>
</label>
</div>
<div class="row">
<label>
Docker data directory path
<input type="text" name="docker_data_dir" value="/opt/docker" placeholder="/opt/docker" />
</label>
</div>
<label>
Firewall ports (CSV)
<input type="text" name="ports_csv" value="22,80,81,443" />
</label>
<label>
SSH public key (for admin user, one per line)
<textarea name="ssh_public_keys" placeholder="ssh-rsa AAAAB3NzaC1yc2E... user@host&#10;ssh-ed25519 AAAAC3NzaC1lZDI1... user@host" rows="4"></textarea>
<div class="note" style="margin-top: 8px;">
<strong>How to generate SSH keys:</strong><br>
1. On your local computer, run: <code>ssh-keygen -t ed25519 -C "your_email@example.com"</code><br>
2. Press Enter to accept default location (~/.ssh/id_ed25519)<br>
3. Copy your PUBLIC key: <code>cat ~/.ssh/id_ed25519.pub</code> (or <code>id_rsa.pub</code> for RSA)<br>
4. Paste the PUBLIC key here (starts with ssh-rsa or ssh-ed25519)<br>
<strong>Note:</strong> You generate keys on YOUR computer, then paste the PUBLIC key here. The server will use it to authenticate you.
</div>
</label>
</fieldset>
<fieldset>
<legend>LAN bonding/bridging (only if enabled)</legend>
<label>LAN interfaces (CSV, e.g. eth0,eth1)
<input type="text" name="lan_interfaces_csv" value="eth0,eth1" />
</label>
<div class="row">
<label>Static IP (CIDR)
<input type="text" name="static_ip_cidr" value="192.168.1.9/24" />
</label>
<label>Gateway
<input type="text" name="gateway_ip" value="192.168.1.1" />
</label>
</div>
<label>DNS (CSV)
<input type="text" name="dns_csv" value="1.1.1.1,8.8.8.8" />
</label>
<p class="note">This will write a netplan file and apply it. Test carefully (risk of remote lockout).</p>
</fieldset>
<fieldset>
<legend>System Owner Information</legend>
<div class="row">
<label>
Owner Name
<input type="text" name="owner_name" value="Scardus" />
</label>
<label>
Owner Website
<input type="text" name="owner_website" value="https://scardustech.com" />
</label>
</div>
<label>
Owner Email
<input type="text" name="owner_email" value="info@scardustech.com" />
</label>
<p class="note">Banners will use templates from loginbanner.md and postloginbanner.md</p>
</fieldset>
<button class="btn" type="submit">Download .sh</button>
</form>
</body>
</html>

613
templates/script.sh.j2 Normal file
View File

@ -0,0 +1,613 @@
#!/usr/bin/env bash
set -euo pipefail
# Generated at: {{ meta.generated_at }}
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash $0"
exit 1
fi
log() { echo "[setup] $*"; }
ensure_pkg() {
local pkg="$1"
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg"
fi
}
backup_file() {
local f="$1"
if [[ -f "$f" ]]; then
cp -a "$f" "${f}.bak.$(date +%Y%m%d%H%M%S)"
fi
}
{% if flags.install_docker %}
install_docker() {
log "Installing Docker + compose plugin"
ensure_pkg ca-certificates
ensure_pkg curl
ensure_pkg gnupg
ensure_pkg lsb-release
ensure_pkg acl
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
local codename
codename="$(. /etc/os-release && echo "$VERSION_CODENAME")"
cat >/etc/apt/sources.list.d/docker.list <<EOF
deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable
EOF
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Configure Docker to use custom data directory if specified
{% if flags.docker_admin_user %}
local docker_dir="{{ params.docker_data_dir }}"
if [[ -d "${docker_dir}" ]]; then
log "Configuring Docker to use custom data directory: ${docker_dir}"
mkdir -p /etc/docker
backup_file /etc/docker/daemon.json
# Create or update daemon.json
if [[ -f /etc/docker/daemon.json ]]; then
# Use jq if available, otherwise use sed/python fallback
if command -v jq >/dev/null 2>&1; then
jq ". + {\"data-root\": \"${docker_dir}\"}" /etc/docker/daemon.json > /tmp/daemon.json.tmp && mv /tmp/daemon.json.tmp /etc/docker/daemon.json
else
# Fallback: create new daemon.json with data-root
cat >/etc/docker/daemon.json <<EOF
{
"data-root": "${docker_dir}"
}
EOF
fi
else
cat >/etc/docker/daemon.json <<EOF
{
"data-root": "${docker_dir}"
}
EOF
fi
chmod 644 /etc/docker/daemon.json
log "Docker daemon.json configured. Restart Docker to apply: systemctl restart docker"
fi
{% endif %}
systemctl enable --now docker
log "Docker installed"
}
{% endif %}
{% if flags.docker_admin_user %}
create_docker_admin_user() {
local user="{{ params.admin_username }}"
log "Creating docker operator user: ${user} (non-login)"
if ! id "${user}" >/dev/null 2>&1; then
# system user, no home by default; create home only if you want it
useradd --system --create-home --home-dir "/home/${user}" --shell /usr/sbin/nologin "${user}"
fi
# docker group exists after docker install; create if missing
if ! getent group docker >/dev/null 2>&1; then
groupadd docker
fi
usermod -aG docker "${user}"
# Create docker data directory with restricted permissions
local docker_dir="{{ params.docker_data_dir }}"
log "Creating docker data directory: ${docker_dir}"
# Create directory if it doesn't exist
if [[ ! -d "${docker_dir}" ]]; then
mkdir -p "${docker_dir}"
fi
# Set ownership to docker user and docker group
chown -R "${user}:docker" "${docker_dir}"
# Set permissions: owner (rwx), group (rwx), others (no access)
chmod 770 "${docker_dir}"
# Set ACL to ensure only docker user and docker group have access
ensure_pkg acl
setfacl -R -m u:"${user}":rwx "${docker_dir}"
setfacl -R -m g:docker:rwx "${docker_dir}"
setfacl -R -m o::--- "${docker_dir}"
setfacl -R -d -m u:"${user}":rwx "${docker_dir}"
setfacl -R -d -m g:docker:rwx "${docker_dir}"
setfacl -R -d -m o::--- "${docker_dir}"
log "Docker directory ${docker_dir} created with restricted permissions for ${user}"
# Optional: allow admins to run docker as that user using sudo
# This does NOT allow interactive login to the user.
cat >/etc/sudoers.d/90-docker-operator <<EOF
# Allow members of sudo group to run docker commands as {{ params.admin_username }}
%sudo ALL=( {{ params.admin_username }} ) NOPASSWD: /usr/bin/docker, /usr/bin/docker-compose, /usr/bin/docker-*
EOF
chmod 0440 /etc/sudoers.d/90-docker-operator
log "Docker operator user configured"
log "NOTE: To use custom docker directory, configure Docker daemon.json:"
log " { \"data-root\": \"${docker_dir}\" }"
}
{% endif %}
{% if flags.open_ports %}
setup_firewall() {
log "Configuring UFW firewall"
ensure_pkg ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
{% for p in params.ports %}
ufw allow {{ p }}/tcp
{% endfor %}
ufw --force enable
ufw status verbose || true
log "UFW configured"
}
{% endif %}
{% if flags.combine_lan %}
setup_netplan_bond_bridge() {
log "Writing netplan for bond+bridge (static IP)"
# WARNING: This can lock you out if done over SSH on a remote host.
# Make sure you have console/iLO/IPMI access or test carefully.
local ifaces=( {% for i in params.lan_ifaces %}"{{ i }}"{% if not loop.last %} {% endif %}{% endfor %} )
# Basic validation
for i in "${ifaces[@]}"; do
if ! ip link show "$i" >/dev/null 2>&1; then
echo "Interface not found: $i"
exit 1
fi
done
backup_file /etc/netplan/01-lan.yaml
cat >/etc/netplan/01-lan.yaml <<'EOF'
network:
version: 2
renderer: networkd
ethernets:
{% for i in params.lan_ifaces %}
{{ i }}:
dhcp4: false
{% endfor %}
bonds:
bond0:
interfaces:
{% for i in params.lan_ifaces %}
- {{ i }}
{% endfor %}
parameters:
mode: 802.3ad
mii-monitor-interval: 100
bridges:
br0:
interfaces:
- bond0
addresses:
- {{ params.static_ip_cidr }}
routes:
- to: default
via: {{ params.gateway_ip }}
nameservers:
addresses:
{% for d in params.dns %}
- {{ d }}
{% endfor %}
EOF
netplan generate
netplan apply
log "Netplan applied"
}
{% endif %}
{% if flags.prelogin_banner %}
setup_prelogin_banner() {
log "Configuring SSH pre-login banner"
ensure_pkg openssh-server
# Use /etc/issue.net for SSH Banner
cat >/etc/issue.net <<'EOF'
{{ params.prelogin_text }}
EOF
# Replace owner placeholders with actual values
sed -i "s|\[OWNER_NAME\]|{{ params.owner_name }}|g" /etc/issue.net
sed -i "s|\[OWNER_WEBSITE\]|{{ params.owner_website }}|g" /etc/issue.net
sed -i "s|\[OWNER_EMAIL\]|{{ params.owner_email }}|g" /etc/issue.net
backup_file /etc/ssh/sshd_config
# Ensure Banner points to /etc/issue.net
if grep -qE '^\s*Banner\s+' /etc/ssh/sshd_config; then
sed -i 's|^\s*Banner\s\+.*|Banner /etc/issue.net|' /etc/ssh/sshd_config
else
echo "Banner /etc/issue.net" >> /etc/ssh/sshd_config
fi
systemctl restart ssh
log "Pre-login banner configured"
}
{% endif %}
{% if flags.postlogin_banner %}
setup_postlogin_banner() {
log "Configuring post-login MOTD"
# Simple approach: overwrite /etc/motd
cat >/etc/motd <<'EOF'
{{ params.postlogin_text }}
EOF
# Replace owner placeholders with actual values
sed -i "s|\[OWNER_NAME\]|{{ params.owner_name }}|g" /etc/motd
sed -i "s|\[OWNER_WEBSITE\]|{{ params.owner_website }}|g" /etc/motd
sed -i "s|\[OWNER_EMAIL\]|{{ params.owner_email }}|g" /etc/motd
log "Post-login banner configured"
}
{% endif %}
{% if flags.ssh_2fa %}
install_google_authenticator() {
log "Installing Google Authenticator (PAM module)"
# Only install the package, do not configure it
ensure_pkg libpam-google-authenticator
log "Google Authenticator installed. Manual configuration required."
log "To configure 2FA, users must run: google-authenticator"
log "Then configure PAM and SSH settings manually."
}
{% endif %}
{% if flags.system_update %}
system_update() {
log "Updating system packages"
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y
apt-get autoremove -y
apt-get autoclean -y
log "System updated"
}
{% endif %}
{% if flags.auto_security_updates %}
setup_auto_security_updates() {
log "Configuring automatic security updates"
ensure_pkg unattended-upgrades
# Enable automatic security updates
cat >/etc/apt/apt.conf.d/50unattended-upgrades <<'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
# Enable automatic updates
echo 'APT::Periodic::Update-Package-Lists "1";' > /etc/apt/apt.conf.d/20auto-upgrades
echo 'APT::Periodic::Unattended-Upgrade "1";' >> /etc/apt/apt.conf.d/20auto-upgrades
systemctl enable unattended-upgrades
systemctl restart unattended-upgrades
log "Automatic security updates configured"
}
{% endif %}
{% if flags.setup_timezone %}
setup_timezone() {
log "Setting timezone to {{ params.timezone }}"
ensure_pkg tzdata
timedatectl set-timezone {{ params.timezone }}
log "Timezone configured"
}
{% endif %}
{% if flags.setup_hostname %}
setup_hostname() {
{% if params.hostname %}
log "Setting hostname to {{ params.hostname }}"
hostnamectl set-hostname {{ params.hostname }}
echo "127.0.0.1 {{ params.hostname }}" >> /etc/hosts
log "Hostname configured"
{% else %}
log "Skipping hostname setup (not specified)"
{% endif %}
}
{% endif %}
{% if flags.setup_ntp %}
setup_ntp() {
log "Configuring NTP time synchronization"
ensure_pkg chrony
# Configure chrony with reliable time servers
backup_file /etc/chrony/chrony.conf
cat >/etc/chrony/chrony.conf <<'EOF'
pool 0.pool.ntp.org iburst
pool 1.pool.ntp.org iburst
pool 2.pool.ntp.org iburst
pool 3.pool.ntp.org iburst
# Record the rate at which the system clock gains/losses time
driftfile /var/lib/chrony/drift
# Allow the system clock to be stepped in the first three updates
makestep 1.0 3
# Enable kernel synchronization of the real-time clock
rtcsync
# Enable hardware timestamping on all interfaces
#hwtimestamp *
# Increase the minimum number of selectable sources
#minsources 2
# Allow NTP client access from local network
#allow 192.168.0.0/16
# Serve time even if not synchronized to a time source
#local stratum 10
# Specify file containing keys for NTP authentication
keyfile /etc/chrony/chrony.keys
# Save the drift between the system clock and the hardware clock
#initstepslew 10 client1.example.com client2.example.com
# Get TAI-UTC offset and leap seconds from the system tz database
leapsectz right/UTC
EOF
systemctl enable chronyd
systemctl restart chronyd
chronyc sources
log "NTP configured"
}
{% endif %}
{% if flags.setup_swap %}
setup_swap() {
log "Configuring swap file ({{ params.swap_size_gb }}GB)"
# Swap Configuration Explanation:
# Swap is virtual memory stored on disk. When RAM is full, Linux moves
# less-used data to swap to free up RAM. This prevents "out of memory"
# crashes but is slower than RAM. Swappiness (0-100) controls how
# aggressively Linux uses swap. Lower values (10) prefer RAM, higher (60+)
# use swap more aggressively. For servers, 10 is recommended.
# Check if swap already exists
if swapon --show | grep -q "/swapfile"; then
log "Swap file already exists, skipping"
return
fi
# Create swap file
fallocate -l {{ params.swap_size_gb }}G /swapfile || dd if=/dev/zero of=/swapfile bs=1G count={{ params.swap_size_gb }}
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# Make swap permanent
if ! grep -q "/swapfile" /etc/fstab; then
echo "/swapfile none swap sw 0 0" >> /etc/fstab
fi
# Configure swappiness (10 = less aggressive, 60 = default)
# Lower value = prefer RAM, higher = use swap more
if ! grep -q "vm.swappiness" /etc/sysctl.conf; then
echo "vm.swappiness=10" >> /etc/sysctl.conf
sysctl vm.swappiness=10
fi
log "Swap file configured ({{ params.swap_size_gb }}GB, swappiness=10)"
}
{% endif %}
{% if flags.ssh_harden %}
harden_ssh() {
log "Hardening SSH configuration"
ensure_pkg openssh-server
backup_file /etc/ssh/sshd_config
# Disable root login
sed -i 's|^#*PermitRootLogin.*|PermitRootLogin no|' /etc/ssh/sshd_config
# Change SSH port if specified
{% if params.ssh_port != 22 %}
sed -i "s|^#*Port.*|Port {{ params.ssh_port }}|" /etc/ssh/sshd_config
{% endif %}
# Security settings
sed -i 's|^#*PasswordAuthentication.*|PasswordAuthentication yes|' /etc/ssh/sshd_config
sed -i 's|^#*PubkeyAuthentication.*|PubkeyAuthentication yes|' /etc/ssh/sshd_config
sed -i 's|^#*PermitEmptyPasswords.*|PermitEmptyPasswords no|' /etc/ssh/sshd_config
sed -i 's|^#*MaxAuthTries.*|MaxAuthTries 3|' /etc/ssh/sshd_config
sed -i 's|^#*ClientAliveInterval.*|ClientAliveInterval 300|' /etc/ssh/sshd_config
sed -i 's|^#*ClientAliveCountMax.*|ClientAliveCountMax 2|' /etc/ssh/sshd_config
# Disable X11 forwarding for security
sed -i 's|^#*X11Forwarding.*|X11Forwarding no|' /etc/ssh/sshd_config
# Use only strong ciphers
if ! grep -q "^Ciphers" /etc/ssh/sshd_config; then
echo "Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr" >> /etc/ssh/sshd_config
fi
systemctl restart ssh
log "SSH hardened"
}
{% endif %}
{% if flags.install_fail2ban %}
install_fail2ban() {
log "Installing and configuring Fail2ban"
ensure_pkg fail2ban
# Create local jail configuration
cat >/etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
destemail = root@localhost
sendername = Fail2Ban
action = %(action_)s
[sshd]
enabled = true
port = {{ params.ssh_port }}
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 3
bantime = 7200
EOF
systemctl enable fail2ban
systemctl restart fail2ban
fail2ban-client status
log "Fail2ban configured"
}
{% endif %}
{% if flags.create_admin_user %}
create_admin_user() {
log "Creating admin user: {{ params.new_admin_username }}"
if id "{{ params.new_admin_username }}" >/dev/null 2>&1; then
log "User {{ params.new_admin_username }} already exists"
else
# Create user with sudo access
useradd -m -s /bin/bash {{ params.new_admin_username }}
usermod -aG sudo {{ params.new_admin_username }}
# Create .ssh directory
mkdir -p /home/{{ params.new_admin_username }}/.ssh
chmod 700 /home/{{ params.new_admin_username }}/.ssh
# Add SSH public keys if provided
{% if params.ssh_public_keys %}
cat >/home/{{ params.new_admin_username }}/.ssh/authorized_keys <<'EOF'
{% for key in params.ssh_public_keys %}
{{ key }}
{% endfor %}
EOF
chmod 600 /home/{{ params.new_admin_username }}/.ssh/authorized_keys
chown -R {{ params.new_admin_username }}:{{ params.new_admin_username }} /home/{{ params.new_admin_username }}/.ssh
log "SSH keys added for {{ params.new_admin_username }}"
{% else %}
log "No SSH keys provided - user will need to set password or add keys manually"
{% endif %}
log "Admin user {{ params.new_admin_username }} created with sudo access"
log "IMPORTANT: Set a password with: passwd {{ params.new_admin_username }}"
fi
}
{% endif %}
{% if flags.install_monitoring_tools %}
install_monitoring_tools() {
log "Installing monitoring and system utilities"
# Essential monitoring tools
ensure_pkg htop
ensure_pkg iotop
ensure_pkg net-tools
ensure_pkg curl
ensure_pkg wget
ensure_pkg vim
ensure_pkg nano
ensure_pkg tree
ensure_pkg unzip
ensure_pkg zip
ensure_pkg rsync
ensure_pkg tmux
ensure_pkg screen
log "Monitoring tools installed"
}
{% endif %}
{% if flags.install_build_tools %}
install_build_tools() {
log "Installing build tools and development utilities"
ensure_pkg build-essential
ensure_pkg git
ensure_pkg make
ensure_pkg cmake
ensure_pkg pkg-config
log "Build tools installed"
}
{% endif %}
main() {
log "Starting setup"
# System Setup (run first)
{% if flags.system_update %} system_update {% endif %}
{% if flags.setup_timezone %} setup_timezone {% endif %}
{% if flags.setup_hostname %} setup_hostname {% endif %}
{% if flags.setup_ntp %} setup_ntp {% endif %}
{% if flags.setup_swap %} setup_swap {% endif %}
{% if flags.auto_security_updates %} setup_auto_security_updates {% endif %}
# Security & Hardening
{% if flags.ssh_harden %} harden_ssh {% endif %}
{% if flags.install_fail2ban %} install_fail2ban {% endif %}
{% if flags.prelogin_banner %} setup_prelogin_banner {% endif %}
{% if flags.postlogin_banner %} setup_postlogin_banner {% endif %}
{% if flags.ssh_2fa %} install_google_authenticator {% endif %}
# User Management
{% if flags.create_admin_user %} create_admin_user {% endif %}
# Docker & Services
{% if flags.install_docker %} install_docker {% endif %}
{% if flags.docker_admin_user %} create_docker_admin_user {% endif %}
{% if flags.open_ports %} setup_firewall {% endif %}
{% if flags.combine_lan %} setup_netplan_bond_bridge {% endif %}
# Monitoring & Utilities
{% if flags.install_monitoring_tools %} install_monitoring_tools {% endif %}
{% if flags.install_build_tools %} install_build_tools {% endif %}
log "Done"
}
main "$@"