first release

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-06-06 12:21:16 +02:00
parent ffc7876d17
commit 2542cf5455
13 changed files with 1037 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import sqlite3
import os
from werkzeug.security import generate_password_hash
DB_PATH = os.environ.get('DB_PATH', '/data/dyndns.db')
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
db = get_db()
db.executescript('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dyndns_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
subdomain TEXT UNIQUE NOT NULL,
current_ip TEXT,
last_updated TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS update_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dyndns_user_id INTEGER NOT NULL,
old_ip TEXT,
new_ip TEXT NOT NULL,
result TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (dyndns_user_id) REFERENCES dyndns_users(id)
);
''')
existing = db.execute('SELECT id FROM admin_users LIMIT 1').fetchone()
if not existing:
db.execute(
'INSERT INTO admin_users (username, password_hash) VALUES (?, ?)',
('admin', generate_password_hash('admin'))
)
db.commit()
db.close()
def get_setting(key, default=''):
db = get_db()
row = db.execute('SELECT value FROM settings WHERE key = ?', (key,)).fetchone()
db.close()
return row['value'] if row else default
def set_setting(key, value):
db = get_db()
db.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', (key, value))
db.commit()
db.close()
+318
View File
@@ -0,0 +1,318 @@
import os
from datetime import datetime
from functools import wraps
from flask import (Flask, Response, flash, redirect, render_template,
request, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from database import get_db, get_setting, init_db, set_setting
from plesk import test_connection, update_dns_record
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def login_required(f):
@wraps(f)
def wrapped(*args, **kwargs):
if 'admin_id' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return wrapped
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@app.route('/')
def index():
return redirect(url_for('dashboard') if 'admin_id' in session else url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
db = get_db()
admin = db.execute(
'SELECT * FROM admin_users WHERE username = ?', (username,)
).fetchone()
db.close()
if admin and check_password_hash(admin['password_hash'], password):
session['admin_id'] = admin['id']
session['admin_username'] = admin['username']
return redirect(url_for('dashboard'))
flash('Benutzername oder Passwort falsch.', 'danger')
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@app.route('/dashboard')
@login_required
def dashboard():
db = get_db()
users = db.execute('''
SELECT u.*,
(SELECT COUNT(*) FROM update_log l WHERE l.dyndns_user_id = u.id) AS update_count
FROM dyndns_users u
ORDER BY u.subdomain
''').fetchall()
logs = db.execute('''
SELECT l.*, u.subdomain, u.username AS dyndns_username
FROM update_log l
JOIN dyndns_users u ON l.dyndns_user_id = u.id
ORDER BY l.timestamp DESC
LIMIT 30
''').fetchall()
db.close()
base_domain = get_setting('plesk_base_domain')
return render_template('dashboard.html', users=users, logs=logs, base_domain=base_domain)
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@app.route('/settings')
@login_required
def settings():
return render_template('settings.html',
plesk_url=get_setting('plesk_url'),
plesk_api_key=get_setting('plesk_api_key'),
plesk_base_domain=get_setting('plesk_base_domain'),
plesk_verify_ssl=get_setting('plesk_verify_ssl', '1'),
)
@app.route('/settings/plesk', methods=['POST'])
@login_required
def settings_plesk():
plesk_url = request.form.get('plesk_url', '').strip().rstrip('/')
plesk_api_key = request.form.get('plesk_api_key', '').strip()
plesk_base_domain = request.form.get('plesk_base_domain', '').strip()
plesk_verify_ssl = '1' if request.form.get('plesk_verify_ssl') else '0'
set_setting('plesk_url', plesk_url)
set_setting('plesk_api_key', plesk_api_key)
set_setting('plesk_base_domain', plesk_base_domain)
set_setting('plesk_verify_ssl', plesk_verify_ssl)
if 'test_connection' in request.form:
try:
info = test_connection(plesk_url, plesk_api_key, verify_ssl=(plesk_verify_ssl == '1'))
ver = info.get('version', '?')
flash(f'Verbindung OK — Plesk {ver}', 'success')
except Exception as exc:
flash(f'Verbindungsfehler: {exc}', 'danger')
else:
flash('Plesk-Einstellungen gespeichert.', 'success')
return redirect(url_for('settings'))
@app.route('/settings/password', methods=['POST'])
@login_required
def settings_password():
current = request.form.get('current_password', '')
new_pw = request.form.get('new_password', '')
new_pw2 = request.form.get('new_password2', '')
db = get_db()
admin = db.execute('SELECT * FROM admin_users WHERE id = ?', (session['admin_id'],)).fetchone()
if not check_password_hash(admin['password_hash'], current):
flash('Aktuelles Passwort falsch.', 'danger')
elif new_pw != new_pw2:
flash('Neue Passwörter stimmen nicht überein.', 'danger')
elif len(new_pw) < 6:
flash('Mindestens 6 Zeichen erforderlich.', 'danger')
else:
db.execute('UPDATE admin_users SET password_hash = ? WHERE id = ?',
(generate_password_hash(new_pw), session['admin_id']))
db.commit()
flash('Passwort geändert.', 'success')
db.close()
return redirect(url_for('settings'))
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
@app.route('/users')
@login_required
def users():
db = get_db()
user_list = db.execute('SELECT * FROM dyndns_users ORDER BY subdomain').fetchall()
db.close()
base_domain = get_setting('plesk_base_domain')
return render_template('users.html', users=user_list, base_domain=base_domain)
@app.route('/users/add', methods=['POST'])
@login_required
def user_add():
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
subdomain = request.form.get('subdomain', '').strip().lower()
if not username or not password or not subdomain:
flash('Alle Felder müssen ausgefüllt sein.', 'danger')
return redirect(url_for('users'))
db = get_db()
try:
db.execute(
'INSERT INTO dyndns_users (username, password_hash, subdomain) VALUES (?, ?, ?)',
(username, generate_password_hash(password), subdomain),
)
db.commit()
flash(f'Benutzer "{username}" angelegt.', 'success')
except Exception as exc:
flash(f'Fehler: {exc}', 'danger')
finally:
db.close()
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/edit', methods=['POST'])
@login_required
def user_edit(user_id):
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
subdomain = request.form.get('subdomain', '').strip().lower()
db = get_db()
try:
if password:
db.execute(
'UPDATE dyndns_users SET username=?, password_hash=?, subdomain=? WHERE id=?',
(username, generate_password_hash(password), subdomain, user_id),
)
else:
db.execute(
'UPDATE dyndns_users SET username=?, subdomain=? WHERE id=?',
(username, subdomain, user_id),
)
db.commit()
flash('Benutzer aktualisiert.', 'success')
except Exception as exc:
flash(f'Fehler: {exc}', 'danger')
finally:
db.close()
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
def user_delete(user_id):
db = get_db()
db.execute('DELETE FROM update_log WHERE dyndns_user_id = ?', (user_id,))
db.execute('DELETE FROM dyndns_users WHERE id = ?', (user_id,))
db.commit()
db.close()
flash('Benutzer gelöscht.', 'success')
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/toggle', methods=['POST'])
@login_required
def user_toggle(user_id):
db = get_db()
db.execute('UPDATE dyndns_users SET active = 1 - active WHERE id = ?', (user_id,))
db.commit()
db.close()
return redirect(url_for('users'))
# ---------------------------------------------------------------------------
# DynDNS v2 update endpoint (Speedport "Anderer Anbieter")
# ---------------------------------------------------------------------------
@app.route('/nic/update')
def dyndns_update():
auth = request.authorization
if not auth:
return Response(
'badauth',
401,
{'WWW-Authenticate': 'Basic realm="DynDNS Update"'},
mimetype='text/plain',
)
myip = request.args.get('myip') or request.args.get('ip') or request.remote_addr
db = get_db()
user = db.execute(
'SELECT * FROM dyndns_users WHERE username = ? AND active = 1',
(auth.username,),
).fetchone()
if not user or not check_password_hash(user['password_hash'], auth.password):
db.close()
return Response('badauth', 401, mimetype='text/plain')
plesk_url = get_setting('plesk_url')
plesk_api_key = get_setting('plesk_api_key')
plesk_base_domain = get_setting('plesk_base_domain')
plesk_verify_ssl = get_setting('plesk_verify_ssl', '1') == '1'
if not plesk_url or not plesk_api_key or not plesk_base_domain:
db.close()
return Response('911', 500, mimetype='text/plain')
old_ip = user['current_ip']
if old_ip == myip:
db.close()
return Response(f'nochg {myip}', 200, mimetype='text/plain')
try:
update_dns_record(
plesk_url, plesk_api_key, plesk_base_domain,
user['subdomain'], myip, verify_ssl=plesk_verify_ssl,
)
db.execute(
'UPDATE dyndns_users SET current_ip=?, last_updated=? WHERE id=?',
(myip, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user['id']),
)
db.execute(
'INSERT INTO update_log (dyndns_user_id, old_ip, new_ip, result) VALUES (?,?,?,?)',
(user['id'], old_ip, myip, 'good'),
)
db.commit()
db.close()
return Response(f'good {myip}', 200, mimetype='text/plain')
except Exception as exc:
db.execute(
'INSERT INTO update_log (dyndns_user_id, old_ip, new_ip, result) VALUES (?,?,?,?)',
(user['id'], old_ip, myip, f'error: {exc}'),
)
db.commit()
db.close()
return Response('dnserr', 500, mimetype='text/plain')
# ---------------------------------------------------------------------------
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=False)
+68
View File
@@ -0,0 +1,68 @@
import requests
def _headers(api_key):
return {
'X-API-Key': api_key,
'Content-Type': 'application/json',
'Accept': 'application/json',
}
def test_connection(plesk_url, api_key, verify_ssl=True):
resp = requests.get(
f'{plesk_url.rstrip("/")}/api/v2/server',
headers=_headers(api_key),
verify=verify_ssl,
timeout=10,
)
resp.raise_for_status()
return resp.json()
def update_dns_record(plesk_url, api_key, domain, subdomain, ip, verify_ssl=True):
base = plesk_url.rstrip('/')
hdrs = _headers(api_key)
# Find existing A record for this subdomain
resp = requests.get(
f'{base}/api/v2/dns/records',
params={'domain_name': domain, 'type': 'A'},
headers=hdrs,
verify=verify_ssl,
timeout=15,
)
resp.raise_for_status()
records = resp.json()
# Match by host (Plesk returns just the subdomain part, e.g. "mypc")
host_lower = subdomain.lower().rstrip('.')
match = next(
(r for r in records if r.get('host', '').lower().rstrip('.') == host_lower),
None
)
if match:
resp = requests.put(
f'{base}/api/v2/dns/records/{match["id"]}',
json={'value': ip},
headers=hdrs,
verify=verify_ssl,
timeout=15,
)
resp.raise_for_status()
else:
resp = requests.post(
f'{base}/api/v2/dns/records',
json={
'domain_name': domain,
'type': 'A',
'host': subdomain,
'value': ip,
'ttl': 300,
},
headers=hdrs,
verify=verify_ssl,
timeout=15,
)
resp.raise_for_status()
+4
View File
@@ -0,0 +1,4 @@
flask==3.0.3
werkzeug==3.0.3
requests==2.32.3
gunicorn==22.0.0
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DynDNS Manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
:root { --sidebar-bg: #1e2a38; --sidebar-hover: #2d3f52; }
body { background: #f4f6f9; min-height: 100vh; }
#sidebar {
width: 220px; min-width: 220px; min-height: 100vh;
background: var(--sidebar-bg); color: #cdd6e0;
}
#sidebar .brand { color: #4fc3f7; font-weight: 700; font-size: 1.1rem; }
#sidebar .nav-link {
color: #b0bec5; border-radius: 6px; padding: .5rem .75rem;
margin-bottom: 2px; transition: background .15s;
}
#sidebar .nav-link:hover, #sidebar .nav-link.active {
background: var(--sidebar-hover); color: #fff;
}
#sidebar .nav-link i { width: 1.3em; }
.main-content { flex: 1; padding: 2rem; min-width: 0; }
.card { border: none; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.badge-ip { font-family: monospace; font-size: .85em; }
</style>
</head>
<body class="d-flex">
<nav id="sidebar" class="d-flex flex-column p-3">
<a href="{{ url_for('dashboard') }}" class="brand text-decoration-none mb-4 d-flex align-items-center gap-2">
<i class="bi bi-globe2 fs-5"></i> DynDNS
</a>
<div class="nav flex-column">
<a href="{{ url_for('dashboard') }}"
class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="{{ url_for('users') }}"
class="nav-link {% if request.endpoint in ['users'] %}active{% endif %}">
<i class="bi bi-people-fill"></i> Benutzer
</a>
<a href="{{ url_for('settings') }}"
class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}">
<i class="bi bi-gear-fill"></i> Einstellungen
</a>
</div>
<div class="mt-auto">
<hr style="border-color:#2d3f52">
<small class="text-secondary d-block mb-2 px-2">{{ session.admin_username }}</small>
<a href="{{ url_for('logout') }}" class="nav-link text-danger">
<i class="bi bi-box-arrow-left"></i> Abmelden
</a>
</div>
</nav>
<div class="main-content">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% block title %}Dashboard — DynDNS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-speedometer2 text-primary"></i> Dashboard</h4>
</div>
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card text-center p-3">
<div class="fs-2 fw-bold text-primary">{{ users|length }}</div>
<div class="text-muted small">Benutzer gesamt</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center p-3">
<div class="fs-2 fw-bold text-success">{{ users|selectattr('active', 'equalto', 1)|list|length }}</div>
<div class="text-muted small">Aktiv</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center p-3">
<div class="fs-2 fw-bold text-info">{{ users|sum(attribute='update_count') }}</div>
<div class="text-muted small">Updates gesamt</div>
</div>
</div>
</div>
<!-- User table -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Benutzer & aktuelle IPs</span>
<a href="{{ url_for('users') }}" class="btn btn-sm btn-outline-primary">Verwalten</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Subdomain</th>
<th>Hostname</th>
<th>Aktuelle IP</th>
<th>Letztes Update</th>
<th>Updates</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">{{ u.subdomain }}</td>
<td class="text-muted small">
{% if base_domain %}{{ u.subdomain }}.{{ base_domain }}{% else %}—{% endif %}
</td>
<td>
{% if u.current_ip %}
<span class="badge bg-secondary badge-ip">{{ u.current_ip }}</span>
{% else %}
<span class="text-muted">noch kein Update</span>
{% endif %}
</td>
<td class="text-muted small">{{ u.last_updated or '—' }}</td>
<td>{{ u.update_count }}</td>
<td>
{% if u.active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">Noch keine Benutzer angelegt.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Update log -->
<div class="card">
<div class="card-header fw-semibold">Letzte Updates</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Zeit</th>
<th>Benutzer</th>
<th>Alte IP</th>
<th>Neue IP</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{% for l in logs %}
<tr>
<td class="text-muted small">{{ l.timestamp }}</td>
<td>{{ l.subdomain }} <span class="text-muted small">({{ l.dyndns_username }})</span></td>
<td class="badge-ip text-muted small">{{ l.old_ip or '—' }}</td>
<td><span class="badge bg-secondary badge-ip">{{ l.new_ip }}</span></td>
<td>
{% if l.result == 'good' %}
<span class="badge bg-success">good</span>
{% else %}
<span class="badge bg-danger" title="{{ l.result }}">error</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">Keine Einträge.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — DynDNS Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<style>
body { background: #1e2a38; min-height: 100vh; display: flex; align-items: center; }
.login-card { width: 100%; max-width: 380px; }
</style>
</head>
<body class="justify-content-center">
<div class="login-card">
<div class="text-center mb-4">
<span class="text-info fs-2"><i class="bi bi-globe2"></i></span>
<h4 class="text-white mt-2 mb-0">DynDNS Manager</h4>
</div>
<div class="card shadow">
<div class="card-body p-4">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="alert alert-{{ cat }} py-2">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post">
<div class="mb-3">
<label class="form-label fw-semibold">Benutzername</label>
<input name="username" type="text" class="form-control" autofocus required>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Passwort</label>
<input name="password" type="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
</form>
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
{% extends 'base.html' %}
{% block title %}Einstellungen — DynDNS Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-gear-fill text-primary"></i> Einstellungen</h4>
<div class="row g-4">
<!-- Plesk config -->
<div class="col-lg-7">
<div class="card">
<div class="card-header fw-semibold">Plesk-Server</div>
<div class="card-body">
<form method="post" action="{{ url_for('settings_plesk') }}">
<div class="mb-3">
<label class="form-label">Plesk URL</label>
<input name="plesk_url" type="url" class="form-control font-monospace"
placeholder="https://plesk.example.com:8443"
value="{{ plesk_url }}" required>
<div class="form-text">Inkl. Port, ohne abschließenden Slash.</div>
</div>
<div class="mb-3">
<label class="form-label">API-Schlüssel</label>
<input name="plesk_api_key" type="password" class="form-control font-monospace"
placeholder="Plesk API Key" value="{{ plesk_api_key }}">
<div class="form-text">In Plesk: <em>Erweiterungen → API-Schlüssel</em> oder
<em>Admin → API-Schlüssel verwalten</em>.</div>
</div>
<div class="mb-3">
<label class="form-label">Basis-Domain</label>
<input name="plesk_base_domain" type="text" class="form-control font-monospace"
placeholder="example.com" value="{{ plesk_base_domain }}" required>
<div class="form-text">Domain die in Plesk verwaltet wird. Subdomains werden darunter angelegt.</div>
</div>
<div class="mb-4 form-check">
<input name="plesk_verify_ssl" type="checkbox" class="form-check-input" id="sslCheck"
{% if plesk_verify_ssl == '1' %}checked{% endif %}>
<label class="form-check-label" for="sslCheck">SSL-Zertifikat prüfen</label>
<div class="form-text">Deaktivieren bei selbstsignierten Zertifikaten.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Speichern
</button>
<button type="submit" name="test_connection" value="1" class="btn btn-outline-secondary">
<i class="bi bi-plug"></i> Verbindung testen
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Admin password -->
<div class="col-lg-5">
<div class="card">
<div class="card-header fw-semibold">Admin-Passwort ändern</div>
<div class="card-body">
<form method="post" action="{{ url_for('settings_password') }}">
<div class="mb-3">
<label class="form-label">Aktuelles Passwort</label>
<input name="current_password" type="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Neues Passwort</label>
<input name="new_password" type="password" class="form-control" minlength="6" required>
</div>
<div class="mb-4">
<label class="form-label">Neues Passwort (Wiederholung)</label>
<input name="new_password2" type="password" class="form-control" minlength="6" required>
</div>
<button type="submit" class="btn btn-warning">
<i class="bi bi-key"></i> Passwort ändern
</button>
</form>
</div>
</div>
<!-- Speedport hint -->
<div class="card mt-3 border-info">
<div class="card-header fw-semibold text-info">
<i class="bi bi-router"></i> Speedport-Konfiguration
</div>
<div class="card-body small">
<p class="mb-2"><strong>Anbieter:</strong> Anderer Anbieter</p>
<p class="mb-2"><strong>Hostname:</strong> <code>&lt;subdomain&gt;.{{ plesk_base_domain or 'domain.com' }}</code></p>
<p class="mb-2"><strong>Username:</strong> DynDNS-Benutzername</p>
<p class="mb-2"><strong>Passwort:</strong> DynDNS-Passwort</p>
<p class="mb-2"><strong>Updateserver:</strong> <code>&lt;deine-subdomain&gt;</code> (diese App)</p>
<p class="mb-2"><strong>Protokoll:</strong> HTTP (oder HTTPS)</p>
<p class="mb-0"><strong>Update-Pfad:</strong> <code>/nic/update</code></p>
</div>
</div>
</div>
</div>
{% endblock %}
+184
View File
@@ -0,0 +1,184 @@
{% extends 'base.html' %}
{% block title %}Benutzer — DynDNS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-people-fill text-primary"></i> Benutzer</h4>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
<i class="bi bi-plus-lg"></i> Benutzer anlegen
</button>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Subdomain</th>
<th>Hostname</th>
<th>DynDNS-User</th>
<th>Aktuelle IP</th>
<th>Letztes Update</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">{{ u.subdomain }}</td>
<td class="text-muted small font-monospace">
{% if base_domain %}{{ u.subdomain }}.{{ base_domain }}{% else %}—{% endif %}
</td>
<td>{{ u.username }}</td>
<td>
{% if u.current_ip %}
<span class="badge bg-secondary font-monospace">{{ u.current_ip }}</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td class="text-muted small">{{ u.last_updated or '—' }}</td>
<td>
{% if u.active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#editModal{{ u.id }}"
title="Bearbeiten">
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="{{ url_for('user_toggle', user_id=u.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-warning" title="Aktivieren/Deaktivieren">
<i class="bi bi-{% if u.active %}pause{% else %}play{% endif %}-fill"></i>
</button>
</form>
<button class="btn btn-outline-danger"
data-bs-toggle="modal"
data-bs-target="#delModal{{ u.id }}"
title="Löschen">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
<!-- Edit modal -->
<div class="modal fade" id="editModal{{ u.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Benutzer bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('user_edit', user_id=u.id) }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">DynDNS-Benutzername</label>
<input name="username" type="text" class="form-control"
value="{{ u.username }}" required>
</div>
<div class="mb-3">
<label class="form-label">Neues Passwort <span class="text-muted">(leer = unverändert)</span></label>
<input name="password" type="password" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Subdomain</label>
<div class="input-group">
<input name="subdomain" type="text" class="form-control font-monospace"
value="{{ u.subdomain }}" required>
{% if base_domain %}
<span class="input-group-text text-muted">.{{ base_domain }}</span>
{% endif %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete modal -->
<div class="modal fade" id="delModal{{ u.id }}" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Benutzer löschen?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<strong>{{ u.username }}</strong> ({{ u.subdomain }}) wirklich löschen?
Alle Update-Logs werden ebenfalls entfernt.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<form method="post" action="{{ url_for('user_delete', user_id=u.id) }}" class="d-inline">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</div>
</div>
</div>
</div>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">
Noch keine Benutzer. <a href="#" data-bs-toggle="modal" data-bs-target="#addModal">Jetzt anlegen.</a>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Add modal -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Neuen Benutzer anlegen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('user_add') }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">DynDNS-Benutzername</label>
<input name="username" type="text" class="form-control"
placeholder="z.B. stefan" required>
<div class="form-text">Wird im Speedport als „Username" eingetragen.</div>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input name="password" type="password" class="form-control" required>
<div class="form-text">Wird im Speedport als „Passwort" eingetragen.</div>
</div>
<div class="mb-3">
<label class="form-label">Subdomain</label>
<div class="input-group">
<input name="subdomain" type="text" class="form-control font-monospace"
placeholder="mypc" required
pattern="[a-z0-9]([a-z0-9\-]*[a-z0-9])?"
title="Kleinbuchstaben, Ziffern und Bindestriche">
{% if base_domain %}
<span class="input-group-text text-muted">.{{ base_domain }}</span>
{% endif %}
</div>
<div class="form-text">Nur Kleinbuchstaben, Ziffern und Bindestriche.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
from database import init_db
from main import app
init_db()