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