#!/usr/bin/env python3 """ Guacamole Web Management GUI """ import os, subprocess, json, secrets, shutil, re, platform from functools import wraps from flask import (Flask, render_template, request, redirect, url_for, session, jsonify, flash, Response, stream_with_context) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) INSTALL_DIR = '/opt/guacamole' HTPASSWD = '/etc/nginx/.guac_htpasswd' GEOIP_CONF = '/etc/nginx/conf.d/guacamole-geoip.conf' GEOIP_MMCONF = '/etc/GeoIP.conf' FAIL2BAN_CFG = '/etc/fail2ban/jail.d/guacamole.conf' CONFIG_FILE = os.path.join(BASE_DIR, 'config.json') NGINX_CONFS = [ '/etc/nginx/sites-available/guacamole', '/etc/nginx/conf.d/guacamole.conf', ] SELFSIGNED_DIR = '/etc/ssl/guacamole' app = Flask(__name__, template_folder='templates') app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) # ── helpers ────────────────────────────────────────────────────────────────── def load_config(): if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE) as f: return json.load(f) return {'username': 'admin', 'password': 'changeme123'} def save_config(cfg): with open(CONFIG_FILE, 'w') as f: json.dump(cfg, f, indent=2) os.chmod(CONFIG_FILE, 0o600) def run(cmd, stdin=None, timeout=30): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, input=stdin) return r.returncode == 0, (r.stdout + r.stderr).strip() except subprocess.TimeoutExpired: return False, 'Timeout' except Exception as e: return False, str(e) def detect_compose(): ok, _ = run(['docker', 'compose', 'version']) return 'docker compose' if ok else 'docker-compose' COMPOSE = detect_compose().split() def get_nginx_conf(): for p in NGINX_CONFS: if os.path.exists(p): return p return None def nginx_reload(): ok, out = run(['nginx', '-t']) if ok: run(['systemctl', 'reload', 'nginx']) return ok, out def container_running(name): ok, out = run(['docker', 'ps', '--filter', f'name=^/{name}$', '--filter', 'status=running', '--format', '{{.Names}}']) return name in out def service_active(name): ok, _ = run(['systemctl', 'is-active', name]) return ok def get_domain(): conf = get_nginx_conf() if conf and os.path.exists(conf): with open(conf) as f: for line in f: m = re.search(r'server_name\s+(.+?);', line) if m: return m.group(1).strip() return 'localhost' def geoip_countries(): if not os.path.exists(GEOIP_CONF): return [] with open(GEOIP_CONF) as f: return re.findall(r'^\s+([A-Z]{2})\s+1;', f.read(), re.MULTILINE) def fail2ban_banned(jail): ok, out = run(['fail2ban-client', 'status', jail]) if not ok: return [] m = re.search(r'Banned IP list:\s*(.*)', out) if m: return [ip for ip in m.group(1).split() if ip] return [] def htpasswd_users(): if not os.path.exists(HTPASSWD): return [] with open(HTPASSWD) as f: return [line.split(':')[0] for line in f if ':' in line] def ssl_active(): conf = get_nginx_conf() if conf and os.path.exists(conf): with open(conf) as f: return 'ssl_certificate' in f.read() return False # ── auth decorators ─────────────────────────────────────────────────────────── def login_required(f): @wraps(f) def inner(*args, **kwargs): if not session.get('logged_in'): return redirect(url_for('login', next=request.path)) return f(*args, **kwargs) return inner def api_auth(f): @wraps(f) def inner(*args, **kwargs): if not session.get('logged_in'): return jsonify(ok=False, msg='Unauthorized'), 401 return f(*args, **kwargs) return inner # ── page routes ─────────────────────────────────────────────────────────────── @app.route('/') @login_required def index(): return redirect(url_for('dashboard')) @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': cfg = load_config() if (request.form.get('username') == cfg['username'] and request.form.get('password') == cfg['password']): session.permanent = True session['logged_in'] = True return redirect(request.args.get('next') or url_for('dashboard')) flash('Pogrešno korisničko ime ili lozinka', 'danger') return render_template('login.html') @app.route('/logout') def logout(): session.clear() return redirect(url_for('login')) @app.route('/dashboard') @login_required def dashboard(): return render_template('dashboard.html', domain=get_domain()) @app.route('/basicauth') @login_required def basicauth(): return render_template('basicauth.html', users=htpasswd_users()) @app.route('/geoip') @login_required def geoip(): enabled = os.path.exists(GEOIP_CONF) countries = ' '.join(geoip_countries()) mm_id = mm_key = '' if os.path.exists(GEOIP_MMCONF): with open(GEOIP_MMCONF) as f: txt = f.read() m = re.search(r'AccountID\s+(\S+)', txt) if m: mm_id = m.group(1) return render_template('geoip.html', enabled=enabled, countries=countries, mm_id=mm_id) @app.route('/fail2ban') @login_required def fail2ban(): jails = ['nginx-basicauth', 'nginx-guacamole', 'sshd'] banned = {j: fail2ban_banned(j) for j in jails} cfg = {} if os.path.exists(FAIL2BAN_CFG): with open(FAIL2BAN_CFG) as f: txt = f.read() m = re.search(r'maxretry\s*=\s*(\d+)', txt) if m: cfg['maxretry'] = m.group(1) m = re.search(r'bantime\s*=\s*(\d+)', txt) if m: cfg['bantime'] = m.group(1) return render_template('fail2ban.html', banned=banned, cfg=cfg) @app.route('/guacamole') @login_required def guacamole(): containers = {n: container_running(n) for n in ['guacamole', 'guacd', 'guac_postgres']} return render_template('guacamole.html', containers=containers) @app.route('/ssl') @login_required def ssl(): active = ssl_active() domain = get_domain() cert_info = '' if active: ok, out = run(['certbot', 'certificates']) cert_info = out return render_template('ssl.html', active=active, domain=domain, cert_info=cert_info) @app.route('/settings') @login_required def settings(): cfg = load_config() return render_template('settings.html', username=cfg['username']) # ── API – status ────────────────────────────────────────────────────────────── @app.route('/api/status') @api_auth def api_status(): containers = {n: container_running(n) for n in ['guacamole', 'guacd', 'guac_postgres']} services = {s: service_active(s) for s in ['nginx', 'fail2ban', 'docker']} banned_total = sum(len(fail2ban_banned(j)) for j in ['nginx-basicauth', 'nginx-guacamole', 'sshd']) return jsonify( containers=containers, services=services, geoip=os.path.exists(GEOIP_CONF), ssl=ssl_active(), banned=banned_total, domain=get_domain(), ) # ── API – Basic Auth ────────────────────────────────────────────────────────── @app.route('/api/basicauth/add', methods=['POST']) @api_auth def api_ba_add(): user = request.json.get('username', '').strip() pw = request.json.get('password', '') if not re.match(r'^[a-zA-Z0-9_.-]+$', user): return jsonify(ok=False, msg='Nevažeće korisničko ime') if not pw: return jsonify(ok=False, msg='Lozinka je obavezna') flag = '-b' if os.path.exists(HTPASSWD) else '-bc' ok, out = run(['htpasswd', flag, HTPASSWD, user, pw]) if ok: nginx_reload() return jsonify(ok=ok, msg=out or 'Korisnik dodan') @app.route('/api/basicauth/update', methods=['POST']) @api_auth def api_ba_update(): user = request.json.get('username', '').strip() pw = request.json.get('password', '') if not pw: return jsonify(ok=False, msg='Lozinka je obavezna') ok, out = run(['htpasswd', '-b', HTPASSWD, user, pw]) if ok: nginx_reload() return jsonify(ok=ok, msg=out or 'Lozinka promijenjena') @app.route('/api/basicauth/delete', methods=['POST']) @api_auth def api_ba_delete(): user = request.json.get('username', '').strip() ok, out = run(['htpasswd', '-D', HTPASSWD, user]) if ok: nginx_reload() return jsonify(ok=ok, msg=out or 'Korisnik uklonjen') # ── API – GeoIP ─────────────────────────────────────────────────────────────── def _write_geoip_conf(countries): entries = ''.join(f' {c} 1;\n' for c in countries) with open(GEOIP_CONF, 'w') as f: f.write(f"""geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {{ $geoip2_data_country_code country iso_code; }} map $geoip2_data_country_code $allowed_country {{ default 0; {entries}}} """) def _apply_geoip_nginx(): conf = get_nginx_conf() if not conf: return with open(conf) as f: txt = f.read() txt = re.sub(r'\s*if \(\$allowed_country = 0\).*?\n', '', txt) txt = re.sub( r'(auth_basic\s+"Restricted[^"]*";)', r'if ($allowed_country = 0) { return 403; }\n \1', txt ) with open(conf, 'w') as f: f.write(txt) def _remove_geoip_nginx(): conf = get_nginx_conf() if not conf: return with open(conf) as f: txt = f.read() txt = re.sub(r'[ \t]*if \(\$allowed_country = 0\)[^\n]*\n', '', txt) with open(conf, 'w') as f: f.write(txt) @app.route('/api/geoip/enable', methods=['POST']) @api_auth def api_geoip_enable(): data = request.json mm_id = data.get('mm_id', '').strip() mm_key = data.get('mm_key', '').strip() countries = [c.upper() for c in data.get('countries', '').split() if c] if not (mm_id and mm_key and countries): return jsonify(ok=False, msg='Popunite sva polja') # Instaliraj GeoIP paket ako treba if not shutil.which('geoipupdate'): ok, out = run(['apt-get', 'install', '-y', 'geoipupdate', 'libnginx-mod-http-geoip2']) if not ok: return jsonify(ok=False, msg=f'Instalacija paketa: {out}') # MaxMind config with open(GEOIP_MMCONF, 'w') as f: f.write(f"AccountID {mm_id}\nLicenseKey {mm_key}\n" f"EditionIDs GeoLite2-Country\n" f"DatabaseDirectory /usr/share/GeoIP\n") ok, out = run(['geoipupdate']) if not ok: return jsonify(ok=False, msg=f'geoipupdate: {out}') _write_geoip_conf(countries) _apply_geoip_nginx() nginx_reload() return jsonify(ok=True, msg='GeoIP aktiviran') @app.route('/api/geoip/update', methods=['POST']) @api_auth def api_geoip_update(): countries = [c.upper() for c in request.json.get('countries', '').split() if c] if not countries: return jsonify(ok=False, msg='Unesite barem jednu zemlju') _write_geoip_conf(countries) ok, out = nginx_reload() return jsonify(ok=ok, msg='Ažurirane dozvoljene države' if ok else out) @app.route('/api/geoip/disable', methods=['POST']) @api_auth def api_geoip_disable(): if os.path.exists(GEOIP_CONF): os.remove(GEOIP_CONF) _remove_geoip_nginx() nginx_reload() return jsonify(ok=True, msg='GeoIP onemogućen') @app.route('/api/geoip/refresh', methods=['POST']) @api_auth def api_geoip_refresh(): ok, out = run(['geoipupdate']) return jsonify(ok=ok, msg=out) # ── API – fail2ban ──────────────────────────────────────────────────────────── @app.route('/api/fail2ban/unban', methods=['POST']) @api_auth def api_unban(): ip = request.json.get('ip', '').strip() jail = request.json.get('jail', 'nginx-basicauth') if not re.match(r'^[\d.:a-fA-F]+$', ip): return jsonify(ok=False, msg='Nevažeća IP adresa') ok, out = run(['fail2ban-client', 'set', jail, 'unbanip', ip]) return jsonify(ok=ok, msg=out or f'{ip} odbanjivan') @app.route('/api/fail2ban/ban', methods=['POST']) @api_auth def api_ban(): ip = request.json.get('ip', '').strip() jail = request.json.get('jail', 'nginx-basicauth') if not re.match(r'^[\d.:a-fA-F]+$', ip): return jsonify(ok=False, msg='Nevažeća IP adresa') ok, out = run(['fail2ban-client', 'set', jail, 'banip', ip]) return jsonify(ok=ok, msg=out or f'{ip} baniran') @app.route('/api/fail2ban/settings', methods=['POST']) @api_auth def api_fail2ban_settings(): maxretry = request.json.get('maxretry', '') bantime = request.json.get('bantime', '') if not (maxretry.isdigit() and bantime.lstrip('-').isdigit()): return jsonify(ok=False, msg='Nevažeće vrijednosti') if os.path.exists(FAIL2BAN_CFG): with open(FAIL2BAN_CFG) as f: txt = f.read() txt = re.sub(r'maxretry\s*=\s*\d+', f'maxretry = {maxretry}', txt) txt = re.sub(r'bantime\s*=\s*\d+', f'bantime = {bantime}', txt) with open(FAIL2BAN_CFG, 'w') as f: f.write(txt) ok, out = run(['systemctl', 'restart', 'fail2ban']) return jsonify(ok=ok, msg='Postavke ažurirane' if ok else out) # ── API – Guacamole containers ──────────────────────────────────────────────── @app.route('/api/guacamole/', methods=['POST']) @api_auth def api_guacamole(action): if action not in ('start', 'stop', 'restart'): return jsonify(ok=False, msg='Nevažeća akcija') ok, out = run(COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', action], timeout=60) return jsonify(ok=ok, msg=out or action.capitalize()) # ── API – SSL ───────────────────────────────────────────────────────────────── @app.route('/api/ssl/enable', methods=['POST']) @api_auth def api_ssl_enable(): domain = request.json.get('domain', '').strip() email = request.json.get('email', '').strip() if not (domain and email): return jsonify(ok=False, msg='Domena i email su obavezni') if not shutil.which('certbot'): ok, out = run(['apt-get', 'install', '-y', 'certbot', 'python3-certbot-nginx']) if not ok: return jsonify(ok=False, msg=f'Instalacija certbota: {out}') ok, out = run(['certbot', '--nginx', '-d', domain, '--email', email, '--agree-tos', '--non-interactive', '--redirect'], timeout=120) if ok: nginx_reload() return jsonify(ok=ok, msg=out) @app.route('/api/ssl/renew', methods=['POST']) @api_auth def api_ssl_renew(): ok, out = run(['certbot', 'renew', '--force-renewal'], timeout=120) if ok: nginx_reload() return jsonify(ok=ok, msg=out) # ── API – Nginx ─────────────────────────────────────────────────────────────── @app.route('/api/nginx/reload', methods=['POST']) @api_auth def api_nginx_reload(): ok, out = nginx_reload() return jsonify(ok=ok, msg=out or 'Nginx reloadiran') # ── API – Settings ──────────────────────────────────────────────────────────── @app.route('/api/settings/password', methods=['POST']) @api_auth def api_change_password(): current = request.json.get('current', '') new_pw = request.json.get('new_password', '') cfg = load_config() if cfg['password'] != current: return jsonify(ok=False, msg='Pogrešna trenutna lozinka') if len(new_pw) < 8: return jsonify(ok=False, msg='Nova lozinka mora imati najmanje 8 znakova') cfg['password'] = new_pw save_config(cfg) return jsonify(ok=True, msg='Lozinka promijenjena') # ── helpers – verzije i update ─────────────────────────────────────────────── def detect_pkg_manager(): for pm, cmd in [('apt', 'apt-get'), ('dnf', 'dnf'), ('yum', 'yum'), ('pacman', 'pacman'), ('zypper', 'zypper')]: if shutil.which(cmd): return pm return 'unknown' def get_guac_version(): ok, out = run(['docker', 'inspect', '--format', '{{.Config.Image}}', 'guacamole']) if ok and ':' in out: return out.strip().split(':')[-1] return 'nepoznato' def get_nginx_version(): ok, out = run(['nginx', '-v']) m = re.search(r'nginx/(\S+)', out) return m.group(1) if m else 'nepoznato' def get_docker_version(): ok, out = run(['docker', '--version']) m = re.search(r'version\s+(\S+)', out, re.I) return m.group(1).rstrip(',') if m else 'nepoznato' def get_fail2ban_version(): ok, out = run(['fail2ban-client', '--version']) m = re.search(r'(\d+\.\d+[\.\d]*)', out) return m.group(1) if m else 'nepoznato' def check_guac_update(): ok, out = run(['docker', 'pull', '--quiet', f'guacamole/guacamole:{get_guac_version()}'], timeout=60) return ok def get_selfsigned_info(): crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt') if not os.path.exists(crt): return None ok, out = run(['openssl', 'x509', '-noout', '-dates', '-subject', '-in', crt]) return out if ok else None # ── stranica – ažuriranja ───────────────────────────────────────────────────── @app.route('/updates') @login_required def updates(): versions = { 'guacamole': get_guac_version(), 'nginx': get_nginx_version(), 'docker': get_docker_version(), 'fail2ban': get_fail2ban_version(), 'os': platform.freedesktop_os_release().get('PRETTY_NAME', platform.system()), } pkg = detect_pkg_manager() return render_template('updates.html', versions=versions, pkg=pkg) @app.route('/api/update/guacamole', methods=['POST']) @api_auth def api_update_guacamole(): version = get_guac_version() images = [f'guacamole/guacamole:{version}', f'guacamole/guacd:{version}', 'postgres:15-alpine'] def generate(): yield f'data: {json.dumps("Preuzimanje novih Docker image-a...")}\n\n' for img in images: yield f'data: {json.dumps(f" → docker pull {img}")}\n\n' proc = subprocess.Popen(['docker', 'pull', img], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: l = line.strip() if l: yield f'data: {json.dumps(" " + l)}\n\n' proc.wait() yield f'data: {json.dumps("Restartanje kontejnera...")}\n\n' proc = subprocess.Popen( COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', 'up', '-d', '--force-recreate'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: l = line.strip() if l: yield f'data: {json.dumps(l)}\n\n' proc.wait() yield f'data: {json.dumps("✓ Guacamole ažuriran")}\n\n' yield 'data: __DONE__\n\n' return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @app.route('/api/update/nginx', methods=['POST']) @api_auth def api_update_nginx(): def generate(): pkg = detect_pkg_manager() cmds = { 'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'nginx'], 'dnf': ['dnf', 'upgrade', '-y', 'nginx'], 'yum': ['yum', 'upgrade', '-y', 'nginx'], 'pacman': ['pacman', '-S', '--noconfirm', 'nginx'], 'zypper': ['zypper', 'update', '-y', 'nginx'], } if pkg not in cmds: yield f'data: {json.dumps("Nepoznati package manager")}\n\n' yield 'data: __DONE__\n\n' return yield f'data: {json.dumps(f"Ažuriranje nginx ({pkg})...")}\n\n' proc = subprocess.Popen(cmds[pkg], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: l = line.strip() if l: yield f'data: {json.dumps(l)}\n\n' proc.wait() subprocess.run(['systemctl', 'reload', 'nginx'], capture_output=True) yield f'data: {json.dumps("✓ Nginx ažuriran i reloadiran")}\n\n' yield 'data: __DONE__\n\n' return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @app.route('/api/update/system', methods=['POST']) @api_auth def api_update_system(): def generate(): pkg = detect_pkg_manager() cmds = { 'apt': [['apt-get', 'update', '-y'], ['apt-get', 'upgrade', '-y', '--with-new-pkgs']], 'dnf': [['dnf', 'upgrade', '-y']], 'yum': [['yum', 'upgrade', '-y']], 'pacman': [['pacman', '-Syu', '--noconfirm']], 'zypper': [['zypper', 'update', '-y']], } if pkg not in cmds: yield f'data: {json.dumps("Nepoznati package manager")}\n\n' yield 'data: __DONE__\n\n' return yield f'data: {json.dumps(f"Ažuriranje sistema ({pkg})...")}\n\n' for cmd in cmds[pkg]: yield f'data: {json.dumps("$ " + " ".join(cmd))}\n\n' proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: l = line.strip() if l: yield f'data: {json.dumps(l)}\n\n' proc.wait() yield f'data: {json.dumps("✓ Sistemske nadogradnje završene")}\n\n' yield 'data: __DONE__\n\n' return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @app.route('/api/update/fail2ban', methods=['POST']) @api_auth def api_update_fail2ban(): pkg = detect_pkg_manager() cmds = { 'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'fail2ban'], 'dnf': ['dnf', 'upgrade', '-y', 'fail2ban'], 'yum': ['yum', 'upgrade', '-y', 'fail2ban'], 'pacman': ['pacman', '-S', '--noconfirm', 'fail2ban'], 'zypper': ['zypper', 'update', '-y', 'fail2ban'], } ok, out = run(cmds.get(pkg, ['echo', 'Nepoznati package manager']), timeout=120) if ok: run(['systemctl', 'restart', 'fail2ban']) return jsonify(ok=ok, msg=out) # ── stranica – SSL (prošireno: self-signed) ─────────────────────────────────── @app.route('/api/ssl/selfsigned', methods=['POST']) @api_auth def api_ssl_selfsigned(): domain = get_domain() os.makedirs(SELFSIGNED_DIR, exist_ok=True) crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt') key = os.path.join(SELFSIGNED_DIR, 'selfsigned.key') san = f'IP:{domain}' if re.match(r'^\d+\.\d+\.\d+\.\d+$', domain) else f'DNS:{domain}' ok, out = run([ 'openssl', 'req', '-x509', '-nodes', '-days', '3650', '-newkey', 'rsa:2048', '-keyout', key, '-out', crt, '-subj', f'/CN={domain}/O=Guacamole/C=HR', '-addext', f'subjectAltName={san}' ]) if not ok: return jsonify(ok=False, msg=out) os.chmod(key, 0o600) conf = get_nginx_conf() if conf: with open(conf) as f: txt = f.read() # Dodaj ili zamijeni SSL blok if 'ssl_certificate' not in txt: ssl_block = ( f' ssl_certificate {crt};\n' f' ssl_certificate_key {key};\n' f' ssl_protocols TLSv1.2 TLSv1.3;\n' f' ssl_ciphers HIGH:!aNULL:!MD5;\n\n' ) txt = txt.replace('listen 80;', 'listen 443 ssl;') txt = re.sub(r'(server_name\s+[^;]+;)', r'\1\n\n' + ssl_block, txt) # Dodaj HTTP redirect server blok ispred redirect = (f'server {{\n listen 80;\n server_name {domain};\n' f' return 301 https://$host$request_uri;\n}}\n\n') txt = redirect + txt with open(conf, 'w') as f: f.write(txt) ok2, out2 = nginx_reload() if not ok2: return jsonify(ok=False, msg=f'Nginx greška: {out2}') return jsonify(ok=True, msg=f'Self-signed certifikat kreiran za {domain} (vrijedi 10 god)') @app.route('/api/ssl/selfsigned_info') @api_auth def api_ssl_selfsigned_info(): info = get_selfsigned_info() return jsonify(ok=bool(info), info=info or 'Nema self-signed certifikata') # ── stranica – backup ───────────────────────────────────────────────────────── @app.route('/backup') @login_required def backup(): backups = sorted( [f for f in os.listdir('/root') if f.startswith('guacamole_backup_') and f.endswith('.tar.gz')], reverse=True ) if os.path.exists('/root') else [] return render_template('backup.html', backups=backups) @app.route('/api/backup/create', methods=['POST']) @api_auth def api_backup_create(): import datetime ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') dest = f'/root/guacamole_backup_{ts}.tar.gz' run(['docker', 'exec', 'guac_postgres', 'pg_dump', '-U', 'guacamole_user', 'guacamole_db'], timeout=60) ok, out = run(['bash', '-c', f'docker exec guac_postgres pg_dump -U guacamole_user guacamole_db ' f'> /tmp/guacamole_db.sql 2>&1 && ' f'tar -czf {dest} -C / --ignore-failed-read ' f'opt/guacamole etc/nginx/.guac_htpasswd ' f'etc/nginx/conf.d/guacamole-maps.conf ' f'etc/nginx/conf.d/guacamole-geoip.conf ' f'etc/nginx/conf.d/guacamole.conf ' f'etc/nginx/sites-available/guacamole ' f'etc/fail2ban/jail.d/guacamole.conf ' f'etc/fail2ban/filter.d/nginx-basicauth.conf ' f'etc/fail2ban/filter.d/nginx-guacamole.conf ' f'etc/ssl/guacamole ' f'tmp/guacamole_db.sql 2>/dev/null; ' f'rm -f /tmp/guacamole_db.sql' ], timeout=120) if ok and os.path.exists(dest): os.chmod(dest, 0o600) size = os.path.getsize(dest) return jsonify(ok=True, msg=f'Backup kreiran: {os.path.basename(dest)} ({size//1024} KB)') return jsonify(ok=False, msg=out or 'Backup nije uspio') @app.route('/api/backup/delete', methods=['POST']) @api_auth def api_backup_delete(): name = request.json.get('name', '') if not re.match(r'^guacamole_backup_\d{8}_\d{6}\.tar\.gz$', name): return jsonify(ok=False, msg='Nevažeći naziv backupa') path = f'/root/{name}' if os.path.exists(path): os.remove(path) return jsonify(ok=True, msg='Backup obrisan') return jsonify(ok=False, msg='Backup nije pronađen') # ── Live logs (SSE) ─────────────────────────────────────────────────────────── @app.route('/api/logs/') @login_required def api_logs(service): allowed = { 'guacamole': ['docker', 'logs', '-f', '--tail=100', 'guacamole'], 'guacd': ['docker', 'logs', '-f', '--tail=100', 'guacd'], 'postgres': ['docker', 'logs', '-f', '--tail=100', 'guac_postgres'], 'nginx': ['tail', '-f', '/var/log/nginx/access.log'], 'nginx-error': ['tail', '-f', '/var/log/nginx/error.log'], 'fail2ban': ['journalctl', '-fu', 'fail2ban', '-n', '100'], } if service not in allowed: return jsonify(ok=False, msg='Nevažeći servis'), 400 def generate(): proc = subprocess.Popen(allowed[service], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) try: for line in proc.stdout: yield f'data: {json.dumps(line.rstrip())}\n\n' finally: proc.terminate() return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) # ── main ────────────────────────────────────────────────────────────────────── if __name__ == '__main__': port = int(os.environ.get('PORT', 5555)) app.run(host='127.0.0.1', port=port, debug=False)