Files
GuacPanel/webgui/app.py
T
njomac 1a213f807f feat: Basic Auth se moze ukljuciti/iskljuciti iz Web GUI-ja
Panel je dosad znao samo dodavati i brisati htpasswd korisnike, a sama
auth_basic direktiva se mogla dirati jedino rucno u nginx konfiguraciji.

Dodan prekidac po lokaciji — /guacamole/ i /guacadmin/ su odvojeni, pa
se zastita moze skinuti sa samo jedne. Stanje se cita iz konfiguracije,
ne pamti se zasebno, pa GUI uvijek pokazuje ono sto nginx stvarno radi.

Sigurnosne kocnice:
- Nakon izmjene ide nginx -t; ako padne, konfiguracija se vraca na staro
  prije nego se reload uopce dogodi.
- Ne moze se ukljuciti zastita ako nema nijednog htpasswd korisnika —
  to bi zakljucalo sve.
- Ne moze se obrisati zadnji korisnik dok je Basic Auth aktivan. Ta rupa
  je postojala i prije: brisanje zadnjeg korisnika ostavljalo je prazan
  htpasswd uz aktivan auth_basic, sto zakljuca i Guacamole i sam panel.

Parsiranje ide po location bloku s brojanjem viticastih zagrada, ne
regexom preko cijele datoteke, da se ne pogodi kriva lokacija.

Provjereno na zivoj instalaciji: iskljucenje na /guacamole/ daje 200 bez
prijave dok /guacadmin/ ostaje na 401; ponovno ukljucenje vraca 401 i
konfiguracija je bajt-identicna originalu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:26 +02:00

