Files
GuacPanel/install.sh
T
njomac 50c125e0db Initial commit — GuacPanel v1.0
All-in-one installer za Apache Guacamole s Nginx reverse proxy,
fail2ban, SSL (Let's Encrypt / Self-signed), GeoIP blokiranjem
i Flask web GUI za upravljanje.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:21:44 +02:00

1830 lines
77 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
set -euo pipefail
# ================================================================
# Apache Guacamole + Nginx + Web GUI — All-in-One Installer
# Podržano: Ubuntu/Debian, RHEL/CentOS/Rocky/Alma, Fedora,
# Arch/Manjaro, openSUSE
# ================================================================
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[⚠]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}${BLUE}══════════ $* ══════════${NC}"; }
[[ $EUID -ne 0 ]] && error "Pokrenite kao root: sudo bash $0"
# ── Konfigurabilne varijable ──────────────────────────────────────────────────
GUAC_VERSION="1.5.5"
GUAC_PORT="8080"
INSTALL_DIR="/opt/guacamole"
# ── OS detekcija ──────────────────────────────────────────────────────────────
section "Detekcija operativnog sustava"
[[ -f /etc/os-release ]] || error "/etc/os-release nije pronađen — nepodržan OS"
source /etc/os-release
OS_ID="${ID:-unknown}"
OS_VERSION_ID="${VERSION_ID:-0}"
OS_LIKE="${ID_LIKE:-}"
determine_pkg_manager() {
local id="$1" like="$2" ver="$3"
if [[ "$id" =~ ^(ubuntu|debian|linuxmint|pop|kali|raspbian)$ ]] \
|| echo "$like" | grep -qi debian; then
echo "apt"
elif [[ "$id" == "fedora" ]]; then
echo "dnf"
elif [[ "$id" =~ ^(centos|rhel|rocky|almalinux|ol)$ ]] \
|| echo "$like" | grep -qiE "rhel|fedora|centos"; then
if (( ${ver%%.*} >= 8 )); then echo "dnf"; else echo "yum"; fi
elif [[ "$id" =~ ^(arch|manjaro|endeavouros|garuda)$ ]]; then
echo "pacman"
elif [[ "$id" =~ ^(opensuse|sles|opensuse-leap|opensuse-tumbleweed)$ ]] \
|| echo "$like" | grep -qi suse; then
echo "zypper"
else
echo "unknown"
fi
}
PKG=$(determine_pkg_manager "$OS_ID" "$OS_LIKE" "$OS_VERSION_ID")
[[ "$PKG" == "unknown" ]] && error "Nepodržani OS: $OS_ID"
info "OS: ${PRETTY_NAME:-$OS_ID} | Package manager: $PKG"
pkg_update() {
case "$PKG" in
apt) apt-get update -y ;;
dnf) dnf check-update -y || true ;;
yum) yum check-update -y || true ;;
pacman) pacman -Sy --noconfirm ;;
zypper) zypper refresh ;;
esac
}
pkg_install() {
case "$PKG" in
apt) apt-get install -y "$@" ;;
dnf) dnf install -y "$@" ;;
yum) yum install -y "$@" ;;
pacman) pacman -S --noconfirm "$@" ;;
zypper) zypper install -yn "$@" ;;
esac
}
# ── Korisnički unos ───────────────────────────────────────────────────────────
section "Konfiguracija instalacije"
echo ""
read -rp "$(echo -e "${CYAN}Domena${NC} (npr. rdp.firma.com) ili Enter za IP: ")" DOMAIN
DOMAIN="${DOMAIN:-$(hostname -I | awk '{print $1}')}"
read -rp "$(echo -e "${CYAN}Nginx Basic Auth${NC} korisničko ime [admin]: ")" BASIC_USER
BASIC_USER="${BASIC_USER:-admin}"
while true; do
read -rsp "$(echo -e "${CYAN}Nginx Basic Auth${NC} lozinka: ")" BASIC_PASS; echo
[[ -n "$BASIC_PASS" ]] && break
warn "Lozinka ne može biti prazna"
done
echo ""
echo -e "${CYAN}SSL certifikat?${NC}"
echo " L = Let's Encrypt (besplatno, treba valjana domena)"
echo " S = Self-signed (radi s IP adresom, browser pokazuje upozorenje)"
echo " N = Bez SSL (HTTP only)"
read -rp "Odabir [L/S/N]: " SSL_CHOICE
SSL_CHOICE=$(echo "${SSL_CHOICE:-n}" | tr '[:upper:]' '[:lower:]')
DO_SSL="n"; DO_SELFSIGNED="n"; LE_EMAIL=""
case "$SSL_CHOICE" in
l)
DO_SSL="y"
read -rp "$(echo -e "${CYAN}Email${NC} za Let's Encrypt: ")" LE_EMAIL
[[ -z "$LE_EMAIL" ]] && error "Email je obavezan za Let's Encrypt"
;;
s) DO_SELFSIGNED="y" ;;
esac
echo ""
read -rp "$(echo -e "${CYAN}GeoIP blokiranje${NC}? [y/N]: ")" DO_GEOIP
DO_GEOIP="${DO_GEOIP:-n}"
MM_ACCOUNT_ID=""; MM_LICENSE_KEY=""; GEO_COUNTRIES=""
if [[ "$DO_GEOIP" =~ ^[Yy]$ ]]; then
warn "Potreban besplatni MaxMind račun: https://www.maxmind.com/en/geolite2/signup"
read -rp "MaxMind Account ID: " MM_ACCOUNT_ID
read -rp "MaxMind License Key: " MM_LICENSE_KEY
read -rp "Dozvoljene zemlje (ISO kodovi, razmak, npr: HR BA SI): " GEO_COUNTRIES
fi
echo ""
read -rp "$(echo -e "${CYAN}Web GUI${NC} — instalirati web sučelje za upravljanje? [y/N]: ")" DO_WEBGUI
DO_WEBGUI="${DO_WEBGUI:-n}"
GUI_USER="admin"; GUI_PASS=""; GUI_PORT=5555; GUI_PATH="/guacadmin/"
if [[ "$DO_WEBGUI" =~ ^[Yy]$ ]]; then
read -rp "GUI korisničko ime [admin]: " GUI_USER
GUI_USER="${GUI_USER:-admin}"
while true; do
read -rsp "GUI lozinka (min 8 znakova): " GUI_PASS; echo
[[ ${#GUI_PASS} -ge 8 ]] && break
warn "Lozinka mora imati najmanje 8 znakova"
done
read -rp "GUI URL path [/guacadmin/]: " inp_path
GUI_PATH="${inp_path:-/guacadmin/}"
[[ "$GUI_PATH" != /* ]] && GUI_PATH="/${GUI_PATH}"
[[ "$GUI_PATH" != */ ]] && GUI_PATH="${GUI_PATH}/"
fi
# Generirane lozinke
POSTGRES_PASS=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
SECRET_KEY=$(openssl rand -hex 32)
GUI_SECRET=$(openssl rand -hex 32)
echo ""
echo -e "${BOLD}${YELLOW}════ Sačuvajte ove podatke ════${NC}"
echo -e " PostgreSQL lozinka : ${CYAN}${POSTGRES_PASS}${NC}"
echo -e " Guacamole admin : ${CYAN}guacadmin / guacadmin${NC} (promijenite odmah!)"
[[ "$DO_WEBGUI" =~ ^[Yy]$ ]] && echo -e " Web GUI : ${CYAN}${GUI_USER} / ${GUI_PASS}${NC}"
[[ "$DO_SELFSIGNED" =~ ^[Yy]$ ]] && echo -e " SSL tip : ${CYAN}Self-signed (browser upozorenje je normalno)${NC}"
echo -e "${BOLD}${YELLOW}════════════════════════════════${NC}"
echo ""
read -rp "Pritisnite Enter za nastavak..."
# ── Ažuriranje i preduvjeti ───────────────────────────────────────────────────
section "Ažuriranje i instalacija preduvjeta"
pkg_update
case "$PKG" in
apt)
pkg_install curl wget gnupg2 ca-certificates lsb-release \
openssl apache2-utils nginx fail2ban
;;
dnf|yum)
if [[ "$OS_ID" =~ ^(centos|rhel|rocky|almalinux|ol)$ ]]; then
pkg_install epel-release 2>/dev/null || true
pkg_update
fi
pkg_install curl wget gnupg2 ca-certificates openssl \
httpd-tools nginx fail2ban
;;
pacman) pkg_install curl wget openssl apache nginx fail2ban ;;
zypper) pkg_install curl wget openssl apache2-utils nginx fail2ban ;;
esac
info "Preduvjeti instalirani"
# ── Docker ────────────────────────────────────────────────────────────────────
section "Docker"
install_docker_apt() {
local distro_id="$1"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${distro_id}/gpg" \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
local codename
codename=$(. /etc/os-release && echo "${VERSION_CODENAME:-$(lsb_release -cs 2>/dev/null)}")
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${distro_id} ${codename} stable" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -y
pkg_install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
}
if command -v docker &>/dev/null; then
info "Docker već instaliran: $(docker --version)"
else
info "Instaliranje Docker-a..."
case "$PKG" in
apt)
local_id="$OS_ID"
[[ "$OS_ID" =~ ^(linuxmint|pop)$ ]] && local_id="ubuntu"
[[ "$OS_ID" =~ ^(kali|raspbian)$ ]] && local_id="debian"
install_docker_apt "$local_id"
;;
dnf|yum)
pkg_install yum-utils
yum-config-manager --add-repo \
https://download.docker.com/linux/centos/docker-ce.repo
pkg_install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
;;
pacman) pkg_install docker docker-compose ;;
zypper)
zypper addrepo https://download.docker.com/linux/opensuse/docker-ce.repo || true
zypper refresh
pkg_install docker-ce docker-ce-cli containerd.io docker-compose-plugin
;;
esac
systemctl enable --now docker
info "Docker instaliran: $(docker --version)"
fi
if docker compose version &>/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
info "Docker Compose plugin: $(docker compose version)"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
info "Docker Compose standalone: $(docker-compose --version)"
else
error "Docker Compose nije pronađen. Reinstalirajte Docker."
fi
# ── Guacamole ─────────────────────────────────────────────────────────────────
section "Priprema Guacamole"
mkdir -p "${INSTALL_DIR}"/{extensions,db-init}
info "Radni direktorij: ${INSTALL_DIR}"
info "Preuzimanje SQL init skripte..."
docker run --rm "guacamole/guacamole:${GUAC_VERSION}" \
/opt/guacamole/bin/initdb.sh --postgresql \
> "${INSTALL_DIR}/db-init/initdb.sql"
info "SQL init skripta preuzeta"
section "TOTP 2FA ekstenzija"
TOTP_JAR="guacamole-auth-totp-${GUAC_VERSION}.jar"
TOTP_URL="https://downloads.apache.org/guacamole/${GUAC_VERSION}/binary/${TOTP_JAR}"
if wget -q --timeout=30 "$TOTP_URL" -O "${INSTALL_DIR}/extensions/${TOTP_JAR}"; then
info "TOTP ekstenzija preuzeta"
else
warn "TOTP preuzimanje nije uspjelo. Ručno: https://guacamole.apache.org/releases/"
fi
cat > "${INSTALL_DIR}/.env" << EOF
POSTGRES_PASS=${POSTGRES_PASS}
GUAC_VERSION=${GUAC_VERSION}
SECRET_KEY=${SECRET_KEY}
EOF
chmod 600 "${INSTALL_DIR}/.env"
section "docker-compose.yml"
cat > "${INSTALL_DIR}/docker-compose.yml" << EOF
version: '3.8'
services:
guacd:
image: guacamole/guacd:${GUAC_VERSION}
container_name: guacd
restart: unless-stopped
networks: [guac_net]
postgres:
image: postgres:15-alpine
container_name: guac_postgres
restart: unless-stopped
environment:
POSTGRES_DB: guacamole_db
POSTGRES_USER: guacamole_user
POSTGRES_PASSWORD: \${POSTGRES_PASS}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db-init/initdb.sql:/docker-entrypoint-initdb.d/initdb.sql:ro
networks: [guac_net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U guacamole_user -d guacamole_db"]
interval: 10s
timeout: 5s
retries: 10
guacamole:
image: guacamole/guacamole:${GUAC_VERSION}
container_name: guacamole
restart: unless-stopped
depends_on:
postgres: {condition: service_healthy}
guacd: {condition: service_started}
environment:
GUACD_HOSTNAME: guacd
POSTGRESQL_HOSTNAME: postgres
POSTGRESQL_DATABASE: guacamole_db
POSTGRESQL_USERNAME: guacamole_user
POSTGRESQL_PASSWORD: \${POSTGRES_PASS}
volumes:
- ./extensions:/etc/guacamole/extensions:ro
ports:
- "127.0.0.1:${GUAC_PORT}:8080"
networks: [guac_net]
networks:
guac_net:
driver: bridge
volumes:
postgres_data:
EOF
info "docker-compose.yml kreiran"
section "Pokretanje kontejnera"
cd "${INSTALL_DIR}"
$COMPOSE_CMD up -d
info "Čekanje PostgreSQL (max 90s)..."
for i in $(seq 1 18); do
docker exec guac_postgres pg_isready -U guacamole_user -d guacamole_db &>/dev/null \
&& { info "PostgreSQL spreman"; break; }
[[ $i -eq 18 ]] && error "PostgreSQL timeout. Provjeri: docker logs guac_postgres"
sleep 5
done
info "Čekanje Guacamole aplikacije (30s)..."
sleep 30
docker ps --filter "name=guacamole" --filter "status=running" | grep -q guacamole \
|| error "Guacamole se nije pokrenuo. Provjeri: docker logs guacamole"
info "Guacamole kontejner aktivan"
# ── Nginx ─────────────────────────────────────────────────────────────────────
section "Nginx konfiguracija"
systemctl enable nginx
cat > /etc/nginx/conf.d/guacamole-maps.conf << 'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=guac_req:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=guac_ws:10m rate=30r/m;
EOF
if [[ "$PKG" == "apt" ]]; then
NGINX_CONF="/etc/nginx/sites-available/guacamole"
NGINX_ENABLED="/etc/nginx/sites-enabled/guacamole"
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
rm -f /etc/nginx/sites-enabled/default
else
NGINX_CONF="/etc/nginx/conf.d/guacamole.conf"
NGINX_ENABLED=""
fi
cat > "$NGINX_CONF" << EOF
server {
listen 80;
server_name ${DOMAIN};
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location = / { return 301 /guacamole/; }
location /guacamole/ {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.guac_htpasswd;
limit_req zone=guac_req burst=20 nodelay;
proxy_pass http://127.0.0.1:${GUAC_PORT}/guacamole/;
proxy_buffering off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
}
}
EOF
[[ -n "$NGINX_ENABLED" ]] && ln -sf "$NGINX_CONF" "$NGINX_ENABLED"
nginx -t || error "Nginx konfiguracija sadrži grešku"
systemctl reload nginx
info "Nginx konfiguriran"
# ── Basic Auth ────────────────────────────────────────────────────────────────
section "HTTP Basic Auth"
htpasswd -bc /etc/nginx/.guac_htpasswd "$BASIC_USER" "$BASIC_PASS"
chmod 640 /etc/nginx/.guac_htpasswd
chown root:www-data /etc/nginx/.guac_htpasswd 2>/dev/null || \
chown root:nginx /etc/nginx/.guac_htpasswd 2>/dev/null || true
info "Basic Auth postavljen za: ${BASIC_USER}"
# ── fail2ban ──────────────────────────────────────────────────────────────────
section "fail2ban"
cat > /etc/fail2ban/filter.d/nginx-basicauth.conf << 'EOF'
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) /guacamole.*" 401
ignoreregex =
EOF
cat > /etc/fail2ban/filter.d/nginx-guacamole.conf << 'EOF'
[Definition]
failregex = ^<HOST> .* "POST /guacamole/api/tokens HTTP.*" 403
ignoreregex =
EOF
cat > /etc/fail2ban/jail.d/guacamole.conf << EOF
[nginx-basicauth]
enabled = true
port = http,https
filter = nginx-basicauth
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600
[nginx-guacamole]
enabled = true
port = http,https
filter = nginx-guacamole
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600
[sshd]
enabled = true
maxretry = 3
findtime = 300
bantime = 86400
EOF
systemctl enable --now fail2ban
info "fail2ban konfiguriran"
# ── SSL ───────────────────────────────────────────────────────────────────────
if [[ "$DO_SSL" =~ ^[Yy]$ ]]; then
section "SSL (Let's Encrypt)"
case "$PKG" in
apt) pkg_install certbot python3-certbot-nginx ;;
dnf|yum) pkg_install certbot python3-certbot-nginx ;;
pacman) pkg_install certbot certbot-nginx ;;
zypper) pkg_install certbot python3-certbot-nginx || warn "certbot-nginx instaliraj ručno" ;;
esac
certbot --nginx -d "$DOMAIN" --email "$LE_EMAIL" \
--agree-tos --non-interactive --redirect
if systemctl list-unit-files certbot.timer &>/dev/null; then
systemctl enable --now certbot.timer
else
echo "0 3 * * * root certbot renew --quiet --post-hook 'systemctl reload nginx'" >> /etc/crontab
fi
nginx -t && systemctl reload nginx
info "SSL aktivan, auto-obnova postavljena"
fi
# ── Self-signed SSL ───────────────────────────────────────────────────────────
if [[ "$DO_SELFSIGNED" =~ ^[Yy]$ ]]; then
section "Self-signed SSL certifikat"
CERT_DIR="/etc/ssl/guacamole"
mkdir -p "$CERT_DIR"
# SAN: IP adresa ili domena
if [[ "${DOMAIN}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
SAN="IP:${DOMAIN}"
else
SAN="DNS:${DOMAIN}"
fi
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout "${CERT_DIR}/selfsigned.key" \
-out "${CERT_DIR}/selfsigned.crt" \
-subj "/CN=${DOMAIN}/O=Guacamole/C=HR" \
-addext "subjectAltName=${SAN}" 2>/dev/null
chmod 600 "${CERT_DIR}/selfsigned.key"
info "Self-signed certifikat kreiran (vrijedi 10 godina)"
# Prepiši nginx config za HTTPS
cat > "$NGINX_CONF" << EOF
# HTTP → HTTPS redirect
server {
listen 80;
server_name ${DOMAIN};
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name ${DOMAIN};
ssl_certificate ${CERT_DIR}/selfsigned.crt;
ssl_certificate_key ${CERT_DIR}/selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location = / { return 301 /guacamole/; }
location /guacamole/ {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.guac_htpasswd;
limit_req zone=guac_req burst=20 nodelay;
proxy_pass http://127.0.0.1:${GUAC_PORT}/guacamole/;
proxy_buffering off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
}
}
EOF
[[ -n "$NGINX_ENABLED" ]] && ln -sf "$NGINX_CONF" "$NGINX_ENABLED"
nginx -t && systemctl reload nginx
info "Nginx konfiguriran za HTTPS (self-signed)"
warn "Browser će pokazati upozorenje — to je normalno za self-signed certifikat"
warn "Chrome: klikni 'Napredno' → 'Nastavi na ${DOMAIN}'"
fi
# ── GeoIP ─────────────────────────────────────────────────────────────────────
if [[ "$DO_GEOIP" =~ ^[Yy]$ ]]; then
section "GeoIP blokiranje"
case "$PKG" in
apt) pkg_install libnginx-mod-http-geoip2 geoipupdate || warn "Provjeri apt repozitorij" ;;
dnf|yum) pkg_install nginx-mod-http-geoip2 || warn "Modul možda nije dostupan" ;;
pacman) warn "GeoIP na Arch-u: instaliraj ručno (AUR: nginx-mod-http-geoip2)" ;;
esac
if command -v geoipupdate &>/dev/null; then
mkdir -p /etc/GeoIP /usr/share/GeoIP
cat > /etc/GeoIP.conf << EOF
AccountID ${MM_ACCOUNT_ID}
LicenseKey ${MM_LICENSE_KEY}
EditionIDs GeoLite2-Country
DatabaseDirectory /usr/share/GeoIP
EOF
geoipupdate && info "GeoIP baza preuzeta" || warn "geoipupdate nije uspio"
fi
COUNTRY_MAP_ENTRIES=""
for cc in $GEO_COUNTRIES; do COUNTRY_MAP_ENTRIES+=" ${cc} 1;\n"; done
cat > /etc/nginx/conf.d/guacamole-geoip.conf << EOF
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
\$geoip2_data_country_code country iso_code;
}
map \$geoip2_data_country_code \$allowed_country {
default 0;
$(echo -e "$COUNTRY_MAP_ENTRIES")}
EOF
sed -i '/auth_basic.*Restricted/i\ if ($allowed_country = 0) { return 403; }' "$NGINX_CONF"
nginx -t && systemctl reload nginx
info "GeoIP aktivan — dozvoljene: ${GEO_COUNTRIES}"
fi
# ── Firewall ──────────────────────────────────────────────────────────────────
section "Firewall"
if command -v ufw &>/dev/null; then
ufw allow 22/tcp comment "SSH" 2>/dev/null || true
ufw allow 80/tcp comment "HTTP" 2>/dev/null || true
ufw allow 443/tcp comment "HTTPS" 2>/dev/null || true
ufw --force enable
info "UFW pravila postavljena"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-service={ssh,http,https} 2>/dev/null || true
firewall-cmd --reload
info "firewalld pravila postavljena"
else
warn "Firewall nije pronađen — konfigurirajte ručno"
fi
# ── Web GUI ───────────────────────────────────────────────────────────────────
if [[ "$DO_WEBGUI" =~ ^[Yy]$ ]]; then
section "Web GUI instalacija"
WEBGUI_DIR="${INSTALL_DIR}/webgui"
mkdir -p "${WEBGUI_DIR}/templates"
# Python
case "$PKG" in
apt) pkg_install python3 python3-pip python3-venv ;;
dnf|yum) pkg_install python3 python3-pip ;;
pacman) pkg_install python python-pip ;;
zypper) pkg_install python3 python3-pip ;;
esac
info "Python: $(python3 --version)"
# ── app.py ──────────────────────────────────────────────────
cat > "${WEBGUI_DIR}/app.py" << 'APPEOF'
#!/usr/bin/env python3
import os, subprocess, json, secrets, shutil, re
from functools import wraps
from flask import (Flask, render_template, request, redirect,
url_for, session, jsonify, flash, Response, stream_with_context)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INSTALL_DIR = '/opt/guacamole'
HTPASSWD = '/etc/nginx/.guac_htpasswd'
GEOIP_CONF = '/etc/nginx/conf.d/guacamole-geoip.conf'
GEOIP_MMCONF = '/etc/GeoIP.conf'
FAIL2BAN_CFG = '/etc/fail2ban/jail.d/guacamole.conf'
CONFIG_FILE = os.path.join(BASE_DIR, 'config.json')
NGINX_CONFS = ['/etc/nginx/sites-available/guacamole',
'/etc/nginx/conf.d/guacamole.conf']
app = Flask(__name__, template_folder='templates')
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
def load_config():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE) as f:
return json.load(f)
return {'username': 'admin', 'password': 'changeme123'}
def save_config(cfg):
with open(CONFIG_FILE, 'w') as f:
json.dump(cfg, f, indent=2)
os.chmod(CONFIG_FILE, 0o600)
def run(cmd, stdin=None, timeout=30):
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, input=stdin)
return r.returncode == 0, (r.stdout + r.stderr).strip()
except subprocess.TimeoutExpired:
return False, 'Timeout'
except Exception as e:
return False, str(e)
def detect_compose():
ok, _ = run(['docker', 'compose', 'version'])
return 'docker compose' if ok else 'docker-compose'
COMPOSE = detect_compose().split()
def get_nginx_conf():
for p in NGINX_CONFS:
if os.path.exists(p):
return p
return None
def nginx_reload():
ok, out = run(['nginx', '-t'])
if ok:
run(['systemctl', 'reload', 'nginx'])
return ok, out
def container_running(name):
ok, out = run(['docker', 'ps', '--filter', f'name=^/{name}$',
'--filter', 'status=running', '--format', '{{.Names}}'])
return name in out
def service_active(name):
ok, _ = run(['systemctl', 'is-active', name])
return ok
def get_domain():
conf = get_nginx_conf()
if conf and os.path.exists(conf):
with open(conf) as f:
for line in f:
m = re.search(r'server_name\s+(.+?);', line)
if m:
return m.group(1).strip()
return 'localhost'
def geoip_countries():
if not os.path.exists(GEOIP_CONF):
return []
with open(GEOIP_CONF) as f:
return re.findall(r'^\s+([A-Z]{2})\s+1;', f.read(), re.MULTILINE)
def fail2ban_banned(jail):
ok, out = run(['fail2ban-client', 'status', jail])
if not ok:
return []
m = re.search(r'Banned IP list:\s*(.*)', out)
if m:
return [ip for ip in m.group(1).split() if ip]
return []
def htpasswd_users():
if not os.path.exists(HTPASSWD):
return []
with open(HTPASSWD) as f:
return [line.split(':')[0] for line in f if ':' in line]
def ssl_active():
conf = get_nginx_conf()
if conf and os.path.exists(conf):
with open(conf) as f:
return 'ssl_certificate' in f.read()
return False
def login_required(f):
@wraps(f)
def inner(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login', next=request.path))
return f(*args, **kwargs)
return inner
def api_auth(f):
@wraps(f)
def inner(*args, **kwargs):
if not session.get('logged_in'):
return jsonify(ok=False, msg='Unauthorized'), 401
return f(*args, **kwargs)
return inner
@app.route('/')
@login_required
def index():
return redirect(url_for('dashboard'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
cfg = load_config()
if (request.form.get('username') == cfg['username'] and
request.form.get('password') == cfg['password']):
session.permanent = True
session['logged_in'] = True
return redirect(request.args.get('next') or url_for('dashboard'))
flash('Pogrešno korisničko ime ili lozinka', 'danger')
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
@app.route('/dashboard')
@login_required
def dashboard():
return render_template('dashboard.html', domain=get_domain())
@app.route('/basicauth')
@login_required
def basicauth():
return render_template('basicauth.html', users=htpasswd_users())
@app.route('/geoip')
@login_required
def geoip():
enabled = os.path.exists(GEOIP_CONF)
countries = ' '.join(geoip_countries())
mm_id = ''
if os.path.exists(GEOIP_MMCONF):
with open(GEOIP_MMCONF) as f:
txt = f.read()
m = re.search(r'AccountID\s+(\S+)', txt)
if m:
mm_id = m.group(1)
return render_template('geoip.html', enabled=enabled,
countries=countries, mm_id=mm_id)
@app.route('/fail2ban')
@login_required
def fail2ban():
jails = ['nginx-basicauth', 'nginx-guacamole', 'sshd']
banned = {j: fail2ban_banned(j) for j in jails}
cfg = {}
if os.path.exists(FAIL2BAN_CFG):
with open(FAIL2BAN_CFG) as f:
txt = f.read()
m = re.search(r'maxretry\s*=\s*(\d+)', txt)
if m:
cfg['maxretry'] = m.group(1)
m = re.search(r'bantime\s*=\s*(\d+)', txt)
if m:
cfg['bantime'] = m.group(1)
return render_template('fail2ban.html', banned=banned, cfg=cfg)
@app.route('/guacamole')
@login_required
def guacamole():
containers = {n: container_running(n)
for n in ['guacamole', 'guacd', 'guac_postgres']}
return render_template('guacamole.html', containers=containers)
@app.route('/ssl')
@login_required
def ssl():
active = ssl_active()
domain = get_domain()
cert_info = ''
if active:
ok, out = run(['certbot', 'certificates'])
cert_info = out
return render_template('ssl.html', active=active, domain=domain, cert_info=cert_info)
@app.route('/settings')
@login_required
def settings():
cfg = load_config()
return render_template('settings.html', username=cfg['username'])
@app.route('/api/status')
@api_auth
def api_status():
containers = {n: container_running(n)
for n in ['guacamole', 'guacd', 'guac_postgres']}
services = {s: service_active(s) for s in ['nginx', 'fail2ban', 'docker']}
banned_total = sum(len(fail2ban_banned(j))
for j in ['nginx-basicauth', 'nginx-guacamole', 'sshd'])
return jsonify(containers=containers, services=services,
geoip=os.path.exists(GEOIP_CONF), ssl=ssl_active(),
banned=banned_total, domain=get_domain())
@app.route('/api/basicauth/add', methods=['POST'])
@api_auth
def api_ba_add():
user = request.json.get('username', '').strip()
pw = request.json.get('password', '')
if not re.match(r'^[a-zA-Z0-9_.-]+$', user):
return jsonify(ok=False, msg='Nevažeće korisničko ime')
if not pw:
return jsonify(ok=False, msg='Lozinka je obavezna')
flag = '-b' if os.path.exists(HTPASSWD) else '-bc'
ok, out = run(['htpasswd', flag, HTPASSWD, user, pw])
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out or 'Korisnik dodan')
@app.route('/api/basicauth/update', methods=['POST'])
@api_auth
def api_ba_update():
user = request.json.get('username', '').strip()
pw = request.json.get('password', '')
if not pw:
return jsonify(ok=False, msg='Lozinka je obavezna')
ok, out = run(['htpasswd', '-b', HTPASSWD, user, pw])
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out or 'Lozinka promijenjena')
@app.route('/api/basicauth/delete', methods=['POST'])
@api_auth
def api_ba_delete():
user = request.json.get('username', '').strip()
ok, out = run(['htpasswd', '-D', HTPASSWD, user])
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out or 'Korisnik uklonjen')
def _write_geoip_conf(countries):
entries = ''.join(f' {c} 1;\n' for c in countries)
with open(GEOIP_CONF, 'w') as f:
f.write(f"geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {{\n"
f" $geoip2_data_country_code country iso_code;\n}}\n\n"
f"map $geoip2_data_country_code $allowed_country {{\n"
f" default 0;\n{entries}}}\n")
def _apply_geoip_nginx():
conf = get_nginx_conf()
if not conf:
return
with open(conf) as f:
txt = f.read()
txt = re.sub(r'\s*if \(\$allowed_country = 0\).*?\n', '', txt)
txt = re.sub(r'(auth_basic\s+"Restricted[^"]*";)',
r'if ($allowed_country = 0) { return 403; }\n \1', txt)
with open(conf, 'w') as f:
f.write(txt)
def _remove_geoip_nginx():
conf = get_nginx_conf()
if not conf:
return
with open(conf) as f:
txt = f.read()
txt = re.sub(r'[ \t]*if \(\$allowed_country = 0\)[^\n]*\n', '', txt)
with open(conf, 'w') as f:
f.write(txt)
@app.route('/api/geoip/enable', methods=['POST'])
@api_auth
def api_geoip_enable():
data = request.json
mm_id = data.get('mm_id', '').strip()
mm_key = data.get('mm_key', '').strip()
countries = [c.upper() for c in data.get('countries', '').split() if c]
if not (mm_id and mm_key and countries):
return jsonify(ok=False, msg='Popunite sva polja')
if not shutil.which('geoipupdate'):
ok, out = run(['apt-get', 'install', '-y', 'geoipupdate', 'libnginx-mod-http-geoip2'])
if not ok:
return jsonify(ok=False, msg=f'Instalacija: {out}')
with open(GEOIP_MMCONF, 'w') as f:
f.write(f"AccountID {mm_id}\nLicenseKey {mm_key}\n"
f"EditionIDs GeoLite2-Country\nDatabaseDirectory /usr/share/GeoIP\n")
ok, out = run(['geoipupdate'])
if not ok:
return jsonify(ok=False, msg=f'geoipupdate: {out}')
_write_geoip_conf(countries)
_apply_geoip_nginx()
nginx_reload()
return jsonify(ok=True, msg='GeoIP aktiviran')
@app.route('/api/geoip/update', methods=['POST'])
@api_auth
def api_geoip_update():
countries = [c.upper() for c in request.json.get('countries', '').split() if c]
if not countries:
return jsonify(ok=False, msg='Unesite barem jednu zemlju')
_write_geoip_conf(countries)
ok, out = nginx_reload()
return jsonify(ok=ok, msg='Ažurirane dozvoljene države' if ok else out)
@app.route('/api/geoip/disable', methods=['POST'])
@api_auth
def api_geoip_disable():
if os.path.exists(GEOIP_CONF):
os.remove(GEOIP_CONF)
_remove_geoip_nginx()
nginx_reload()
return jsonify(ok=True, msg='GeoIP onemogućen')
@app.route('/api/geoip/refresh', methods=['POST'])
@api_auth
def api_geoip_refresh():
ok, out = run(['geoipupdate'])
return jsonify(ok=ok, msg=out)
@app.route('/api/fail2ban/unban', methods=['POST'])
@api_auth
def api_unban():
ip = request.json.get('ip', '').strip()
jail = request.json.get('jail', 'nginx-basicauth')
if not re.match(r'^[\d.:a-fA-F]+$', ip):
return jsonify(ok=False, msg='Nevažeća IP adresa')
ok, out = run(['fail2ban-client', 'set', jail, 'unbanip', ip])
return jsonify(ok=ok, msg=out or f'{ip} odbanjivan')
@app.route('/api/fail2ban/ban', methods=['POST'])
@api_auth
def api_ban():
ip = request.json.get('ip', '').strip()
jail = request.json.get('jail', 'nginx-basicauth')
if not re.match(r'^[\d.:a-fA-F]+$', ip):
return jsonify(ok=False, msg='Nevažeća IP adresa')
ok, out = run(['fail2ban-client', 'set', jail, 'banip', ip])
return jsonify(ok=ok, msg=out or f'{ip} baniran')
@app.route('/api/fail2ban/settings', methods=['POST'])
@api_auth
def api_fail2ban_settings():
maxretry = request.json.get('maxretry', '')
bantime = request.json.get('bantime', '')
if not (str(maxretry).isdigit() and str(bantime).lstrip('-').isdigit()):
return jsonify(ok=False, msg='Nevažeće vrijednosti')
if os.path.exists(FAIL2BAN_CFG):
with open(FAIL2BAN_CFG) as f:
txt = f.read()
txt = re.sub(r'maxretry\s*=\s*\d+', f'maxretry = {maxretry}', txt)
txt = re.sub(r'bantime\s*=\s*\d+', f'bantime = {bantime}', txt)
with open(FAIL2BAN_CFG, 'w') as f:
f.write(txt)
ok, out = run(['systemctl', 'restart', 'fail2ban'])
return jsonify(ok=ok, msg='Postavke ažurirane' if ok else out)
@app.route('/api/guacamole/<action>', methods=['POST'])
@api_auth
def api_guacamole(action):
if action not in ('start', 'stop', 'restart'):
return jsonify(ok=False, msg='Nevažeća akcija')
ok, out = run(COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', action], timeout=60)
return jsonify(ok=ok, msg=out or action.capitalize())
@app.route('/api/ssl/enable', methods=['POST'])
@api_auth
def api_ssl_enable():
domain = request.json.get('domain', '').strip()
email = request.json.get('email', '').strip()
if not (domain and email):
return jsonify(ok=False, msg='Domena i email su obavezni')
if not shutil.which('certbot'):
ok, out = run(['apt-get', 'install', '-y', 'certbot', 'python3-certbot-nginx'])
if not ok:
return jsonify(ok=False, msg=f'Instalacija: {out}')
ok, out = run(['certbot', '--nginx', '-d', domain, '--email', email,
'--agree-tos', '--non-interactive', '--redirect'], timeout=120)
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out)
@app.route('/api/ssl/renew', methods=['POST'])
@api_auth
def api_ssl_renew():
ok, out = run(['certbot', 'renew', '--force-renewal'], timeout=120)
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out)
@app.route('/api/nginx/reload', methods=['POST'])
@api_auth
def api_nginx_reload():
ok, out = nginx_reload()
return jsonify(ok=ok, msg=out or 'Nginx reloadiran')
@app.route('/api/settings/password', methods=['POST'])
@api_auth
def api_change_password():
current = request.json.get('current', '')
new_pw = request.json.get('new_password', '')
cfg = load_config()
if cfg['password'] != current:
return jsonify(ok=False, msg='Pogrešna trenutna lozinka')
if len(new_pw) < 8:
return jsonify(ok=False, msg='Lozinka mora imati najmanje 8 znakova')
cfg['password'] = new_pw
save_config(cfg)
return jsonify(ok=True, msg='Lozinka promijenjena')
@app.route('/api/logs/<service>')
@login_required
def api_logs(service):
allowed = {
'guacamole': ['docker', 'logs', '-f', '--tail=100', 'guacamole'],
'guacd': ['docker', 'logs', '-f', '--tail=100', 'guacd'],
'postgres': ['docker', 'logs', '-f', '--tail=100', 'guac_postgres'],
'nginx': ['tail', '-f', '/var/log/nginx/access.log'],
'nginx-error': ['tail', '-f', '/var/log/nginx/error.log'],
'fail2ban': ['journalctl', '-fu', 'fail2ban', '-n', '100'],
}
if service not in allowed:
return jsonify(ok=False, msg='Nevažeći servis'), 400
def generate():
proc = subprocess.Popen(allowed[service], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
for line in proc.stdout:
yield f'data: {json.dumps(line.rstrip())}\n\n'
finally:
proc.terminate()
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5555))
app.run(host='127.0.0.1', port=port, debug=False)
APPEOF
# ── base.html ───────────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/base.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="hr" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Guacamole Manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
body { min-height:100vh; background:#0f1117; }
#sidebar { width:230px; min-height:100vh; background:#161b22; border-right:1px solid #30363d;
position:fixed; top:0; left:0; display:flex; flex-direction:column; z-index:100; }
#sidebar .brand { padding:1.25rem 1rem; border-bottom:1px solid #30363d; font-weight:700;
font-size:1rem; color:#58a6ff; display:flex; align-items:center; gap:.5rem; }
#sidebar .nav-link { color:#8b949e; padding:.55rem 1rem; border-radius:6px; margin:2px 8px;
display:flex; align-items:center; gap:.6rem; font-size:.9rem; transition:all .15s; }
#sidebar .nav-link:hover { background:#21262d; color:#c9d1d9; }
#sidebar .nav-link.active { background:#1f6feb22; color:#58a6ff; }
#sidebar .nav-link i { font-size:1rem; width:18px; text-align:center; }
#sidebar .sidebar-footer { margin-top:auto; padding:1rem; border-top:1px solid #30363d; font-size:.8rem; }
#content { margin-left:230px; padding:2rem; }
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
.status-ok { background:#3fb950; box-shadow:0 0 6px #3fb95066; }
.status-err { background:#f85149; box-shadow:0 0 6px #f8514966; }
.status-warn { background:#d29922; box-shadow:0 0 6px #d2992266; }
.card { background:#161b22; border:1px solid #30363d; }
.card-header { background:#1c2128; border-bottom:1px solid #30363d; }
.badge-country { background:#1f6feb33; color:#58a6ff; border:1px solid #1f6feb66;
font-size:.75rem; padding:3px 8px; border-radius:12px; margin:2px; display:inline-block; }
#log-output { background:#010409; color:#c9d1d9; font-family:monospace; font-size:.8rem;
height:400px; overflow-y:auto; padding:1rem; border-radius:6px; border:1px solid #30363d; }
.toast-container { position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999; }
</style>
</head>
<body>
<div id="sidebar">
<div class="brand"><i class="bi bi-display"></i> Guacamole</div>
<nav class="nav flex-column mt-2">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}"><i class="bi bi-grid-1x2"></i> Dashboard</a>
<a class="nav-link {% if request.endpoint == 'basicauth' %}active{% endif %}" href="{{ url_for('basicauth') }}"><i class="bi bi-person-lock"></i> Basic Auth</a>
<a class="nav-link {% if request.endpoint == 'geoip' %}active{% endif %}" href="{{ url_for('geoip') }}"><i class="bi bi-globe"></i> GeoIP</a>
<a class="nav-link {% if request.endpoint == 'fail2ban' %}active{% endif %}" href="{{ url_for('fail2ban') }}"><i class="bi bi-shield-x"></i> fail2ban</a>
<a class="nav-link {% if request.endpoint == 'guacamole' %}active{% endif %}" href="{{ url_for('guacamole') }}"><i class="bi bi-boxes"></i> Kontejneri</a>
<a class="nav-link {% if request.endpoint == 'ssl' %}active{% endif %}" href="{{ url_for('ssl') }}"><i class="bi bi-lock"></i> SSL</a>
<a class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}" href="{{ url_for('settings') }}"><i class="bi bi-gear"></i> Postavke</a>
</nav>
<div class="sidebar-footer text-muted">
<a href="{{ url_for('logout') }}" class="text-danger text-decoration-none"><i class="bi bi-box-arrow-right"></i> Odjava</a>
</div>
</div>
<div id="content">{% block content %}{% endblock %}</div>
<div class="toast-container">
<div id="toast" class="toast align-items-center border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="toast-body"></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const toast = new bootstrap.Toast(document.getElementById('toast'), {delay:4000});
function showToast(msg, ok=true) {
const el = document.getElementById('toast');
el.className = `toast align-items-center border-0 text-bg-${ok ? 'success' : 'danger'}`;
document.getElementById('toast-body').textContent = msg;
toast.show();
}
async function api(url, body={}) {
const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
return r.json();
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
HTMLEOF
# ── login.html ──────────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/login.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="hr" data-bs-theme="dark">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login Guacamole Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
body { background:#0f1117; min-height:100vh; display:flex; align-items:center; justify-content:center; }
.login-card { width:360px; background:#161b22; border:1px solid #30363d; border-radius:12px; padding:2rem; }
.brand { color:#58a6ff; font-size:1.4rem; font-weight:700; }
</style>
</head>
<body>
<div class="login-card">
<div class="text-center mb-4">
<i class="bi bi-display fs-1 text-primary"></i>
<div class="brand mt-1">Guacamole Manager</div>
<small class="text-muted">Web upravljačko sučelje</small>
</div>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}<div class="alert alert-{{ cat }} py-2 small">{{ msg }}</div>{% endfor %}
{% endwith %}
<form method="post">
<div class="mb-3">
<label class="form-label small text-muted">Korisničko ime</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input name="username" type="text" class="form-control" placeholder="admin" autofocus required>
</div>
</div>
<div class="mb-4">
<label class="form-label small text-muted">Lozinka</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input name="password" type="password" class="form-control" placeholder="••••••••" required>
</div>
</div>
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-box-arrow-in-right"></i> Prijava</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
HTMLEOF
# ── dashboard.html ──────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/dashboard.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}Dashboard Guacamole Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div><h4 class="mb-0">Dashboard</h4><small class="text-muted">{{ domain }}</small></div>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStatus()"><i class="bi bi-arrow-clockwise"></i> Osvježi</button>
</div>
<div class="row g-3 mb-4">
<div class="col-12"><h6 class="text-muted text-uppercase small mb-2">Docker kontejneri</h6></div>
{% for name, label in [('guacamole','Guacamole Web'),('guacd','Guacd Daemon'),('guac_postgres','PostgreSQL')] %}
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-2">
<span class="status-dot status-warn" id="dot-{{ name }}"></span>
<div><div class="fw-semibold">{{ label }}</div><small class="text-muted" id="txt-{{ name }}">Učitavanje...</small></div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-3 mb-4">
<div class="col-12"><h6 class="text-muted text-uppercase small mb-2">Sistemski servisi</h6></div>
{% for name, label in [('nginx','Nginx'),('fail2ban','fail2ban'),('docker','Docker')] %}
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-2">
<span class="status-dot status-warn" id="dot-svc-{{ name }}"></span>
<div><div class="fw-semibold">{{ label }}</div><small class="text-muted" id="txt-svc-{{ name }}">Učitavanje...</small></div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="card p-3"><div class="d-flex align-items-center gap-3">
<i class="bi bi-globe fs-3 text-info"></i>
<div><div class="small text-muted">GeoIP blokiranje</div><span id="badge-geoip" class="badge bg-secondary">...</span></div>
</div></div>
</div>
<div class="col-md-4">
<div class="card p-3"><div class="d-flex align-items-center gap-3">
<i class="bi bi-lock fs-3 text-success"></i>
<div><div class="small text-muted">SSL certifikat</div><span id="badge-ssl" class="badge bg-secondary">...</span></div>
</div></div>
</div>
<div class="col-md-4">
<div class="card p-3"><div class="d-flex align-items-center gap-3">
<i class="bi bi-shield-x fs-3 text-danger"></i>
<div><div class="small text-muted">Zabanjeni IP-ovi</div><span id="badge-banned" class="fs-4 fw-bold">...</span></div>
</div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadStatus() {
const data = await fetch('/api/status').then(r => r.json());
for (const [name, running] of Object.entries(data.containers)) {
document.getElementById(`dot-${name}`).className = `status-dot ${running ? 'status-ok' : 'status-err'}`;
document.getElementById(`txt-${name}`).textContent = running ? 'Aktivan' : 'Nije aktivan';
}
for (const [name, active] of Object.entries(data.services)) {
document.getElementById(`dot-svc-${name}`).className = `status-dot ${active ? 'status-ok' : 'status-err'}`;
document.getElementById(`txt-svc-${name}`).textContent = active ? 'Aktivan' : 'Nije aktivan';
}
const geoip = document.getElementById('badge-geoip');
geoip.textContent = data.geoip ? 'Aktivno' : 'Neaktivno';
geoip.className = `badge ${data.geoip ? 'bg-success' : 'bg-secondary'}`;
const ssl = document.getElementById('badge-ssl');
ssl.textContent = data.ssl ? 'Aktivan' : 'Nije aktivan';
ssl.className = `badge ${data.ssl ? 'bg-success' : 'bg-warning text-dark'}`;
document.getElementById('badge-banned').textContent = data.banned;
}
loadStatus();
setInterval(loadStatus, 15000);
</script>
{% endblock %}
HTMLEOF
# ── basicauth.html ──────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/basicauth.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}Basic Auth{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-person-lock me-2"></i>Basic Auth korisnici</h4>
<div class="row g-4">
<div class="col-md-5">
<div class="card">
<div class="card-header d-flex justify-content-between"><span class="fw-semibold">Korisnici</span><span class="badge bg-secondary">{{ users|length }}</span></div>
<ul class="list-group list-group-flush">
{% for user in users %}
<li class="list-group-item d-flex justify-content-between align-items-center" style="background:transparent;border-color:#30363d">
<span><i class="bi bi-person me-2 text-muted"></i>{{ user }}</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-sm" onclick="fillUpdate('{{ user }}')"><i class="bi bi-key"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteUser('{{ user }}')"><i class="bi bi-trash"></i></button>
</div>
</li>
{% else %}
<li class="list-group-item text-muted" style="background:transparent">Nema korisnika</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-7">
<div class="card mb-3">
<div class="card-header fw-semibold"><i class="bi bi-person-plus me-1"></i> Dodaj korisnika</div>
<div class="card-body">
<div class="row g-2">
<div class="col-5"><input id="add-user" type="text" class="form-control form-control-sm" placeholder="Korisničko ime"></div>
<div class="col-5"><input id="add-pass" type="password" class="form-control form-control-sm" placeholder="Lozinka"></div>
<div class="col-2"><button class="btn btn-primary btn-sm w-100" onclick="addUser()"><i class="bi bi-plus-lg"></i></button></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-key me-1"></i> Promijeni lozinku</div>
<div class="card-body">
<div class="row g-2">
<div class="col-5"><input id="upd-user" type="text" class="form-control form-control-sm" placeholder="Korisničko ime"></div>
<div class="col-5"><input id="upd-pass" type="password" class="form-control form-control-sm" placeholder="Nova lozinka"></div>
<div class="col-2"><button class="btn btn-outline-primary btn-sm w-100" onclick="updateUser()"><i class="bi bi-check-lg"></i></button></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function fillUpdate(user) { document.getElementById('upd-user').value = user; document.getElementById('upd-pass').focus(); }
async function addUser() {
const r = await api('/api/basicauth/add', {username: document.getElementById('add-user').value.trim(), password: document.getElementById('add-pass').value});
showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function updateUser() {
const r = await api('/api/basicauth/update', {username: document.getElementById('upd-user').value.trim(), password: document.getElementById('upd-pass').value});
showToast(r.msg, r.ok);
}
async function deleteUser(username) {
if (!confirm(`Ukloniti korisnika "${username}"?`)) return;
const r = await api('/api/basicauth/delete', {username});
showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000);
}
</script>
{% endblock %}
HTMLEOF
# ── geoip.html ──────────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/geoip.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}GeoIP{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-globe me-2"></i>GeoIP blokiranje</h4>
{% if enabled %}<span class="badge bg-success fs-6">Aktivno</span>{% else %}<span class="badge bg-secondary fs-6">Neaktivno</span>{% endif %}
</div>
{% if enabled %}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Dozvoljene države</div>
<div class="card-body">
<div class="mb-3">{% for cc in countries.split() %}<span class="badge-country">{{ cc }}</span>{% endfor %}</div>
<div class="input-group input-group-sm">
<input id="countries-input" type="text" class="form-control" placeholder="HR BA SI" value="{{ countries }}">
<button class="btn btn-primary" onclick="updateCountries()"><i class="bi bi-check-lg"></i> Spremi</button>
</div>
<small class="text-muted">ISO-2 kodovi, razdvojeni razmakom</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Akcije</div>
<div class="card-body d-flex flex-column gap-2">
<button class="btn btn-outline-info" onclick="refreshDB()"><i class="bi bi-cloud-download"></i> Ažuriraj GeoIP bazu</button>
<button class="btn btn-outline-danger" onclick="disableGeoIP()"><i class="bi bi-x-circle"></i> Onemogući GeoIP</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-header fw-semibold">Konfiguracija GeoIP</div>
<div class="card-body">
<div class="alert alert-info small mb-4"><i class="bi bi-info-circle me-1"></i>Potreban besplatni MaxMind račun: <a href="https://www.maxmind.com/en/geolite2/signup" target="_blank" class="alert-link">maxmind.com</a></div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label small text-muted">MaxMind Account ID</label><input id="mm-id" type="text" class="form-control" value="{{ mm_id }}" placeholder="123456"></div>
<div class="col-md-6"><label class="form-label small text-muted">MaxMind License Key</label><input id="mm-key" type="password" class="form-control" placeholder="••••••••••••••••"></div>
<div class="col-12"><label class="form-label small text-muted">Dozvoljene države (ISO-2 kodovi)</label><input id="countries-input" type="text" class="form-control" placeholder="HR BA SI RS AT DE"></div>
<div class="col-12"><button class="btn btn-success" onclick="enableGeoIP()"><i class="bi bi-globe-americas"></i> Aktiviraj GeoIP blokiranje</button></div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header small text-muted">Česti ISO-2 kodovi (klikni za dodavanje)</div>
<div class="card-body">
{% for cc, name in [('HR','Hrvatska'),('BA','Bosna i Hercegovina'),('SI','Slovenija'),('RS','Srbija'),('ME','Crna Gora'),('AT','Austrija'),('DE','Njemačka')] %}
<span class="badge-country" style="cursor:pointer" onclick="addCountry('{{ cc }}')" title="{{ name }}">{{ cc }} <small class="text-muted">{{ name }}</small></span>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function enableGeoIP() {
const btn = event.target; btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Aktivacija...';
const r = await api('/api/geoip/enable', {mm_id: document.getElementById('mm-id').value.trim(), mm_key: document.getElementById('mm-key').value.trim(), countries: document.getElementById('countries-input').value.trim()});
showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1500); else { btn.disabled = false; btn.innerHTML = '<i class="bi bi-globe-americas"></i> Aktiviraj GeoIP blokiranje'; }
}
async function updateCountries() {
const r = await api('/api/geoip/update', {countries: document.getElementById('countries-input').value.trim()});
showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function disableGeoIP() {
if (!confirm('Sigurno onemogućiti GeoIP?')) return;
const r = await api('/api/geoip/disable'); showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function refreshDB() {
const btn = event.target; btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Ažuriranje...';
const r = await api('/api/geoip/refresh'); showToast(r.ok ? 'GeoIP baza ažurirana' : r.msg, r.ok);
btn.disabled = false; btn.innerHTML = '<i class="bi bi-cloud-download"></i> Ažuriraj GeoIP bazu';
}
function addCountry(cc) {
const inp = document.getElementById('countries-input');
const existing = inp.value.split(' ').filter(x => x);
if (!existing.includes(cc)) inp.value = [...existing, cc].join(' ');
}
</script>
{% endblock %}
HTMLEOF
# ── fail2ban.html ───────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/fail2ban.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}fail2ban{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-shield-x me-2"></i>fail2ban</h4>
<div class="row g-4">
<div class="col-md-8">
{% for jail, ips in banned.items() %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between"><span class="fw-semibold">{{ jail }}</span><span class="badge {{ 'bg-danger' if ips else 'bg-secondary' }}">{{ ips|length }} zabanjeno</span></div>
<div class="card-body p-0">
{% if ips %}
<table class="table table-sm mb-0" style="border-color:#30363d">
<thead class="table-dark"><tr><th>IP adresa</th><th class="text-end">Akcija</th></tr></thead>
<tbody>
{% for ip in ips %}
<tr><td class="font-monospace">{{ ip }}</td><td class="text-end"><button class="btn btn-outline-success btn-sm" onclick="unban('{{ ip }}', '{{ jail }}')"><i class="bi bi-check-circle"></i> Odbani</button></td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted small p-3 mb-0">Nema zabanjenih IP-ova</p>{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header fw-semibold"><i class="bi bi-shield-plus me-1"></i> Ručni ban</div>
<div class="card-body">
<div class="mb-2"><input id="ban-ip" type="text" class="form-control form-control-sm" placeholder="192.168.1.100"></div>
<select id="ban-jail" class="form-select form-select-sm mb-2">{% for j in banned.keys() %}<option>{{ j }}</option>{% endfor %}</select>
<button class="btn btn-danger btn-sm w-100" onclick="banIP()"><i class="bi bi-ban"></i> Baniraj IP</button>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-sliders me-1"></i> Postavke</div>
<div class="card-body">
<div class="mb-2"><label class="form-label small text-muted">Max pokušaja (maxretry)</label><input id="maxretry" type="number" class="form-control form-control-sm" value="{{ cfg.get('maxretry', 5) }}" min="1" max="20"></div>
<div class="mb-3"><label class="form-label small text-muted">Trajanje bana (sekunde)</label><input id="bantime" type="number" class="form-control form-control-sm" value="{{ cfg.get('bantime', 3600) }}" min="60"><small class="text-muted">3600=1h · 86400=1dan</small></div>
<button class="btn btn-primary btn-sm w-100" onclick="saveSettings()"><i class="bi bi-floppy"></i> Spremi</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function unban(ip, jail) { const r = await api('/api/fail2ban/unban', {ip, jail}); showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000); }
async function banIP() {
const ip = document.getElementById('ban-ip').value.trim();
const jail = document.getElementById('ban-jail').value;
if (!ip) return showToast('Unesite IP adresu', false);
const r = await api('/api/fail2ban/ban', {ip, jail}); showToast(r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function saveSettings() {
const r = await api('/api/fail2ban/settings', {maxretry: document.getElementById('maxretry').value, bantime: document.getElementById('bantime').value});
showToast(r.msg, r.ok);
}
</script>
{% endblock %}
HTMLEOF
# ── guacamole.html ──────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/guacamole.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}Kontejneri{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-boxes me-2"></i>Docker kontejneri</h4>
<div class="row g-3 mb-4">
{% for name, running in containers.items() %}
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-3">
<span class="status-dot {{ 'status-ok' if running else 'status-err' }}"></span>
<div><div class="fw-semibold">{{ name }}</div><small class="{{ 'text-success' if running else 'text-danger' }}">{{ 'Aktivan' if running else 'Nije aktivan' }}</small></div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="card mb-4">
<div class="card-header fw-semibold">Upravljanje</div>
<div class="card-body d-flex gap-2 flex-wrap">
<button class="btn btn-success" onclick="containerAction('start')"><i class="bi bi-play-fill"></i> Start</button>
<button class="btn btn-warning" onclick="containerAction('restart')"><i class="bi bi-arrow-clockwise"></i> Restart</button>
<button class="btn btn-danger" onclick="containerAction('stop')"><i class="bi bi-stop-fill"></i> Stop</button>
<div class="ms-auto">
<select id="log-source" class="form-select form-select-sm d-inline-block w-auto" onchange="switchLog(this.value)">
<option value="guacamole">guacamole</option>
<option value="guacd">guacd</option>
<option value="postgres">postgres</option>
<option value="nginx">nginx access</option>
<option value="nginx-error">nginx error</option>
<option value="fail2ban">fail2ban</option>
</select>
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="toggleLog()"><i class="bi bi-terminal"></i> <span id="log-btn-txt">Log</span></button>
</div>
</div>
</div>
<div id="log-panel" class="card d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-terminal me-1"></i>Live log: <span id="log-title">guacamole</span></span>
<button class="btn btn-sm btn-outline-secondary" onclick="clearLog()"><i class="bi bi-trash"></i> Očisti</button>
</div>
<div id="log-output"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let logSource = null, logVisible = false;
async function containerAction(action) {
const btn = event.target.closest('button'); const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> ${action}...`;
const r = await api(`/api/guacamole/${action}`); showToast(r.ok ? `${action} izvršen` : r.msg, r.ok);
btn.disabled = false; btn.innerHTML = orig; if (r.ok) setTimeout(() => location.reload(), 2000);
}
function switchLog(service) { if (logVisible) { stopLog(); startLog(service); } document.getElementById('log-title').textContent = service; }
function toggleLog() {
if (logVisible) { stopLog(); logVisible = false; document.getElementById('log-panel').classList.add('d-none'); document.getElementById('log-btn-txt').textContent = 'Log'; }
else { logVisible = true; document.getElementById('log-panel').classList.remove('d-none'); document.getElementById('log-btn-txt').textContent = 'Zatvori'; startLog(document.getElementById('log-source').value); }
}
function startLog(service) {
stopLog(); const out = document.getElementById('log-output');
logSource = new EventSource(`/api/logs/${service}`);
logSource.onmessage = e => { const line = document.createElement('div'); line.textContent = JSON.parse(e.data); out.appendChild(line); out.scrollTop = out.scrollHeight; };
}
function stopLog() { if (logSource) { logSource.close(); logSource = null; } }
function clearLog() { document.getElementById('log-output').innerHTML = ''; }
</script>
{% endblock %}
HTMLEOF
# ── ssl.html ────────────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/ssl.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}SSL{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-lock me-2"></i>SSL certifikat</h4>
{% if active %}<span class="badge bg-success fs-6">Aktivan</span>{% else %}<span class="badge bg-warning text-dark fs-6">Nije aktivan</span>{% endif %}
</div>
{% if active %}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Informacije</div>
<div class="card-body">
<dl class="row mb-0"><dt class="col-5 text-muted small">Domena</dt><dd class="col-7">{{ domain }}</dd></dl>
{% if cert_info %}<pre class="mt-2 p-2 rounded small text-muted" style="background:#0d1117;border:1px solid #30363d;font-size:.75rem;max-height:200px;overflow:auto">{{ cert_info }}</pre>{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card"><div class="card-header fw-semibold">Akcije</div><div class="card-body">
<button class="btn btn-outline-primary" onclick="renewSSL()"><i class="bi bi-arrow-repeat"></i> Ručna obnova certifikata</button>
</div></div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-header fw-semibold">Aktivacija Let's Encrypt SSL</div>
<div class="card-body">
<div class="alert alert-warning small mb-4"><i class="bi bi-exclamation-triangle me-1"></i>Domena mora biti javno dostupna i DNS mora pokazivati na ovaj server.</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label small text-muted">Domena</label><input id="ssl-domain" type="text" class="form-control" value="{{ domain }}" placeholder="rdp.firma.com"></div>
<div class="col-md-6"><label class="form-label small text-muted">Email</label><input id="ssl-email" type="email" class="form-control" placeholder="admin@firma.com"></div>
<div class="col-12"><button class="btn btn-success" onclick="enableSSL()"><i class="bi bi-lock"></i> Aktiviraj SSL</button></div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function enableSSL() {
const btn = event.target; btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Aktivacija...';
const r = await api('/api/ssl/enable', {domain: document.getElementById('ssl-domain').value.trim(), email: document.getElementById('ssl-email').value.trim()});
showToast(r.ok ? 'SSL aktiviran!' : r.msg, r.ok); if (r.ok) setTimeout(() => location.reload(), 2000);
else { btn.disabled = false; btn.innerHTML = '<i class="bi bi-lock"></i> Aktiviraj SSL'; }
}
async function renewSSL() {
const btn = event.target; btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Obnavljanje...';
const r = await api('/api/ssl/renew'); showToast(r.ok ? 'Certifikat obnovljen' : r.msg, r.ok);
btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Ručna obnova certifikata';
}
</script>
{% endblock %}
HTMLEOF
# ── settings.html ───────────────────────────────────────────
cat > "${WEBGUI_DIR}/templates/settings.html" << 'HTMLEOF'
{% extends 'base.html' %}
{% block title %}Postavke{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>Postavke</h4>
<div class="row g-4">
<div class="col-md-5">
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-key me-1"></i> Promjena lozinke GUI-ja</div>
<div class="card-body">
<div class="mb-3"><label class="form-label small text-muted">Korisničko ime</label><input type="text" class="form-control" value="{{ username }}" readonly style="background:#0d1117;opacity:.7"></div>
<div class="mb-3"><label class="form-label small text-muted">Trenutna lozinka</label><input id="cur-pw" type="password" class="form-control" placeholder="••••••••"></div>
<div class="mb-3"><label class="form-label small text-muted">Nova lozinka</label><input id="new-pw" type="password" class="form-control" placeholder="Min. 8 znakova"></div>
<div class="mb-3"><label class="form-label small text-muted">Potvrdi novu lozinku</label><input id="new-pw2" type="password" class="form-control" placeholder="••••••••"></div>
<button class="btn btn-primary w-100" onclick="changePassword()"><i class="bi bi-floppy"></i> Spremi lozinku</button>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-info-circle me-1"></i> Informacije</div>
<div class="card-body">
<dl class="row">
<dt class="col-5 text-muted small">Instalacijski dir</dt><dd class="col-7 font-monospace small">/opt/guacamole</dd>
<dt class="col-5 text-muted small">Nginx konfig</dt><dd class="col-7 font-monospace small">/etc/nginx/.../guacamole</dd>
<dt class="col-5 text-muted small">Basic Auth file</dt><dd class="col-7 font-monospace small">/etc/nginx/.guac_htpasswd</dd>
<dt class="col-5 text-muted small">fail2ban jail</dt><dd class="col-7 font-monospace small">/etc/fail2ban/jail.d/guacamole.conf</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function changePassword() {
const cur = document.getElementById('cur-pw').value;
const np = document.getElementById('new-pw').value;
const np2 = document.getElementById('new-pw2').value;
if (np !== np2) return showToast('Lozinke se ne podudaraju', false);
if (np.length < 8) return showToast('Lozinka mora imati najmanje 8 znakova', false);
const r = await api('/api/settings/password', {current: cur, new_password: np});
showToast(r.msg, r.ok);
if (r.ok) ['cur-pw','new-pw','new-pw2'].forEach(id => document.getElementById(id).value = '');
}
</script>
{% endblock %}
HTMLEOF
# ── config.json ─────────────────────────────────────────────
cat > "${WEBGUI_DIR}/config.json" << EOF
{"username": "${GUI_USER}", "password": "${GUI_PASS}"}
EOF
chmod 600 "${WEBGUI_DIR}/config.json"
# ── Flask virtualenv ─────────────────────────────────────────
python3 -m venv "${WEBGUI_DIR}/venv"
"${WEBGUI_DIR}/venv/bin/pip" install --quiet --upgrade pip
"${WEBGUI_DIR}/venv/bin/pip" install --quiet flask
info "Flask instaliran"
# ── Systemd servis ───────────────────────────────────────────
cat > /etc/systemd/system/guacamole-webgui.service << EOF
[Unit]
Description=Guacamole Web Management GUI
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=${WEBGUI_DIR}
Environment=PORT=${GUI_PORT}
Environment=SECRET_KEY=${GUI_SECRET}
ExecStart=${WEBGUI_DIR}/venv/bin/python3 ${WEBGUI_DIR}/app.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now guacamole-webgui
info "Servis guacamole-webgui pokrenut"
# ── Nginx location za GUI ────────────────────────────────────
GUI_LOCATION="
# Guacamole Web GUI
location ${GUI_PATH} {
auth_basic \"Restricted Access\";
auth_basic_user_file /etc/nginx/.guac_htpasswd;
proxy_pass http://127.0.0.1:${GUI_PORT}/;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 3600s;
proxy_set_header X-Accel-Buffering no;
}"
python3 - << PYEOF
content = open('${NGINX_CONF}').read()
block = """${GUI_LOCATION}"""
idx = content.rfind('}')
open('${NGINX_CONF}', 'w').write(content[:idx] + block + '\n}')
PYEOF
nginx -t && systemctl reload nginx
info "Nginx location ${GUI_PATH} dodan"
fi
# ── Status provjera ───────────────────────────────────────────────────────────
section "Status servisa"
check_service() {
systemctl is-active --quiet "$1" 2>/dev/null \
&& info "$1: aktivan" || warn "$1: NIJE aktivan"
}
check_container() {
docker ps --filter "name=$1" --filter "status=running" | grep -q "$1" \
&& info "Docker [$1]: aktivan" || warn "Docker [$1]: NIJE aktivan"
}
check_service nginx
check_service fail2ban
check_service docker
check_container guacamole
check_container guacd
check_container guac_postgres
[[ "$DO_WEBGUI" =~ ^[Yy]$ ]] && check_service guacamole-webgui
# ── Finalni sažetak ───────────────────────────────────────────────────────────
section "Instalacija završena"
PROTO="http"
[[ "$DO_SSL" =~ ^[Yy]$ ]] && PROTO="https"
[[ "$DO_SELFSIGNED" =~ ^[Yy]$ ]] && PROTO="https"
echo ""
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Guacamole:${NC}"
echo -e " ${CYAN}${PROTO}://${DOMAIN}/guacamole/${NC}"
echo ""
echo -e "${BOLD} Nginx Basic Auth:${NC} ${BASIC_USER} / [vaša lozinka]"
echo -e "${BOLD} Guacamole admin:${NC} guacadmin / guacadmin ${RED}← PROMIJENI!${NC}"
if [[ "$DO_WEBGUI" =~ ^[Yy]$ ]]; then
echo ""
echo -e "${BOLD} Web GUI:${NC}"
echo -e " ${CYAN}${PROTO}://${DOMAIN}${GUI_PATH}${NC}"
echo -e " Korisnik: ${GUI_USER} | Lozinka: ${GUI_PASS}"
fi
[[ "$DO_GEOIP" =~ ^[Yy]$ ]] && echo -e "\n${BOLD} GeoIP:${NC} dozvoljene države: ${GEO_COUNTRIES}"
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${NC}"
echo ""
# Spremi sve u credentials datoteku
CRED_FILE="${INSTALL_DIR}/credentials.txt"
cat > "$CRED_FILE" << EOF
Guacamole instalacija
=====================
Datum: $(date)
Guacamole URL : ${PROTO}://${DOMAIN}/guacamole/
Nginx Basic Auth: ${BASIC_USER} / ${BASIC_PASS}
Admin login : guacadmin / guacadmin (PROMIJENI!)
PostgreSQL pass : ${POSTGRES_PASS}
EOF
if [[ "$DO_SELFSIGNED" =~ ^[Yy]$ ]]; then
cat >> "$CRED_FILE" << EOF
SSL : Self-signed, vrijedi 10 god
Certifikat : /etc/ssl/guacamole/selfsigned.crt
Privatni ključ : /etc/ssl/guacamole/selfsigned.key
EOF
fi
if [[ "$DO_WEBGUI" =~ ^[Yy]$ ]]; then
cat >> "$CRED_FILE" << EOF
Web GUI URL : ${PROTO}://${DOMAIN}${GUI_PATH}
GUI korisnik : ${GUI_USER} / ${GUI_PASS}
EOF
fi
chmod 600 "$CRED_FILE"
info "Sve lozinke sačuvane u: ${CRED_FILE} (samo root može čitati)"