Initial commit — GuacPanel v1.0

All-in-one installer za Apache Guacamole s Nginx reverse proxy,
fail2ban, SSL (Let's Encrypt / Self-signed), GeoIP blokiranjem
i Flask web GUI za upravljanje.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
njomac
2026-04-26 11:21:44 +02:00
commit 50c125e0db
17 changed files with 5249 additions and 0 deletions
+19
View File
@@ -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
+66
View File
@@ -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.
+373
View File
@@ -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
================================================================
Executable
+1829
View File
File diff suppressed because it is too large Load Diff
Executable
+737
View File
@@ -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
+814
View File
@@ -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/<action>', methods=['POST'])
@api_auth
def api_guacamole(action):
if action not in ('start', 'stop', 'restart'):
return jsonify(ok=False, msg='Nevažeća akcija')
ok, out = run(COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', action],
timeout=60)
return jsonify(ok=ok, msg=out or action.capitalize())
# ── 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/<service>')
@login_required
def api_logs(service):
allowed = {
'guacamole': ['docker', 'logs', '-f', '--tail=100', 'guacamole'],
'guacd': ['docker', 'logs', '-f', '--tail=100', 'guacd'],
'postgres': ['docker', 'logs', '-f', '--tail=100', 'guac_postgres'],
'nginx': ['tail', '-f', '/var/log/nginx/access.log'],
'nginx-error': ['tail', '-f', '/var/log/nginx/error.log'],
'fail2ban': ['journalctl', '-fu', 'fail2ban', '-n', '100'],
}
if service not in allowed:
return jsonify(ok=False, msg='Nevažeći servis'), 400
def generate():
proc = subprocess.Popen(allowed[service], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
for line in proc.stdout:
yield f'data: {json.dumps(line.rstrip())}\n\n'
finally:
proc.terminate()
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'})
# ── main ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5555))
app.run(host='127.0.0.1', port=port, debug=False)
+79
View File
@@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block title %}Backup{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-archive me-2"></i>Backup</h4>
<button class="btn btn-primary" onclick="createBackup(this)">
<i class="bi bi-plus-circle"></i> Napravi backup
</button>
</div>
<div class="alert alert-info small mb-4">
<i class="bi bi-info-circle me-1"></i>
Backup uključuje: Guacamole konfiguraciju, PostgreSQL bazu, nginx konfig,
Basic Auth korisnike, fail2ban pravila i SSL certifikate.
Sprema se u <code>/root/</code> (čitljivo samo root-u).
</div>
<!-- Lista backupa -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Dostupni backupi</span>
<span class="badge bg-secondary">{{ backups|length }}</span>
</div>
{% if backups %}
<div class="list-group list-group-flush">
{% for b in backups %}
<div class="list-group-item d-flex justify-content-between align-items-center"
style="background:transparent; border-color:#30363d">
<div>
<i class="bi bi-file-zip me-2 text-muted"></i>
<span class="font-monospace small">{{ b }}</span>
</div>
<button class="btn btn-outline-danger btn-sm" onclick="deleteBackup('{{ b }}')">
<i class="bi bi-trash"></i>
</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="card-body text-muted">
<i class="bi bi-inbox me-1"></i> Nema dostupnih backupa
</div>
{% endif %}
</div>
<!-- Progress -->
<div id="backup-progress" class="card mt-4 d-none">
<div class="card-body d-flex align-items-center gap-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
<span>Kreiranje backupa, molim pričekajte...</span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function createBackup(btn) {
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Backup...';
document.getElementById('backup-progress').classList.remove('d-none');
const r = await api('/api/backup/create');
showToast(r.msg, r.ok);
btn.disabled = false;
btn.innerHTML = orig;
document.getElementById('backup-progress').classList.add('d-none');
if (r.ok) setTimeout(() => location.reload(), 1500);
}
async function deleteBackup(name) {
if (!confirm(`Obrisati backup "${name}"?`)) return;
const r = await api('/api/backup/delete', {name});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 800);
}
</script>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="hr" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Guacamole Manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
body { min-height: 100vh; background: #0f1117; }
#sidebar {
width: 230px; min-height: 100vh; background: #161b22;
border-right: 1px solid #30363d; position: fixed; top: 0; left: 0;
display: flex; flex-direction: column; z-index: 100;
}
#sidebar .brand {
padding: 1.25rem 1rem; border-bottom: 1px solid #30363d;
font-weight: 700; font-size: 1rem; color: #58a6ff;
display: flex; align-items: center; gap: .5rem;
}
#sidebar .nav-link {
color: #8b949e; padding: .55rem 1rem; border-radius: 6px;
margin: 2px 8px; display: flex; align-items: center; gap: .6rem;
font-size: .9rem; transition: all .15s;
}
#sidebar .nav-link:hover { background: #21262d; color: #c9d1d9; }
#sidebar .nav-link.active { background: #1f6feb22; color: #58a6ff; }
#sidebar .nav-link i { font-size: 1rem; width: 18px; text-align: center; }
#sidebar .sidebar-footer {
margin-top: auto; padding: 1rem; border-top: 1px solid #30363d; font-size: .8rem;
}
#content { margin-left: 230px; padding: 2rem; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
}
.status-ok { background: #3fb950; box-shadow: 0 0 6px #3fb95066; }
.status-err { background: #f85149; box-shadow: 0 0 6px #f8514966; }
.status-warn { background: #d29922; box-shadow: 0 0 6px #d2992266; }
.card { background: #161b22; border: 1px solid #30363d; }
.card-header { background: #1c2128; border-bottom: 1px solid #30363d; }
.badge-country {
background: #1f6feb33; color: #58a6ff; border: 1px solid #1f6feb66;
font-size: .75rem; padding: 3px 8px; border-radius: 12px; margin: 2px;
display: inline-block;
}
#log-output {
background: #010409; color: #c9d1d9; font-family: monospace;
font-size: .8rem; height: 400px; overflow-y: auto; padding: 1rem;
border-radius: 6px; border: 1px solid #30363d;
}
.toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; }
@media (max-width: 768px) {
#sidebar { width: 100%; min-height: auto; position: relative; }
#content { margin-left: 0; }
}
</style>
</head>
<body>
<!-- Sidebar -->
<div id="sidebar">
<div class="brand">
<i class="bi bi-display"></i> Guacamole
</div>
<nav class="nav flex-column mt-2">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
href="{{ url_for('dashboard') }}">
<i class="bi bi-grid-1x2"></i> Dashboard
</a>
<a class="nav-link {% if request.endpoint == 'basicauth' %}active{% endif %}"
href="{{ url_for('basicauth') }}">
<i class="bi bi-person-lock"></i> Basic Auth
</a>
<a class="nav-link {% if request.endpoint == 'geoip' %}active{% endif %}"
href="{{ url_for('geoip') }}">
<i class="bi bi-globe"></i> GeoIP
</a>
<a class="nav-link {% if request.endpoint == 'fail2ban' %}active{% endif %}"
href="{{ url_for('fail2ban') }}">
<i class="bi bi-shield-x"></i> fail2ban
</a>
<a class="nav-link {% if request.endpoint == 'guacamole' %}active{% endif %}"
href="{{ url_for('guacamole') }}">
<i class="bi bi-boxes"></i> Kontejneri
</a>
<a class="nav-link {% if request.endpoint == 'ssl' %}active{% endif %}"
href="{{ url_for('ssl') }}">
<i class="bi bi-lock"></i> SSL
</a>
<a class="nav-link {% if request.endpoint == 'updates' %}active{% endif %}"
href="{{ url_for('updates') }}">
<i class="bi bi-arrow-up-circle"></i> Ažuriranja
</a>
<a class="nav-link {% if request.endpoint == 'backup' %}active{% endif %}"
href="{{ url_for('backup') }}">
<i class="bi bi-archive"></i> Backup
</a>
<a class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}"
href="{{ url_for('settings') }}">
<i class="bi bi-gear"></i> Postavke
</a>
</nav>
<div class="sidebar-footer text-muted">
<a href="{{ url_for('logout') }}" class="text-danger text-decoration-none">
<i class="bi bi-box-arrow-right"></i> Odjava
</a>
</div>
</div>
<!-- Content -->
<div id="content">
{% block content %}{% endblock %}
</div>
<!-- Toast container -->
<div class="toast-container">
<div id="toast" class="toast align-items-center border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="toast-body"></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const toast = new bootstrap.Toast(document.getElementById('toast'), {delay: 4000});
function showToast(msg, ok=true) {
const el = document.getElementById('toast');
el.className = `toast align-items-center border-0 text-bg-${ok ? 'success' : 'danger'}`;
document.getElementById('toast-body').textContent = msg;
toast.show();
}
async function api(url, body={}) {
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
return r.json();
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% block title %}Basic Auth Guacamole Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-person-lock me-2"></i>Basic Auth korisnici</h4>
<div class="row g-4">
<!-- Lista korisnika -->
<div class="col-md-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Korisnici</span>
<span class="badge bg-secondary">{{ users|length }}</span>
</div>
<ul class="list-group list-group-flush" id="user-list">
{% for user in users %}
<li class="list-group-item d-flex justify-content-between align-items-center"
style="background:transparent; border-color:#30363d">
<span><i class="bi bi-person me-2 text-muted"></i>{{ user }}</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-sm"
onclick="fillUpdate('{{ user }}')">
<i class="bi bi-key"></i>
</button>
<button class="btn btn-outline-danger btn-sm"
onclick="deleteUser('{{ user }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
{% else %}
<li class="list-group-item text-muted" style="background:transparent">
Nema korisnika
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Forme -->
<div class="col-md-7">
<!-- Dodaj korisnika -->
<div class="card mb-3">
<div class="card-header fw-semibold">
<i class="bi bi-person-plus me-1"></i> Dodaj korisnika
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-5">
<input id="add-user" type="text" class="form-control form-control-sm"
placeholder="Korisničko ime">
</div>
<div class="col-5">
<input id="add-pass" type="password" class="form-control form-control-sm"
placeholder="Lozinka">
</div>
<div class="col-2">
<button class="btn btn-primary btn-sm w-100" onclick="addUser()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Promijeni lozinku -->
<div class="card">
<div class="card-header fw-semibold">
<i class="bi bi-key me-1"></i> Promijeni lozinku
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-5">
<input id="upd-user" type="text" class="form-control form-control-sm"
placeholder="Korisničko ime">
</div>
<div class="col-5">
<input id="upd-pass" type="password" class="form-control form-control-sm"
placeholder="Nova lozinka">
</div>
<div class="col-2">
<button class="btn btn-outline-primary btn-sm w-100" onclick="updateUser()">
<i class="bi bi-check-lg"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function fillUpdate(user) {
document.getElementById('upd-user').value = user;
document.getElementById('upd-pass').focus();
}
async function addUser() {
const username = document.getElementById('add-user').value.trim();
const password = document.getElementById('add-pass').value;
const r = await api('/api/basicauth/add', {username, password});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function updateUser() {
const username = document.getElementById('upd-user').value.trim();
const password = document.getElementById('upd-pass').value;
const r = await api('/api/basicauth/update', {username, password});
showToast(r.msg, r.ok);
}
async function deleteUser(username) {
if (!confirm(`Ukloniti korisnika "${username}"?`)) return;
const r = await api('/api/basicauth/delete', {username});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
</script>
{% endblock %}
+130
View File
@@ -0,0 +1,130 @@
{% extends 'base.html' %}
{% block title %}Dashboard Guacamole Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-0">Dashboard</h4>
<small class="text-muted">{{ domain }}</small>
</div>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStatus()">
<i class="bi bi-arrow-clockwise"></i> Osvježi
</button>
</div>
<!-- Kontejneri -->
<div class="row g-3 mb-4">
<div class="col-12"><h6 class="text-muted text-uppercase small mb-2">Docker kontejneri</h6></div>
{% for name, label in [('guacamole','Guacamole Web'), ('guacd','Guacd Daemon'), ('guac_postgres','PostgreSQL')] %}
<div class="col-md-4">
<div class="card p-3" id="card-{{ name }}">
<div class="d-flex align-items-center gap-2">
<span class="status-dot status-warn" id="dot-{{ name }}"></span>
<div>
<div class="fw-semibold">{{ label }}</div>
<small class="text-muted" id="txt-{{ name }}">Učitavanje...</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Servisi -->
<div class="row g-3 mb-4">
<div class="col-12"><h6 class="text-muted text-uppercase small mb-2">Sistemski servisi</h6></div>
{% for name, label, icon in [('nginx','Nginx','bi-server'), ('fail2ban','fail2ban','bi-shield'), ('docker','Docker','bi-box')] %}
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-2">
<span class="status-dot status-warn" id="dot-svc-{{ name }}"></span>
<div>
<div class="fw-semibold"><i class="bi {{ icon }} me-1"></i>{{ label }}</div>
<small class="text-muted" id="txt-svc-{{ name }}">Učitavanje...</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Info kartice -->
<div class="row g-3">
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-globe fs-3 text-info"></i>
<div>
<div class="small text-muted">GeoIP blokiranje</div>
<span id="badge-geoip" class="badge bg-secondary">Provjera...</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-lock fs-3 text-success"></i>
<div>
<div class="small text-muted">SSL certifikat</div>
<span id="badge-ssl" class="badge bg-secondary">Provjera...</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-shield-x fs-3 text-danger"></i>
<div>
<div class="small text-muted">Zabanjeni IP-ovi</div>
<span id="badge-banned" class="fs-4 fw-bold">...</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadStatus() {
const data = await fetch('/api/status').then(r => r.json());
// Containers
const containers = {
guacamole: data.containers.guacamole,
guacd: data.containers.guacd,
guac_postgres: data.containers.guac_postgres
};
for (const [name, running] of Object.entries(containers)) {
document.getElementById(`dot-${name}`).className =
`status-dot ${running ? 'status-ok' : 'status-err'}`;
document.getElementById(`txt-${name}`).textContent =
running ? 'Aktivan' : 'Nije aktivan';
}
// Services
for (const [name, active] of Object.entries(data.services)) {
document.getElementById(`dot-svc-${name}`).className =
`status-dot ${active ? 'status-ok' : 'status-err'}`;
document.getElementById(`txt-svc-${name}`).textContent =
active ? 'Aktivan' : 'Nije aktivan';
}
// Badges
const geoip = document.getElementById('badge-geoip');
geoip.textContent = data.geoip ? 'Aktivno' : 'Neaktivno';
geoip.className = `badge ${data.geoip ? 'bg-success' : 'bg-secondary'}`;
const ssl = document.getElementById('badge-ssl');
ssl.textContent = data.ssl ? 'Aktivan' : 'Nije aktivan';
ssl.className = `badge ${data.ssl ? 'bg-success' : 'bg-warning text-dark'}`;
document.getElementById('badge-banned').textContent = data.banned;
}
loadStatus();
setInterval(loadStatus, 15000);
</script>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends 'base.html' %}
{% block title %}fail2ban Guacamole Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-shield-x me-2"></i>fail2ban</h4>
<div class="row g-4">
<!-- Zabanjeni IP-ovi -->
<div class="col-md-8">
{% for jail, ips in banned.items() %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<span class="fw-semibold">{{ jail }}</span>
<span class="badge {{ 'bg-danger' if ips else 'bg-secondary' }}">
{{ ips|length }} zabanjeno
</span>
</div>
<div class="card-body p-0">
{% if ips %}
<table class="table table-sm mb-0" style="border-color:#30363d">
<thead class="table-dark">
<tr>
<th>IP adresa</th>
<th class="text-end">Akcija</th>
</tr>
</thead>
<tbody>
{% for ip in ips %}
<tr>
<td class="font-monospace">{{ ip }}</td>
<td class="text-end">
<button class="btn btn-outline-success btn-sm"
onclick="unban('{{ ip }}', '{{ jail }}')">
<i class="bi bi-check-circle"></i> Odbani
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted small p-3 mb-0">Nema zabanjenih IP-ova</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Akcije i postavke -->
<div class="col-md-4">
<!-- Ručni ban -->
<div class="card mb-3">
<div class="card-header fw-semibold">
<i class="bi bi-shield-plus me-1"></i> Ručni ban
</div>
<div class="card-body">
<div class="mb-2">
<input id="ban-ip" type="text" class="form-control form-control-sm"
placeholder="192.168.1.100">
</div>
<select id="ban-jail" class="form-select form-select-sm mb-2">
{% for j in banned.keys() %}
<option>{{ j }}</option>
{% endfor %}
</select>
<button class="btn btn-danger btn-sm w-100" onclick="banIP()">
<i class="bi bi-ban"></i> Baniraj IP
</button>
</div>
</div>
<!-- Postavke -->
<div class="card">
<div class="card-header fw-semibold">
<i class="bi bi-sliders me-1"></i> Postavke
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small text-muted">Max pokušaja (maxretry)</label>
<input id="maxretry" type="number" class="form-control form-control-sm"
value="{{ cfg.get('maxretry', 5) }}" min="1" max="20">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Trajanje bana (sekunde)</label>
<input id="bantime" type="number" class="form-control form-control-sm"
value="{{ cfg.get('bantime', 3600) }}" min="60">
<small class="text-muted">3600=1h · 86400=1dan · 604800=1tjedan</small>
</div>
<button class="btn btn-primary btn-sm w-100" onclick="saveSettings()">
<i class="bi bi-floppy"></i> Spremi
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function unban(ip, jail) {
const r = await api('/api/fail2ban/unban', {ip, jail});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function banIP() {
const ip = document.getElementById('ban-ip').value.trim();
const jail = document.getElementById('ban-jail').value;
if (!ip) return showToast('Unesite IP adresu', false);
const r = await api('/api/fail2ban/ban', {ip, jail});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function saveSettings() {
const r = await api('/api/fail2ban/settings', {
maxretry: document.getElementById('maxretry').value,
bantime: document.getElementById('bantime').value
});
showToast(r.msg, r.ok);
}
</script>
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends 'base.html' %}
{% block title %}GeoIP Guacamole Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-globe me-2"></i>GeoIP blokiranje</h4>
{% if enabled %}
<span class="badge bg-success fs-6">Aktivno</span>
{% else %}
<span class="badge bg-secondary fs-6">Neaktivno</span>
{% endif %}
</div>
{% if enabled %}
<!-- GeoIP je aktivan -->
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Dozvoljene države</div>
<div class="card-body">
<div id="country-badges" class="mb-3">
{% for cc in countries.split() %}
<span class="badge-country">{{ cc }}</span>
{% endfor %}
</div>
<div class="input-group input-group-sm">
<input id="countries-input" type="text" class="form-control"
placeholder="HR BA SI RS AT" value="{{ countries }}">
<button class="btn btn-primary" onclick="updateCountries()">
<i class="bi bi-check-lg"></i> Spremi
</button>
</div>
<small class="text-muted">ISO-2 kodovi zemalja, razdvojeni razmakom</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Akcije</div>
<div class="card-body d-flex flex-column gap-2">
<button class="btn btn-outline-info" onclick="refreshDB()">
<i class="bi bi-cloud-download"></i> Ažuriraj GeoIP bazu
</button>
<button class="btn btn-outline-danger" onclick="disableGeoIP()">
<i class="bi bi-x-circle"></i> Onemogući GeoIP
</button>
</div>
</div>
</div>
</div>
{% else %}
<!-- GeoIP nije aktivan -->
<div class="card">
<div class="card-header fw-semibold">
<i class="bi bi-globe me-1"></i> Konfiguracija GeoIP blokiranja
</div>
<div class="card-body">
<div class="alert alert-info small mb-4">
<i class="bi bi-info-circle me-1"></i>
Potreban je besplatni MaxMind račun:
<a href="https://www.maxmind.com/en/geolite2/signup" target="_blank" class="alert-link">
maxmind.com/en/geolite2/signup
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small text-muted">MaxMind Account ID</label>
<input id="mm-id" type="text" class="form-control" value="{{ mm_id }}"
placeholder="123456">
</div>
<div class="col-md-6">
<label class="form-label small text-muted">MaxMind License Key</label>
<input id="mm-key" type="password" class="form-control"
placeholder="••••••••••••••••">
</div>
<div class="col-12">
<label class="form-label small text-muted">Dozvoljene države (ISO-2 kodovi)</label>
<input id="countries-input" type="text" class="form-control"
placeholder="HR BA SI RS AT DE">
<small class="text-muted">Sve ostale države bit će blokirane</small>
</div>
<div class="col-12">
<button class="btn btn-success" onclick="enableGeoIP()">
<i class="bi bi-globe-americas"></i> Aktiviraj GeoIP blokiranje
</button>
</div>
</div>
</div>
</div>
<!-- Referenca ISO kodova -->
<div class="card mt-3">
<div class="card-header small text-muted">Česti ISO-2 kodovi</div>
<div class="card-body">
{% 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')] %}
<span class="badge-country" style="cursor:pointer"
onclick="addCountry('{{ cc }}')" title="{{ name }}">
{{ cc }} <small class="text-muted">{{ name }}</small>
</span>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function enableGeoIP() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Aktivacija...';
const r = await api('/api/geoip/enable', {
mm_id: document.getElementById('mm-id').value.trim(),
mm_key: document.getElementById('mm-key').value.trim(),
countries: document.getElementById('countries-input').value.trim()
});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1500);
else { btn.disabled = false; btn.innerHTML = '<i class="bi bi-globe-americas"></i> Aktiviraj GeoIP blokiranje'; }
}
async function updateCountries() {
const r = await api('/api/geoip/update', {
countries: document.getElementById('countries-input').value.trim()
});
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function disableGeoIP() {
if (!confirm('Sigurno onemogućiti GeoIP?')) return;
const r = await api('/api/geoip/disable');
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1000);
}
async function refreshDB() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Ažuriranje...';
const r = await api('/api/geoip/refresh');
showToast(r.ok ? 'GeoIP baza ažurirana' : r.msg, r.ok);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download"></i> Ažuriraj GeoIP bazu';
}
function addCountry(cc) {
const inp = document.getElementById('countries-input');
const existing = inp.value.split(' ').filter(x => x);
if (!existing.includes(cc)) {
inp.value = [...existing, cc].join(' ');
}
}
</script>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block title %}Kontejneri Guacamole Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-boxes me-2"></i>Docker kontejneri</h4>
<!-- Status kartice -->
<div class="row g-3 mb-4">
{% for name, running in containers.items() %}
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex align-items-center gap-3">
<span class="status-dot {{ 'status-ok' if running else 'status-err' }}"></span>
<div>
<div class="fw-semibold">{{ name }}</div>
<small class="{{ 'text-success' if running else 'text-danger' }}">
{{ 'Aktivan' if running else 'Nije aktivan' }}
</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Akcije -->
<div class="card mb-4">
<div class="card-header fw-semibold">Upravljanje</div>
<div class="card-body d-flex gap-2 flex-wrap">
<button class="btn btn-success" onclick="containerAction('start')">
<i class="bi bi-play-fill"></i> Start
</button>
<button class="btn btn-warning" onclick="containerAction('restart')">
<i class="bi bi-arrow-clockwise"></i> Restart
</button>
<button class="btn btn-danger" onclick="containerAction('stop')">
<i class="bi bi-stop-fill"></i> Stop
</button>
<div class="ms-auto">
<select id="log-source" class="form-select form-select-sm d-inline-block w-auto"
onchange="switchLog(this.value)">
<option value="guacamole">guacamole</option>
<option value="guacd">guacd</option>
<option value="postgres">postgres</option>
<option value="nginx">nginx access</option>
<option value="nginx-error">nginx error</option>
<option value="fail2ban">fail2ban</option>
</select>
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="toggleLog()">
<i class="bi bi-terminal" id="log-icon"></i>
<span id="log-btn-txt">Log</span>
</button>
</div>
</div>
</div>
<!-- Live log -->
<div id="log-panel" class="card d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-terminal me-1"></i>
Live log: <span id="log-title">guacamole</span>
</span>
<button class="btn btn-sm btn-outline-secondary" onclick="clearLog()">
<i class="bi bi-trash"></i> Očisti
</button>
</div>
<div id="log-output"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let logSource = null;
let logVisible = false;
async function containerAction(action) {
const btn = event.target.closest('button');
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> ${action}...`;
const r = await api(`/api/guacamole/${action}`);
showToast(r.ok ? `${action} izvršen` : r.msg, r.ok);
btn.disabled = false;
btn.innerHTML = orig;
if (r.ok) setTimeout(() => location.reload(), 2000);
}
function switchLog(service) {
if (logVisible) {
stopLog();
startLog(service);
}
document.getElementById('log-title').textContent = service;
}
function toggleLog() {
if (logVisible) {
stopLog();
logVisible = false;
document.getElementById('log-panel').classList.add('d-none');
document.getElementById('log-btn-txt').textContent = 'Log';
} else {
logVisible = true;
document.getElementById('log-panel').classList.remove('d-none');
document.getElementById('log-btn-txt').textContent = 'Zatvori';
startLog(document.getElementById('log-source').value);
}
}
function startLog(service) {
stopLog();
const out = document.getElementById('log-output');
logSource = new EventSource(`/api/logs/${service}`);
logSource.onmessage = e => {
const line = document.createElement('div');
line.textContent = JSON.parse(e.data);
out.appendChild(line);
out.scrollTop = out.scrollHeight;
};
}
function stopLog() {
if (logSource) { logSource.close(); logSource = null; }
}
function clearLog() {
document.getElementById('log-output').innerHTML = '';
}
</script>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="hr" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login Guacamole Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
body { background: #0f1117; min-height: 100vh;
display: flex; align-items: center; justify-content: center; }
.login-card { width: 360px; background: #161b22;
border: 1px solid #30363d; border-radius: 12px; padding: 2rem; }
.brand { color: #58a6ff; font-size: 1.4rem; font-weight: 700; }
</style>
</head>
<body>
<div class="login-card">
<div class="text-center mb-4">
<i class="bi bi-display fs-1 text-primary"></i>
<div class="brand mt-1">Guacamole Manager</div>
<small class="text-muted">Web upravljačko sučelje</small>
</div>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="alert alert-{{ cat }} py-2 small">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post">
<div class="mb-3">
<label class="form-label small text-muted">Korisničko ime</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input name="username" type="text" class="form-control"
placeholder="admin" autofocus required>
</div>
</div>
<div class="mb-4">
<label class="form-label small text-muted">Lozinka</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input name="password" type="password" class="form-control"
placeholder="••••••••" required>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right"></i> Prijava
</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
{% extends 'base.html' %}
{% block title %}Postavke Guacamole Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>Postavke</h4>
<div class="row g-4">
<div class="col-md-5">
<div class="card">
<div class="card-header fw-semibold">
<i class="bi bi-key me-1"></i> Promjena lozinke GUI-ja
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label small text-muted">Korisničko ime</label>
<input type="text" class="form-control" value="{{ username }}" readonly
style="background:#0d1117; opacity:.7">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Trenutna lozinka</label>
<input id="cur-pw" type="password" class="form-control" placeholder="••••••••">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Nova lozinka</label>
<input id="new-pw" type="password" class="form-control" placeholder="Min. 8 znakova">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Potvrdi novu lozinku</label>
<input id="new-pw2" type="password" class="form-control" placeholder="••••••••">
</div>
<button class="btn btn-primary w-100" onclick="changePassword()">
<i class="bi bi-floppy"></i> Spremi lozinku
</button>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header fw-semibold">
<i class="bi bi-info-circle me-1"></i> Informacije
</div>
<div class="card-body">
<dl class="row">
<dt class="col-5 text-muted small">Instalacijski dir</dt>
<dd class="col-7 font-monospace small">/opt/guacamole</dd>
<dt class="col-5 text-muted small">Nginx konfig</dt>
<dd class="col-7 font-monospace small">/etc/nginx/.../guacamole</dd>
<dt class="col-5 text-muted small">Basic Auth file</dt>
<dd class="col-7 font-monospace small">/etc/nginx/.guac_htpasswd</dd>
<dt class="col-5 text-muted small">GeoIP konfig</dt>
<dd class="col-7 font-monospace small">/etc/nginx/conf.d/guacamole-geoip.conf</dd>
<dt class="col-5 text-muted small">fail2ban jail</dt>
<dd class="col-7 font-monospace small">/etc/fail2ban/jail.d/guacamole.conf</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header fw-semibold">
<i class="bi bi-terminal me-1"></i> Brze naredbe (u terminalu)
</div>
<div class="card-body">
<pre class="small mb-0" style="background:#0d1117; border:1px solid #30363d; padding:.75rem; border-radius:6px; color:#8b949e"># 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</pre>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function changePassword() {
const cur = document.getElementById('cur-pw').value;
const np = document.getElementById('new-pw').value;
const np2 = document.getElementById('new-pw2').value;
if (np !== np2) return showToast('Lozinke se ne podudaraju', false);
if (np.length < 8) return showToast('Lozinka mora imati najmanje 8 znakova', false);
const r = await api('/api/settings/password', {current: cur, new_password: np});
showToast(r.msg, r.ok);
if (r.ok) {
['cur-pw','new-pw','new-pw2'].forEach(id =>
document.getElementById(id).value = '');
}
}
</script>
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends 'base.html' %}
{% block title %}SSL{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-lock me-2"></i>SSL certifikat</h4>
{% if active %}
<span class="badge bg-success fs-6">Aktivan</span>
{% else %}
<span class="badge bg-warning text-dark fs-6">Nije aktivan</span>
{% endif %}
</div>
{% if active %}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Informacije</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Domena / IP</dt>
<dd class="col-7">{{ domain }}</dd>
</dl>
{% if cert_info %}
<pre class="mt-2 p-2 rounded small text-muted"
style="background:#0d1117;border:1px solid #30363d;font-size:.75rem;max-height:200px;overflow:auto">{{ cert_info }}</pre>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Akcije</div>
<div class="card-body d-flex flex-column gap-2">
<button class="btn btn-outline-primary" onclick="renewSSL()">
<i class="bi bi-arrow-repeat"></i> Obnovi Let's Encrypt certifikat
</button>
<button class="btn btn-outline-secondary" onclick="loadSelfSignedInfo()">
<i class="bi bi-info-circle"></i> Provjeri self-signed info
</button>
</div>
</div>
</div>
</div>
<pre id="ss-info" class="mt-3 p-3 rounded small d-none"
style="background:#0d1117;border:1px solid #30363d;color:#8b949e"></pre>
{% else %}
<div class="row g-4">
<!-- Let's Encrypt -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-globe me-1"></i> Let's Encrypt
<span class="badge bg-success ms-1 small">Preporučeno</span>
</div>
<div class="card-body">
<ul class="small text-muted mb-3">
<li>Besplatan, povjerljiv certifikat</li>
<li>Browser ne pokazuje upozorenje</li>
<li>Zahtijeva javnu domenu (ne radi s IP-om)</li>
<li>Auto-obnova svakih 90 dana</li>
</ul>
<div class="alert alert-warning small py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
DNS mora pokazivati na ovaj server.
</div>
<div class="row g-2 mt-1">
<div class="col-12">
<input id="ssl-domain" type="text" class="form-control form-control-sm"
value="{{ domain }}" placeholder="rdp.firma.com">
</div>
<div class="col-12">
<input id="ssl-email" type="email" class="form-control form-control-sm"
placeholder="admin@firma.com">
</div>
<div class="col-12">
<button class="btn btn-success w-100" onclick="enableSSL()">
<i class="bi bi-lock"></i> Aktiviraj Let's Encrypt
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Self-signed -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-shield-lock me-1"></i> Self-signed certifikat
</div>
<div class="card-body">
<ul class="small text-muted mb-3">
<li>Radi s IP adresom ili domenom</li>
<li>Enkriptiran promet (HTTPS)</li>
<li>Browser pokazuje upozorenje pri prvom posjetu</li>
<li>Vrijedi 10 godina, bez potrebe za obnovom</li>
</ul>
<div class="alert alert-info small py-2">
<i class="bi bi-info-circle me-1"></i>
Idealno za internu mrežu ili testiranje.<br>
Generirat ce se za: <strong>{{ domain }}</strong>
</div>
<button class="btn btn-outline-primary w-100 mt-2" onclick="enableSelfSigned()">
<i class="bi bi-shield-lock"></i> Generiraj self-signed certifikat
</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function enableSSL() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Aktivacija (moze potrajati)...';
const r = await api('/api/ssl/enable', {
domain: document.getElementById('ssl-domain').value.trim(),
email: document.getElementById('ssl-email').value.trim()
});
showToast(r.ok ? 'SSL aktiviran!' : r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 2000);
else { btn.disabled = false; btn.innerHTML = '<i class="bi bi-lock"></i> Aktiviraj Let\'s Encrypt'; }
}
async function enableSelfSigned() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Generiranje...';
const r = await api('/api/ssl/selfsigned');
showToast(r.msg, r.ok);
if (r.ok) setTimeout(() => location.reload(), 1500);
else { btn.disabled = false; btn.innerHTML = '<i class="bi bi-shield-lock"></i> Generiraj self-signed certifikat'; }
}
async function renewSSL() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Obnavljanje...';
const r = await api('/api/ssl/renew');
showToast(r.ok ? 'Certifikat obnovljen' : r.msg, r.ok);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Obnovi Let\'s Encrypt certifikat';
}
async function loadSelfSignedInfo() {
const r = await fetch('/api/ssl/selfsigned_info').then(r => r.json());
const el = document.getElementById('ss-info');
el.textContent = r.info;
el.classList.remove('d-none');
}
</script>
{% endblock %}
+207
View File
@@ -0,0 +1,207 @@
{% extends 'base.html' %}
{% block title %}Ažuriranja{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-arrow-up-circle me-2"></i>Ažuriranja</h4>
<!-- Trenutne verzije -->
<div class="card mb-4">
<div class="card-header fw-semibold"><i class="bi bi-info-circle me-1"></i> Trenutne verzije</div>
<div class="card-body">
<div class="row g-3">
{% for name, ver in versions.items() %}
<div class="col-md-4">
<div class="p-3 rounded" style="background:#0d1117; border:1px solid #30363d">
<div class="small text-muted text-uppercase mb-1">{{ name }}</div>
<div class="fw-semibold font-monospace">{{ ver }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Akcije ažuriranja -->
<div class="row g-4">
<!-- Guacamole -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-display me-1"></i> Guacamole
</div>
<div class="card-body">
<p class="text-muted small">
Preuzima najnovije Docker image-e za Guacamole, guacd i PostgreSQL
te restarta kontejnere. Konfiguracija i podaci se čuvaju.
</p>
<button class="btn btn-primary" onclick="startUpdate('guacamole', this)">
<i class="bi bi-cloud-download"></i> Ažuriraj Guacamole
</button>
</div>
</div>
</div>
<!-- Nginx -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-server me-1"></i> Nginx
</div>
<div class="card-body">
<p class="text-muted small">
Ažurira nginx paket putem sistemskog package managera
({{ pkg }}) i automatski reloada konfiguraciju.
</p>
<button class="btn btn-primary" onclick="startUpdate('nginx', this)">
<i class="bi bi-arrow-up-circle"></i> Ažuriraj Nginx
</button>
</div>
</div>
</div>
<!-- fail2ban -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-shield me-1"></i> fail2ban
</div>
<div class="card-body">
<p class="text-muted small">
Ažurira fail2ban paket i restarta servis.
Jail konfiguracije ostaju netaknute.
</p>
<button class="btn btn-primary" onclick="updateFail2ban(this)">
<i class="bi bi-arrow-up-circle"></i> Ažuriraj fail2ban
</button>
</div>
</div>
</div>
<!-- Sistem -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-hdd me-1"></i> Sistemske nadogradnje
</div>
<div class="card-body">
<p class="text-muted small">
Ažurira sve instalirane sistemske pakete putem <code>{{ pkg }}</code>.
Može potrajati nekoliko minuta.
</p>
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
Nakon sistemske nadogradnje može biti potreban restart servera.
</div>
<button class="btn btn-warning" onclick="startUpdate('system', this)">
<i class="bi bi-hdd-rack"></i> Ažuriraj sistem
</button>
</div>
</div>
</div>
</div>
<!-- Output konzola -->
<div id="output-panel" class="card mt-4 d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-terminal me-1"></i>
Output: <span id="output-title"></span>
</span>
<div>
<span id="output-status" class="badge bg-warning me-2">U tijeku...</span>
<button class="btn btn-sm btn-outline-secondary" onclick="clearOutput()">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div id="log-output"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let activeSource = null;
function startUpdate(type, btn) {
const titles = {
guacamole: 'Guacamole',
nginx: 'Nginx',
system: 'Sistemske nadogradnje'
};
if (activeSource) {
activeSource.close();
activeSource = null;
}
document.getElementById('output-panel').classList.remove('d-none');
document.getElementById('output-title').textContent = titles[type] || type;
document.getElementById('output-status').textContent = 'U tijeku...';
document.getElementById('output-status').className = 'badge bg-warning me-2';
const out = document.getElementById('log-output');
out.innerHTML = '';
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Ažuriranje...';
activeSource = new EventSource(`/api/update/${type}`);
activeSource.onmessage = e => {
const data = JSON.parse(e.data);
if (data === '__DONE__') {
activeSource.close();
activeSource = null;
btn.disabled = false;
btn.innerHTML = origHtml;
document.getElementById('output-status').textContent = 'Završeno';
document.getElementById('output-status').className = 'badge bg-success me-2';
return;
}
const line = document.createElement('div');
line.textContent = data;
if (data.startsWith('✓')) line.style.color = '#3fb950';
out.appendChild(line);
out.scrollTop = out.scrollHeight;
};
activeSource.onerror = () => {
activeSource.close();
activeSource = null;
btn.disabled = false;
btn.innerHTML = origHtml;
document.getElementById('output-status').textContent = 'Greška';
document.getElementById('output-status').className = 'badge bg-danger me-2';
};
}
async function updateFail2ban(btn) {
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Ažuriranje...';
document.getElementById('output-panel').classList.remove('d-none');
document.getElementById('output-title').textContent = 'fail2ban';
document.getElementById('output-status').textContent = 'U tijeku...';
document.getElementById('output-status').className = 'badge bg-warning me-2';
const r = await api('/api/update/fail2ban');
const out = document.getElementById('log-output');
out.innerHTML = '';
const line = document.createElement('div');
line.textContent = r.msg;
out.appendChild(line);
btn.disabled = false;
btn.innerHTML = orig;
document.getElementById('output-status').textContent = r.ok ? 'Završeno' : 'Greška';
document.getElementById('output-status').className = `badge ${r.ok ? 'bg-success' : 'bg-danger'} me-2`;
showToast(r.ok ? 'fail2ban ažuriran' : r.msg, r.ok);
}
function clearOutput() {
document.getElementById('log-output').innerHTML = '';
document.getElementById('output-panel').classList.add('d-none');
}
</script>
{% endblock %}