commit 50c125e0db239fb996a02297bc173af62860c889 Author: njomac Date: Sun Apr 26 11:21:44 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2338a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.env + +# Claude +.claude/ + +# Credentials (nikad ne commitaj!) +credentials.txt +config.json +*.key +*.pem + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a5810b --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# GuacPanel + +Web-based remote desktop pristup i upravljanje — Apache Guacamole + Nginx + Web GUI. + +## Što je GuacPanel? + +All-in-one installer i upravljačko sučelje za Apache Guacamole. +Jedna skripta instalira i konfigurira sve što je potrebno za siguran +web-based RDP/VNC/SSH pristup udaljenim računalima. + +## Komponente + +- **Apache Guacamole** — web-based remote desktop gateway (RDP, VNC, SSH) +- **PostgreSQL** — baza podataka (Docker) +- **Nginx** — reverse proxy s Basic Auth zaštitom +- **fail2ban** — automatsko blokiranje brute-force napada +- **TOTP 2FA** — dvofaktorska autentikacija +- **Web GUI** — web sučelje za upravljanje svim postavkama + +## Podržani OS + +Ubuntu · Debian · RHEL · CentOS · Rocky Linux · AlmaLinux · Fedora · Arch · openSUSE + +## Instalacija + +```bash +sudo bash install.sh +``` + +Skripta pita za sve potrebne podatke i automatski instalira sve komponente. + +## Sigurnosni slojevi + +1. GeoIP blokiranje (po izboru) +2. Nginx rate limiting +3. HTTP Basic Auth +4. fail2ban (auto-ban nakon 5 neuspjelih pokušaja) +5. Guacamole vlastita autentikacija +6. TOTP 2FA +7. SSL/TLS (Let's Encrypt ili Self-signed) + +## Datoteke + +| Datoteka | Opis | +|---|---| +| `install.sh` | Glavni installer — sve u jednoj skripti | +| `manage.sh` | CLI upravljanje nakon instalacije | +| `webgui/` | Flask web GUI aplikacija | +| `UPUTE.txt` | Detaljne upute i dokumentacija | + +## Web GUI stranice + +| Stranica | Funkcija | +|---|---| +| Dashboard | Status svih servisa | +| Basic Auth | Upravljanje korisnicima | +| GeoIP | Blokiranje po državama | +| fail2ban | Prikaz i upravljanje banovima | +| Kontejneri | Start/Stop/Restart + live logovi | +| SSL | Let's Encrypt ili Self-signed | +| Ažuriranja | Update Guacamole, Nginx, sistema | +| Backup | Backup i restore konfiguracije | + +## Dokumentacija + +Pogledajte `UPUTE.txt` za detaljne upute. diff --git a/UPUTE.txt b/UPUTE.txt new file mode 100644 index 0000000..a145f9c --- /dev/null +++ b/UPUTE.txt @@ -0,0 +1,373 @@ +================================================================ + Apache Guacamole + Nginx + Web GUI — Instalacijske upute +================================================================ + +SADRŽAJ +------- +1. Što je ovo? +2. Preduvjeti +3. Instalacija +4. Pitanja pri instalaciji +5. Nakon instalacije +6. Web GUI — stranice i funkcije +7. Upravljanje putem terminala (manage.sh) +8. Sigurnosni slojevi +9. SSL opcije +10. Korisne naredbe +11. Struktura datoteka + + +================================================================ +1. ŠTO JE OVO? +================================================================ + +Kompletno rješenje za pristup Windows/Linux računalima putem +web browsera koristeći RDP, VNC ili SSH protokole. + +Komponente: + - Apache Guacamole — web-based remote desktop gateway + - PostgreSQL — baza podataka (u Dockeru) + - Nginx — reverse proxy s Basic Auth zaštitom + - fail2ban — automatsko blokiranje brute-force napada + - TOTP 2FA — dvofaktorska autentikacija (Google Auth) + - Web GUI — web sučelje za upravljanje svim gore navedenim + +Podržani operativni sustavi: + - Ubuntu / Debian / Linux Mint / Pop!_OS + - RHEL / CentOS / Rocky Linux / AlmaLinux + - Fedora + - Arch Linux / Manjaro + - openSUSE + + +================================================================ +2. PREDUVJETI +================================================================ + + - Svježa instalacija Linuxa + - Root pristup (sudo) + - Internetska veza (za preuzimanje paketa i Docker imagea) + - Za Let's Encrypt SSL: javna domena koja pokazuje na server + - Za GeoIP: besplatni MaxMind račun (maxmind.com/en/geolite2/signup) + + +================================================================ +3. INSTALACIJA +================================================================ + +Kopirati datoteke na server i pokrenuti: + + sudo bash install.sh + +Skripta je potpuno automatizirana — sve instalira sama. +Nema potrebe pokretati ništa drugo nakon toga. + +Što skripta instalira: + - Docker + Docker Compose + - Nginx, fail2ban, openssl, apache2-utils + - Apache Guacamole (guacamole + guacd + PostgreSQL) u Dockeru + - TOTP 2FA ekstenziju za Guacamole + - Nginx reverse proxy konfiguraciju + - HTTP Basic Auth + - fail2ban pravila + - SSL certifikat (po izboru) + - GeoIP blokiranje (po izboru) + - Web GUI za upravljanje (po izboru) + + +================================================================ +4. PITANJA PRI INSTALACIJI +================================================================ + +Skripta postavlja sljedeća pitanja redom: + + 1. DOMENA + Unesite domenu (npr. rdp.firma.com) + Pritisnite Enter za automatsko korištenje IP adrese servera. + + 2. NGINX BASIC AUTH — korisničko ime + Zadano: admin + + 3. NGINX BASIC AUTH — lozinka + Obavezno polje. Ovo je PRVI sloj zaštite ispred Guacamolea. + + 4. SSL CERTIFIKAT + L = Let's Encrypt — besplatan, treba valjana domena, auto-obnova + S = Self-signed — radi s IP adresom, browser pokazuje upozorenje, + vrijedi 10 godina, bez obnove + N = Bez SSL — HTTP only (ok za internu mrežu) + + Za L: zatražit će i email adresu za Let's Encrypt obavijesti. + + 5. GEOIP BLOKIRANJE + y = da, n = ne + Blokira pristup iz svih zemalja osim onih koje navedete. + Potrebni: MaxMind Account ID i License Key (besplatno). + Primjer zemalja: HR BA SI RS AT DE + + 6. WEB GUI + y = da, n = ne + Instalira Flask web aplikaciju za upravljanje svim postavkama. + Ako da: + - Korisničko ime za GUI + - Lozinka za GUI (min 8 znakova) + - URL path (zadano: /guacadmin/) + +Nakon unosa svih podataka skripta prikazuje generirane lozinke +koje treba sačuvati, a zatim automatski instalira sve. + +Sve lozinke se na kraju spremaju u: + /opt/guacamole/credentials.txt + (čitljivo samo root korisniku) + + +================================================================ +5. NAKON INSTALACIJE +================================================================ + +Pristup Guacamolea: + http(s)://DOMENA-ILI-IP/guacamole/ + + Korak 1: Browser traži Nginx Basic Auth (korisnik/lozinka iz koraka 3) + Korak 2: Guacamole login stranica + Korisnik: guacadmin + Lozinka: guacadmin + Korak 3: TOTP 2FA (ako je ekstenzija preuzeta) + Skenirajte QR kod s Google Authenticator ili Authy + + !! ODMAH PROMIJENITE guacadmin lozinku nakon prvog prijave !! + Settings → Users → guacadmin → Promijeni lozinku + +Pristup Web GUI (ako je instaliran): + http(s)://DOMENA-ILI-IP/guacadmin/ + Korisnik/lozinka: ono što ste upisali pri instalaciji + +Za self-signed SSL — browser upozorenje: + Chrome: kliknite "Napredno" → "Nastavi na [IP/domena]" + Firefox: kliknite "Napredno" → "Prihvati rizik i nastavi" + Edge: kliknite "Detalji" → "Nastavi na stranicu" + + +================================================================ +6. WEB GUI — STRANICE I FUNKCIJE +================================================================ + + DASHBOARD + - Prikaz statusa svih Docker kontejnera (guacamole, guacd, postgres) + - Prikaz statusa sistemskih servisa (nginx, fail2ban, docker) + - Status GeoIP blokiranja + - Status SSL certifikata + - Broj trenutno zabanjenih IP adresa + - Auto-osvježava se svakih 15 sekundi + + BASIC AUTH + - Prikaz svih korisnika + - Dodavanje novog korisnika + - Promjena lozinke postojećeg korisnika + - Brisanje korisnika + + GEOIP + - Uključivanje GeoIP blokiranja (treba MaxMind account) + - Promjena dozvoljenih zemalja (ISO-2 kodovi) + - Onemogućavanje GeoIP blokiranja + - Ažuriranje GeoIP baze podataka + - Brza selekcija čestih zemalja klikom + + FAIL2BAN + - Prikaz zabanjenih IP adresa po jailu + - Odbanjivanje IP adrese + - Ručno baniranje IP adrese + - Promjena maxretry (broj pokušaja prije bana) + - Promjena bantime (trajanje bana u sekundama) + Primjeri: 3600=1h, 86400=1dan, 604800=1tjedan + + KONTEJNERI + - Status svakog Docker kontejnera + - Start / Stop / Restart svih kontejnera + - Live log streaming (guacamole, guacd, postgres, + nginx access, nginx error, fail2ban) + + SSL + - Aktivacija Let's Encrypt certifikata + - Generiranje self-signed certifikata + - Ručna obnova Let's Encrypt certifikata + - Prikaz informacija o self-signed certifikatu + + AŽURIRANJA + - Ažuriranje Guacamole (docker pull + restart) + Live output — vidite svaki korak u realnom vremenu + - Ažuriranje Nginx + Live output + - Ažuriranje fail2ban + - Ažuriranje svih sistemskih paketa + Live output (može potrajati duže) + + BACKUP + - Kreiranje backupa (konfiguracija + PostgreSQL baza) + Sprema se u /root/guacamole_backup_DATUM_VRIJEME.tar.gz + - Prikaz dostupnih backupa + - Brisanje backupa + + POSTAVKE + - Promjena lozinke za Web GUI + + +================================================================ +7. UPRAVLJANJE PUTEM TERMINALA (manage.sh) +================================================================ + +Alternativno, sve se može upravljati i putem CLI skripte: + + sudo bash manage.sh + +Meni opcije: + 1) Status servisa + 2) Basic Auth (korisnici) + 3) SSL certifikat + 4) GeoIP blokiranje + 5) fail2ban (banned IP-ovi) + 6) Guacamole (Docker kontejneri) + 7) Nginx + 8) Backup & Restore + + +================================================================ +8. SIGURNOSNI SLOJEVI +================================================================ + +Pristup Guacamolea prolazi kroz sljedeće slojeve zaštite: + + 1. GEOIP (ako je uključen) + Blokira sve IP adrese iz zemalja koje nisu na listi. + Vraca HTTP 403. + + 2. RATE LIMITING (nginx) + Ograničava broj zahtjeva po IP adresi. + Sprječava preopterećenje servisa. + + 3. NGINX BASIC AUTH + Korisničko ime i lozinka prije nego se uopće vidi + Guacamole login stranica. Drugi sloj. + + 4. FAIL2BAN + Nakon 5 neuspjelih pokušaja Basic Auth ili Guacamole + logina, IP adresa se automatski bani na 1 sat. + SSH je zaštićen s 3 pokušaja, ban 24 sata. + + 5. GUACAMOLE LOGIN + Vlastita autentikacija Guacamole aplikacije. + + 6. TOTP 2FA (ako je ekstenzija instalirana) + Jednokratni kod iz authenticator aplikacije. + + 7. SSL/TLS (ako je konfiguriran) + Enkriptira sav promet između browsera i servera. + + +================================================================ +9. SSL OPCIJE +================================================================ + +LET'S ENCRYPT: + - Besplatan certifikat, povjerljiv u svim browserima + - Zahtijeva domenu koja je javno dostupna + - DNS mora pokazivati na IP servera + - Auto-obnova (svakih 90 dana, automatski) + - Certifikati: /etc/letsencrypt/live/DOMENA/ + +SELF-SIGNED: + - Radi s IP adresom ili domenom + - Enkriptiran promet (HTTPS) + - Browser pokazuje upozorenje — normalno je, kliknite "Nastavi" + - Vrijedi 10 godina, ne treba obnovu + - Certifikati: /etc/ssl/guacamole/selfsigned.crt + /etc/ssl/guacamole/selfsigned.key + +BEZ SSL (HTTP): + - Promet nije enkriptiran + - Ok za internu mrežu gdje je fizička sigurnost osigurana + - Može se naknadno dodati SSL kroz Web GUI (SSL stranica) + + +================================================================ +10. KORISNE NAREDBE +================================================================ + +--- Guacamole --- + Logovi: docker logs -f guacamole + Restart: cd /opt/guacamole && docker compose restart + Stop: cd /opt/guacamole && docker compose stop + Status: docker ps + +--- Nginx --- + Test konfiguracije: nginx -t + Reload: systemctl reload nginx + Logovi: tail -f /var/log/nginx/access.log + Error logovi: tail -f /var/log/nginx/error.log + +--- fail2ban --- + Status: fail2ban-client status + Zabanjeni (nginx): fail2ban-client status nginx-basicauth + Odbani IP: fail2ban-client set nginx-basicauth unbanip IP + Baniraj IP: fail2ban-client set nginx-basicauth banip IP + +--- SSL --- + Obnovi certifikat: certbot renew --force-renewal + Provjera certif.: certbot certificates + Self-signed info: openssl x509 -noout -dates -in /etc/ssl/guacamole/selfsigned.crt + +--- Web GUI --- + Start: systemctl start guacamole-webgui + Stop: systemctl stop guacamole-webgui + Restart: systemctl restart guacamole-webgui + Logovi: journalctl -u guacamole-webgui -f + +--- Backup --- + Ručni backup: tar -czf /root/backup_$(date +%Y%m%d).tar.gz /opt/guacamole + + +================================================================ +11. STRUKTURA DATOTEKA +================================================================ + +/opt/guacamole/ + docker-compose.yml — Docker definicija kontejnera + .env — PostgreSQL lozinka (samo root) + credentials.txt — Sve lozinke (samo root) + db-init/ + initdb.sql — PostgreSQL inicijalizacijska skripta + extensions/ + guacamole-auth-totp-*.jar — TOTP 2FA ekstenzija + webgui/ — Web GUI aplikacija + app.py — Flask backend + config.json — GUI korisnik i lozinka + venv/ — Python virtualenv + templates/ — HTML stranice + +/etc/nginx/ + sites-available/guacamole — Nginx konfiguracija (Debian/Ubuntu) + conf.d/guacamole.conf — Nginx konfiguracija (RHEL/Arch) + conf.d/guacamole-maps.conf — WebSocket i rate limit zone + conf.d/guacamole-geoip.conf — GeoIP konfiguracija (ako je uključena) + .guac_htpasswd — Basic Auth korisnici + +/etc/fail2ban/ + jail.d/guacamole.conf — fail2ban jail konfiguracija + filter.d/nginx-basicauth.conf + filter.d/nginx-guacamole.conf + +/etc/ssl/guacamole/ — Self-signed certifikati (ako su generirani) + selfsigned.crt + selfsigned.key + +/etc/systemd/system/ + guacamole-webgui.service — Web GUI systemd servis + +/root/ + guacamole_backup_*.tar.gz — Backupi (samo root) + + +================================================================ + Verzija dokumenta: 1.0 + Guacamole verzija: 1.5.5 +================================================================ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d4481ea --- /dev/null +++ b/install.sh @@ -0,0 +1,1829 @@ +#!/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)" diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..d70638d --- /dev/null +++ b/manage.sh @@ -0,0 +1,737 @@ +#!/bin/bash +set -euo pipefail + +# ================================================================ +# Guacamole Management Script +# Upravljanje instalacijom nakon postavljanja +# ================================================================ + +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; } +section() { echo -e "\n${BOLD}${BLUE}══════════ $* ══════════${NC}"; } +ok() { echo -e "${GREEN}$*${NC}"; } + +[[ $EUID -ne 0 ]] && { echo -e "${RED}Pokrenite kao root: sudo bash $0${NC}"; exit 1; } + +# --------------------------------------------------------------- +# Detekcija instalacije +# --------------------------------------------------------------- +INSTALL_DIR="/opt/guacamole" +GUAC_PORT="8080" + +detect_nginx_conf() { + if [[ -f /etc/nginx/sites-available/guacamole ]]; then + echo "/etc/nginx/sites-available/guacamole" + elif [[ -f /etc/nginx/conf.d/guacamole.conf ]]; then + echo "/etc/nginx/conf.d/guacamole.conf" + else + echo "" + fi +} + +detect_compose_cmd() { + if docker compose version &>/dev/null 2>&1; then + echo "docker compose" + elif command -v docker-compose &>/dev/null; then + echo "docker-compose" + else + echo "" + fi +} + +NGINX_CONF=$(detect_nginx_conf) +COMPOSE_CMD=$(detect_compose_cmd) + +[[ -z "$COMPOSE_CMD" ]] && { error "Docker Compose nije pronađen"; exit 1; } + +# --------------------------------------------------------------- +# Pomoćne funkcije +# --------------------------------------------------------------- +nginx_reload() { + nginx -t && systemctl reload nginx + info "Nginx reloadiran" +} + +get_domain() { + if [[ -n "$NGINX_CONF" ]]; then + grep -m1 "server_name" "$NGINX_CONF" | awk '{print $2}' | tr -d ';' + else + hostname -I | awk '{print $1}' + fi +} + +pause() { + echo "" + read -rp "Pritisnite Enter za povratak u meni..." +} + +confirm() { + read -rp "$(echo -e "${YELLOW}$* [y/N]: ${NC}")" ans + [[ "$ans" =~ ^[Yy]$ ]] +} + +# --------------------------------------------------------------- +# Status pregled +# --------------------------------------------------------------- +show_status() { + section "Status servisa" + + echo -e "\n${BOLD}Docker kontejneri:${NC}" + for c in guacamole guacd guac_postgres; do + if docker ps --filter "name=^/${c}$" --filter "status=running" | grep -q "$c" 2>/dev/null; then + echo -e " ${GREEN}●${NC} $c — aktivan" + else + echo -e " ${RED}●${NC} $c — NIJE aktivan" + fi + done + + echo -e "\n${BOLD}Sistemski servisi:${NC}" + for svc in nginx fail2ban docker; do + if systemctl is-active --quiet "$svc" 2>/dev/null; then + echo -e " ${GREEN}●${NC} $svc — aktivan" + else + echo -e " ${RED}●${NC} $svc — NIJE aktivan" + fi + done + + echo -e "\n${BOLD}SSL certifikat:${NC}" + local domain + domain=$(get_domain) + if certbot certificates 2>/dev/null | grep -q "$domain"; then + local expiry + expiry=$(certbot certificates 2>/dev/null | grep "Expiry Date" | head -1 | awk '{print $3, $4}') + echo -e " ${GREEN}●${NC} Aktivan — ističe: $expiry" + else + echo -e " ${YELLOW}●${NC} SSL nije postavljen" + fi + + echo -e "\n${BOLD}fail2ban — zabanjeni IP-ovi:${NC}" + if systemctl is-active --quiet fail2ban 2>/dev/null; then + local banned + banned=$(fail2ban-client status nginx-basicauth 2>/dev/null | grep "Banned IP" | cut -d: -f2 | xargs) + if [[ -n "$banned" && "$banned" != " " ]]; then + echo -e " ${RED}$banned${NC}" + else + echo -e " ${GREEN}Nema zabanjenih IP-ova${NC}" + fi + else + echo -e " ${YELLOW}fail2ban nije aktivan${NC}" + fi + + echo -e "\n${BOLD}Nginx konfiguracija:${NC} ${NGINX_CONF:-nije pronađena}" + echo -e "${BOLD}Instalacijski dir:${NC} ${INSTALL_DIR}" + echo -e "${BOLD}Domena:${NC} $(get_domain)" + + pause +} + +# --------------------------------------------------------------- +# Basic Auth upravljanje +# --------------------------------------------------------------- +menu_basicauth() { + while true; do + clear + section "Basic Auth upravljanje" + echo "" + echo " 1) Promijeni lozinku postojećeg korisnika" + echo " 2) Dodaj novog korisnika" + echo " 3) Ukloni korisnika" + echo " 4) Prikaži korisnike" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + echo "" + read -rp "Korisničko ime: " user + read -rsp "Nova lozinka: " pass; echo + if htpasswd -b /etc/nginx/.guac_htpasswd "$user" "$pass" 2>/dev/null; then + nginx_reload + info "Lozinka promijenjena za: $user" + else + error "Korisnik '$user' ne postoji. Dodaj ga opcijom 2." + fi + pause + ;; + 2) + echo "" + read -rp "Novo korisničko ime: " user + read -rsp "Lozinka: " pass; echo + htpasswd -b /etc/nginx/.guac_htpasswd "$user" "$pass" + nginx_reload + info "Korisnik dodan: $user" + pause + ;; + 3) + echo "" + read -rp "Korisničko ime za uklanjanje: " user + if confirm "Sigurno želite ukloniti korisnika '$user'?"; then + htpasswd -D /etc/nginx/.guac_htpasswd "$user" + nginx_reload + info "Korisnik uklonjen: $user" + fi + pause + ;; + 4) + echo "" + echo -e "${BOLD}Korisnici u /etc/nginx/.guac_htpasswd:${NC}" + cut -d: -f1 /etc/nginx/.guac_htpasswd 2>/dev/null | while read -r u; do + echo " • $u" + done + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# SSL upravljanje +# --------------------------------------------------------------- +menu_ssl() { + while true; do + clear + section "SSL upravljanje" + echo "" + echo " 1) Omogući SSL (Let's Encrypt)" + echo " 2) Obnovi certifikat ručno" + echo " 3) Prikaži informacije o certifikatu" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + echo "" + local domain + domain=$(get_domain) + read -rp "Domena [${domain}]: " input_domain + domain="${input_domain:-$domain}" + read -rp "Email za Let's Encrypt: " email + [[ -z "$email" ]] && { warn "Email je obavezan"; pause; continue; } + + if ! command -v certbot &>/dev/null; then + warn "Certbot nije instaliran. Instaliram..." + if command -v apt-get &>/dev/null; then + apt-get install -y certbot python3-certbot-nginx + elif command -v dnf &>/dev/null; then + dnf install -y certbot python3-certbot-nginx + elif command -v yum &>/dev/null; then + yum install -y certbot python3-certbot-nginx + else + error "Instalirajte certbot ručno" + pause; continue + fi + fi + + certbot --nginx -d "$domain" --email "$email" \ + --agree-tos --non-interactive --redirect + + # Auto-obnova + if systemctl list-unit-files certbot.timer &>/dev/null; then + systemctl enable --now certbot.timer + else + grep -q "certbot renew" /etc/crontab 2>/dev/null || \ + echo "0 3 * * * root certbot renew --quiet --post-hook 'systemctl reload nginx'" \ + >> /etc/crontab + fi + + nginx_reload + info "SSL aktivan za: $domain" + pause + ;; + 2) + certbot renew --force-renewal + systemctl reload nginx + info "Certifikat obnovljen" + pause + ;; + 3) + certbot certificates 2>/dev/null || warn "Nema instaliranih certifikata" + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# GeoIP upravljanje +# --------------------------------------------------------------- +menu_geoip() { + while true; do + clear + section "GeoIP upravljanje" + echo "" + echo " 1) Omogući GeoIP blokiranje" + echo " 2) Promijeni dozvoljene zemlje" + echo " 3) Onemogući GeoIP blokiranje" + echo " 4) Ažuriraj GeoIP bazu" + echo " 5) Prikaži trenutne postavke" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + echo "" + if [[ -f /etc/nginx/conf.d/guacamole-geoip.conf ]]; then + warn "GeoIP je već konfiguriran. Koristi opciju 2 za promjenu zemalja." + pause; continue + fi + + warn "Potreban besplatni MaxMind račun: https://www.maxmind.com/en/geolite2/signup" + read -rp "MaxMind Account ID: " mm_id + read -rp "MaxMind License Key: " mm_key + read -rp "Dozvoljene zemlje (ISO kodovi, razmak, npr: HR BA SI): " countries + + # Instalacija ovisno o distro + if command -v apt-get &>/dev/null; then + apt-get install -y libnginx-mod-http-geoip2 geoipupdate 2>/dev/null || \ + warn "Neki paketi nisu instalirani — provjeri ručno" + elif command -v dnf &>/dev/null; then + dnf install -y nginx-mod-http-geoip2 geoipupdate 2>/dev/null || \ + warn "Neki paketi nisu dostupni — provjeri ručno" + fi + + # MaxMind konfiguracija + mkdir -p /usr/share/GeoIP + cat > /etc/GeoIP.conf << MMEOF +AccountID ${mm_id} +LicenseKey ${mm_key} +EditionIDs GeoLite2-Country +DatabaseDirectory /usr/share/GeoIP +MMEOF + + geoipupdate && info "GeoIP baza preuzeta" || \ + { error "geoipupdate nije uspio — provjeri kredencijale"; pause; continue; } + + _write_geoip_conf "$countries" + _apply_geoip_to_nginx + nginx_reload + info "GeoIP blokiranje aktivno — dozvoljene: $countries" + pause + ;; + 2) + echo "" + if [[ ! -f /etc/nginx/conf.d/guacamole-geoip.conf ]]; then + warn "GeoIP nije omogućen. Odaberi opciju 1." + pause; continue + fi + echo -e "Trenutne dozvoljene zemlje:" + grep -E "^\s+[A-Z]{2}\s+1;" /etc/nginx/conf.d/guacamole-geoip.conf \ + | awk '{print " "$1}' | tr '\n' ' ' + echo "" + read -rp "Nove dozvoljene zemlje (ISO kodovi, razmak): " countries + _write_geoip_conf "$countries" + nginx_reload + info "Ažurirane dozvoljene države: $countries" + pause + ;; + 3) + echo "" + if confirm "Sigurno želite onemogućiti GeoIP blokiranje?"; then + rm -f /etc/nginx/conf.d/guacamole-geoip.conf + + # Ukloni GeoIP provjeru iz nginx location bloka + if [[ -n "$NGINX_CONF" ]]; then + sed -i '/allowed_country/d' "$NGINX_CONF" + fi + + nginx_reload + info "GeoIP blokiranje onemogućeno" + fi + pause + ;; + 4) + if command -v geoipupdate &>/dev/null; then + geoipupdate && info "GeoIP baza ažurirana" || error "Ažuriranje nije uspjelo" + else + error "geoipupdate nije instaliran" + fi + pause + ;; + 5) + echo "" + if [[ -f /etc/nginx/conf.d/guacamole-geoip.conf ]]; then + echo -e "${BOLD}GeoIP status:${NC} ${GREEN}Aktivan${NC}" + echo -e "${BOLD}Dozvoljene zemlje:${NC}" + grep -E "^\s+[A-Z]{2}\s+1;" /etc/nginx/conf.d/guacamole-geoip.conf \ + | awk '{print " "$1}' | tr '\n' ' ' + echo "" + if [[ -f /usr/share/GeoIP/GeoLite2-Country.mmdb ]]; then + echo -e "${BOLD}GeoIP baza:${NC} $(stat -c '%y' /usr/share/GeoIP/GeoLite2-Country.mmdb | cut -d. -f1)" + fi + else + echo -e "${BOLD}GeoIP status:${NC} ${YELLOW}Nije aktivan${NC}" + fi + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +_write_geoip_conf() { + local countries="$1" + local entries="" + for cc in $countries; do + 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 "$entries")} +EOF +} + +_apply_geoip_to_nginx() { + if [[ -n "$NGINX_CONF" ]]; then + # Ukloni staru provjeru ako postoji pa dodaj novu + sed -i '/allowed_country/d' "$NGINX_CONF" + sed -i '/auth_basic.*Restricted/i\ if ($allowed_country = 0) { return 403; }' "$NGINX_CONF" + fi +} + +# --------------------------------------------------------------- +# fail2ban upravljanje +# --------------------------------------------------------------- +menu_fail2ban() { + while true; do + clear + section "fail2ban upravljanje" + echo "" + echo " 1) Prikaži zabanjene IP-ove" + echo " 2) Odbani IP adresu" + echo " 3) Ručno banaj IP adresu" + echo " 4) Promijeni broj pokušaja prije bana" + echo " 5) Promijeni trajanje bana" + echo " 6) Prikaži fail2ban log" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + echo "" + for jail in nginx-basicauth nginx-guacamole sshd; do + echo -e "${BOLD}[$jail]${NC}" + fail2ban-client status "$jail" 2>/dev/null \ + | grep -E "Currently banned|Banned IP" || echo " jail nije aktivan" + echo "" + done + pause + ;; + 2) + echo "" + read -rp "IP adresa za odbanjivanje: " ip + for jail in nginx-basicauth nginx-guacamole sshd; do + fail2ban-client set "$jail" unbanip "$ip" 2>/dev/null && \ + info "$ip odbanjivan iz $jail" || true + done + pause + ;; + 3) + echo "" + read -rp "IP adresa za baniranje: " ip + read -rp "Jail [nginx-basicauth]: " jail + jail="${jail:-nginx-basicauth}" + fail2ban-client set "$jail" banip "$ip" + info "$ip baniran u $jail" + pause + ;; + 4) + echo "" + local conf="/etc/fail2ban/jail.d/guacamole.conf" + current=$(grep -m1 "maxretry" "$conf" 2>/dev/null | awk '{print $3}') + read -rp "Broj pokušaja prije bana [trenutno: ${current:-5}]: " retries + retries="${retries:-${current:-5}}" + sed -i "s/^maxretry.*/maxretry = $retries/" "$conf" + systemctl restart fail2ban + info "maxretry postavljeno na: $retries" + pause + ;; + 5) + echo "" + local conf="/etc/fail2ban/jail.d/guacamole.conf" + current=$(grep -m1 "bantime" "$conf" 2>/dev/null | awk '{print $3}') + echo "Unesite trajanje u sekundama (3600=1h, 86400=1dan, 604800=1tjedan)" + read -rp "Trajanje bana [trenutno: ${current:-3600}s]: " bantime + bantime="${bantime:-${current:-3600}}" + sed -i "s/^bantime.*/bantime = $bantime/" "$conf" + systemctl restart fail2ban + info "bantime postavljeno na: ${bantime}s" + pause + ;; + 6) + echo "" + journalctl -u fail2ban -n 50 --no-pager | tail -50 + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# Guacamole upravljanje +# --------------------------------------------------------------- +menu_guacamole() { + while true; do + clear + section "Guacamole upravljanje" + echo "" + echo " 1) Prikaži logove (live)" + echo " 2) Restart svih kontejnera" + echo " 3) Stop svih kontejnera" + echo " 4) Start svih kontejnera" + echo " 5) Ažuriraj Guacamole (pull nove image-e)" + echo " 6) Prikaži korištenje resursa (CPU/RAM)" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + echo -e "${YELLOW}Ctrl+C za izlaz${NC}" + cd "$INSTALL_DIR" && $COMPOSE_CMD logs -f --tail=100 + ;; + 2) + cd "$INSTALL_DIR" && $COMPOSE_CMD restart + info "Kontejneri restartani" + pause + ;; + 3) + if confirm "Sigurno želite zaustaviti Guacamole?"; then + cd "$INSTALL_DIR" && $COMPOSE_CMD stop + info "Kontejneri zaustavljeni" + fi + pause + ;; + 4) + cd "$INSTALL_DIR" && $COMPOSE_CMD start + info "Kontejneri pokrenuti" + pause + ;; + 5) + echo "" + warn "Ovo će preuzeti najnovije Docker image-e i restartati servise." + if confirm "Nastaviti?"; then + cd "$INSTALL_DIR" + $COMPOSE_CMD pull + $COMPOSE_CMD up -d --force-recreate + info "Guacamole ažuriran" + fi + pause + ;; + 6) + docker stats guacamole guacd guac_postgres --no-stream + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# Nginx upravljanje +# --------------------------------------------------------------- +menu_nginx() { + while true; do + clear + section "Nginx upravljanje" + echo "" + echo " 1) Reload konfiguracije" + echo " 2) Test konfiguracije" + echo " 3) Prikaži nginx access log (live)" + echo " 4) Promijeni rate limit (zahtjevi/minuti)" + echo " 5) Prikaži aktivne konekcije" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + nginx_reload + pause + ;; + 2) + nginx -t + pause + ;; + 3) + echo -e "${YELLOW}Ctrl+C za izlaz${NC}" + tail -f /var/log/nginx/access.log + ;; + 4) + echo "" + local zones_conf="/etc/nginx/conf.d/guacamole-maps.conf" + current=$(grep "guac_req" "$zones_conf" 2>/dev/null | grep -oP '\d+(?=r/m)' | head -1) + read -rp "Rate limit zahtjevi/minuti [trenutno: ${current:-10}]: " rate + rate="${rate:-${current:-10}}" + sed -i "s/rate=[0-9]*r\/m rate=.*guac_req/rate=${rate}r\/m/" "$zones_conf" 2>/dev/null || \ + sed -i "s/guac_req:10m rate=[0-9]*r\/m/guac_req:10m rate=${rate}r\/m/" "$zones_conf" + nginx_reload + info "Rate limit postavljen na: ${rate} req/min" + pause + ;; + 5) + echo "" + ss -tnp | grep nginx || echo "Nema aktivnih nginx konekcija" + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# Backup & restore +# --------------------------------------------------------------- +menu_backup() { + while true; do + clear + section "Backup & Restore" + echo "" + echo " 1) Napravi backup (konfiguracija + baza)" + echo " 2) Prikaži dostupne backupe" + echo " 3) Vrati backup" + echo " 0) Povratak" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) + local ts backup_file + ts=$(date +%Y%m%d_%H%M%S) + backup_file="/root/guacamole_backup_${ts}.tar.gz" + + echo "" + info "Backupiranje baze podataka..." + docker exec guac_postgres pg_dump -U guacamole_user guacamole_db \ + > /tmp/guacamole_db_backup.sql + + info "Arhiviranje konfiguracije..." + tar -czf "$backup_file" \ + -C / \ + --ignore-failed-read \ + opt/guacamole \ + etc/nginx/.guac_htpasswd \ + etc/nginx/conf.d/guacamole-maps.conf \ + etc/nginx/conf.d/guacamole-geoip.conf \ + etc/nginx/sites-available/guacamole \ + etc/nginx/conf.d/guacamole.conf \ + etc/fail2ban/jail.d/guacamole.conf \ + etc/fail2ban/filter.d/nginx-basicauth.conf \ + etc/fail2ban/filter.d/nginx-guacamole.conf \ + tmp/guacamole_db_backup.sql \ + 2>/dev/null || true + + rm -f /tmp/guacamole_db_backup.sql + chmod 600 "$backup_file" + info "Backup kreiran: $backup_file" + echo -e "Veličina: $(du -sh "$backup_file" | cut -f1)" + pause + ;; + 2) + echo "" + if ls /root/guacamole_backup_*.tar.gz &>/dev/null; then + ls -lh /root/guacamole_backup_*.tar.gz + else + warn "Nema dostupnih backupa u /root/" + fi + pause + ;; + 3) + echo "" + if ! ls /root/guacamole_backup_*.tar.gz &>/dev/null; then + warn "Nema dostupnih backupa" + pause; continue + fi + + ls -1 /root/guacamole_backup_*.tar.gz | nl + read -rp "Broj backupa za vraćanje: " num + local backup + backup=$(ls -1 /root/guacamole_backup_*.tar.gz | sed -n "${num}p") + + [[ -z "$backup" ]] && { warn "Nevažeći odabir"; pause; continue; } + + if confirm "Vraćanje: $backup — ovo će prepisati trenutne postavke. Nastaviti?"; then + tar -xzf "$backup" -C / + + # Restore baze + if tar -tzf "$backup" | grep -q "guacamole_db_backup.sql"; then + docker exec -i guac_postgres psql -U guacamole_user guacamole_db \ + < /tmp/guacamole_db_backup.sql + rm -f /tmp/guacamole_db_backup.sql + fi + + nginx_reload + systemctl restart fail2ban + cd "$INSTALL_DIR" && $COMPOSE_CMD restart + info "Backup vraćen: $backup" + fi + pause + ;; + 0) return ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +# --------------------------------------------------------------- +# Glavni meni +# --------------------------------------------------------------- +main_menu() { + while true; do + clear + echo -e "${BOLD}${BLUE}" + echo " ╔═══════════════════════════════════════╗" + echo " ║ Guacamole Management Console ║" + echo " ╚═══════════════════════════════════════╝${NC}" + echo "" + echo -e " ${BOLD}1)${NC} Status servisa" + echo -e " ${BOLD}2)${NC} Basic Auth (korisnici)" + echo -e " ${BOLD}3)${NC} SSL certifikat" + echo -e " ${BOLD}4)${NC} GeoIP blokiranje" + echo -e " ${BOLD}5)${NC} fail2ban (banned IP-ovi)" + echo -e " ${BOLD}6)${NC} Guacamole (Docker kontejneri)" + echo -e " ${BOLD}7)${NC} Nginx" + echo -e " ${BOLD}8)${NC} Backup & Restore" + echo "" + echo -e " ${BOLD}0)${NC} Izlaz" + echo "" + read -rp "Odabir: " choice + + case "$choice" in + 1) show_status ;; + 2) menu_basicauth ;; + 3) menu_ssl ;; + 4) menu_geoip ;; + 5) menu_fail2ban ;; + 6) menu_guacamole ;; + 7) menu_nginx ;; + 8) menu_backup ;; + 0) echo ""; exit 0 ;; + *) warn "Nevažeći odabir" ;; + esac + done +} + +main_menu diff --git a/webgui/app.py b/webgui/app.py new file mode 100644 index 0000000..a4ac67f --- /dev/null +++ b/webgui/app.py @@ -0,0 +1,814 @@ +#!/usr/bin/env python3 +""" +Guacamole Web Management GUI +""" +import os, subprocess, json, secrets, shutil, re, platform +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', +] +SELFSIGNED_DIR = '/etc/ssl/guacamole' + +app = Flask(__name__, template_folder='templates') +app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) + +# ── helpers ────────────────────────────────────────────────────────────────── + +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 + +# ── auth decorators ─────────────────────────────────────────────────────────── + +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 + +# ── page routes ─────────────────────────────────────────────────────────────── + +@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 = mm_key = '' + 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']) + +# ── API – status ────────────────────────────────────────────────────────────── + +@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(), + ) + +# ── API – Basic Auth ────────────────────────────────────────────────────────── + +@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') + +# ── API – GeoIP ─────────────────────────────────────────────────────────────── + +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 {{ + $geoip2_data_country_code country iso_code; +}} + +map $geoip2_data_country_code $allowed_country {{ + default 0; +{entries}}} +""") + +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') + + # Instaliraj GeoIP paket ako treba + 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 paketa: {out}') + + # MaxMind config + with open(GEOIP_MMCONF, 'w') as f: + f.write(f"AccountID {mm_id}\nLicenseKey {mm_key}\n" + f"EditionIDs GeoLite2-Country\n" + f"DatabaseDirectory /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) + +# ── API – fail2ban ──────────────────────────────────────────────────────────── + +@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 (maxretry.isdigit() and 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) + +# ── API – Guacamole containers ──────────────────────────────────────────────── + +@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()) + +# ── API – SSL ───────────────────────────────────────────────────────────────── + +@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 certbota: {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) + +# ── API – Nginx ─────────────────────────────────────────────────────────────── + +@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') + +# ── API – Settings ──────────────────────────────────────────────────────────── + +@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='Nova lozinka mora imati najmanje 8 znakova') + cfg['password'] = new_pw + save_config(cfg) + return jsonify(ok=True, msg='Lozinka promijenjena') + +# ── helpers – verzije i update ─────────────────────────────────────────────── + +def detect_pkg_manager(): + for pm, cmd in [('apt', 'apt-get'), ('dnf', 'dnf'), ('yum', 'yum'), + ('pacman', 'pacman'), ('zypper', 'zypper')]: + if shutil.which(cmd): + return pm + return 'unknown' + +def get_guac_version(): + ok, out = run(['docker', 'inspect', '--format', '{{.Config.Image}}', 'guacamole']) + if ok and ':' in out: + return out.strip().split(':')[-1] + return 'nepoznato' + +def get_nginx_version(): + ok, out = run(['nginx', '-v']) + m = re.search(r'nginx/(\S+)', out) + return m.group(1) if m else 'nepoznato' + +def get_docker_version(): + ok, out = run(['docker', '--version']) + m = re.search(r'version\s+(\S+)', out, re.I) + return m.group(1).rstrip(',') if m else 'nepoznato' + +def get_fail2ban_version(): + ok, out = run(['fail2ban-client', '--version']) + m = re.search(r'(\d+\.\d+[\.\d]*)', out) + return m.group(1) if m else 'nepoznato' + +def check_guac_update(): + ok, out = run(['docker', 'pull', '--quiet', + f'guacamole/guacamole:{get_guac_version()}'], timeout=60) + return ok + +def get_selfsigned_info(): + crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt') + if not os.path.exists(crt): + return None + ok, out = run(['openssl', 'x509', '-noout', '-dates', '-subject', '-in', crt]) + return out if ok else None + +# ── stranica – ažuriranja ───────────────────────────────────────────────────── + +@app.route('/updates') +@login_required +def updates(): + versions = { + 'guacamole': get_guac_version(), + 'nginx': get_nginx_version(), + 'docker': get_docker_version(), + 'fail2ban': get_fail2ban_version(), + 'os': platform.freedesktop_os_release().get('PRETTY_NAME', platform.system()), + } + pkg = detect_pkg_manager() + return render_template('updates.html', versions=versions, pkg=pkg) + +@app.route('/api/update/guacamole', methods=['POST']) +@api_auth +def api_update_guacamole(): + version = get_guac_version() + images = [f'guacamole/guacamole:{version}', + f'guacamole/guacd:{version}', + 'postgres:15-alpine'] + + def generate(): + yield f'data: {json.dumps("Preuzimanje novih Docker image-a...")}\n\n' + for img in images: + yield f'data: {json.dumps(f" → docker pull {img}")}\n\n' + proc = subprocess.Popen(['docker', 'pull', img], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + l = line.strip() + if l: + yield f'data: {json.dumps(" " + l)}\n\n' + proc.wait() + + yield f'data: {json.dumps("Restartanje kontejnera...")}\n\n' + proc = subprocess.Popen( + COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', 'up', '-d', '--force-recreate'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + l = line.strip() + if l: + yield f'data: {json.dumps(l)}\n\n' + proc.wait() + yield f'data: {json.dumps("✓ Guacamole ažuriran")}\n\n' + yield 'data: __DONE__\n\n' + + return Response(stream_with_context(generate()), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + +@app.route('/api/update/nginx', methods=['POST']) +@api_auth +def api_update_nginx(): + def generate(): + pkg = detect_pkg_manager() + cmds = { + 'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'nginx'], + 'dnf': ['dnf', 'upgrade', '-y', 'nginx'], + 'yum': ['yum', 'upgrade', '-y', 'nginx'], + 'pacman': ['pacman', '-S', '--noconfirm', 'nginx'], + 'zypper': ['zypper', 'update', '-y', 'nginx'], + } + if pkg not in cmds: + yield f'data: {json.dumps("Nepoznati package manager")}\n\n' + yield 'data: __DONE__\n\n' + return + + yield f'data: {json.dumps(f"Ažuriranje nginx ({pkg})...")}\n\n' + proc = subprocess.Popen(cmds[pkg], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + l = line.strip() + if l: + yield f'data: {json.dumps(l)}\n\n' + proc.wait() + + subprocess.run(['systemctl', 'reload', 'nginx'], capture_output=True) + yield f'data: {json.dumps("✓ Nginx ažuriran i reloadiran")}\n\n' + yield 'data: __DONE__\n\n' + + return Response(stream_with_context(generate()), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + +@app.route('/api/update/system', methods=['POST']) +@api_auth +def api_update_system(): + def generate(): + pkg = detect_pkg_manager() + cmds = { + 'apt': [['apt-get', 'update', '-y'], + ['apt-get', 'upgrade', '-y', '--with-new-pkgs']], + 'dnf': [['dnf', 'upgrade', '-y']], + 'yum': [['yum', 'upgrade', '-y']], + 'pacman': [['pacman', '-Syu', '--noconfirm']], + 'zypper': [['zypper', 'update', '-y']], + } + if pkg not in cmds: + yield f'data: {json.dumps("Nepoznati package manager")}\n\n' + yield 'data: __DONE__\n\n' + return + + yield f'data: {json.dumps(f"Ažuriranje sistema ({pkg})...")}\n\n' + for cmd in cmds[pkg]: + yield f'data: {json.dumps("$ " + " ".join(cmd))}\n\n' + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + l = line.strip() + if l: + yield f'data: {json.dumps(l)}\n\n' + proc.wait() + + yield f'data: {json.dumps("✓ Sistemske nadogradnje završene")}\n\n' + yield 'data: __DONE__\n\n' + + return Response(stream_with_context(generate()), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + +@app.route('/api/update/fail2ban', methods=['POST']) +@api_auth +def api_update_fail2ban(): + pkg = detect_pkg_manager() + cmds = { + 'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'fail2ban'], + 'dnf': ['dnf', 'upgrade', '-y', 'fail2ban'], + 'yum': ['yum', 'upgrade', '-y', 'fail2ban'], + 'pacman': ['pacman', '-S', '--noconfirm', 'fail2ban'], + 'zypper': ['zypper', 'update', '-y', 'fail2ban'], + } + ok, out = run(cmds.get(pkg, ['echo', 'Nepoznati package manager']), timeout=120) + if ok: + run(['systemctl', 'restart', 'fail2ban']) + return jsonify(ok=ok, msg=out) + +# ── stranica – SSL (prošireno: self-signed) ─────────────────────────────────── + +@app.route('/api/ssl/selfsigned', methods=['POST']) +@api_auth +def api_ssl_selfsigned(): + domain = get_domain() + os.makedirs(SELFSIGNED_DIR, exist_ok=True) + crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt') + key = os.path.join(SELFSIGNED_DIR, 'selfsigned.key') + + san = f'IP:{domain}' if re.match(r'^\d+\.\d+\.\d+\.\d+$', domain) else f'DNS:{domain}' + + ok, out = run([ + 'openssl', 'req', '-x509', '-nodes', '-days', '3650', + '-newkey', 'rsa:2048', '-keyout', key, '-out', crt, + '-subj', f'/CN={domain}/O=Guacamole/C=HR', + '-addext', f'subjectAltName={san}' + ]) + if not ok: + return jsonify(ok=False, msg=out) + + os.chmod(key, 0o600) + + conf = get_nginx_conf() + if conf: + with open(conf) as f: + txt = f.read() + + # Dodaj ili zamijeni SSL blok + if 'ssl_certificate' not in txt: + ssl_block = ( + f' ssl_certificate {crt};\n' + f' ssl_certificate_key {key};\n' + f' ssl_protocols TLSv1.2 TLSv1.3;\n' + f' ssl_ciphers HIGH:!aNULL:!MD5;\n\n' + ) + txt = txt.replace('listen 80;', 'listen 443 ssl;') + txt = re.sub(r'(server_name\s+[^;]+;)', r'\1\n\n' + ssl_block, txt) + + # Dodaj HTTP redirect server blok ispred + redirect = (f'server {{\n listen 80;\n server_name {domain};\n' + f' return 301 https://$host$request_uri;\n}}\n\n') + txt = redirect + txt + + with open(conf, 'w') as f: + f.write(txt) + + ok2, out2 = nginx_reload() + if not ok2: + return jsonify(ok=False, msg=f'Nginx greška: {out2}') + + return jsonify(ok=True, msg=f'Self-signed certifikat kreiran za {domain} (vrijedi 10 god)') + +@app.route('/api/ssl/selfsigned_info') +@api_auth +def api_ssl_selfsigned_info(): + info = get_selfsigned_info() + return jsonify(ok=bool(info), info=info or 'Nema self-signed certifikata') + +# ── stranica – backup ───────────────────────────────────────────────────────── + +@app.route('/backup') +@login_required +def backup(): + backups = sorted( + [f for f in os.listdir('/root') if f.startswith('guacamole_backup_') and f.endswith('.tar.gz')], + reverse=True + ) if os.path.exists('/root') else [] + return render_template('backup.html', backups=backups) + +@app.route('/api/backup/create', methods=['POST']) +@api_auth +def api_backup_create(): + import datetime + ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + dest = f'/root/guacamole_backup_{ts}.tar.gz' + + run(['docker', 'exec', 'guac_postgres', 'pg_dump', '-U', 'guacamole_user', + 'guacamole_db'], timeout=60) + + ok, out = run(['bash', '-c', + f'docker exec guac_postgres pg_dump -U guacamole_user guacamole_db ' + f'> /tmp/guacamole_db.sql 2>&1 && ' + f'tar -czf {dest} -C / --ignore-failed-read ' + f'opt/guacamole etc/nginx/.guac_htpasswd ' + f'etc/nginx/conf.d/guacamole-maps.conf ' + f'etc/nginx/conf.d/guacamole-geoip.conf ' + f'etc/nginx/conf.d/guacamole.conf ' + f'etc/nginx/sites-available/guacamole ' + f'etc/fail2ban/jail.d/guacamole.conf ' + f'etc/fail2ban/filter.d/nginx-basicauth.conf ' + f'etc/fail2ban/filter.d/nginx-guacamole.conf ' + f'etc/ssl/guacamole ' + f'tmp/guacamole_db.sql 2>/dev/null; ' + f'rm -f /tmp/guacamole_db.sql' + ], timeout=120) + + if ok and os.path.exists(dest): + os.chmod(dest, 0o600) + size = os.path.getsize(dest) + return jsonify(ok=True, msg=f'Backup kreiran: {os.path.basename(dest)} ({size//1024} KB)') + return jsonify(ok=False, msg=out or 'Backup nije uspio') + +@app.route('/api/backup/delete', methods=['POST']) +@api_auth +def api_backup_delete(): + name = request.json.get('name', '') + if not re.match(r'^guacamole_backup_\d{8}_\d{6}\.tar\.gz$', name): + return jsonify(ok=False, msg='Nevažeći naziv backupa') + path = f'/root/{name}' + if os.path.exists(path): + os.remove(path) + return jsonify(ok=True, msg='Backup obrisan') + return jsonify(ok=False, msg='Backup nije pronađen') + +# ── Live logs (SSE) ─────────────────────────────────────────────────────────── + +@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'}) + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5555)) + app.run(host='127.0.0.1', port=port, debug=False) diff --git a/webgui/templates/backup.html b/webgui/templates/backup.html new file mode 100644 index 0000000..518033f --- /dev/null +++ b/webgui/templates/backup.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} +{% block title %}Backup{% endblock %} + +{% block content %} +
+

