Files
njomac 50c125e0db Initial commit — GuacPanel v1.0
All-in-one installer za Apache Guacamole s Nginx reverse proxy,
fail2ban, SSL (Let's Encrypt / Self-signed), GeoIP blokiranjem
i Flask web GUI za upravljanje.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:21:44 +02:00

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