Skip to content
Snippets Groups Projects
Commit 1be9b019 authored by void's avatar void
Browse files

Umbau von PiHole auf Kea DHCP und Bind

parent cb907c40
No related branches found
No related tags found
1 merge request!148Umbau von PiHole auf Kea DHCP und Bind
Showing
with 1505 additions and 1217 deletions
---
- include_tasks: ../functions/get_secret.yml
with_items:
- { path: "/etc/kea/kea_api_password", length: 22 }
- { path: "/etc/kea/kea_ddns_key", length: 44 }
- name: "Instaliere debian Pakete"
apt:
update_cache: yes
state: present
name:
- bind9
- name: "Set owner for bind config directory"
file:
path: "/etc/bind"
state: directory
owner: "bind"
group: "bind"
- name: "Copy Service Config Files"
template:
src: "{{ item }}"
dest: "/etc/bind/{{ item }}"
with_items:
- db.warpzone.lan
- named.conf.local
- named.conf.options
- name: "Purge DDNS Updates"
ansible.builtin.file:
path: /etc/bind/db.warpzone.lan.jnl
state: absent
- name: "Enable and restart named.service"
systemd:
name: "named.service"
state: restarted
enabled: True
- name: "Get all active leases from Kea"
uri:
url: "http://127.0.0.1:8000"
method: POST
user: "kea-api"
password: "{{ kea_api_password }}"
body_format: json
body:
command: "lease4-get-all"
service: ["dhcp4"]
headers:
Content-Type: "application/json"
register: all_leases
- name: "Display number of leases found"
debug:
msg: "Found {{ all_leases.json[0].arguments.leases | length }} active leases"
- name: "Force DDNS update for each lease"
uri:
url: "http://127.0.0.1:8000"
method: POST
user: "kea-api"
password: "{{ kea_api_password }}"
body_format: json
body:
command: "lease4-resend-ddns"
service: ["dhcp4"]
arguments:
ip-address: "{{ item['ip-address'] }}"
headers:
Content-Type: "application/json"
loop: "{{ all_leases.json[0].arguments.leases }}"
register: ddns_results
when:
- all_leases.json[0].arguments.leases is defined
- item.hostname is defined
- item.hostname != ""
- item['ip-address'] is defined
- item['ip-address'] != ""
; zonefile for warpzone.lan.1200
; TTL min. 300
$TTL 300
@ IN SOA dhcpdns.warpzone.lan. webmaster.warpzone.ms. (
{{ ansible_date_time.epoch }} ; Serial
1200 ; Refresh 1200 - 43200
120 ; Retry 120 -7200
604800 ; Expire 604800 - 2419200
3600 ) ; Negative Cache TTL 3600 - 86400
@ IN A {{ int_ip4 }}
@ IN AAAA {{ ext_ip6 }}
; Nameserver
@ IN NS dhcpdns.warpzone.lan.
; Manual Static entries
{% for host in groups['all'] %}
{% if hostvars[host].int_ip4 is defined %}
{% if hostvars[host].int_ip4.startswith('10.0.0') or hostvars[host].int_ip4.startswith('192.168.0') %}
; Internal Host {{ host }}
{{ host }} IN A {{ hostvars[host].int_ip4 }}
{% if hostvars[host].webserver_domains is defined %}
{% for domain in hostvars[host].webserver_domains %}
{% if domain.endswith('warpzone.lan') %}
{{ domain | replace('.warpzone.lan','') }} IN A {{ hostvars[host].int_ip4 }}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
//
// This file contains the own local DNS server configuration.
// This is where you declare the zones associated with this server's domain(s).
// Consider adding the 1918 zones here, if they are not used in your organization
// include "/etc/bind/zones.rfc1918";
//
// ----------------------- Zones -----------------------
// Forward-Lookup
zone "warpzone.lan" {
type master;
file "/etc/bind/db.warpzone.lan";
check-names ignore;
allow-query { any; };
allow-update { key "kea-ddns-key"; };
};
// Reverse-Lookup
//zone "2.168.192.in-addr.arpa" {
// type master;
// file "/etc/bind/zones/db.2.168.192.inv";
//};
// ----------------------- Zones -----------------------
key "kea-ddns-key" {
algorithm hmac-sha256;
secret "{{ kea_ddns_key }}";
};
// This file contains all the configuration options for the DNS server.
// If there is a firewall between you and nameservers you want
// to talk to, you may need to fix the firewall to allow multiple
// ports to talk. See http://www.kb.cert.org/vuls/id/800113
// If your ISP provided one or more IP addresses for stable
// nameservers, you probably want to use them as forwarders.
// Uncomment the following block, and insert the addresses replacing
// the all-0's placeholder.
//========================================================================
// If BIND logs error messages about the root key being expired,
// you will need to update your keys. See https://www.isc.org/bind-keys
//========================================================================
//************************************************************************************
// ACL's define a address_match_list e.g. IP address(es), which can then be referenced
// (used) in a number of statements and the view clause(s). acl's MUST be defined before
// they are referenced in any statement or clause. For this reason they are usually defined
// first in the named.conf file. 'acl-name' is an arbitrary (but unique) quoted string defining
// the specific list. The 'acl-name' is the method used to subsequently reference the particular
// list. Any number of acl's may be defined.
/*
* Deny transfers by default except for the listed hosts.
* If we have other name servers, place them here.
*/
acl "acl_trusted_transfer" {
none;
};
//********************************************************************************
options {
/*
* Is a quoted string defining the absolute path for the server e.g. "/var/named".
* All subsequent relative paths use this base directory. If no directory options
* is specified the directory from which BIND was loaded is used.
*/
directory "/var/cache/bind";
/*
* Is a quoted string and allows you to define where the pid (Process Identifier)
* used by BIND is written. If not present it is distribution or OS specific
* typically /var/run/named.pid or /etc/named.pid. It may be defined using an
* absolute path or relative to the directory parameter.
*/
pid-file "/var/run/named/named.pid";
/*
* Specifies the string that will be returned to a version.bind query when using
* the chaos class only. version_string is a quoted string, for example, "get lost"
* or something equally to the point. We tend to use it in all named.conf files to
* avoid giving out a version number such that an attacker can exploit known
* version-specific weaknesses.
*/
version "not currently available";
/*
* Turns on BIND to listen for IPv6 queries. If this statement is not present and the
* server supports IPv6 (only or in dual stack mode) the server will listen for IPv6 on
* port 53 on all server interfaces. If the OS supports RFC 3493 and RFC 3542 compliant
* IPv6 sockets and the address_match_list uses the special any name then a single listen
* is issued to the wildcard address. If the OS does not support this feature a socket is
* opened for every required address and port. The port default is 53.
* Multiple listen-on-v6 statements are allowed.
*/
listen-on-v6 { any; };
/* Defines the port and IP address(es) on which BIND will listen for incoming queries.
* The default is port 53 on all server interfaces.
* Multiple listen-on statements are allowed.
*/
listen-on { any; };
/* Notify behaviour is applicable to both master zones (with 'type master;')
* and slave zones (with 'type slave;') and if set to 'yes' (the default) then,
* when a zone is loaded or changed, for example, after a zone transfer, NOTIFY
* messages are sent to the name servers defined in the NS records for the zone
* (except itself and the 'Primary Master' name server defined in the SOA record)
* and to any IPs listed in any also-notify statement.
* If set to 'no' NOTIFY messages are not sent.
* If set to 'explicit' NOTIFY is only sent to those IP(s) listed in an also-notify statement.
*/
notify no;
/*
* Defines an match list of IP address(es) which are allowed
* to issue queries to the server.
* Only trusted addresses are allowed to perform queries.
* We will allow anyone to query our master zones below.
* This prevent becoming a public free DNS server.
*/
allow-query {
any;
};
/*
* Defines an match list of IP address(es) which are allowed to
* issue queries that access the local query cache.
* Only trusted addresses are allowed to use query cache.
*/
allow-query-cache {
any;
};
/*
* Defines a match list of IP address(es) which are allowed to
* issue recursive queries to the server.
* Only trusted addresses are allowed to use recursion.
*/
allow-recursion {
any;
};
/*
* Dfines a match list e.g. IP address(es) that are allowed to transfer
* the zone information from the server (master or slave for the zone).
* The default behaviour is to allow zone transfers to any host.
*/
allow-transfer {
none;
};
/*
* Defines an match list of host IP address(es) that are allowed
* to submit dynamic updates for master zones, and thus this
* statement enables Dynamic DNS.
*/
allow-update {
none;
};
/*
* Indicates that a resolver (a caching or caching-only name server) will attempt
* to validate replies from DNSSEC enabled (signed) zones. To perform this task
* the server alos needs either a valid trusted-keys clause (containing one or more
* trusted-anchors or a managed-keys clause.
* Since 9.5 the default value is dnssec-validation yes;
*/
dnssec-validation auto;
/*
* If auth-nxdomain is 'yes' allows the server to answer authoritatively
* (the AA bit is set) when returning NXDOMAIN (domain does not exist) answers,
* if 'no' (the default) the server will not answer authoritatively.
*/
auth-nxdomain no; # conform to RFC1035
/*
* By default empty-zones-enable is set to 'yes' which means that
* reverse queries for IPv4 and IPv6 addresses covered by RFCs 1918,
* 4193, 5737 and 6598 (as well as IPv6 local address (locally assigned),
* IPv6 link local addresses, the IPv6 loopback address and the IPv6 unknown address)
* but which is not not covered by a locally defined zone clause will automatically
* return an NXDOMAIN response from the local name server. This prevents reverse map queries
* to such addresses escaping to the DNS hierarchy where they are simply noise and increase
* the already high level of query pollution caused by mis-configuration.
*/
empty-zones-enable yes;
/*
* If recursion is set to 'yes' (the default) the server will always provide
* recursive query behaviour if requested by the client (resolver).
* If set to 'no' the server will only provide iterative query behaviour -
* normally resulting in a referral. If the answer to the query already
* exists in the cache it will be returned irrespective of the value of this statement.
* This statement essentially controls caching behaviour in the server.
*/
recursion yes;
/*
* additional-from-auth and additional-from-cache control the behaviour when
* zones have additional (out-of-zone) data or when following CNAME or DNAME records.
* These options are for used for configuring authoritative-only (non-caching) servers
* and are only effective if recursion no is specified in a global options clause or
* in a view clause. The default in both cases is yes.
*/
//additional-from-auth no;
//additional-from-cache no;
/*
* Defines a list of IP address(es) and optional port numbers
* to which queries will be forwarded.
*/
forwarders {
// Router DNS
// 10.0.0.1;
// Cloudflare DNS
1.1.1.1;
// Google Public DNS
8.8.8.8;
8.8.4.4;
// OpenDNS
208.67.222.222;
208.67.220.220;
};
};
apispec==6.8.2
blinker==1.9.0
certifi==2025.6.15
charset-normalizer==3.4.2
click==8.2.1
Flask==3.1.1
flask-apispec==0.11.4
flask-swagger-ui==5.21.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
marshmallow==3.26.1
packaging==25.0
requests==2.32.4
urllib3==2.5.0
webargs==8.7.0
Werkzeug==3.1.3
---
- include_tasks: ../functions/get_secret.yml
with_items:
- { path: "/etc/kea/kea_api_password", length: 22 }
- name: "Instaliere debian Pakete"
apt:
update_cache: yes
state: present
name:
- python3
- python3-pip
- python3-virtualenv
- name: "Create dhcpinfo directories"
file:
path: "{{ item }}"
state: directory
with_items:
- "{{ basedir }}"
- "{{ basedir }}/templates"
- "{{ basedir }}/venv"
- name: "copy dhcpinfo files"
template:
src: "{{ item }}"
dest: "{{ basedir }}/{{ item }}"
with_items:
- app.py
- requirements.txt
- templates/leases.html
- name: Installiere Python-Pakete in venv
pip:
requirements: "{{ basedir }}/requirements.txt"
virtualenv: "{{ basedir }}/venv"
- name: "Create Systemd Unit File"
template:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
with_items:
- dhcpinfo.service
- name: "Enable and Restart Systemd Service"
systemd:
name: "dhcpinfo.service"
state: restarted
enabled: True
daemon_reload: yes
from flask import Flask, render_template, jsonify
import requests
import json
from datetime import datetime
import logging
import os
from requests.auth import HTTPBasicAuth
import sys
from marshmallow import Schema, fields
from flask_apispec import FlaskApiSpec, marshal_with, doc
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask_swagger_ui import get_swaggerui_blueprint
# Flask App Setup
app = Flask(__name__)
logging.basicConfig(
level=logging.DEBUG,
stream=sys.stdout,
format='%(asctime)s %(levelname)s %(message)s'
)
# Kea Configuration
KEA_CONTROL_AGENT_URL = "http://127.0.0.1:8000"
KEA_SERVICE = "dhcp4" # oder "dhcp6" für IPv6
KEA_USERNAME = "kea-api"
KEA_PASSWORD = "{{ kea_api_password }}"
# Web Server Configuration
WEB_HOST = "0.0.0.0"
WEB_PORT = 80
WEB_DEBUG = True
# Auto-Refresh Intervall in Sekunden (0 = kein Auto-Refresh)
AUTO_REFRESH = 30
# Marshmallow-Schemas (nur einmal definiert, werden überall verwendet)
class LeaseSchema(Schema):
ip_address = fields.Str(data_key='ip-address')
hw_address = fields.Str(data_key='hw-address')
hostname = fields.Str()
state = fields.Int()
cltt = fields.Int()
valid_lft = fields.Int()
subnet_id = fields.Int(data_key='subnet-id')
class StatusSchema(Schema):
connected = fields.Bool()
message = fields.Str()
url = fields.Str()
service = fields.Str()
username = fields.Str()
timestamp = fields.Str()
class KeaDHCPClient:
def __init__(self, url, service, username=None, password=None):
self.url = url
self.service = service
self.username = username
self.password = password
self.auth = HTTPBasicAuth(username, password) if username and password else None
def send_command(self, command, arguments=None):
"""Sendet einen Befehl an den Kea Control Agent mit Basic Auth"""
payload = {
"command": command,
"service": [self.service]
}
if arguments:
payload["arguments"] = arguments
headers = {'Content-Type': 'application/json'}
try:
# Request mit Basic Authentication
response = requests.post(
self.url,
json=payload,
headers=headers,
auth=self.auth,
timeout=10,
verify=True # SSL-Zertifikat verifizieren bei HTTPS
)
# HTTP-Status prüfen
response.raise_for_status()
# JSON-Response parsen
json_response = response.json()
# Kea-spezifische Fehler prüfen
if isinstance(json_response, list) and len(json_response) > 0:
result_code = json_response[0].get("result", -1)
if result_code != 0:
error_text = json_response[0].get("text", "Unbekannter Kea-Fehler")
logging.error(f"Kea API Fehler (Code {result_code}): {error_text}")
return json_response
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
logging.error("Authentifizierung fehlgeschlagen - Benutzername/Passwort prüfen")
elif e.response.status_code == 403:
logging.error("Zugriff verweigert - Benutzer hat keine Berechtigung")
else:
logging.error(f"HTTP-Fehler bei Kea API Request: {e}")
return None
except requests.exceptions.ConnectionError as e:
logging.error(f"Verbindungsfehler zu Kea Control Agent: {e}")
return None
except requests.exceptions.Timeout as e:
logging.error(f"Timeout bei Kea API Request: {e}")
return None
except requests.exceptions.RequestException as e:
logging.error(f"Allgemeiner Fehler bei Kea API Request: {e}")
return None
except json.JSONDecodeError as e:
logging.error(f"Fehler beim Parsen der JSON-Antwort: {e}")
return None
def test_connection(self):
"""Testet die Verbindung und Authentifizierung"""
try:
response = self.send_command("list-commands")
if response and len(response) > 0 and response[0].get("result") == 0:
return True, "Verbindung erfolgreich"
else:
return False, "Kea API Fehler"
except Exception as e:
return False, f"Verbindungstest fehlgeschlagen: {str(e)}"
def get_all_leases(self):
"""Ruft alle DHCP-Leases ab"""
command = "lease4-get-all" if self.service == "dhcp4" else "lease6-get-all"
return self.send_command(command)
def get_server_config(self):
"""Ruft die Server-Konfiguration ab"""
return self.send_command("config-get")
def get_statistics(self):
"""Ruft Server-Statistiken ab"""
return self.send_command("statistic-get-all")
def get_server_info(self):
"""Ruft Server-Informationen ab"""
return self.send_command("build-report")
def format_timestamp(timestamp):
"""Formatiert Unix-Timestamp zu lesbarem Datum"""
if timestamp and timestamp > 0:
return datetime.fromtimestamp(timestamp).strftime('%d.%m.%Y %H:%M:%S')
return "N/A"
def format_lease_state(state):
"""Formatiert Lease-Status"""
states = {
0: "Verfügbar",
1: "Vergeben",
2: "Abgelaufen",
3: "Zurückgegeben"
}
return states.get(state, f"Unbekannt ({state})")
@app.route('/')
def index():
"""Hauptseite mit Lease-Übersicht"""
kea_client = KeaDHCPClient(KEA_CONTROL_AGENT_URL, KEA_SERVICE, KEA_USERNAME, KEA_PASSWORD)
# Verbindung testen
connection_ok, connection_message = kea_client.test_connection()
if not connection_ok:
return render_template('leases.html',
leases=[],
error=f"Verbindungsfehler: {connection_message}",
connection_status="Fehler",
last_update=datetime.now().strftime('%d.%m.%Y %H:%M:%S'))
# Leases abrufen
lease_response = kea_client.get_all_leases()
leases = []
error_message = None
if lease_response and lease_response[0].get("result") == 0:
# Erfolgreich - Leases verarbeiten
lease_data = lease_response[0].get("arguments", {})
leases = lease_data.get("leases", [])
# Leases für die Anzeige formatieren
for lease in leases:
lease['valid_lft_formatted'] = format_timestamp(lease.get('cltt', 0) + lease.get('valid_lft', 0))
lease['cltt_formatted'] = format_timestamp(lease.get('cltt', 0))
lease['state_formatted'] = format_lease_state(lease.get('state', 0))
else:
error_message = "Fehler beim Abrufen der Leases von Kea DHCP Server"
if lease_response:
error_message += f": {lease_response[0].get('text', 'Unbekannter Fehler')}"
# Statistiken abrufen (optional)
stats_response = kea_client.get_statistics()
logging.debug(f"Raw statistics response: {stats_response}")
statistics = {}
if stats_response and stats_response[0].get("result") == 0:
stats_data = stats_response[0].get("arguments", {})
logging.debug(f"Parsed statistics data: {stats_data}")
for stat_name, stat_values in stats_data.items():
if isinstance(stat_values, list) and len(stat_values) > 0:
statistics[stat_name] = stat_values[0][0] # Extract the actual statistic value
else:
logging.warning(f"Unexpected format for statistic {stat_name}: {stat_values}")
# Server-Info abrufen
server_info = {}
info_response = kea_client.get_server_info()
if info_response and info_response[0].get("result") == 0:
server_info = info_response[0].get("arguments", {})
return render_template('leases.html',
leases=leases,
error=error_message,
statistics=statistics,
server_info=server_info,
total_leases=len(leases),
connection_status="Verbunden",
auto_refresh=AUTO_REFRESH,
last_update=datetime.now().strftime('%d.%m.%Y %H:%M:%S'))
@doc(description='Liste aller DHCP-Leases', tags=['leases'])
@marshal_with(LeaseSchema(many=True))
@app.route('/api/leases')
def api_leases():
"""JSON API für Leases"""
kea_client = KeaDHCPClient(KEA_CONTROL_AGENT_URL, KEA_SERVICE, KEA_USERNAME, KEA_PASSWORD)
lease_response = kea_client.get_all_leases()
if lease_response and lease_response[0].get("result") == 0:
lease_data = lease_response[0].get("arguments", {})
return lease_data.get("leases", [])
else:
return {"error": "Fehler beim Abrufen der Leases"}, 500
@doc(description='Status und Verbindung zur Kea API', tags=['status'])
@marshal_with(StatusSchema)
@app.route('/api/status')
def api_status():
"""API-Endpunkt für Verbindungsstatus"""
kea_client = KeaDHCPClient(KEA_CONTROL_AGENT_URL, KEA_SERVICE, KEA_USERNAME, KEA_PASSWORD)
connection_ok, message = kea_client.test_connection()
return {
"connected": connection_ok,
"message": message,
"url": KEA_CONTROL_AGENT_URL,
"service": KEA_SERVICE,
"username": KEA_USERNAME,
"timestamp": datetime.now().isoformat()
}
@app.route('/metrics')
def metrics():
"""Prometheus Metrics Endpoint für Kea Statistiken"""
kea_client = KeaDHCPClient(KEA_CONTROL_AGENT_URL, KEA_SERVICE, KEA_USERNAME, KEA_PASSWORD)
stats_response = kea_client.get_statistics()
output = []
connection_ok, _ = kea_client.test_connection()
output.append(f"kea_connection_ok {1 if connection_ok else 0}")
# Lease-Anzahl ermitteln
lease_response = kea_client.get_all_leases()
total_leases = 0
if lease_response and lease_response[0].get("result") == 0:
lease_data = lease_response[0].get("arguments", {})
total_leases = len(lease_data.get("leases", []))
output.append(f"kea_total_leases {total_leases}")
if stats_response and stats_response[0].get("result") == 0:
stats_data = stats_response[0].get("arguments", {})
for stat_name, stat_values in stats_data.items():
if isinstance(stat_values, list) and len(stat_values) > 0:
value = stat_values[0][0]
prom_name = stat_name.lower().replace('.', '_').replace('-', '_').replace('[', '_').replace(']', '')
output.append(f"kea_{prom_name} {value}")
return '\n'.join(output) + '\n', 200, {'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'}
# OpenAPI/Swagger-Konfiguration
app.config.update({
'APISPEC_SPEC': APISpec(
title='Warpzone DHCP API',
version='1.0.0',
openapi_version='3.0.2',
plugins=[MarshmallowPlugin()],
),
'APISPEC_SWAGGER_URL': '/swagger.yaml',
})
docs = FlaskApiSpec(app)
docs.register(api_leases)
docs.register(api_status)
swaggerui_blueprint = get_swaggerui_blueprint(
'/swagger',
'/swagger.yaml',
config={'app_name': "Warpzone DHCP API"}
)
app.register_blueprint(swaggerui_blueprint, url_prefix='/swagger')
if __name__ == '__main__':
# Konfiguration ausgeben (ohne Passwort)
print(f"🚀 Starte Kea DHCP Web Interface")
print(f"🐞 Debug-Modus: {WEB_DEBUG}")
print(f"📡 Kea Control Agent URL: {KEA_CONTROL_AGENT_URL}")
print(f"🔧 Kea Service: {KEA_SERVICE}")
print(f"👤 Benutzername: {KEA_USERNAME}")
print(f"🔒 Passwort: {'*' * len(KEA_PASSWORD) if KEA_PASSWORD else 'NICHT GESETZT'}")
print(f"🔄 Auto-Refresh: {AUTO_REFRESH} Sekunden")
print(f"🌐 Web Interface: http://{WEB_HOST}:{WEB_PORT}")
print(f"📄 Swagger UI: http://{WEB_HOST}:{WEB_PORT}/swagger")
app.run(debug=WEB_DEBUG, host=WEB_HOST, port=WEB_PORT)
[Unit]
Description=DHCP Info Web Interface
After=network.target network-online.target
Wants=network-online.target
Requires=isc-kea-ctrl-agent.service isc-kea-dhcp4-server.service
After=isc-kea-ctrl-agent.service isc-kea-dhcp4-server.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/srv/dhcpinfo
ExecStart={{ basedir }}/venv/bin/python /srv/dhcpinfo/app.py
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=dhcpinfo
[Install]
WantedBy=multi-user.target
{% raw %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warpzone DHCP Leases</title>
<style>
:root {
color-scheme: light dark;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #222;
}
body.dark-mode {
background-color: #181a1b !important;
color: #e0e0e0 !important;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.container.dark-mode {
background: #23272e !important;
color: #e0e0e0 !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header.dark-mode {
background: linear-gradient(135deg, #232b4a 0%, #3a2a4d 100%) !important;
color: #fff !important;
}
.connection-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #e9ecef;
border-bottom: 1px solid #dee2e6;
font-size: 14px;
}
.connection-status.dark-mode {
background: #23272e !important;
border-bottom: 1px solid #333 !important;
color: #b0b0b0 !important;
}
.status-connected { color: #28a745; }
.status-error { color: #dc3545; }
.connection-info {
display: flex;
gap: 20px;
font-size: 12px;
color: #6c757d;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.stats.dark-mode {
background: #23272e !important;
border-bottom: 1px solid #333 !important;
}
.stat-card {
background: white;
padding: 15px;
border-radius: 6px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card.dark-mode {
background: #181a1b !important;
color: #e0e0e0 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.3) !important;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #495057;
}
.stat-label {
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
}
.controls {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
}
.controls.dark-mode {
background: #23272e !important;
border-bottom: 1px solid #333 !important;
}
.refresh-btn {
background: #28a745;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.refresh-btn:hover {
background: #218838;
}
.search-box {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
width: 300px;
}
.search-box.dark-mode {
background: #23272e !important;
color: #e0e0e0 !important;
border: 1px solid #444 !important;
}
.table-container {
overflow-x: auto;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table.dark-mode {
background: #23272e !important;
color: #e0e0e0 !important;
}
th, td {
padding: 12px 8px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #495057;
color: white;
font-weight: 600;
position: sticky;
top: 0;
}
th.dark-mode {
background: #333 !important;
color: #fff !important;
}
tr:hover {
background-color: #f8f9fa;
}
tr.dark-mode:hover {
background-color: #232b33 !important;
}
.status-active { color: #28a745; font-weight: bold; }
.status-expired { color: #dc3545; font-weight: bold; }
.status-available { color: #6c757d; }
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
margin: 20px;
border-radius: 4px;
border: 1px solid #f5c6cb;
}
.error.dark-mode {
background: #4b2323 !important;
color: #ffb3b3 !important;
border: 1px solid #a33 !important;
}
.last-update {
font-size: 12px;
color: #6c757d;
}
.server-info {
background: #e7f3ff;
padding: 15px;
margin: 20px;
border-radius: 4px;
border: 1px solid #bee5eb;
font-size: 12px;
}
.server-info.dark-mode {
background: #232b33 !important;
border: 1px solid #444 !important;
color: #b0e0ff !important;
}
@media (max-width: 768px) {
.stats {
grid-template-columns: 1fr;
}
.controls {
flex-direction: column;
gap: 10px;
}
.search-box {
width: 100%;
}
.connection-info {
flex-direction: column;
gap: 5px;
}
}
</style>
<script>
// Dark mode toggle and persistence
function toggleDarkMode(force) {
const isDarkMode = force !== undefined ? force : !document.body.classList.contains('dark-mode');
if (isDarkMode) {
document.body.classList.add('dark-mode');
document.querySelector('.container').classList.add('dark-mode');
document.querySelector('.header').classList.add('dark-mode');
document.querySelector('.connection-status').classList.add('dark-mode');
document.querySelector('.stats')?.classList.add('dark-mode');
document.querySelector('.controls').classList.add('dark-mode');
document.querySelectorAll('.stat-card').forEach(e => e.classList.add('dark-mode'));
document.querySelector('.search-box').classList.add('dark-mode');
document.querySelector('table').classList.add('dark-mode');
document.querySelectorAll('th').forEach(e => e.classList.add('dark-mode'));
document.querySelectorAll('tr').forEach(e => e.classList.add('dark-mode'));
document.querySelector('.error')?.classList.add('dark-mode');
document.querySelector('.server-info')?.classList.add('dark-mode');
} else {
document.body.classList.remove('dark-mode');
document.querySelector('.container').classList.remove('dark-mode');
document.querySelector('.header').classList.remove('dark-mode');
document.querySelector('.connection-status').classList.remove('dark-mode');
document.querySelector('.stats')?.classList.remove('dark-mode');
document.querySelector('.controls').classList.remove('dark-mode');
document.querySelectorAll('.stat-card').forEach(e => e.classList.remove('dark-mode'));
document.querySelector('.search-box').classList.remove('dark-mode');
document.querySelector('table').classList.remove('dark-mode');
document.querySelectorAll('th').forEach(e => e.classList.remove('dark-mode'));
document.querySelectorAll('tr').forEach(e => e.classList.remove('dark-mode'));
document.querySelector('.error')?.classList.remove('dark-mode');
document.querySelector('.server-info')?.classList.remove('dark-mode');
}
localStorage.setItem('darkMode', isDarkMode);
}
function clearDarkModePreference() {
localStorage.removeItem('darkMode');
// Reload to apply system preference
location.reload();
}
// Apply dark mode on page load if preference is saved or system prefers dark
window.addEventListener('DOMContentLoaded', () => {
let isDarkMode = localStorage.getItem('darkMode');
if (isDarkMode === null) {
// No preference saved, use system preference
isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
isDarkMode = isDarkMode === 'true';
}
toggleDarkMode(isDarkMode);
});
</script>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 Warpzone DHCP Server - DHCP Leases</h1>
<button onclick="toggleDarkMode()" style="float:right;margin-top:-40px;background:#444;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;">🌙 Dark Mode</button>
<button onclick="clearDarkModePreference()" style="float:right;margin-top:-40px;margin-right:110px;background:#888;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;">🗑️ Reset Dark Mode</button>
</div>
<div class="connection-status">
<div>
<strong>Verbindungsstatus:</strong>
<span class="{% if connection_status == 'Verbunden' %}status-connected{% else %}status-error{% endif %}">
{{ connection_status }}
</span>
</div>
<div class="connection-info">
<span>🕐 Letztes Update: {{ last_update }}</span>
<span>🔄 Auto-Refresh: {{ auto_refresh }} Sekunden</span>
<span>
<a href="/swagger/" target="_blank" style="color:#007bff;text-decoration:underline;">🧩 Swagger API</a>
</span>
</div>
</div>
{% if server_info %}
<div class="server-info">
<strong>Server-Info:</strong>
{% if server_info.get('extended-version') %}
Kea {{ server_info['extended-version'] }}
{% endif %}
{% if server_info.get('config-control') %}
| Config Backend aktiv
{% endif %}
</div>
{% endif %}
{% if statistics %}
<div class="stats">
<div class="stat-card">
<div class="stat-value">{{ total_leases }}</div>
<div class="stat-label">Aktive Leases</div>
</div>
{% if statistics['subnet[1].assigned-addresses'] %}
<div class="stat-card">
<div class="stat-value">{{ statistics['subnet[1].assigned-addresses'] }}</div>
<div class="stat-label">Subnet[1] Assigned Addresses</div>
</div>
{% endif %}
{% if statistics['subnet[1].total-addresses'] %}
<div class="stat-card">
<div class="stat-value">{{ statistics['subnet[1].total-addresses'] }}</div>
<div class="stat-label">Subnet[1] Total Addresses</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="controls">
<button class="refresh-btn" onclick="location.reload()">🔄 Aktualisieren</button>
<input type="text" class="search-box" id="searchInput" placeholder="Suche nach IP, MAC oder Hostname...">
<div class="last-update">Letztes Update: {{ last_update }}</div>
</div>
{% if error %}
<div class="error">
<strong>Fehler:</strong> {{ error }}
<br><small>Prüfen Sie die Kea Control Agent Konfiguration und Zugangsdaten.</small>
</div>
{% else %}
<div class="table-container">
<table id="leasesTable">
<thead>
<tr>
<th>IP-Adresse</th>
<th>MAC-Adresse</th>
<th>Hostname</th>
<th>Status</th>
<th>Lease Start</th>
<th>Lease Ende</th>
</tr>
</thead>
<tbody>
{% for lease in leases|sort(attribute='cltt', reverse=True) %}
<tr>
<td><strong>{{ lease.get('ip-address', 'N/A') }}</strong></td>
<td><code>{{ lease.get('hw-address', 'N/A') }}</code></td>
<td>
{% if lease.get('hostname', '').startswith('wled-') or lease.get('hostname', '').startswith('esphome_') or lease.get('hostname', '').startswith('tasmota-') %}
<a href="http://{{ lease.get('hostname') }}" target="_blank">{{ lease.get('hostname') }}</a>
{% else %}
{{ lease.get('hostname', 'N/A') }}
{% endif %}
</td>
<td>
<span class="{% if lease.get('state') == 1 %}status-active{% elif lease.get('state') == 2 %}status-expired{% else %}status-available{% endif %}">
{{ lease.state_formatted }}
</span>
</td>
<td>{{ lease.cltt_formatted }}</td>
<td>{{ lease.valid_lft_formatted }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<script>
// Auto-refresh alle 30 Sekunden
setInterval(function() {
location.reload();
}, {{ auto_refresh * 1000 }});
// Such-Funktionalität
document.getElementById('searchInput').addEventListener('keyup', function() {
const searchValue = this.value.toLowerCase();
const table = document.getElementById('leasesTable');
const rows = table.getElementsByTagName('tr');
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
const cells = row.getElementsByTagName('td');
let found = false;
for (let j = 0; j < cells.length; j++) {
if (cells[j].textContent.toLowerCase().includes(searchValue)) {
found = true;
break;
}
}
row.style.display = found ? '' : 'none';
}
});
</script>
</body>
</html>
{% endraw %}
\ No newline at end of file
---
- include_tasks: ../functions/get_secret.yml
with_items:
- { path: "/etc/kea/kea_api_password", length: 22 }
- { path: "/etc/kea/kea_ddns_key", length: 44 }
- name: "Instaliere debian Pakete"
apt:
update_cache: yes
state: present
name:
- isc-kea
- name: "Copy Service Config Files"
template:
src: "{{ item }}"
dest: "/etc/kea/{{ item }}"
with_items:
- kea-ctrl-agent.conf
- kea-dhcp-ddns.conf
- kea-dhcp4.conf
- name: enable and restart isc-kea-dhcp-ddns-server.service
systemd:
name: "isc-kea-dhcp-ddns-server.service"
state: restarted
enabled: True
- name: enable and restart isc-kea-dhcp4-server.service
systemd:
name: "isc-kea-dhcp4-server.service"
state: restarted
enabled: True
- name: disable isc-kea-dhcp6-server.service
systemd:
name: "isc-kea-dhcp6-server.service"
state: stopped
enabled: False
- name: enable and restart isc-kea-ctrl-agent.service
systemd:
name: "isc-kea-ctrl-agent.service"
state: restarted
enabled: True
// For official documentation, see: https://kea.readthedocs.io/
{
"Control-agent": {
"http-host": "127.0.0.1",
"http-port": 8000,
"authentication": {
"type": "basic",
"realm": "Kea Control Agent",
"directory": "/etc/kea",
"clients": [
{
"user": "kea-api",
"password-file": "kea_api_password"
}
]
},
"control-sockets": {
"dhcp4": {
"socket-type": "unix",
"socket-name": "kea4-ctrl-socket"
},
"dhcp6": {
"socket-type": "unix",
"socket-name": "kea6-ctrl-socket"
},
"d2": {
"socket-type": "unix",
"socket-name": "kea-ddns-ctrl-socket"
}
},
"loggers": [
{
"name": "kea-ctrl-agent",
"output-options": [
{
"output": "stdout",
"pattern": "%-5p %m\n"
}
],
"severity": "INFO",
"debuglevel": 0
}
]
}
}
// For official documentation, see: https://kea.readthedocs.io/
{
"DhcpDdns": {
"ip-address": "127.0.0.1",
"port": 53001,
"dns-server-timeout" : 1000,
"user-context": { "version": 1 },
"control-socket": {
"socket-type": "unix",
"socket-name": "kea-ddns-ctrl-socket"
},
"forward-ddns":
{
"ddns-domains":
[
{
"comment": "warpzone.lan",
"name": "warpzone.lan.",
"key-name": "kea-ddns-key",
"dns-servers":
[
{
"ip-address": "127.0.0.1"
}
]
},
]
},
# // ----------------- Reverse DDNS ------------------
# // We will update Reverse DNS for one zone "2.0.192.in-addr-arpa". It
# // uses TSIG with key "d2.sha1.key" and is served by two DNS servers:
# // one listening at "172.16.1.1" on 53001 and the other at "192.168.2.10".
# "reverse-ddns":
# {
# "ddns-domains":
# [
# {
# "name": "2.0.192.in-addr.arpa.",
# "key-name": "d2.sha1.key",
# "dns-servers":
# [
# {
# "ip-address": "172.16.1.1",
# "port": 53001
# },
# {
# "ip-address": "192.168.2.10"
# }
# ]
# }
# ]
# },
"tsig-keys": [
{
"name": "kea-ddns-key",
"algorithm": "hmac-sha256",
"secret": "{{ kea_ddns_key }}"
}
],
"loggers": [
{
"name": "kea-dhcp-ddns",
"output-options": [
{
"output": "stdout",
"pattern": "%-5p %m\n"
}
],
"severity": "INFO",
"debuglevel": 0
}
]
}
}
// For official documentation, see: https://kea.readthedocs.io/
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [ "*" ]
},
"control-socket": {
"socket-type": "unix",
"socket-name": "kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"lfc-interval": 3600
},
"expired-leases-processing": {
"reclaim-timer-wait-time": 10,
"flush-reclaimed-timer-wait-time": 25,
"hold-reclaimed-time": 3600,
"max-reclaim-leases": 100,
"max-reclaim-time": 250,
"unwarned-reclaim-cycles": 5
},
"renew-timer": 3600,
"rebind-timer": 7200,
"valid-lifetime": 14400,
"option-data": [
{
"name": "domain-name-servers",
"data": "{{ int_ip4 }}"
},
{
"name": "domain-name",
"data": "warpzone.lan"
},
{
"name": "domain-search",
"data": "warpzone.lan"
}
],
"subnet4": [
{
"id": 1,
"subnet": "10.0.0.0/22",
"pools": [ { "pool": "10.0.0.3 - 10.0.3.254" } ],
"option-data": [
{
"name": "routers",
"data": "10.0.0.1"
}
]
}
],
"loggers": [
{
"name": "kea-dhcp4",
"output-options": [
{
"output": "stdout",
"pattern": "%-5p %m\n"
}
],
"severity": "INFO",
"debuglevel": 0
}
],
"hooks-libraries": [
{
"library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so"
}
],
"server-hostname": "{{ inventory_hostname }}",
"ddns-send-updates": true,
"ddns-override-no-update": false,
"ddns-override-client-update": false,
"ddns-replace-client-name": "never",
"ddns-generated-prefix": "",
"ddns-qualifying-suffix": "warpzone.lan",
"ddns-update-on-renew": true,
"ddns-conflict-resolution-mode": "check-with-dhcid",
"hostname-char-set": "",
"hostname-char-replacement": "",
"dhcp-ddns": {
"enable-updates": true,
"server-ip": "127.0.0.1",
"server-port":53001,
"sender-ip":"",
"sender-port":0,
"max-queue-size":1024,
"ncr-protocol":"UDP",
"ncr-format":"JSON"
},
}
}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
# Host spezifische Variablen # Host spezifische Variablen
motd_lines: motd_lines:
- "pihole - Interner pihole DNS @ warpzone" - "dhcpdns - Interner DHCP und DNS @ warpzone"
- "Haupt-IP @ eth0: {{ansible_eth0.ipv4.address}}" - "Haupt-IP @ eth0: {{ansible_eth0.ipv4.address}}"
- "IPv6-IP @ eth0: {{ ext_ip6 }}" - "IPv6-IP @ eth0: {{ ext_ip6 }}"
...@@ -10,13 +10,14 @@ debian_sources: ...@@ -10,13 +10,14 @@ debian_sources:
- "deb http://ftp2.de.debian.org/debian/ bookworm main contrib non-free non-free-firmware" - "deb http://ftp2.de.debian.org/debian/ bookworm main contrib non-free non-free-firmware"
- "deb http://ftp.debian.org/debian bookworm-updates main contrib non-free non-free-firmware" - "deb http://ftp.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
- "deb http://security.debian.org/ bookworm-security main contrib non-free non-free-firmware" - "deb http://security.debian.org/ bookworm-security main contrib non-free non-free-firmware"
- "deb https://download.docker.com/linux/debian bookworm stable" - "deb https://dl.cloudsmith.io/public/isc/kea-2-6/deb/debian bookworm main"
- "deb https://packages.sury.org/bind/ bookworm main"
debian_keys_id: debian_keys_id:
debian_keys_url: debian_keys_url:
- "https://download.docker.com/linux/debian/gpg" - "https://dl.cloudsmith.io/public/isc/kea-2-6/gpg.63D408891D8B8D01.key"
- "https://packages.sury.org/bind/apt.gpg"
# Primäre IP Adressen des Hosts # Primäre IP Adressen des Hosts
#ext_ip4: <keine> #ext_ip4: <keine>
...@@ -32,7 +33,7 @@ webserver_ssl: false ...@@ -32,7 +33,7 @@ webserver_ssl: false
# Liste der gehosteten Domänen # Liste der gehosteten Domänen
webserver_domains: webserver_domains:
- "pihole.warpzone.lan" - "dhcpdns.warpzone.lan"
administratorenteam: administratorenteam:
- "void" - "void"
...@@ -45,8 +46,5 @@ alert: ...@@ -45,8 +46,5 @@ alert:
load: load:
warn: 15 warn: 15
crit: 30 crit: 30
containers:
- { name: "dockerstats-app-1" }
- { name: "pihole-app-1" }
disks: disks:
- { mountpoint: "/", warn: "1 GB", crit: "512 MB" } - { mountpoint: "/", warn: "1 GB", crit: "512 MB" }
\ No newline at end of file
...@@ -31,14 +31,15 @@ webserver_ssl: false ...@@ -31,14 +31,15 @@ webserver_ssl: false
# Liste der gehosteten Domänen # Liste der gehosteten Domänen
webserver_domains: webserver_domains:
- "warpsrvint.warpzone"
- "esphome.warpzone.lan" - "esphome.warpzone.lan"
- "fridgeserver.warpzone.lan" - "fridgeserver.warpzone.lan"
- "grafana.warpzone.lan" - "grafana.warpzone.lan"
- "services.warpzone.lan"
- "ha.warpzone.lan" - "ha.warpzone.lan"
- "mqtt.warpzone.lan"
- "omada.warpzone.lan" - "omada.warpzone.lan"
- "services.warpzone.lan"
- "tasmoadmin.warpzone.lan" - "tasmoadmin.warpzone.lan"
- "warpsrvint.warpzone.lan"
- "zigbee2mqtt.warpzone.lan" - "zigbee2mqtt.warpzone.lan"
administratorenteam: administratorenteam:
......
...@@ -38,9 +38,10 @@ prod: ...@@ -38,9 +38,10 @@ prod:
ansible_ssh_host: 192.168.0.202 ansible_ssh_host: 192.168.0.202
ansible_user: root ansible_user: root
pihole: dhcpdns:
ansible_ssh_host: 10.0.0.2 ansible_ssh_host: 10.0.0.2
ansible_user: root ansible_user: root
ansible_ssh_common_args: '-o ProxyCommand="ssh -W %h:%p -q root@webserver.warpzone.ms"'
# Öffentlicher Webserver Warpzone # Öffentlicher Webserver Warpzone
# VM auf Tiffany # VM auf Tiffany
......
- include_tasks: ../functions/get_secret.yml
with_items:
- { path: "{{ basedir }}/secrets/admin_password", type: create, length: 24 }
- name: "create folder struct for {{ servicename }}"
file:
path: "{{ item }}"
state: "directory"
with_items:
- "{{ basedir }}"
- "{{ basedir }}/secrets"
- "{{ basedir }}/etc"
- name: "create config files for {{ servicename }}"
template:
src: "{{ item }}"
dest: "{{ basedir }}/{{ item }}"
with_items:
- docker-compose.yml
- etc/pihole.toml
register: config
- name: "stop {{ servicename}} docker"
community.docker.docker_compose_v2:
project_src: "{{ basedir }}"
state: absent
when: config.changed
- name: "start {{ servicename}} docker"
community.docker.docker_compose_v2:
project_src: "{{ basedir }}"
state: present
\ No newline at end of file
services:
app:
image: pihole/pihole:2025.02.6
restart: always
network_mode: host
volumes:
- '{{ basedir }}/etc:/etc/pihole'
hostname: pihole
environment:
TZ: 'Europe/Berlin'
WEBPASSWORD: '{{ admin_password }}'
cap_add:
- NET_ADMIN
- SYS_NICE
- SYS_TIME
- NET_BIND_SERVICE
- NET_RAW
This diff is collapsed.
...@@ -250,23 +250,20 @@ ...@@ -250,23 +250,20 @@
domain: "zigbee2mqtt.warpzone.lan" domain: "zigbee2mqtt.warpzone.lan"
} }
- hosts: pihole - hosts: dhcpdns
remote_user: root remote_user: root
roles: roles:
- { role: common/cronapt, tags: cronapt } - { role: common/cronapt, tags: cronapt }
- { role: common/docker, tags: docker }
- { role: common/prometheus-node, tags: prometheus-node } - { role: common/prometheus-node, tags: prometheus-node }
- { - {
role: common/docker_dockerstats, tags: [ dockerstats, docker_services ], role: dhcpdns/kea, tags: kea
servicename: dockerstats, }
basedir: /srv/dockerstats, - {
metrics_port: 9487 role: dhcpdns/bind, tags: bind
} }
- { - {
role: pihole/docker_pihole, tags: pihole, role: dhcpdns/dhcpinfo, tags: dhcpinfo,
servicename: pihole, basedir: /srv/dhcpinfo
basedir: /srv/pihole,
domain: "pihole.warpzone.lan"
} }
- hosts: webserver - hosts: webserver
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment