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:
+19
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user