Backup

+ +
+ +
+ + Backup uključuje: Guacamole konfiguraciju, PostgreSQL bazu, nginx konfig, + Basic Auth korisnike, fail2ban pravila i SSL certifikate. + Sprema se u /root/ (čitljivo samo root-u). +
+ + +
+
+ Dostupni backupi + {{ backups|length }} +
+ {% if backups %} +
+ {% for b in backups %} +
+
+ + {{ b }} +
+ +
+ {% endfor %} +
+ {% else %} +
+ Nema dostupnih backupa +
+ {% endif %} +
+ + +
+
+
+ Kreiranje backupa, molim pričekajte... +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/base.html b/webgui/templates/base.html new file mode 100644 index 0000000..0e63e3d --- /dev/null +++ b/webgui/templates/base.html @@ -0,0 +1,147 @@ + + + + + + {% block title %}Guacamole Manager{% endblock %} + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ +
+ + + +{% block scripts %}{% endblock %} + + diff --git a/webgui/templates/basicauth.html b/webgui/templates/basicauth.html new file mode 100644 index 0000000..49ba088 --- /dev/null +++ b/webgui/templates/basicauth.html @@ -0,0 +1,122 @@ +{% extends 'base.html' %} +{% block title %}Basic Auth – Guacamole Manager{% 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 %} diff --git a/webgui/templates/dashboard.html b/webgui/templates/dashboard.html new file mode 100644 index 0000000..8ac39c1 --- /dev/null +++ b/webgui/templates/dashboard.html @@ -0,0 +1,130 @@ +{% 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, icon in [('nginx','Nginx','bi-server'), ('fail2ban','fail2ban','bi-shield'), ('docker','Docker','bi-box')] %} +
+
+
+ +
+
{{ label }}
+ Učitavanje... +
+
+
+
+ {% endfor %} +
+ + +
+
+
+
+ +
+
GeoIP blokiranje
+ Provjera... +
+
+
+
+
+
+
+ +
+
SSL certifikat
+ Provjera... +
+
+
+
+
+
+
+ +
+
Zabanjeni IP-ovi
+ ... +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/fail2ban.html b/webgui/templates/fail2ban.html new file mode 100644 index 0000000..61f7b58 --- /dev/null +++ b/webgui/templates/fail2ban.html @@ -0,0 +1,124 @@ +{% extends 'base.html' %} +{% block title %}fail2ban – Guacamole Manager{% 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 · 604800=1tjedan +
+ +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/geoip.html b/webgui/templates/geoip.html new file mode 100644 index 0000000..a0d2a33 --- /dev/null +++ b/webgui/templates/geoip.html @@ -0,0 +1,159 @@ +{% extends 'base.html' %} +{% block title %}GeoIP – Guacamole Manager{% 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 zemalja, razdvojeni razmakom +
+
+
+
+
+
Akcije
+
+ + +
+
+
+
+ +{% else %} + +
+
+ Konfiguracija GeoIP blokiranja +
+
+
+ + Potreban je besplatni MaxMind račun: + + maxmind.com/en/geolite2/signup + +
+ +
+
+ + +
+
+ + +
+
+ + + Sve ostale države bit će blokirane +
+
+ +
+
+
+
+ + +
+
Česti ISO-2 kodovi
+
+ {% for cc, name in [('HR','Hrvatska'),('BA','Bosna i Hercegovina'),('SI','Slovenija'), + ('RS','Srbija'),('ME','Crna Gora'),('MK','Sjeverna Makedonija'), + ('AT','Austrija'),('DE','Njemačka'),('CH','Švicarska')] %} + + {{ cc }} {{ name }} + + {% endfor %} +
+
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/guacamole.html b/webgui/templates/guacamole.html new file mode 100644 index 0000000..3c9363b --- /dev/null +++ b/webgui/templates/guacamole.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} +{% block title %}Kontejneri – Guacamole Manager{% 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 %} diff --git a/webgui/templates/login.html b/webgui/templates/login.html new file mode 100644 index 0000000..20dcc7c --- /dev/null +++ b/webgui/templates/login.html @@ -0,0 +1,55 @@ + + + + + + Login – Guacamole Manager + + + + + + + + + diff --git a/webgui/templates/settings.html b/webgui/templates/settings.html new file mode 100644 index 0000000..09f330b --- /dev/null +++ b/webgui/templates/settings.html @@ -0,0 +1,97 @@ +{% extends 'base.html' %} +{% block title %}Postavke – Guacamole Manager{% 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
+
GeoIP konfig
+
/etc/nginx/conf.d/guacamole-geoip.conf
+
fail2ban jail
+
/etc/fail2ban/jail.d/guacamole.conf
+
+
+
+ +
+
+ Brze naredbe (u terminalu) +
+
+
# Logovi Guacamolua
+docker logs -f guacamole
+
+# Restart svih kontejnera
+cd /opt/guacamole && docker compose restart
+
+# fail2ban status
+fail2ban-client status
+
+# Backup konfiguracije
+sudo bash /path/to/manage.sh
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/ssl.html b/webgui/templates/ssl.html new file mode 100644 index 0000000..e8ce90e --- /dev/null +++ b/webgui/templates/ssl.html @@ -0,0 +1,159 @@ +{% extends 'base.html' %} +{% block title %}SSL{% endblock %} + +{% block content %} +
+

SSL certifikat

+ {% if active %} + Aktivan + {% else %} + Nije aktivan + {% endif %} +
+ +{% if active %} +
+
+
+
Informacije
+
+
+
Domena / IP
+
{{ domain }}
+
+ {% if cert_info %} +
{{ cert_info }}
+ {% endif %} +
+
+
+
+
+
Akcije
+
+ + +
+
+
+
+

+
+{% else %}
+
+ + +
+
+
+ Let's Encrypt + Preporučeno +
+
+
    +
  • Besplatan, povjerljiv certifikat
  • +
  • Browser ne pokazuje upozorenje
  • +
  • Zahtijeva javnu domenu (ne radi s IP-om)
  • +
  • Auto-obnova svakih 90 dana
  • +
+
+ + DNS mora pokazivati na ovaj server. +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+ Self-signed certifikat +
+
+
    +
  • Radi s IP adresom ili domenom
  • +
  • Enkriptiran promet (HTTPS)
  • +
  • Browser pokazuje upozorenje pri prvom posjetu
  • +
  • Vrijedi 10 godina, bez potrebe za obnovom
  • +
+
+ + Idealno za internu mrežu ili testiranje.
+ Generirat ce se za: {{ domain }} +
+ +
+
+
+ +
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/webgui/templates/updates.html b/webgui/templates/updates.html new file mode 100644 index 0000000..921895d --- /dev/null +++ b/webgui/templates/updates.html @@ -0,0 +1,207 @@ +{% extends 'base.html' %} +{% block title %}Ažuriranja{% endblock %} + +{% block content %} +

Ažuriranja

+ + +
+
Trenutne verzije
+
+
+ {% for name, ver in versions.items() %} +
+
+
{{ name }}
+
{{ ver }}
+
+
+ {% endfor %} +
+
+
+ + +
+ + +
+
+
+ Guacamole +
+
+

+ Preuzima najnovije Docker image-e za Guacamole, guacd i PostgreSQL + te restarta kontejnere. Konfiguracija i podaci se čuvaju. +

+ +
+
+
+ + +
+
+
+ Nginx +
+
+

+ Ažurira nginx paket putem sistemskog package managera + ({{ pkg }}) i automatski reloada konfiguraciju. +

+ +
+
+
+ + +
+
+
+ fail2ban +
+
+

+ Ažurira fail2ban paket i restarta servis. + Jail konfiguracije ostaju netaknute. +

+ +
+
+
+ + +
+
+
+ Sistemske nadogradnje +
+
+

+ Ažurira sve instalirane sistemske pakete putem {{ pkg }}. + Može potrajati nekoliko minuta. +

+
+ + Nakon sistemske nadogradnje može biti potreban restart servera. +
+ +
+
+
+
+ + +
+
+ + + Output: + +
+ U tijeku... + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}