#!/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/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 = ^ .* "(GET|POST|HEAD) /guacamole.*" 401 ignoreregex = EOF cat > /etc/fail2ban/filter.d/nginx-guacamole.conf << 'EOF' [Definition] failregex = ^ .* "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/', 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/') @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' {% block title %}Guacamole Manager{% endblock %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %} HTMLEOF # ── login.html ────────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/login.html" << 'HTMLEOF' Login – Guacamole Manager HTMLEOF # ── dashboard.html ────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/dashboard.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}Dashboard – Guacamole Manager{% endblock %} {% block content %}

Dashboard

{{ domain }}
Docker kontejneri
{% for name, label in [('guacamole','Guacamole Web'),('guacd','Guacd Daemon'),('guac_postgres','PostgreSQL')] %}
{{ label }}
Učitavanje...
{% endfor %}
Sistemski servisi
{% for name, label in [('nginx','Nginx'),('fail2ban','fail2ban'),('docker','Docker')] %}
{{ label }}
Učitavanje...
{% endfor %}
GeoIP blokiranje
...
SSL certifikat
...
Zabanjeni IP-ovi
...
{% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── basicauth.html ────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/basicauth.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}Basic Auth{% endblock %} {% block content %}

Basic Auth korisnici

Korisnici{{ users|length }}
    {% for user in users %}
  • {{ user }}
  • {% else %}
  • Nema korisnika
  • {% endfor %}
Dodaj korisnika
Promijeni lozinku
{% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── geoip.html ────────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/geoip.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}GeoIP{% endblock %} {% block content %}

GeoIP blokiranje

{% if enabled %}Aktivno{% else %}Neaktivno{% endif %}
{% if enabled %}
Dozvoljene države
{% for cc in countries.split() %}{{ cc }}{% endfor %}
ISO-2 kodovi, razdvojeni razmakom
Akcije
{% else %}
Konfiguracija GeoIP
Potreban besplatni MaxMind račun: maxmind.com
Česti ISO-2 kodovi (klikni za dodavanje)
{% for cc, name in [('HR','Hrvatska'),('BA','Bosna i Hercegovina'),('SI','Slovenija'),('RS','Srbija'),('ME','Crna Gora'),('AT','Austrija'),('DE','Njemačka')] %} {{ cc }} {{ name }} {% endfor %}
{% endif %} {% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── fail2ban.html ─────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/fail2ban.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}fail2ban{% endblock %} {% block content %}

fail2ban

{% for jail, ips in banned.items() %}
{{ jail }}{{ ips|length }} zabanjeno
{% if ips %} {% for ip in ips %} {% endfor %}
IP adresaAkcija
{{ ip }}
{% else %}

Nema zabanjenih IP-ova

{% endif %}
{% endfor %}
Ručni ban
Postavke
3600=1h · 86400=1dan
{% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── guacamole.html ────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/guacamole.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}Kontejneri{% endblock %} {% block content %}

Docker kontejneri

{% for name, running in containers.items() %}
{{ name }}
{{ 'Aktivan' if running else 'Nije aktivan' }}
{% endfor %}
Upravljanje
Live log: guacamole
{% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── ssl.html ──────────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/ssl.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}SSL{% endblock %} {% block content %}

SSL certifikat

{% if active %}Aktivan{% else %}Nije aktivan{% endif %}
{% if active %}
Informacije
Domena
{{ domain }}
{% if cert_info %}
{{ cert_info }}
{% endif %}
Akcije
{% else %}
Aktivacija Let's Encrypt SSL
Domena mora biti javno dostupna i DNS mora pokazivati na ovaj server.
{% endif %} {% endblock %} {% block scripts %} {% endblock %} HTMLEOF # ── settings.html ─────────────────────────────────────────── cat > "${WEBGUI_DIR}/templates/settings.html" << 'HTMLEOF' {% extends 'base.html' %} {% block title %}Postavke{% endblock %} {% block content %}

Postavke

Promjena lozinke GUI-ja
Informacije
Instalacijski dir
/opt/guacamole
Nginx konfig
/etc/nginx/.../guacamole
Basic Auth file
/etc/nginx/.guac_htpasswd
fail2ban jail
/etc/fail2ban/jail.d/guacamole.conf
{% endblock %} {% block scripts %} {% 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)"