958 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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))
class PrefixMiddleware:
"""Nginx proxira GUI_PATH (npr. /guacadmin/) na Flask koji slusa na
rootu. Bez SCRIPT_NAME-a url_for i redirecti generiraju putanje izvan
prefiksa (/login umjesto /guacadmin/login) i nginx vraca 404."""
def __init__(self, wsgi_app, prefix=''):
self.wsgi_app = wsgi_app
prefix = (prefix or '').strip('/')
self.prefix = '/' + prefix if prefix else ''
def __call__(self, environ, start_response):
if self.prefix:
environ['SCRIPT_NAME'] = self.prefix
path = environ.get('PATH_INFO', '')
if path.startswith(self.prefix):
environ['PATH_INFO'] = path[len(self.prefix):] or '/'
return self.wsgi_app(environ, start_response)
app.wsgi_app = PrefixMiddleware(app.wsgi_app, os.environ.get('GUI_PATH', ''))
# ── 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]
# ── Basic Auth prekidac ───────────────────────────────────────────────────────
# Lokacije kojima se Basic Auth moze zasebno ukljuciti/iskljuciti.
BASICAUTH_LOCATIONS = {
'/guacamole/': 'Guacamole',
'/guacadmin/': 'Web GUI',
}
_AUTH_LINE = re.compile(
r'^(?P<indent>[ \t]*)(?P<off>#[ \t]*)?'
r'(?P<directive>auth_basic(?:_user_file)?[ \t]+[^\n]*)$'
)
def _location_span(txt, location):
"""Vraca (start, end) tijela location bloka, ili None ako ga nema."""
m = re.search(r'^[ \t]*location[ \t]+' + re.escape(location) + r'[ \t]*\{',
txt, re.MULTILINE)
if not m:
return None
depth, i = 1, m.end()
while i < len(txt) and depth:
if txt[i] == '{':
depth += 1
elif txt[i] == '}':
depth -= 1
i += 1
return (m.end(), i - 1)
def basicauth_state(txt=None):
"""Po lokaciji: True aktivan, False zakomentiran, None nema direktive."""
if txt is None:
conf = get_nginx_conf()
if not conf:
return {loc: None for loc in BASICAUTH_LOCATIONS}
with open(conf) as f:
txt = f.read()
state = {}
for loc in BASICAUTH_LOCATIONS:
span = _location_span(txt, loc)
if not span:
state[loc] = None
continue
body = txt[span[0]:span[1]]
found = [m for m in (_AUTH_LINE.match(l) for l in body.split('\n')) if m]
state[loc] = None if not found else not any(m.group('off') for m in found)
return state
def set_basicauth(txt, location, enabled):
span = _location_span(txt, location)
if not span:
raise ValueError('Lokacija %s nije pronadena u nginx konfiguraciji' % location)
head, body, tail = txt[:span[0]], txt[span[0]:span[1]], txt[span[1]:]
out = []
for line in body.split('\n'):
m = _AUTH_LINE.match(line)
if m:
if enabled:
line = m.group('indent') + m.group('directive')
elif not m.group('off'):
line = m.group('indent') + '# ' + m.group('directive')
out.append(line)
return head + '\n'.join(out) + tail
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
# 'next' je PATH_INFO bez prefiksa; bez script_roota redirect
# izlazi izvan GUI_PATH-a i korisnik zavrsi na Guacamole loginu.
target = request.args.get('next') or ''
if not target.startswith('/') or target.startswith('//'):
target = ''
return redirect(request.script_root + target if target
else 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(),
auth_state=basicauth_state(),
auth_locations=BASICAUTH_LOCATIONS)
@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()
# Prazan htpasswd uz aktivan auth_basic znaci da nitko vise ne moze
# proci — ni kroz Guacamole ni kroz ovaj panel. Vadilo bi se preko SSH-a.
remaining = [u for u in htpasswd_users() if u != user]
if not remaining and any(basicauth_state().values()):
return jsonify(ok=False, msg='Ovo je zadnji korisnik, a Basic Auth je '
'jos aktivan — prvo ga iskljucite')
ok, out = run(['htpasswd', '-D', HTPASSWD, user])
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out or 'Korisnik uklonjen')
@app.route('/api/basicauth/toggle', methods=['POST'])
@api_auth
def api_ba_toggle():
location = request.json.get('location', '')
enabled = bool(request.json.get('enabled'))
if location not in BASICAUTH_LOCATIONS:
return jsonify(ok=False, msg='Nepoznata lokacija')
conf = get_nginx_conf()
if not conf:
return jsonify(ok=False, msg='Nginx konfiguracija nije pronadena')
if enabled and not htpasswd_users():
return jsonify(ok=False, msg='Nema nijednog korisnika — prvo dodajte '
'korisnika, inace nitko ne moze proci')
with open(conf) as f:
original = f.read()
try:
updated = set_basicauth(original, location, enabled)
except ValueError as e:
return jsonify(ok=False, msg=str(e))
with open(conf, 'w') as f:
f.write(updated)
# Ako nginx odbije novu konfiguraciju, vrati staru da server ostane ziv.
ok, out = nginx_reload()
if not ok:
with open(conf, 'w') as f:
f.write(original)
return jsonify(ok=False, msg='Nginx je odbio konfiguraciju, vraceno '
'na staro: ' + out)
what = BASICAUTH_LOCATIONS[location]
return jsonify(ok=True, state=basicauth_state(),
msg='Basic Auth %s za %s' %
('ukljucen' if enabled else 'iskljucen', what))
# ── API – GeoIP ───────────────────────────────────────────────────────────────
def _write_geoip_conf(countries):
entries = ''.join(f' {c} 1;\n' for c in countries)
with open(GEOIP_CONF, 'w') as f:
f.write(f"""geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {{
$geoip2_data_country_code country iso_code;
}}
map $geoip2_data_country_code $allowed_country {{
default 0;
{entries}}}
""")
def _apply_geoip_nginx():
conf = get_nginx_conf()
if not conf:
return
with open(conf) as f:
txt = f.read()
txt = re.sub(r'\s*if \(\$allowed_country = 0\).*?\n', '', txt)
txt = re.sub(
r'(auth_basic\s+"Restricted[^"]*";)',
r'if ($allowed_country = 0) { return 403; }\n \1',
txt
)
with open(conf, 'w') as f:
f.write(txt)
def _remove_geoip_nginx():
conf = get_nginx_conf()
if not conf:
return
with open(conf) as f:
txt = f.read()
txt = re.sub(r'[ \t]*if \(\$allowed_country = 0\)[^\n]*\n', '', txt)
with open(conf, 'w') as f:
f.write(txt)
@app.route('/api/geoip/enable', methods=['POST'])
@api_auth
def api_geoip_enable():
data = request.json
mm_id = data.get('mm_id', '').strip()
mm_key = data.get('mm_key', '').strip()
countries = [c.upper() for c in data.get('countries', '').split() if c]
if not (mm_id and mm_key and countries):
return jsonify(ok=False, msg='Popunite sva polja')
# Instaliraj GeoIP paket ako treba
if not shutil.which('geoipupdate'):
ok, out = run(['apt-get', 'install', '-y', 'geoipupdate',
'libnginx-mod-http-geoip2'])
if not ok:
return jsonify(ok=False, msg=f'Instalacija paketa: {out}')
# MaxMind config
with open(GEOIP_MMCONF, 'w') as f:
f.write(f"AccountID {mm_id}\nLicenseKey {mm_key}\n"
f"EditionIDs GeoLite2-Country\n"
f"DatabaseDirectory /usr/share/GeoIP\n")
ok, out = run(['geoipupdate'])
if not ok:
return jsonify(ok=False, msg=f'geoipupdate: {out}')
_write_geoip_conf(countries)
_apply_geoip_nginx()
nginx_reload()
return jsonify(ok=True, msg='GeoIP aktiviran')
@app.route('/api/geoip/update', methods=['POST'])
@api_auth
def api_geoip_update():
countries = [c.upper() for c in
request.json.get('countries', '').split() if c]
if not countries:
return jsonify(ok=False, msg='Unesite barem jednu zemlju')
_write_geoip_conf(countries)
ok, out = nginx_reload()
return jsonify(ok=ok, msg='Ažurirane dozvoljene države' if ok else out)
@app.route('/api/geoip/disable', methods=['POST'])
@api_auth
def api_geoip_disable():
if os.path.exists(GEOIP_CONF):
os.remove(GEOIP_CONF)
_remove_geoip_nginx()
nginx_reload()
return jsonify(ok=True, msg='GeoIP onemogućen')
@app.route('/api/geoip/refresh', methods=['POST'])
@api_auth
def api_geoip_refresh():
ok, out = run(['geoipupdate'])
return jsonify(ok=ok, msg=out)
# ── API – fail2ban ────────────────────────────────────────────────────────────
@app.route('/api/fail2ban/unban', methods=['POST'])
@api_auth
def api_unban():
ip = request.json.get('ip', '').strip()
jail = request.json.get('jail', 'nginx-basicauth')
if not re.match(r'^[\d.:a-fA-F]+$', ip):
return jsonify(ok=False, msg='Nevažeća IP adresa')
ok, out = run(['fail2ban-client', 'set', jail, 'unbanip', ip])
return jsonify(ok=ok, msg=out or f'{ip} odbanjivan')
@app.route('/api/fail2ban/ban', methods=['POST'])
@api_auth
def api_ban():
ip = request.json.get('ip', '').strip()
jail = request.json.get('jail', 'nginx-basicauth')
if not re.match(r'^[\d.:a-fA-F]+$', ip):
return jsonify(ok=False, msg='Nevažeća IP adresa')
ok, out = run(['fail2ban-client', 'set', jail, 'banip', ip])
return jsonify(ok=ok, msg=out or f'{ip} baniran')
@app.route('/api/fail2ban/settings', methods=['POST'])
@api_auth
def api_fail2ban_settings():
maxretry = request.json.get('maxretry', '')
bantime = request.json.get('bantime', '')
if not (maxretry.isdigit() and bantime.lstrip('-').isdigit()):
return jsonify(ok=False, msg='Nevažeće vrijednosti')
if os.path.exists(FAIL2BAN_CFG):
with open(FAIL2BAN_CFG) as f:
txt = f.read()
txt = re.sub(r'maxretry\s*=\s*\d+', f'maxretry = {maxretry}', txt)
txt = re.sub(r'bantime\s*=\s*\d+', f'bantime = {bantime}', txt)
with open(FAIL2BAN_CFG, 'w') as f:
f.write(txt)
ok, out = run(['systemctl', 'restart', 'fail2ban'])
return jsonify(ok=ok, msg='Postavke ažurirane' if ok else out)
# ── API Guacamole containers ────────────────────────────────────────────────
@app.route('/api/guacamole/<action>', methods=['POST'])
@api_auth
def api_guacamole(action):
if action not in ('start', 'stop', 'restart'):
return jsonify(ok=False, msg='Nevažeća akcija')
ok, out = run(COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', action],
timeout=60)
return jsonify(ok=ok, msg=out or action.capitalize())
# ── API – SSL ─────────────────────────────────────────────────────────────────
@app.route('/api/ssl/enable', methods=['POST'])
@api_auth
def api_ssl_enable():
domain = request.json.get('domain', '').strip()
email = request.json.get('email', '').strip()
if not (domain and email):
return jsonify(ok=False, msg='Domena i email su obavezni')
if not shutil.which('certbot'):
ok, out = run(['apt-get', 'install', '-y', 'certbot',
'python3-certbot-nginx'])
if not ok:
return jsonify(ok=False, msg=f'Instalacija certbota: {out}')
ok, out = run(['certbot', '--nginx', '-d', domain,
'--email', email, '--agree-tos',
'--non-interactive', '--redirect'], timeout=120)
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out)
@app.route('/api/ssl/renew', methods=['POST'])
@api_auth
def api_ssl_renew():
ok, out = run(['certbot', 'renew', '--force-renewal'], timeout=120)
if ok:
nginx_reload()
return jsonify(ok=ok, msg=out)
# ── API – Nginx ───────────────────────────────────────────────────────────────
@app.route('/api/nginx/reload', methods=['POST'])
@api_auth
def api_nginx_reload():
ok, out = nginx_reload()
return jsonify(ok=ok, msg=out or 'Nginx reloadiran')
# ── API – Settings ────────────────────────────────────────────────────────────
@app.route('/api/settings/password', methods=['POST'])
@api_auth
def api_change_password():
current = request.json.get('current', '')
new_pw = request.json.get('new_password', '')
cfg = load_config()
if cfg['password'] != current:
return jsonify(ok=False, msg='Pogrešna trenutna lozinka')
if len(new_pw) < 8:
return jsonify(ok=False, msg='Nova lozinka mora imati najmanje 8 znakova')
cfg['password'] = new_pw
save_config(cfg)
return jsonify(ok=True, msg='Lozinka promijenjena')
# ── helpers verzije i update ───────────────────────────────────────────────
def detect_pkg_manager():
for pm, cmd in [('apt', 'apt-get'), ('dnf', 'dnf'), ('yum', 'yum'),
('pacman', 'pacman'), ('zypper', 'zypper')]:
if shutil.which(cmd):
return pm
return 'unknown'
def get_guac_version():
ok, out = run(['docker', 'inspect', '--format', '{{.Config.Image}}', 'guacamole'])
if ok and ':' in out:
return out.strip().split(':')[-1]
return 'nepoznato'
def get_nginx_version():
ok, out = run(['nginx', '-v'])
m = re.search(r'nginx/(\S+)', out)
return m.group(1) if m else 'nepoznato'
def get_docker_version():
ok, out = run(['docker', '--version'])
m = re.search(r'version\s+(\S+)', out, re.I)
return m.group(1).rstrip(',') if m else 'nepoznato'
def get_fail2ban_version():
ok, out = run(['fail2ban-client', '--version'])
m = re.search(r'(\d+\.\d+[\.\d]*)', out)
return m.group(1) if m else 'nepoznato'
def check_guac_update():
ok, out = run(['docker', 'pull', '--quiet',
f'guacamole/guacamole:{get_guac_version()}'], timeout=60)
return ok
def get_selfsigned_info():
crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt')
if not os.path.exists(crt):
return None
ok, out = run(['openssl', 'x509', '-noout', '-dates', '-subject', '-in', crt])
return out if ok else None
# ── stranica ažuriranja ─────────────────────────────────────────────────────
@app.route('/updates')
@login_required
def updates():
versions = {
'guacamole': get_guac_version(),
'nginx': get_nginx_version(),
'docker': get_docker_version(),
'fail2ban': get_fail2ban_version(),
'os': platform.freedesktop_os_release().get('PRETTY_NAME', platform.system()),
}
pkg = detect_pkg_manager()
return render_template('updates.html', versions=versions, pkg=pkg)
@app.route('/api/update/guacamole', methods=['POST'])
@api_auth
def api_update_guacamole():
version = get_guac_version()
images = [f'guacamole/guacamole:{version}',
f'guacamole/guacd:{version}',
'postgres:15-alpine']
def generate():
yield f'data: {json.dumps("Preuzimanje novih Docker image-a...")}\n\n'
for img in images:
yield f'data: {json.dumps(f" → docker pull {img}")}\n\n'
proc = subprocess.Popen(['docker', 'pull', img],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
l = line.strip()
if l:
yield f'data: {json.dumps(" " + l)}\n\n'
proc.wait()
yield f'data: {json.dumps("Restartanje kontejnera...")}\n\n'
proc = subprocess.Popen(
COMPOSE + ['-f', f'{INSTALL_DIR}/docker-compose.yml', 'up', '-d', '--force-recreate'],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
l = line.strip()
if l:
yield f'data: {json.dumps(l)}\n\n'
proc.wait()
yield f'data: {json.dumps("✓ Guacamole ažuriran")}\n\n'
yield 'data: __DONE__\n\n'
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/update/nginx', methods=['POST'])
@api_auth
def api_update_nginx():
def generate():
pkg = detect_pkg_manager()
cmds = {
'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'nginx'],
'dnf': ['dnf', 'upgrade', '-y', 'nginx'],
'yum': ['yum', 'upgrade', '-y', 'nginx'],
'pacman': ['pacman', '-S', '--noconfirm', 'nginx'],
'zypper': ['zypper', 'update', '-y', 'nginx'],
}
if pkg not in cmds:
yield f'data: {json.dumps("Nepoznati package manager")}\n\n'
yield 'data: __DONE__\n\n'
return
yield f'data: {json.dumps(f"Ažuriranje nginx ({pkg})...")}\n\n'
proc = subprocess.Popen(cmds[pkg], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
l = line.strip()
if l:
yield f'data: {json.dumps(l)}\n\n'
proc.wait()
subprocess.run(['systemctl', 'reload', 'nginx'], capture_output=True)
yield f'data: {json.dumps("✓ Nginx ažuriran i reloadiran")}\n\n'
yield 'data: __DONE__\n\n'
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/update/system', methods=['POST'])
@api_auth
def api_update_system():
def generate():
pkg = detect_pkg_manager()
cmds = {
'apt': [['apt-get', 'update', '-y'],
['apt-get', 'upgrade', '-y', '--with-new-pkgs']],
'dnf': [['dnf', 'upgrade', '-y']],
'yum': [['yum', 'upgrade', '-y']],
'pacman': [['pacman', '-Syu', '--noconfirm']],
'zypper': [['zypper', 'update', '-y']],
}
if pkg not in cmds:
yield f'data: {json.dumps("Nepoznati package manager")}\n\n'
yield 'data: __DONE__\n\n'
return
yield f'data: {json.dumps(f"Ažuriranje sistema ({pkg})...")}\n\n'
for cmd in cmds[pkg]:
yield f'data: {json.dumps("$ " + " ".join(cmd))}\n\n'
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
l = line.strip()
if l:
yield f'data: {json.dumps(l)}\n\n'
proc.wait()
yield f'data: {json.dumps("✓ Sistemske nadogradnje završene")}\n\n'
yield 'data: __DONE__\n\n'
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/update/fail2ban', methods=['POST'])
@api_auth
def api_update_fail2ban():
pkg = detect_pkg_manager()
cmds = {
'apt': ['apt-get', 'install', '--only-upgrade', '-y', 'fail2ban'],
'dnf': ['dnf', 'upgrade', '-y', 'fail2ban'],
'yum': ['yum', 'upgrade', '-y', 'fail2ban'],
'pacman': ['pacman', '-S', '--noconfirm', 'fail2ban'],
'zypper': ['zypper', 'update', '-y', 'fail2ban'],
}
ok, out = run(cmds.get(pkg, ['echo', 'Nepoznati package manager']), timeout=120)
if ok:
run(['systemctl', 'restart', 'fail2ban'])
return jsonify(ok=ok, msg=out)
# ── stranica SSL (prošireno: self-signed) ───────────────────────────────────
@app.route('/api/ssl/selfsigned', methods=['POST'])
@api_auth
def api_ssl_selfsigned():
domain = get_domain()
os.makedirs(SELFSIGNED_DIR, exist_ok=True)
crt = os.path.join(SELFSIGNED_DIR, 'selfsigned.crt')
key = os.path.join(SELFSIGNED_DIR, 'selfsigned.key')
san = f'IP:{domain}' if re.match(r'^\d+\.\d+\.\d+\.\d+$', domain) else f'DNS:{domain}'
ok, out = run([
'openssl', 'req', '-x509', '-nodes', '-days', '3650',
'-newkey', 'rsa:2048', '-keyout', key, '-out', crt,
'-subj', f'/CN={domain}/O=Guacamole/C=HR',
'-addext', f'subjectAltName={san}'
])
if not ok:
return jsonify(ok=False, msg=out)
os.chmod(key, 0o600)
conf = get_nginx_conf()
if conf:
with open(conf) as f:
txt = f.read()
# Dodaj ili zamijeni SSL blok
if 'ssl_certificate' not in txt:
ssl_block = (
f' ssl_certificate {crt};\n'
f' ssl_certificate_key {key};\n'
f' ssl_protocols TLSv1.2 TLSv1.3;\n'
f' ssl_ciphers HIGH:!aNULL:!MD5;\n\n'
)
txt = txt.replace('listen 80;', 'listen 443 ssl;')
txt = re.sub(r'(server_name\s+[^;]+;)', r'\1\n\n' + ssl_block, txt)
# Dodaj HTTP redirect server blok ispred
redirect = (f'server {{\n listen 80;\n server_name {domain};\n'
f' return 301 https://$host$request_uri;\n}}\n\n')
txt = redirect + txt
with open(conf, 'w') as f:
f.write(txt)
ok2, out2 = nginx_reload()
if not ok2:
return jsonify(ok=False, msg=f'Nginx greška: {out2}')
return jsonify(ok=True, msg=f'Self-signed certifikat kreiran za {domain} (vrijedi 10 god)')
@app.route('/api/ssl/selfsigned_info')
@api_auth
def api_ssl_selfsigned_info():
info = get_selfsigned_info()
return jsonify(ok=bool(info), info=info or 'Nema self-signed certifikata')
# ── stranica backup ─────────────────────────────────────────────────────────
@app.route('/backup')
@login_required
def backup():
backups = sorted(
[f for f in os.listdir('/root') if f.startswith('guacamole_backup_') and f.endswith('.tar.gz')],
reverse=True
) if os.path.exists('/root') else []
return render_template('backup.html', backups=backups)
@app.route('/api/backup/create', methods=['POST'])
@api_auth
def api_backup_create():
import datetime
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
dest = f'/root/guacamole_backup_{ts}.tar.gz'
run(['docker', 'exec', 'guac_postgres', 'pg_dump', '-U', 'guacamole_user',
'guacamole_db'], timeout=60)
ok, out = run(['bash', '-c',
f'docker exec guac_postgres pg_dump -U guacamole_user guacamole_db '
f'> /tmp/guacamole_db.sql 2>&1 && '
f'tar -czf {dest} -C / --ignore-failed-read '
f'opt/guacamole etc/nginx/.guac_htpasswd '
f'etc/nginx/conf.d/guacamole-maps.conf '
f'etc/nginx/conf.d/guacamole-geoip.conf '
f'etc/nginx/conf.d/guacamole.conf '
f'etc/nginx/sites-available/guacamole '
f'etc/fail2ban/jail.d/guacamole.conf '
f'etc/fail2ban/filter.d/nginx-basicauth.conf '
f'etc/fail2ban/filter.d/nginx-guacamole.conf '
f'etc/ssl/guacamole '
f'tmp/guacamole_db.sql 2>/dev/null; '
f'rm -f /tmp/guacamole_db.sql'
], timeout=120)
if ok and os.path.exists(dest):
os.chmod(dest, 0o600)
size = os.path.getsize(dest)
return jsonify(ok=True, msg=f'Backup kreiran: {os.path.basename(dest)} ({size//1024} KB)')
return jsonify(ok=False, msg=out or 'Backup nije uspio')
@app.route('/api/backup/delete', methods=['POST'])
@api_auth
def api_backup_delete():
name = request.json.get('name', '')
if not re.match(r'^guacamole_backup_\d{8}_\d{6}\.tar\.gz$', name):
return jsonify(ok=False, msg='Nevažeći naziv backupa')
path = f'/root/{name}'
if os.path.exists(path):
os.remove(path)
return jsonify(ok=True, msg='Backup obrisan')
return jsonify(ok=False, msg='Backup nije pronađen')
# ── Live logs (SSE) ───────────────────────────────────────────────────────────
@app.route('/api/logs/<service>')
@login_required
def api_logs(service):
allowed = {
'guacamole': ['docker', 'logs', '-f', '--tail=100', 'guacamole'],
'guacd': ['docker', 'logs', '-f', '--tail=100', 'guacd'],
'postgres': ['docker', 'logs', '-f', '--tail=100', 'guac_postgres'],
'nginx': ['tail', '-f', '/var/log/nginx/access.log'],
'nginx-error': ['tail', '-f', '/var/log/nginx/error.log'],
'fail2ban': ['journalctl', '-fu', 'fail2ban', '-n', '100'],
}
if service not in allowed:
return jsonify(ok=False, msg='Nevažeći servis'), 400
def generate():
proc = subprocess.Popen(allowed[service], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
for line in proc.stdout:
yield f'data: {json.dumps(line.rstrip())}\n\n'
finally:
proc.terminate()
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'})
# ── main ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5555))
app.run(host='127.0.0.1', port=port, debug=False)