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