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>
This commit is contained in:
+116
-1
@@ -129,6 +129,73 @@ def htpasswd_users():
|
|||||||
with open(HTPASSWD) as f:
|
with open(HTPASSWD) as f:
|
||||||
return [line.split(':')[0] for line in f if ':' in line]
|
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():
|
def ssl_active():
|
||||||
conf = get_nginx_conf()
|
conf = get_nginx_conf()
|
||||||
if conf and os.path.exists(conf):
|
if conf and os.path.exists(conf):
|
||||||
@@ -192,7 +259,9 @@ def dashboard():
|
|||||||
@app.route('/basicauth')
|
@app.route('/basicauth')
|
||||||
@login_required
|
@login_required
|
||||||
def basicauth():
|
def basicauth():
|
||||||
return render_template('basicauth.html', users=htpasswd_users())
|
return render_template('basicauth.html', users=htpasswd_users(),
|
||||||
|
auth_state=basicauth_state(),
|
||||||
|
auth_locations=BASICAUTH_LOCATIONS)
|
||||||
|
|
||||||
@app.route('/geoip')
|
@app.route('/geoip')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -300,11 +369,57 @@ def api_ba_update():
|
|||||||
@api_auth
|
@api_auth
|
||||||
def api_ba_delete():
|
def api_ba_delete():
|
||||||
user = request.json.get('username', '').strip()
|
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])
|
ok, out = run(['htpasswd', '-D', HTPASSWD, user])
|
||||||
if ok:
|
if ok:
|
||||||
nginx_reload()
|
nginx_reload()
|
||||||
return jsonify(ok=ok, msg=out or 'Korisnik uklonjen')
|
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 ───────────────────────────────────────────────────────────────
|
# ── API – GeoIP ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _write_geoip_conf(countries):
|
def _write_geoip_conf(countries):
|
||||||
|
|||||||
@@ -4,6 +4,40 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4"><i class="bi bi-person-lock me-2"></i>Basic Auth korisnici</h4>
|
<h4 class="mb-4"><i class="bi bi-person-lock me-2"></i>Basic Auth korisnici</h4>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i> Basic Auth zaštita
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for loc, label in auth_locations.items() %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center
|
||||||
|
{% if not loop.last %}mb-3 pb-3 border-bottom{% endif %}"
|
||||||
|
style="border-color:#30363d !important">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ label }}</div>
|
||||||
|
<small class="text-muted"><code>{{ loc }}</code></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
{% if auth_state[loc] is none %}
|
||||||
|
<span class="badge bg-secondary">nije konfiguriran</span>
|
||||||
|
{% else %}
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch"
|
||||||
|
id="sw-{{ loop.index }}" {% if auth_state[loc] %}checked{% endif %}
|
||||||
|
onchange="toggleAuth('{{ loc }}', this)">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="alert alert-warning mt-3 mb-0 py-2 small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Isključite li zaštitu, na toj lokaciji nestaje prvi sloj obrane i
|
||||||
|
<strong>fail2ban jail <code>nginx-basicauth</code> ostaje bez posla</strong>.
|
||||||
|
Za <code>/guacadmin/</code> to znači da je ovaj panel izložen samo
|
||||||
|
s vlastitom prijavom.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- Lista korisnika -->
|
<!-- Lista korisnika -->
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
@@ -112,6 +146,21 @@ async function updateUser() {
|
|||||||
showToast(r.msg, r.ok);
|
showToast(r.msg, r.ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAuth(location, el) {
|
||||||
|
const enabled = el.checked;
|
||||||
|
if (!enabled && !confirm(
|
||||||
|
`Isključiti Basic Auth za ${location}?\n\n` +
|
||||||
|
`Nakon toga se toj lokaciji pristupa bez prompta.`)) {
|
||||||
|
el.checked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.disabled = true;
|
||||||
|
const r = await api('/api/basicauth/toggle', {location, enabled});
|
||||||
|
showToast(r.msg, r.ok);
|
||||||
|
if (!r.ok) el.checked = !enabled; // odbijeno — vrati prekidac
|
||||||
|
el.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUser(username) {
|
async function deleteUser(username) {
|
||||||
if (!confirm(`Ukloniti korisnika "${username}"?`)) return;
|
if (!confirm(`Ukloniti korisnika "${username}"?`)) return;
|
||||||
const r = await api('/api/basicauth/delete', {username});
|
const r = await api('/api/basicauth/delete', {username});
|
||||||
|
|||||||
Reference in New Issue
Block a user